package service import ( "context" "crypto/rand" "encoding/hex" "encoding/json" "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/pgxpool" ) type BookingService struct { pool *pgxpool.Pool queries *repository.Queries calendar *CalendarService event *EventService } func NewBookingService(pool *pgxpool.Pool, queries *repository.Queries, calendar *CalendarService, event *EventService) *BookingService { return &BookingService{pool: pool, queries: queries, calendar: calendar, event: event} } func (s *BookingService) CreateLink(ctx context.Context, userID uuid.UUID, calID uuid.UUID, config models.BookingConfig) (*models.BookingLink, error) { role, err := s.calendar.GetRole(ctx, calID, userID) if err != nil { return nil, err } if role != "owner" { return nil, models.ErrForbidden } tokenBytes := make([]byte, 16) if _, err := rand.Read(tokenBytes); err != nil { return nil, models.ErrInternal } token := hex.EncodeToString(tokenBytes) whJSON, err := json.Marshal(config.WorkingHours) if err != nil { return nil, models.ErrInternal } _, err = s.queries.CreateBookingLink(ctx, repository.CreateBookingLinkParams{ ID: utils.ToPgUUID(uuid.New()), CalendarID: utils.ToPgUUID(calID), Token: token, DurationMinutes: int32(config.DurationMinutes), BufferMinutes: int32(config.BufferMinutes), Timezone: config.Timezone, WorkingHours: whJSON, Active: config.Active, }) if err != nil { return nil, models.ErrInternal } return &models.BookingLink{ Token: token, PublicURL: fmt.Sprintf("/booking/%s", token), Settings: config, }, nil } func (s *BookingService) GetAvailability(ctx context.Context, token string, rangeStart, rangeEnd time.Time) (*models.BookingAvailability, error) { bl, err := s.queries.GetBookingLinkByToken(ctx, token) if err != nil { if err == pgx.ErrNoRows { return nil, models.ErrNotFound } return nil, models.ErrInternal } if !bl.Active { return nil, models.NewNotFoundError("booking link is not active") } var workingHours map[string][]models.Slot if err := json.Unmarshal(bl.WorkingHours, &workingHours); err != nil { return nil, models.ErrInternal } loc, err := time.LoadLocation(bl.Timezone) if err != nil { loc = time.UTC } calID := utils.FromPgUUID(bl.CalendarID) events, err := s.queries.ListEventsByCalendarInRange(ctx, repository.ListEventsByCalendarInRangeParams{ CalendarID: bl.CalendarID, EndTime: utils.ToPgTimestamptz(rangeStart), StartTime: utils.ToPgTimestamptz(rangeEnd), }) if err != nil { return nil, models.ErrInternal } recurring, err := s.queries.ListRecurringEventsByCalendar(ctx, repository.ListRecurringEventsByCalendarParams{ CalendarID: bl.CalendarID, StartTime: utils.ToPgTimestamptz(rangeEnd), }) if err != nil { return nil, models.ErrInternal } var busyBlocks []models.TimeSlot for _, ev := range events { if ev.RecurrenceRule.Valid { continue } start := utils.FromPgTimestamptz(ev.StartTime) end := utils.FromPgTimestamptz(ev.EndTime) buf := time.Duration(bl.BufferMinutes) * time.Minute busyBlocks = append(busyBlocks, models.TimeSlot{ Start: start.Add(-buf), End: end.Add(buf), }) } for _, ev := range recurring { occs := s.event.expandRecurrence(ev, rangeStart, rangeEnd) for _, occ := range occs { if occ.OccurrenceStartTime != nil { buf := time.Duration(bl.BufferMinutes) * time.Minute busyBlocks = append(busyBlocks, models.TimeSlot{ Start: occ.OccurrenceStartTime.Add(-buf), End: occ.OccurrenceEndTime.Add(buf), }) } } } _ = calID duration := time.Duration(bl.DurationMinutes) * time.Minute dayNames := []string{"sun", "mon", "tue", "wed", "thu", "fri", "sat"} var slots []models.TimeSlot for d := rangeStart; d.Before(rangeEnd); d = d.Add(24 * time.Hour) { localDay := d.In(loc) dayName := dayNames[localDay.Weekday()] windows, ok := workingHours[dayName] if !ok || len(windows) == 0 { continue } for _, w := range windows { wStart, err1 := time.Parse("15:04", w.Start) wEnd, err2 := time.Parse("15:04", w.End) if err1 != nil || err2 != nil { continue } windowStart := time.Date(localDay.Year(), localDay.Month(), localDay.Day(), wStart.Hour(), wStart.Minute(), 0, 0, loc).UTC() windowEnd := time.Date(localDay.Year(), localDay.Month(), localDay.Day(), wEnd.Hour(), wEnd.Minute(), 0, 0, loc).UTC() for slotStart := windowStart; slotStart.Add(duration).Before(windowEnd) || slotStart.Add(duration).Equal(windowEnd); slotStart = slotStart.Add(duration) { slotEnd := slotStart.Add(duration) if !isConflict(slotStart, slotEnd, busyBlocks) { slots = append(slots, models.TimeSlot{Start: slotStart, End: slotEnd}) } } } } sort.Slice(slots, func(i, j int) bool { return slots[i].Start.Before(slots[j].Start) }) if slots == nil { slots = []models.TimeSlot{} } return &models.BookingAvailability{ Token: token, Timezone: bl.Timezone, DurationMinutes: int(bl.DurationMinutes), Slots: slots, }, nil } func (s *BookingService) Reserve(ctx context.Context, token string, name, email string, slotStart, slotEnd time.Time, notes *string) (*models.Event, error) { bl, err := s.queries.GetBookingLinkByToken(ctx, token) if err != nil { if err == pgx.ErrNoRows { return nil, models.ErrNotFound } return nil, models.ErrInternal } if !bl.Active { return nil, models.NewNotFoundError("booking link is not active") } tx, err := s.pool.Begin(ctx) if err != nil { return nil, models.ErrInternal } defer tx.Rollback(ctx) qtx := s.queries.WithTx(tx) overlap, err := qtx.CheckEventOverlapForUpdate(ctx, repository.CheckEventOverlapForUpdateParams{ CalendarID: bl.CalendarID, EndTime: utils.ToPgTimestamptz(slotStart), StartTime: utils.ToPgTimestamptz(slotEnd), }) if err != nil { return nil, models.ErrInternal } if overlap { return nil, models.NewConflictError("slot no longer available") } cal, err := s.queries.GetCalendarByID(ctx, bl.CalendarID) if err != nil { return nil, models.ErrInternal } title := fmt.Sprintf("Booking: %s", name) desc := fmt.Sprintf("Booked by %s (%s)", name, email) if notes != nil && *notes != "" { desc += "\nNotes: " + *notes } eventID := uuid.New() ownerID := utils.FromPgUUID(cal.OwnerID) ev, err := qtx.CreateEvent(ctx, repository.CreateEventParams{ ID: utils.ToPgUUID(eventID), CalendarID: bl.CalendarID, Title: title, Description: utils.ToPgText(desc), StartTime: utils.ToPgTimestamptz(slotStart.UTC()), EndTime: utils.ToPgTimestamptz(slotEnd.UTC()), Timezone: bl.Timezone, Tags: []string{"booking"}, CreatedBy: utils.ToPgUUID(ownerID), UpdatedBy: utils.ToPgUUID(ownerID), }) if err != nil { return nil, models.ErrInternal } if err := tx.Commit(ctx); err != nil { return nil, models.ErrInternal } return eventFromDB(ev, []models.Reminder{}, []models.Attendee{}, []models.Attachment{}), nil } func isConflict(start, end time.Time, busy []models.TimeSlot) bool { for _, b := range busy { if start.Before(b.End) && end.After(b.Start) { return true } } return false }