first commit

Made-with: Cursor
This commit is contained in:
Michilis
2026-02-28 02:17:55 +00:00
commit 41f6ae916f
92 changed files with 12332 additions and 0 deletions

View File

@@ -0,0 +1,318 @@
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
}