- 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
229 lines
5.9 KiB
Go
229 lines
5.9 KiB
Go
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
|
|
}
|