package service import ( "context" "crypto/rand" "crypto/sha256" "encoding/base64" "encoding/hex" "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 generatePrivateToken() (string, error) { b := make([]byte, 32) if _, err := rand.Read(b); err != nil { return "", err } hash := sha256.Sum256(b) return hex.EncodeToString(hash[:]), 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) availabilityURL(token string) string { if token == "" { return "" } return fmt.Sprintf("%s/availability/%s", 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 } privateToken, err := generatePrivateToken() if err != nil { return nil, models.ErrInternal } if err := qtx.SetCalendarPublicToken(ctx, repository.SetCalendarPublicTokenParams{ ID: utils.ToPgUUID(calID), PublicToken: pgtype.Text{String: privateToken, Valid: true}, }); 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) defRem := (*int)(nil) if cal.DefaultReminderMinutes.Valid { v := int(cal.DefaultReminderMinutes.Int32) defRem = &v } return &models.Calendar{ ID: utils.FromPgUUID(cal.ID), Name: cal.Name, Color: cal.Color, IsPublic: cal.IsPublic, CountForAvailability: cal.CountForAvailability, DefaultReminderMinutes: defRem, SortOrder: int(cal.SortOrder), ICalURL: s.icalURL(privateToken), AvailabilityURL: s.availabilityURL(privateToken), 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 { defRem := (*int)(nil) if r.DefaultReminderMinutes.Valid { v := int(r.DefaultReminderMinutes.Int32) defRem = &v } calendars[i] = models.Calendar{ ID: utils.FromPgUUID(r.ID), Name: r.Name, Color: r.Color, IsPublic: r.IsPublic, CountForAvailability: r.CountForAvailability, DefaultReminderMinutes: defRem, SortOrder: int(r.SortOrder), Role: r.Role, CreatedAt: utils.FromPgTimestamptz(r.CreatedAt), UpdatedAt: utils.FromPgTimestamptz(r.UpdatedAt), } } // Populate ICalURL and AvailabilityURL (requires separate lookup since ListCalendarsByUser doesn't select public_token) for i := range calendars { 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) calendars[i].AvailabilityURL = s.availabilityURL(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 } // Backfill private token for existing calendars that don't have one if !cal.IsPublic && !cal.PublicToken.Valid { privateToken, err := generatePrivateToken() if err == nil { _ = s.queries.SetCalendarPublicToken(ctx, repository.SetCalendarPublicTokenParams{ ID: utils.ToPgUUID(calID), PublicToken: pgtype.Text{String: privateToken, Valid: true}, }) cal.PublicToken = pgtype.Text{String: privateToken, Valid: true} } } defRem := (*int)(nil) if cal.DefaultReminderMinutes.Valid { v := int(cal.DefaultReminderMinutes.Int32) defRem = &v } return &models.Calendar{ ID: utils.FromPgUUID(cal.ID), Name: cal.Name, Color: cal.Color, IsPublic: cal.IsPublic, CountForAvailability: cal.CountForAvailability, DefaultReminderMinutes: defRem, SortOrder: int(cal.SortOrder), ICalURL: s.icalURL(utils.FromPgTextValue(cal.PublicToken)), AvailabilityURL: s.availabilityURL(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, countForAvailability *bool, defaultReminderMinutes *int, sortOrder *int) (*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} } var pgCountForAvail pgtype.Bool if countForAvailability != nil { pgCountForAvail = pgtype.Bool{Bool: *countForAvailability, Valid: true} } var pgDefRem pgtype.Int4 if defaultReminderMinutes != nil { pgDefRem = pgtype.Int4{Int32: int32(*defaultReminderMinutes), Valid: true} } var pgSort pgtype.Int4 if sortOrder != nil { pgSort = pgtype.Int4{Int32: int32(*sortOrder), Valid: true} } cal, err := s.queries.UpdateCalendar(ctx, repository.UpdateCalendarParams{ ID: utils.ToPgUUID(calID), Name: utils.ToPgTextPtr(name), Color: utils.ToPgTextPtr(color), IsPublic: pgPublic, CountForAvailability: pgCountForAvail, DefaultReminderMinutes: pgDefRem, SortOrder: pgSort, }) if err != nil { if err == pgx.ErrNoRows { return nil, models.ErrNotFound } return nil, models.ErrInternal } if isPublic != nil { if *isPublic { // Switching to public: use base64url token 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 { // Switching to private: use SHA256 token (replace existing token) token, err := generatePrivateToken() 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 !cal.IsPublic && !cal.PublicToken.Valid { // Backfill: private calendar with no token (e.g. from before this feature) privateToken, err := generatePrivateToken() if err == nil { _ = s.queries.SetCalendarPublicToken(ctx, repository.SetCalendarPublicTokenParams{ ID: utils.ToPgUUID(calID), PublicToken: pgtype.Text{String: privateToken, Valid: true}, }) cal.PublicToken = pgtype.Text{String: privateToken, Valid: true} } } s.audit.Log(ctx, "calendar", calID, "UPDATE_CALENDAR", userID) defRem := (*int)(nil) if cal.DefaultReminderMinutes.Valid { v := int(cal.DefaultReminderMinutes.Int32) defRem = &v } return &models.Calendar{ ID: utils.FromPgUUID(cal.ID), Name: cal.Name, Color: cal.Color, IsPublic: cal.IsPublic, CountForAvailability: cal.CountForAvailability, DefaultReminderMinutes: defRem, SortOrder: int(cal.SortOrder), ICalURL: s.icalURL(utils.FromPgTextValue(cal.PublicToken)), AvailabilityURL: s.availabilityURL(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 }