Files
CalendarApi/internal/service/calendar.go
Michilis 2cb9d72a7f Add public/private calendars, full iCal support, and iCal URL import
- Public/private: toggle is_public via PUT /calendars/{id}; generate/clear
  public_token and return ical_url when public
- Public feed: GET /cal/{token}/feed.ics (no auth) for subscription in
  Google/Apple/Outlook calendars
- Full iCal export: use golang-ical; VALARM, ATTENDEE, all-day (VALUE=DATE),
  RRULE, DTSTAMP, CREATED, LAST-MODIFIED
- Full iCal import: parse TZID, VALUE=DATE, VALARM, ATTENDEE, RRULE
- Import from URL: POST /calendars/import-url with calendar_id + url
- Migration: unique index on public_token, calendar_subscriptions table
- Config: BASE_URL for ical_url; Calendar model + API: ical_url field
- Docs: OpenAPI, llms.txt, README, SKILL.md, about/overview

Made-with: Cursor
2026-02-28 04:48:53 +00:00

372 lines
9.8 KiB
Go

package service
import (
"context"
"crypto/rand"
"encoding/base64"
"fmt"
"github.com/calendarapi/internal/models"
"github.com/calendarapi/internal/repository"
"github.com/calendarapi/internal/utils"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
)
type CalendarService struct {
pool *pgxpool.Pool
queries *repository.Queries
audit *AuditService
baseURL string
}
func NewCalendarService(pool *pgxpool.Pool, queries *repository.Queries, audit *AuditService, baseURL string) *CalendarService {
return &CalendarService{pool: pool, queries: queries, audit: audit, baseURL: baseURL}
}
func generatePublicToken() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(b), nil
}
func (s *CalendarService) icalURL(token string) string {
if token == "" {
return ""
}
return fmt.Sprintf("%s/cal/%s/feed.ics", s.baseURL, token)
}
func (s *CalendarService) Create(ctx context.Context, userID uuid.UUID, name, color string) (*models.Calendar, error) {
if err := utils.ValidateCalendarName(name); err != nil {
return nil, err
}
if err := utils.ValidateColor(color); err != nil {
return nil, err
}
if color == "" {
color = "#3B82F6"
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, models.ErrInternal
}
defer tx.Rollback(ctx)
qtx := s.queries.WithTx(tx)
calID := uuid.New()
cal, err := qtx.CreateCalendar(ctx, repository.CreateCalendarParams{
ID: utils.ToPgUUID(calID),
OwnerID: utils.ToPgUUID(userID),
Name: name,
Color: color,
IsPublic: false,
})
if err != nil {
return nil, models.ErrInternal
}
err = qtx.UpsertCalendarMember(ctx, repository.UpsertCalendarMemberParams{
CalendarID: utils.ToPgUUID(calID),
UserID: utils.ToPgUUID(userID),
Role: "owner",
})
if err != nil {
return nil, models.ErrInternal
}
if err := tx.Commit(ctx); err != nil {
return nil, models.ErrInternal
}
s.audit.Log(ctx, "calendar", calID, "CREATE_CALENDAR", userID)
return &models.Calendar{
ID: utils.FromPgUUID(cal.ID),
Name: cal.Name,
Color: cal.Color,
IsPublic: cal.IsPublic,
ICalURL: s.icalURL(utils.FromPgTextValue(cal.PublicToken)),
Role: "owner",
CreatedAt: utils.FromPgTimestamptz(cal.CreatedAt),
UpdatedAt: utils.FromPgTimestamptz(cal.UpdatedAt),
}, nil
}
func (s *CalendarService) List(ctx context.Context, userID uuid.UUID) ([]models.Calendar, error) {
rows, err := s.queries.ListCalendarsByUser(ctx, utils.ToPgUUID(userID))
if err != nil {
return nil, models.ErrInternal
}
calendars := make([]models.Calendar, len(rows))
for i, r := range rows {
calendars[i] = models.Calendar{
ID: utils.FromPgUUID(r.ID),
Name: r.Name,
Color: r.Color,
IsPublic: r.IsPublic,
Role: r.Role,
CreatedAt: utils.FromPgTimestamptz(r.CreatedAt),
UpdatedAt: utils.FromPgTimestamptz(r.UpdatedAt),
}
}
// Populate ICalURL for public calendars (requires a separate lookup since ListCalendarsByUser doesn't select public_token)
for i := range calendars {
if calendars[i].IsPublic {
cal, err := s.queries.GetCalendarByID(ctx, utils.ToPgUUID(calendars[i].ID))
if err == nil && cal.PublicToken.Valid {
calendars[i].ICalURL = s.icalURL(cal.PublicToken.String)
}
}
}
return calendars, nil
}
func (s *CalendarService) Get(ctx context.Context, userID uuid.UUID, calID uuid.UUID) (*models.Calendar, error) {
role, err := s.getRole(ctx, calID, userID)
if err != nil {
return nil, err
}
cal, err := s.queries.GetCalendarByID(ctx, utils.ToPgUUID(calID))
if err != nil {
if err == pgx.ErrNoRows {
return nil, models.ErrNotFound
}
return nil, models.ErrInternal
}
return &models.Calendar{
ID: utils.FromPgUUID(cal.ID),
Name: cal.Name,
Color: cal.Color,
IsPublic: cal.IsPublic,
ICalURL: s.icalURL(utils.FromPgTextValue(cal.PublicToken)),
Role: role,
CreatedAt: utils.FromPgTimestamptz(cal.CreatedAt),
UpdatedAt: utils.FromPgTimestamptz(cal.UpdatedAt),
}, nil
}
func (s *CalendarService) Update(ctx context.Context, userID uuid.UUID, calID uuid.UUID, name, color *string, isPublic *bool) (*models.Calendar, error) {
role, err := s.getRole(ctx, calID, userID)
if err != nil {
return nil, err
}
if role != "owner" && role != "editor" {
return nil, models.ErrForbidden
}
if isPublic != nil && role != "owner" {
return nil, models.NewForbiddenError("only owner can change is_public")
}
if name != nil {
if err := utils.ValidateCalendarName(*name); err != nil {
return nil, err
}
}
if color != nil {
if err := utils.ValidateColor(*color); err != nil {
return nil, err
}
}
var pgPublic pgtype.Bool
if isPublic != nil {
pgPublic = pgtype.Bool{Bool: *isPublic, Valid: true}
}
cal, err := s.queries.UpdateCalendar(ctx, repository.UpdateCalendarParams{
ID: utils.ToPgUUID(calID),
Name: utils.ToPgTextPtr(name),
Color: utils.ToPgTextPtr(color),
IsPublic: pgPublic,
})
if err != nil {
if err == pgx.ErrNoRows {
return nil, models.ErrNotFound
}
return nil, models.ErrInternal
}
if isPublic != nil {
if *isPublic && !cal.PublicToken.Valid {
token, err := generatePublicToken()
if err != nil {
return nil, models.ErrInternal
}
_ = s.queries.SetCalendarPublicToken(ctx, repository.SetCalendarPublicTokenParams{
ID: utils.ToPgUUID(calID),
PublicToken: pgtype.Text{String: token, Valid: true},
})
cal.PublicToken = pgtype.Text{String: token, Valid: true}
} else if !*isPublic && cal.PublicToken.Valid {
_ = s.queries.SetCalendarPublicToken(ctx, repository.SetCalendarPublicTokenParams{
ID: utils.ToPgUUID(calID),
PublicToken: pgtype.Text{Valid: false},
})
cal.PublicToken = pgtype.Text{Valid: false}
}
}
s.audit.Log(ctx, "calendar", calID, "UPDATE_CALENDAR", userID)
return &models.Calendar{
ID: utils.FromPgUUID(cal.ID),
Name: cal.Name,
Color: cal.Color,
IsPublic: cal.IsPublic,
ICalURL: s.icalURL(utils.FromPgTextValue(cal.PublicToken)),
Role: role,
CreatedAt: utils.FromPgTimestamptz(cal.CreatedAt),
UpdatedAt: utils.FromPgTimestamptz(cal.UpdatedAt),
}, nil
}
func (s *CalendarService) Delete(ctx context.Context, userID uuid.UUID, calID uuid.UUID) error {
role, err := s.getRole(ctx, calID, userID)
if err != nil {
return err
}
if role != "owner" {
return models.ErrForbidden
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return models.ErrInternal
}
defer tx.Rollback(ctx)
qtx := s.queries.WithTx(tx)
pgCalID := utils.ToPgUUID(calID)
if err := qtx.SoftDeleteEventsByCalendar(ctx, pgCalID); err != nil {
return models.ErrInternal
}
if err := qtx.SoftDeleteCalendar(ctx, pgCalID); err != nil {
return models.ErrInternal
}
if err := tx.Commit(ctx); err != nil {
return models.ErrInternal
}
s.audit.Log(ctx, "calendar", calID, "DELETE_CALENDAR", userID)
return nil
}
func (s *CalendarService) Share(ctx context.Context, ownerID uuid.UUID, calID uuid.UUID, targetEmail, role string) error {
ownerRole, err := s.getRole(ctx, calID, ownerID)
if err != nil {
return err
}
if ownerRole != "owner" {
return models.ErrForbidden
}
if role != "editor" && role != "viewer" {
return models.NewValidationError("role must be editor or viewer")
}
targetUser, err := s.queries.GetUserByEmail(ctx, utils.NormalizeEmail(targetEmail))
if err != nil {
if err == pgx.ErrNoRows {
return models.NewNotFoundError("user not found")
}
return models.ErrInternal
}
targetID := utils.FromPgUUID(targetUser.ID)
if targetID == ownerID {
return models.NewValidationError("cannot share with yourself")
}
err = s.queries.UpsertCalendarMember(ctx, repository.UpsertCalendarMemberParams{
CalendarID: utils.ToPgUUID(calID),
UserID: utils.ToPgUUID(targetID),
Role: role,
})
if err != nil {
return models.ErrInternal
}
s.audit.Log(ctx, "calendar", calID, "SHARE_CALENDAR", ownerID)
return nil
}
func (s *CalendarService) ListMembers(ctx context.Context, userID uuid.UUID, calID uuid.UUID) ([]models.CalendarMember, error) {
if _, err := s.getRole(ctx, calID, userID); err != nil {
return nil, err
}
rows, err := s.queries.ListCalendarMembers(ctx, utils.ToPgUUID(calID))
if err != nil {
return nil, models.ErrInternal
}
members := make([]models.CalendarMember, len(rows))
for i, r := range rows {
members[i] = models.CalendarMember{
UserID: utils.FromPgUUID(r.UserID),
Email: r.Email,
Role: r.Role,
}
}
return members, nil
}
func (s *CalendarService) RemoveMember(ctx context.Context, ownerID uuid.UUID, calID uuid.UUID, targetUserID uuid.UUID) error {
role, err := s.getRole(ctx, calID, ownerID)
if err != nil {
return err
}
if role != "owner" {
return models.ErrForbidden
}
targetRole, err := s.getRole(ctx, calID, targetUserID)
if err != nil {
return err
}
if targetRole == "owner" {
return models.NewValidationError("cannot remove owner")
}
err = s.queries.DeleteCalendarMember(ctx, repository.DeleteCalendarMemberParams{
CalendarID: utils.ToPgUUID(calID),
UserID: utils.ToPgUUID(targetUserID),
})
if err != nil {
return models.ErrInternal
}
s.audit.Log(ctx, "calendar", calID, "REMOVE_MEMBER", ownerID)
return nil
}
func (s *CalendarService) GetRole(ctx context.Context, calID uuid.UUID, userID uuid.UUID) (string, error) {
return s.getRole(ctx, calID, userID)
}
func (s *CalendarService) getRole(ctx context.Context, calID uuid.UUID, userID uuid.UUID) (string, error) {
role, err := s.queries.GetCalendarMemberRole(ctx, repository.GetCalendarMemberRoleParams{
CalendarID: utils.ToPgUUID(calID),
UserID: utils.ToPgUUID(userID),
})
if err != nil {
if err == pgx.ErrNoRows {
return "", models.ErrNotFound
}
return "", models.ErrInternal
}
return role, nil
}