package service import ( "context" "sort" "strings" "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" ) type AvailabilityService struct { queries *repository.Queries calendar *CalendarService event *EventService } func NewAvailabilityService(queries *repository.Queries, calendar *CalendarService, event *EventService) *AvailabilityService { return &AvailabilityService{queries: queries, calendar: calendar, event: event} } func (s *AvailabilityService) GetBusyBlocks(ctx context.Context, userID uuid.UUID, calendarID uuid.UUID, rangeStart, rangeEnd time.Time) (*models.AvailabilityResponse, error) { if _, err := s.calendar.GetRole(ctx, calendarID, userID); err != nil { return nil, err } pgCalID := utils.ToPgUUID(calendarID) events, err := s.queries.ListEventsByCalendarInRange(ctx, repository.ListEventsByCalendarInRangeParams{ CalendarID: pgCalID, EndTime: utils.ToPgTimestamptz(rangeStart), StartTime: utils.ToPgTimestamptz(rangeEnd), }) if err != nil { return nil, models.ErrInternal } recurring, err := s.queries.ListRecurringEventsByCalendar(ctx, repository.ListRecurringEventsByCalendarParams{ CalendarID: pgCalID, StartTime: utils.ToPgTimestamptz(rangeEnd), }) if err != nil { return nil, models.ErrInternal } var busy []models.BusyBlock for _, ev := range events { if ev.RecurrenceRule.Valid { continue } busy = append(busy, models.BusyBlock{ Start: utils.FromPgTimestamptz(ev.StartTime), End: utils.FromPgTimestamptz(ev.EndTime), EventID: utils.FromPgUUID(ev.ID), }) } for _, ev := range recurring { occs := s.event.expandRecurrence(ev, rangeStart, rangeEnd) for _, occ := range occs { if occ.OccurrenceStartTime != nil { busy = append(busy, models.BusyBlock{ Start: *occ.OccurrenceStartTime, End: *occ.OccurrenceEndTime, EventID: occ.ID, }) } } } sort.Slice(busy, func(i, j int) bool { return busy[i].Start.Before(busy[j].Start) }) if busy == nil { busy = []models.BusyBlock{} } return &models.AvailabilityResponse{ CalendarID: calendarID, RangeStart: rangeStart, RangeEnd: rangeEnd, Busy: busy, }, nil } // getBusyBlocksForCalendar func (s *AvailabilityService) getBusyBlocksForCalendar(ctx context.Context, calID uuid.UUID, rangeStart, rangeEnd time.Time) ([]models.BusyBlock, error) { pgCalID := utils.ToPgUUID(calID) events, err := s.queries.ListEventsByCalendarInRange(ctx, repository.ListEventsByCalendarInRangeParams{ CalendarID: pgCalID, EndTime: utils.ToPgTimestamptz(rangeStart), StartTime: utils.ToPgTimestamptz(rangeEnd), }) if err != nil { return nil, err } recurring, err := s.queries.ListRecurringEventsByCalendar(ctx, repository.ListRecurringEventsByCalendarParams{ CalendarID: pgCalID, StartTime: utils.ToPgTimestamptz(rangeEnd), }) if err != nil { return nil, err } var busy []models.BusyBlock for _, ev := range events { if ev.RecurrenceRule.Valid { continue } busy = append(busy, models.BusyBlock{ Start: utils.FromPgTimestamptz(ev.StartTime), End: utils.FromPgTimestamptz(ev.EndTime), EventID: utils.FromPgUUID(ev.ID), }) } for _, ev := range recurring { occs := s.event.expandRecurrence(ev, rangeStart, rangeEnd) for _, occ := range occs { if occ.OccurrenceStartTime != nil { busy = append(busy, models.BusyBlock{ Start: *occ.OccurrenceStartTime, End: *occ.OccurrenceEndTime, EventID: occ.ID, }) } } } return busy, nil } // GetBusyBlocksByTokenPublic returns busy blocks for a calendar by its public token. No auth required. func (s *AvailabilityService) GetBusyBlocksByTokenPublic(ctx context.Context, token string, rangeStart, rangeEnd time.Time) (*models.AvailabilityResponse, error) { if token == "" { return nil, models.ErrNotFound } cal, err := s.queries.GetCalendarByToken(ctx, pgtype.Text{String: token, Valid: true}) if err != nil { if err == pgx.ErrNoRows { return nil, models.ErrNotFound } return nil, models.ErrInternal } calID := utils.FromPgUUID(cal.ID) busy, err := s.getBusyBlocksForCalendar(ctx, calID, rangeStart, rangeEnd) if err != nil { return nil, models.ErrInternal } sort.Slice(busy, func(i, j int) bool { return busy[i].Start.Before(busy[j].Start) }) if busy == nil { busy = []models.BusyBlock{} } return &models.AvailabilityResponse{ CalendarID: calID, RangeStart: rangeStart, RangeEnd: rangeEnd, Busy: busy, }, nil } // GetBusyBlocksAggregate returns merged busy blocks across multiple calendars by their tokens. No auth required. func (s *AvailabilityService) GetBusyBlocksAggregate(ctx context.Context, tokens []string, rangeStart, rangeEnd time.Time) (*models.AvailabilityResponse, error) { if len(tokens) == 0 { return &models.AvailabilityResponse{ RangeStart: rangeStart, RangeEnd: rangeEnd, Busy: []models.BusyBlock{}, }, nil } seen := make(map[uuid.UUID]bool) var allBusy []models.BusyBlock for _, token := range tokens { token = strings.TrimSpace(token) if token == "" { continue } cal, err := s.queries.GetCalendarByToken(ctx, pgtype.Text{String: token, Valid: true}) if err != nil { if err == pgx.ErrNoRows { continue } return nil, models.ErrInternal } calID := utils.FromPgUUID(cal.ID) if seen[calID] { continue } seen[calID] = true busy, err := s.getBusyBlocksForCalendar(ctx, calID, rangeStart, rangeEnd) if err != nil { return nil, models.ErrInternal } allBusy = append(allBusy, busy...) } sort.Slice(allBusy, func(i, j int) bool { return allBusy[i].Start.Before(allBusy[j].Start) }) if allBusy == nil { allBusy = []models.BusyBlock{} } return &models.AvailabilityResponse{ RangeStart: rangeStart, RangeEnd: rangeEnd, Busy: allBusy, }, nil }