first commit
Made-with: Cursor
This commit is contained in:
264
internal/service/booking.go
Normal file
264
internal/service/booking.go
Normal file
@@ -0,0 +1,264 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user