- 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
277 lines
7.6 KiB
Go
277 lines
7.6 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
|
|
}
|
|
|
|
// Get owner to fetch all calendars that count for availability
|
|
cal, err := s.queries.GetCalendarByID(ctx, bl.CalendarID)
|
|
if err != nil {
|
|
return nil, models.ErrInternal
|
|
}
|
|
ownerID := cal.OwnerID
|
|
|
|
// Include busy blocks from ALL calendars owned by the user that have count_for_availability=true
|
|
availCals, err := s.queries.ListCalendarsByOwnerForAvailability(ctx, ownerID)
|
|
if err != nil {
|
|
return nil, models.ErrInternal
|
|
}
|
|
|
|
var busyBlocks []models.TimeSlot
|
|
buf := time.Duration(bl.BufferMinutes) * time.Minute
|
|
|
|
for _, ac := range availCals {
|
|
events, err := s.queries.ListEventsByCalendarInRange(ctx, repository.ListEventsByCalendarInRangeParams{
|
|
CalendarID: ac.ID,
|
|
EndTime: utils.ToPgTimestamptz(rangeStart),
|
|
StartTime: utils.ToPgTimestamptz(rangeEnd),
|
|
})
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
recurring, err := s.queries.ListRecurringEventsByCalendar(ctx, repository.ListRecurringEventsByCalendarParams{
|
|
CalendarID: ac.ID,
|
|
StartTime: utils.ToPgTimestamptz(rangeEnd),
|
|
})
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
for _, ev := range events {
|
|
if ev.RecurrenceRule.Valid {
|
|
continue
|
|
}
|
|
start := utils.FromPgTimestamptz(ev.StartTime)
|
|
end := utils.FromPgTimestamptz(ev.EndTime)
|
|
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 {
|
|
busyBlocks = append(busyBlocks, models.TimeSlot{
|
|
Start: occ.OccurrenceStartTime.Add(-buf),
|
|
End: occ.OccurrenceEndTime.Add(buf),
|
|
})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
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
|
|
}
|