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

583
internal/service/event.go Normal file
View File

@@ -0,0 +1,583 @@
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: dtStart,
EndTime: dtStart.Add(duration),
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}
}