- OpenAPI: add missing endpoints (add-from-url, subscriptions, public availability) - OpenAPI: CalendarSubscription schema, Subscriptions tag - Frontend app - Migrations: count_for_availability, subscriptions_sync, user_preferences, calendar_settings - Config, rate limit, auth, calendar, booking, ICS, availability, user service updates Made-with: Cursor
584 lines
16 KiB
Go
584 lines
16 KiB
Go
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}
|
|
}
|