Files
CalendarApi/internal/service/booking.go
Michilis 41f6ae916f first commit
Made-with: Cursor
2026-02-28 02:17:55 +00:00

265 lines
7.3 KiB
Go

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
}