package service import ( "context" "fmt" "sort" "time" "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" "github.com/teambition/rrule-go" ) type EventService struct { pool *pgxpool.Pool queries *repository.Queries calendar *CalendarService audit *AuditService scheduler ReminderScheduler } type ReminderScheduler interface { ScheduleReminder(ctx context.Context, eventID, reminderID, userID uuid.UUID, triggerAt time.Time) error } func NewEventService(pool *pgxpool.Pool, queries *repository.Queries, calendar *CalendarService, audit *AuditService, scheduler ReminderScheduler) *EventService { return &EventService{pool: pool, queries: queries, calendar: calendar, audit: audit, scheduler: scheduler} } func (s *EventService) Create(ctx context.Context, userID uuid.UUID, req CreateEventRequest) (*models.Event, error) { if err := utils.ValidateEventTitle(req.Title); err != nil { return nil, err } if req.Timezone == "" { return nil, models.NewValidationError("timezone is required") } if err := utils.ValidateTimezone(req.Timezone); err != nil { return nil, err } if err := utils.ValidateTimeRange(req.StartTime, req.EndTime); err != nil { return nil, err } role, err := s.calendar.GetRole(ctx, req.CalendarID, userID) if err != nil { return nil, err } if role != "owner" && role != "editor" { return nil, models.ErrForbidden } startUTC := req.StartTime.UTC() endUTC := req.EndTime.UTC() if req.RecurrenceRule != nil && *req.RecurrenceRule != "" { if _, err := rrule.StrToRRule(*req.RecurrenceRule); err != nil { return nil, models.NewValidationError("invalid recurrence_rule: " + err.Error()) } } tx, err := s.pool.Begin(ctx) if err != nil { return nil, models.ErrInternal } defer tx.Rollback(ctx) qtx := s.queries.WithTx(tx) eventID := uuid.New() tags := req.Tags if tags == nil { tags = []string{} } dbEvent, err := qtx.CreateEvent(ctx, repository.CreateEventParams{ ID: utils.ToPgUUID(eventID), CalendarID: utils.ToPgUUID(req.CalendarID), Title: req.Title, Description: utils.ToPgTextPtr(req.Description), Location: utils.ToPgTextPtr(req.Location), StartTime: utils.ToPgTimestamptz(startUTC), EndTime: utils.ToPgTimestamptz(endUTC), Timezone: req.Timezone, AllDay: req.AllDay, RecurrenceRule: utils.ToPgTextPtr(req.RecurrenceRule), Tags: tags, CreatedBy: utils.ToPgUUID(userID), UpdatedBy: utils.ToPgUUID(userID), }) if err != nil { return nil, models.ErrInternal } var reminders []models.Reminder for _, mins := range req.Reminders { if err := utils.ValidateReminderMinutes(mins); err != nil { return nil, err } rID := uuid.New() r, err := qtx.CreateReminder(ctx, repository.CreateReminderParams{ ID: utils.ToPgUUID(rID), EventID: utils.ToPgUUID(eventID), MinutesBefore: mins, }) if err != nil { return nil, models.ErrInternal } reminders = append(reminders, models.Reminder{ ID: utils.FromPgUUID(r.ID), MinutesBefore: r.MinutesBefore, }) } if err := tx.Commit(ctx); err != nil { return nil, models.ErrInternal } for _, rem := range reminders { triggerAt := startUTC.Add(-time.Duration(rem.MinutesBefore) * time.Minute) if triggerAt.After(time.Now()) && s.scheduler != nil { _ = s.scheduler.ScheduleReminder(ctx, eventID, rem.ID, userID, triggerAt) } } s.audit.Log(ctx, "event", eventID, "CREATE_EVENT", userID) if reminders == nil { reminders = []models.Reminder{} } return eventFromDB(dbEvent, reminders, []models.Attendee{}, []models.Attachment{}), nil } func (s *EventService) Get(ctx context.Context, userID uuid.UUID, eventID uuid.UUID) (*models.Event, error) { ev, err := s.queries.GetEventByID(ctx, utils.ToPgUUID(eventID)) if err != nil { if err == pgx.ErrNoRows { return nil, models.ErrNotFound } return nil, models.ErrInternal } calID := utils.FromPgUUID(ev.CalendarID) if _, err := s.calendar.GetRole(ctx, calID, userID); err != nil { return nil, err } reminders, err := s.loadReminders(ctx, eventID) if err != nil { return nil, err } attendees, err := s.loadAttendees(ctx, eventID) if err != nil { return nil, err } attachments, err := s.loadAttachments(ctx, eventID) if err != nil { return nil, err } return eventFromDB(ev, reminders, attendees, attachments), nil } func (s *EventService) List(ctx context.Context, userID uuid.UUID, params ListEventParams) ([]models.Event, *string, error) { if err := utils.ValidateRecurrenceRangeLimit(params.RangeStart, params.RangeEnd); err != nil { return nil, nil, err } limit := utils.ClampLimit(params.Limit) var cursorTime pgtype.Timestamptz var cursorID pgtype.UUID if params.Cursor != "" { ct, cid, err := utils.ParseCursor(params.Cursor) if err != nil { return nil, nil, models.NewValidationError("invalid cursor") } cursorTime = utils.ToPgTimestamptzPtr(ct) cursorID = utils.ToPgUUID(*cid) } nonRecurring, err := s.queries.ListEventsInRange(ctx, repository.ListEventsInRangeParams{ UserID: utils.ToPgUUID(userID), RangeEnd: utils.ToPgTimestamptz(params.RangeEnd), RangeStart: utils.ToPgTimestamptz(params.RangeStart), CalendarID: optionalPgUUID(params.CalendarID), Search: utils.ToPgTextPtr(params.Search), Tag: utils.ToPgTextPtr(params.Tag), CursorTime: cursorTime, CursorID: cursorID, Lim: limit + 1, }) if err != nil { return nil, nil, models.ErrInternal } recurring, err := s.queries.ListRecurringEventsInRange(ctx, repository.ListRecurringEventsInRangeParams{ UserID: utils.ToPgUUID(userID), RangeEnd: utils.ToPgTimestamptz(params.RangeEnd), CalendarID: optionalPgUUID(params.CalendarID), }) if err != nil { return nil, nil, models.ErrInternal } var allEvents []models.Event recurringIDs := make(map[uuid.UUID]bool) for _, ev := range recurring { recurringIDs[utils.FromPgUUID(ev.ID)] = true occurrences := s.expandRecurrence(ev, params.RangeStart, params.RangeEnd) allEvents = append(allEvents, occurrences...) } for _, ev := range nonRecurring { eid := utils.FromPgUUID(ev.ID) if recurringIDs[eid] { continue } allEvents = append(allEvents, *eventFromDB(ev, nil, nil, nil)) } sort.Slice(allEvents, func(i, j int) bool { si := effectiveStart(allEvents[i]) sj := effectiveStart(allEvents[j]) if si.Equal(sj) { return allEvents[i].ID.String() < allEvents[j].ID.String() } return si.Before(sj) }) hasMore := int32(len(allEvents)) > limit if hasMore { allEvents = allEvents[:limit] } relatedLoaded := make(map[uuid.UUID]bool) remindersMap := make(map[uuid.UUID][]models.Reminder) attendeesMap := make(map[uuid.UUID][]models.Attendee) attachmentsMap := make(map[uuid.UUID][]models.Attachment) for _, ev := range allEvents { if relatedLoaded[ev.ID] { continue } relatedLoaded[ev.ID] = true remindersMap[ev.ID], _ = s.loadReminders(ctx, ev.ID) attendeesMap[ev.ID], _ = s.loadAttendees(ctx, ev.ID) attachmentsMap[ev.ID], _ = s.loadAttachments(ctx, ev.ID) } for i := range allEvents { allEvents[i].Reminders = remindersMap[allEvents[i].ID] allEvents[i].Attendees = attendeesMap[allEvents[i].ID] allEvents[i].Attachments = attachmentsMap[allEvents[i].ID] } if hasMore { last := allEvents[len(allEvents)-1] cursor := utils.EncodeCursor(effectiveStart(last), last.ID) return allEvents, &cursor, nil } return allEvents, nil, nil } func (s *EventService) Update(ctx context.Context, userID uuid.UUID, eventID uuid.UUID, req UpdateEventRequest) (*models.Event, error) { ev, err := s.queries.GetEventByID(ctx, utils.ToPgUUID(eventID)) if err != nil { if err == pgx.ErrNoRows { return nil, models.ErrNotFound } return nil, models.ErrInternal } calID := utils.FromPgUUID(ev.CalendarID) role, err := s.calendar.GetRole(ctx, calID, userID) if err != nil { return nil, err } if role != "owner" && role != "editor" { return nil, models.ErrForbidden } if req.Title != nil { if err := utils.ValidateEventTitle(*req.Title); err != nil { return nil, err } } if req.Timezone != nil { if err := utils.ValidateTimezone(*req.Timezone); err != nil { return nil, err } } startTime := utils.FromPgTimestamptz(ev.StartTime) endTime := utils.FromPgTimestamptz(ev.EndTime) if req.StartTime != nil { startTime = req.StartTime.UTC() } if req.EndTime != nil { endTime = req.EndTime.UTC() } if err := utils.ValidateTimeRange(startTime, endTime); err != nil { return nil, err } if req.RecurrenceRule != nil && *req.RecurrenceRule != "" { if _, err := rrule.StrToRRule(*req.RecurrenceRule); err != nil { return nil, models.NewValidationError("invalid recurrence_rule: " + err.Error()) } } var pgStart, pgEnd pgtype.Timestamptz if req.StartTime != nil { pgStart = utils.ToPgTimestamptz(startTime) } if req.EndTime != nil { pgEnd = utils.ToPgTimestamptz(endTime) } updated, err := s.queries.UpdateEvent(ctx, repository.UpdateEventParams{ ID: utils.ToPgUUID(eventID), Title: utils.ToPgTextPtr(req.Title), Description: utils.ToPgTextPtr(req.Description), Location: utils.ToPgTextPtr(req.Location), StartTime: pgStart, EndTime: pgEnd, Timezone: utils.ToPgTextPtr(req.Timezone), AllDay: optionalPgBool(req.AllDay), RecurrenceRule: utils.ToPgTextPtr(req.RecurrenceRule), Tags: req.Tags, UpdatedBy: utils.ToPgUUID(userID), }) if err != nil { if err == pgx.ErrNoRows { return nil, models.ErrNotFound } return nil, models.ErrInternal } s.audit.Log(ctx, "event", eventID, "UPDATE_EVENT", userID) reminders, _ := s.loadReminders(ctx, eventID) attendees, _ := s.loadAttendees(ctx, eventID) attachments, _ := s.loadAttachments(ctx, eventID) return eventFromDB(updated, reminders, attendees, attachments), nil } func (s *EventService) Delete(ctx context.Context, userID uuid.UUID, eventID uuid.UUID) error { ev, err := s.queries.GetEventByID(ctx, utils.ToPgUUID(eventID)) if err != nil { if err == pgx.ErrNoRows { return models.ErrNotFound } return models.ErrInternal } calID := utils.FromPgUUID(ev.CalendarID) role, err := s.calendar.GetRole(ctx, calID, userID) if err != nil { return err } if role != "owner" && role != "editor" { return models.ErrForbidden } if err := s.queries.SoftDeleteEvent(ctx, utils.ToPgUUID(eventID)); err != nil { return models.ErrInternal } s.audit.Log(ctx, "event", eventID, "DELETE_EVENT", userID) return nil } func (s *EventService) expandRecurrence(ev repository.Event, rangeStart, rangeEnd time.Time) []models.Event { if !ev.RecurrenceRule.Valid { return nil } ruleStr := ev.RecurrenceRule.String dtStart := utils.FromPgTimestamptz(ev.StartTime) duration := utils.FromPgTimestamptz(ev.EndTime).Sub(dtStart) fullRule := fmt.Sprintf("DTSTART:%s\nRRULE:%s", dtStart.UTC().Format("20060102T150405Z"), ruleStr) rule, err := rrule.StrToRRuleSet(fullRule) if err != nil { return nil } exceptions, _ := s.queries.ListExceptionsByEvent(context.Background(), ev.ID) exceptionDates := make(map[string]bool) for _, ex := range exceptions { if ex.ExceptionDate.Valid { exceptionDates[ex.ExceptionDate.Time.Format("2006-01-02")] = true } } occurrences := rule.Between(rangeStart.UTC(), rangeEnd.UTC(), true) var results []models.Event for _, occ := range occurrences { dateKey := occ.Format("2006-01-02") if exceptionDates[dateKey] { continue } occEnd := occ.Add(duration) occStart := occ results = append(results, models.Event{ ID: utils.FromPgUUID(ev.ID), CalendarID: utils.FromPgUUID(ev.CalendarID), Title: ev.Title, Description: utils.FromPgText(ev.Description), Location: utils.FromPgText(ev.Location), StartTime: occStart, EndTime: occEnd, Timezone: ev.Timezone, AllDay: ev.AllDay, RecurrenceRule: utils.FromPgText(ev.RecurrenceRule), IsOccurrence: true, OccurrenceStartTime: &occStart, OccurrenceEndTime: &occEnd, CreatedBy: utils.FromPgUUID(ev.CreatedBy), UpdatedBy: utils.FromPgUUID(ev.UpdatedBy), CreatedAt: utils.FromPgTimestamptz(ev.CreatedAt), UpdatedAt: utils.FromPgTimestamptz(ev.UpdatedAt), Tags: ev.Tags, Reminders: []models.Reminder{}, Attendees: []models.Attendee{}, Attachments: []models.Attachment{}, }) } return results } func (s *EventService) loadReminders(ctx context.Context, eventID uuid.UUID) ([]models.Reminder, error) { rows, err := s.queries.ListRemindersByEvent(ctx, utils.ToPgUUID(eventID)) if err != nil { return nil, models.ErrInternal } result := make([]models.Reminder, len(rows)) for i, r := range rows { result[i] = models.Reminder{ ID: utils.FromPgUUID(r.ID), MinutesBefore: r.MinutesBefore, } } return result, nil } func (s *EventService) loadAttendees(ctx context.Context, eventID uuid.UUID) ([]models.Attendee, error) { rows, err := s.queries.ListAttendeesByEvent(ctx, utils.ToPgUUID(eventID)) if err != nil { return nil, models.ErrInternal } result := make([]models.Attendee, len(rows)) for i, a := range rows { var uid *uuid.UUID if a.UserID.Valid { u := utils.FromPgUUID(a.UserID) uid = &u } result[i] = models.Attendee{ ID: utils.FromPgUUID(a.ID), UserID: uid, Email: utils.FromPgText(a.Email), Status: a.Status, } } return result, nil } func (s *EventService) loadAttachments(ctx context.Context, eventID uuid.UUID) ([]models.Attachment, error) { rows, err := s.queries.ListAttachmentsByEvent(ctx, utils.ToPgUUID(eventID)) if err != nil { return nil, models.ErrInternal } result := make([]models.Attachment, len(rows)) for i, a := range rows { result[i] = models.Attachment{ ID: utils.FromPgUUID(a.ID), FileURL: a.FileUrl, } } return result, nil } func eventFromDB(ev repository.Event, reminders []models.Reminder, attendees []models.Attendee, attachments []models.Attachment) *models.Event { if reminders == nil { reminders = []models.Reminder{} } if attendees == nil { attendees = []models.Attendee{} } if attachments == nil { attachments = []models.Attachment{} } tags := ev.Tags if tags == nil { tags = []string{} } return &models.Event{ ID: utils.FromPgUUID(ev.ID), CalendarID: utils.FromPgUUID(ev.CalendarID), Title: ev.Title, Description: utils.FromPgText(ev.Description), Location: utils.FromPgText(ev.Location), StartTime: utils.FromPgTimestamptz(ev.StartTime), EndTime: utils.FromPgTimestamptz(ev.EndTime), Timezone: ev.Timezone, AllDay: ev.AllDay, RecurrenceRule: utils.FromPgText(ev.RecurrenceRule), CreatedBy: utils.FromPgUUID(ev.CreatedBy), UpdatedBy: utils.FromPgUUID(ev.UpdatedBy), CreatedAt: utils.FromPgTimestamptz(ev.CreatedAt), UpdatedAt: utils.FromPgTimestamptz(ev.UpdatedAt), Reminders: reminders, Attendees: attendees, Attachments: attachments, Tags: tags, } } type CreateEventRequest struct { CalendarID uuid.UUID Title string Description *string Location *string StartTime time.Time EndTime time.Time Timezone string AllDay bool RecurrenceRule *string Reminders []int32 Tags []string } type UpdateEventRequest struct { Title *string Description *string Location *string StartTime *time.Time EndTime *time.Time Timezone *string AllDay *bool RecurrenceRule *string Tags []string } type ListEventParams struct { RangeStart time.Time RangeEnd time.Time CalendarID *uuid.UUID Search *string Tag *string Limit int Cursor string } func effectiveStart(e models.Event) time.Time { if e.OccurrenceStartTime != nil { return *e.OccurrenceStartTime } return e.StartTime } func optionalPgUUID(id *uuid.UUID) pgtype.UUID { if id == nil { return pgtype.UUID{Valid: false} } return utils.ToPgUUID(*id) } func optionalPgBool(b *bool) pgtype.Bool { if b == nil { return pgtype.Bool{Valid: false} } return pgtype.Bool{Bool: *b, Valid: true} }