package service import ( "context" "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 } func NewCalendarService(pool *pgxpool.Pool, queries *repository.Queries, audit *AuditService) *CalendarService { return &CalendarService{pool: pool, queries: queries, audit: audit} } 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, 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), } } 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, 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 } 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, 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 }