booking: held → confirmed → cancelled as conditional updates, with mail as a queue the drain owns
Every transition is an UPDATE naming the state — and, for a reschedule, the revision — it expects, checking RowsAffected. Zero rows is somebody moved it first; the caller re-reads and renders, never retries. A hold takes the process mutex, checks the caps and the slot for the RESPONSE, then in one transaction takes SQLite's write lock with a no-op write, re-runs the strict busy read behind it, and only then inserts the tentative event and the row. The mutex orders booking requests against each other; the lock orders them against an ordinary CreateEvent by the host, which a test commits between the two checks and watches get caught. The token is 32 random bytes, hashed for lookup and sealed for the mail the queue may have to resend. The page's title and description are snapshotted onto the booking. Mail is a queue table, one row per (booking, kind, recipient, revision), claimed with a random identity and a lease and acknowledged on that identity. The reminder queue gets the same identity — a whole-second claimed_at is not one — and a retry_at separate from its semantic due_at, because writing a retry time into due_at moved the meeting. A claim re-reads the row it claimed and builds its payload from that, so a reschedule landing between candidate and claim cannot send the old time under a valid claim. Mark and advance are one transaction; a reschedule rebuilds a queue row for every reminder RULE inside its own transaction, because a fired reminder has no row. The host's form keeps its authority over what the booker never sees. A booked event's start, end, tz, all_day, kind and rrule belong to the booking flow: UpdateEvent compares the submission with the stored row inside its transaction and refuses any difference, so a stale form re-submitting a time from before a public reschedule fails. Cancelling or deleting a booked event cancels the booking by the host and owes the booker one mail; a held booking on a deleted event just expires.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
24 files changed,
+2211
−229
internal/calendar/handlers_jobs.go+20 −13internal/calendar/mail_bounded.go+14 −0internal/calendar/migrations/0006_alter_reminder_queues.sql+4 −0internal/calendar/migrations/schema.sql+1 −1internal/calendar/models_event.go+18 −2internal/calendar/store.go+15 −0internal/calendar/store_attendee.go+9 −2internal/calendar/store_booking_cancel.go+154 −0internal/calendar/store_booking_confirm.go+159 −0internal/calendar/store_booking_flow.go+257 −0internal/calendar/store_booking_mail.go+157 −0internal/calendar/store_booking_read.go+159 −0internal/calendar/store_booking_sweep.go+173 −0internal/calendar/store_drain.go+121 −69internal/calendar/store_event.go+0 −95internal/calendar/store_event_write.go+152 −0internal/calendar/store_range.go+13 −9internal/calendar/store_reminder.go+33 −30internal/calendar/store_reminder_next.go+29 −7internal/calendar/store_slots.go+10 −1internal/calendar/testing.go+6 −0internal/calendartest/booking_flow_test.go+283 −0internal/calendartest/booking_host_test.go+186 −0internal/calendartest/booking_mail_test.go+238 −0
diff --git a/internal/calendar/handlers_jobs.go b/internal/calendar/handlers_jobs.go| index 77f7873..3f42c14 100644 |
| --- a/internal/calendar/handlers_jobs.go |
| +++ b/internal/calendar/handlers_jobs.go |
| @@ -2,6 +2,7 @@ package calendar |
| import ( |
| "context" |
| + "errors" |
| "fmt" |
| "net/http" |
| "time" |
| @@ -74,24 +75,28 @@ func (h *handlers) drain(ctx context.Context, now time.Time) (int, error) { |
| seen[due.Queue.OccurrenceKey] = true |
| if err := h.sendReminder(ctx, due, now); err != nil { |
| + if errors.Is(err, ErrMailBusy) { |
| + // Nothing was tried: the sender is full. Back in the |
| + // queue in thirty seconds, attempts untouched. |
| + if err := h.store.DeferReminder(ctx, due.Queue.ID, due.ClaimID, now.Unix()); err != nil { |
| + return sent, err |
| + } |
| + continue |
| + } |
| // Leave it claimed; the lease expires and a later pass |
| // retries. Recording the attempt is what stops a |
| // permanently undeliverable address being retried forever. |
| h.logger.Error("calendar: send reminder", "stage", "reminders.send", |
| "err", scrubErr(err), "attempts", due.Queue.Attempts) |
| - if err := h.store.FailReminder(ctx, due.Queue.ID, due.StartUTC); err != nil { |
| + if err := h.store.FailReminder(ctx, due.Queue.ID, due.ClaimID, due.StartUTC); err != nil { |
| return sent, err |
| } |
| continue |
| } |
| - if err := h.store.MarkReminderSent(ctx, due.Queue.ID, now.Unix()); err != nil { |
| - return sent, err |
| - } |
| - // Queue the occurrence AFTER the one just sent. Not "the next |
| - // occurrence from now": a reminder fires before its event |
| - // starts, so that search returns the one just sent and the |
| - // drain re-sends it until its deadline. |
| - if err := h.store.RescheduleAfterFiring(ctx, due.Queue.ReminderID, due.StartUTC); err != nil { |
| + // Mark and advance in one transaction, conditional on the |
| + // claim this worker holds: a reschedule that overtook it finds |
| + // this a no-op, which is the whole point of the claim id. |
| + if err := h.store.CompleteReminder(ctx, due, now.Unix()); err != nil { |
| return sent, err |
| } |
| sent++ |
| @@ -167,14 +172,16 @@ func (h *handlers) DrainOnRequest(ctx context.Context) { |
| } |
| now := time.Now() |
| if err := h.sendReminder(ctx, due, now); err != nil { |
| + if errors.Is(err, ErrMailBusy) { |
| + _ = h.store.DeferReminder(ctx, due.Queue.ID, due.ClaimID, now.Unix()) |
| + return |
| + } |
| h.logger.Error("calendar: opportunistic reminder", "stage", "reminders.send", |
| "err", scrubErr(err)) |
| - _ = h.store.FailReminder(ctx, due.Queue.ID, due.StartUTC) |
| + _ = h.store.FailReminder(ctx, due.Queue.ID, due.ClaimID, due.StartUTC) |
| return |
| } |
| - if err := h.store.MarkReminderSent(ctx, due.Queue.ID, now.Unix()); err != nil { |
| + if err := h.store.CompleteReminder(ctx, due, now.Unix()); err != nil { |
| h.logger.Error("calendar: mark reminder sent", "stage", "reminders.mark", "err", scrubErr(err)) |
| - return |
| } |
| - _ = h.store.RescheduleAfterFiring(ctx, due.Queue.ReminderID, due.StartUTC) |
| } |
diff --git a/internal/calendar/mail_bounded.go b/internal/calendar/mail_bounded.go| new file mode 100644 |
| index 0000000..1d60956 |
| --- /dev/null |
| +++ b/internal/calendar/mail_bounded.go |
| @@ -0,0 +1,14 @@ |
| +package calendar |
| + |
| +import "errors" |
| + |
| +// ErrMailBusy is what a send returns when every in-flight slot of the |
| +// bounded sender is taken. No SMTP call was made, so it is a DEFERRAL |
| +// and never an attempt: a row that hits it goes back to the queue with |
| +// its attempts untouched (booking spec B22). |
| +var ErrMailBusy = errors.New("calendar: mail sender busy") |
| + |
| +// ErrMailTimeout is what a send returns when the underlying sender did |
| +// not come back inside the caller's budget. The send may still be in |
| +// progress and may still arrive; at-least-once holds. |
| +var ErrMailTimeout = errors.New("calendar: mail send timed out") |
diff --git a/internal/calendar/migrations/0006_alter_reminder_queues.sql b/internal/calendar/migrations/0006_alter_reminder_queues.sql| new file mode 100644 |
| index 0000000..2c6a254 |
| --- /dev/null |
| +++ b/internal/calendar/migrations/0006_alter_reminder_queues.sql |
| @@ -0,0 +1,4 @@ |
| +ALTER TABLE `reminder_queues` ADD `claim_id` text; |
| + |
| +ALTER TABLE `reminder_queues` ADD `retry_at` integer; |
| + |
diff --git a/internal/calendar/migrations/schema.sql b/internal/calendar/migrations/schema.sql| index 0e4d3ba..c28eb7e 100644 |
| --- a/internal/calendar/migrations/schema.sql |
| +++ b/internal/calendar/migrations/schema.sql |
| @@ -23,7 +23,7 @@ CREATE TABLE `meta` (`key` text,`value` text NOT NULL,PRIMARY KEY (`key`)); |
| CREATE TABLE `people` (`id` integer PRIMARY KEY AUTOINCREMENT,`address` text NOT NULL,`zone` text NOT NULL,`created_at` datetime NOT NULL,`updated_at` datetime NOT NULL, `name_sealed` blob, `week_start` integer NOT NULL, `default_reminder_min` integer NOT NULL, `default_all_day_min` integer NOT NULL, `handle` text); |
| -CREATE TABLE `reminder_queues` (`id` integer PRIMARY KEY AUTOINCREMENT,`reminder_id` integer NOT NULL,`occurrence_key` text NOT NULL,`due_at` integer NOT NULL,`claimed_at` integer,`fired_at` integer,`attempts` integer NOT NULL,`late_by_sec` integer NOT NULL); |
| +CREATE TABLE `reminder_queues` (`id` integer PRIMARY KEY AUTOINCREMENT,`reminder_id` integer NOT NULL,`occurrence_key` text NOT NULL,`due_at` integer NOT NULL,`claimed_at` integer,`fired_at` integer,`attempts` integer NOT NULL,`late_by_sec` integer NOT NULL, `claim_id` text, `retry_at` integer); |
| CREATE TABLE `reminders` (`id` integer PRIMARY KEY AUTOINCREMENT,`event_id` integer NOT NULL,`person_id` integer NOT NULL,`offset_min` integer NOT NULL,`channel` text NOT NULL,`created_at` datetime NOT NULL, `undeliverable_at` integer); |
diff --git a/internal/calendar/models_event.go b/internal/calendar/models_event.go| index 39a1624..b0b49df 100644 |
| --- a/internal/calendar/models_event.go |
| +++ b/internal/calendar/models_event.go |
| @@ -209,8 +209,24 @@ type ReminderQueue struct { |
| // process killed mid-send must not leave a reminder claimed |
| // forever — better twice than never. |
| ClaimedAt *int64 `gorm:"column:claimed_at"` |
| - FiredAt *int64 `gorm:"column:fired_at"` |
| - Attempts int `gorm:"column:attempts;not null"` |
| + |
| + // ClaimID is WHO holds the lease: random per claim, cleared by any |
| + // rewrite of the row. Every completion — fired, failed, deferred, |
| + // advanced — is conditional on it, so a worker whose claim was |
| + // overtaken by a reschedule cannot mark the replacement fired or |
| + // advance it past a time it never announced. claimed_at alone is |
| + // whole seconds and two claims can share one (booking spec B22). |
| + ClaimID *string `gorm:"column:claim_id"` |
| + |
| + // RetryAt is when a DEFERRED row (the sender was busy; no attempt |
| + // was made) becomes eligible again. It is separate from due_at |
| + // because due_at IS the occurrence minus the offset — the meeting |
| + // time is reconstructed from it — and a retry written there moved |
| + // the meeting. |
| + RetryAt *int64 `gorm:"column:retry_at"` |
| + |
| + FiredAt *int64 `gorm:"column:fired_at"` |
| + Attempts int `gorm:"column:attempts;not null"` |
| // LateBySec is the honesty column. An instance that was asleep |
| // fires late, and the Today page says so rather than pretending the |
diff --git a/internal/calendar/store.go b/internal/calendar/store.go| index 616b5f1..397fd97 100644 |
| --- a/internal/calendar/store.go |
| +++ b/internal/calendar/store.go |
| @@ -5,6 +5,7 @@ import ( |
| "errors" |
| "fmt" |
| "log/slog" |
| + "sync" |
| "time" |
| "gorm.io/gorm" |
| @@ -26,6 +27,20 @@ type Store struct { |
| g *gorm.DB |
| seal *seal.Sealer |
| logger *slog.Logger |
| + |
| + // holdMu serialises hold creation and rescheduling (booking spec |
| + // B12). SQLite begins deferred, so two transactions can both read |
| + // "free" before either takes the write lock; one process per |
| + // instance makes a mutex the honest serialisation. It orders |
| + // booking requests against each other — the write lock taken |
| + // inside the transaction orders them against everything else. |
| + holdMu sync.Mutex |
| + |
| + // afterSlotCheckForTest, when set, runs between a hold's |
| + // response-time slot check and its transaction. A test uses it to |
| + // commit an ordinary event over the slot and prove the in- |
| + // transaction re-check catches it. Nil in production. |
| + afterSlotCheckForTest func() |
| } |
| func NewStore(g *gorm.DB, sealer *seal.Sealer, logger *slog.Logger) *Store { |
diff --git a/internal/calendar/store_attendee.go b/internal/calendar/store_attendee.go| index 6b68eac..877f3df 100644 |
| --- a/internal/calendar/store_attendee.go |
| +++ b/internal/calendar/store_attendee.go |
| @@ -3,6 +3,7 @@ package calendar |
| import ( |
| "context" |
| "fmt" |
| + "gorm.io/gorm" |
| "time" |
| "amadan.net/oficina/calendar/internal/calendar/seal" |
| @@ -15,7 +16,13 @@ import ( |
| // current caller comes straight off Store.Event or Store.CreateEvent, |
| // both of which 404 a row that is not yours. |
| func (s *Store) SetAttendees(ctx context.Context, eventID int64, addresses []string) error { |
| - if err := s.g.WithContext(ctx). |
| + return s.setAttendeesOn(s.g.WithContext(ctx), eventID, addresses) |
| +} |
| + |
| +// setAttendeesOn is SetAttendees on a caller's transaction, so a |
| +// booking's confirmation can record the booker in the same commit. |
| +func (s *Store) setAttendeesOn(tx *gorm.DB, eventID int64, addresses []string) error { |
| + if err := tx. |
| Where("event_id = ?", eventID).Delete(&Attendee{}).Error; err != nil { |
| return fmt.Errorf("calendar: clear attendees: %w", err) |
| } |
| @@ -44,7 +51,7 @@ func (s *Store) SetAttendees(ctx context.Context, eventID int64, addresses []str |
| AddressFP: fp, |
| CreatedAt: now, |
| } |
| - if err := s.g.WithContext(ctx).Create(&row).Error; err != nil { |
| + if err := tx.Create(&row).Error; err != nil { |
| return fmt.Errorf("calendar: add attendee: %w", err) |
| } |
| } |
diff --git a/internal/calendar/store_booking_cancel.go b/internal/calendar/store_booking_cancel.go| new file mode 100644 |
| index 0000000..115eed4 |
| --- /dev/null |
| +++ b/internal/calendar/store_booking_cancel.go |
| @@ -0,0 +1,154 @@ |
| +package calendar |
| + |
| +import ( |
| + "context" |
| + "fmt" |
| + "time" |
| + |
| + "gorm.io/gorm" |
| + |
| + "amadan.net/oficina/calendar/internal/calendar/seal" |
| +) |
| + |
| +// CancelBooking is the booker's cancel: confirmed → cancelled, the |
| +// event cancelled with a sequence bump, the reminder rows gone, every |
| +// unsent message forgotten, and the cancelled messages owed to both |
| +// (booking spec §5.6, §5.7). A second press affects zero rows. |
| +func (s *Store) CancelBooking(ctx context.Context, bookingID int64, by string, now time.Time) (changed bool, err error) { |
| + err = s.g.WithContext(ctx).Transaction(func(tx *gorm.DB) error { |
| + var b Booking |
| + if err := tx.First(&b, bookingID).Error; err != nil { |
| + return fmt.Errorf("calendar: read booking: %w", err) |
| + } |
| + n, err := cancelBookingOn(tx, b, by, now) |
| + if err != nil { |
| + return err |
| + } |
| + if n == 0 { |
| + return nil |
| + } |
| + changed = true |
| + if err := tx.Model(&Event{}).Where("id = ? AND deleted_at IS NULL", b.EventID). |
| + Updates(map[string]any{"status": StatusCancelled, "sequence": gorm.Expr("sequence + 1"), "updated_at": now.UTC()}).Error; err != nil { |
| + return fmt.Errorf("calendar: cancel booked event: %w", err) |
| + } |
| + return nil |
| + }) |
| + return changed, err |
| +} |
| + |
| +// cancelBookingOn is the confirmed → cancelled row transition and its |
| +// side effects, on a transaction, without touching the event — the |
| +// caller owns the event (the booker's cancel sets it cancelled; the |
| +// host's own UpdateEvent/DeleteEvent already did). Returns rows moved. |
| +func cancelBookingOn(tx *gorm.DB, b Booking, by string, now time.Time) (int64, error) { |
| + res := tx.Model(&Booking{}). |
| + Where("id = ? AND state = ?", b.ID, BookingConfirmed). |
| + Updates(map[string]any{"state": BookingCancelled, "cancelled_at": now.Unix(), "cancelled_by": by, "updated_at": now.UTC()}) |
| + if res.Error != nil { |
| + return 0, fmt.Errorf("calendar: cancel booking: %w", res.Error) |
| + } |
| + if res.RowsAffected == 0 { |
| + return 0, nil |
| + } |
| + if err := deleteReminderRowsFor(tx, b.EventID); err != nil { |
| + return 0, err |
| + } |
| + if err := deleteUnsentMail(tx, b.ID); err != nil { |
| + return 0, err |
| + } |
| + var e Event |
| + if err := tx.First(&e, b.EventID).Error; err != nil { |
| + return 0, fmt.Errorf("calendar: read cancelled event: %w", err) |
| + } |
| + // The revision the cancellation mail carries is the one the |
| + // caller's event bump will produce. |
| + to := []string{ToBooker} |
| + if by == CancelledByBooker { |
| + to = []string{ToBooker, ToHost} |
| + } |
| + if err := queueBookingMail(tx, b.ID, MailCancelled, to, e.Sequence+1, now); err != nil { |
| + return 0, err |
| + } |
| + return 1, nil |
| +} |
| + |
| +// cancelBookingsForEvent is what the HOST's cancel or delete does to a |
| +// booking (booking spec §5.5): a confirmed booking is cancelled by the |
| +// host and the booker is told; a held one simply expires, and nobody is |
| +// told — there was nothing to tell them yet. |
| +func cancelBookingsForEvent(tx *gorm.DB, eventID int64, now time.Time) error { |
| + b, ok, err := bookingFor(tx, eventID) |
| + if err != nil || !ok { |
| + return err |
| + } |
| + switch b.State { |
| + case BookingConfirmed: |
| + _, err := cancelBookingOn(tx, b, CancelledByHost, now) |
| + return err |
| + case BookingHeld: |
| + if err := tx.Model(&Booking{}).Where("id = ? AND state = ?", b.ID, BookingHeld). |
| + Updates(map[string]any{"state": BookingExpired, "updated_at": now.UTC()}).Error; err != nil { |
| + return fmt.Errorf("calendar: expire held booking: %w", err) |
| + } |
| + if err := deleteReminderRowsFor(tx, eventID); err != nil { |
| + return err |
| + } |
| + return deleteUnsentMail(tx, b.ID) |
| + } |
| + return nil |
| +} |
| + |
| +// openBookerAddress opens just the address, for the attendee row. |
| +func (s *Store) openBookerAddress(b Booking) (string, error) { |
| + addr, err := s.seal.OpenString(seal.PurposeBookerAddress, b.BookerAddressSealed) |
| + if err != nil { |
| + return "", fmt.Errorf("calendar: open booker address (booking %d): %w", b.ID, err) |
| + } |
| + return addr, nil |
| +} |
| + |
| +// bookedEventRules is B18: the host's form may change an event's |
| +// title, notes, location and — within the booking's state — its |
| +// status; everything that schedules it belongs to the booking flow. |
| +// Compared against the STORED row inside UpdateEvent's transaction, so |
| +// a stale form that re-submits a time from before a public reschedule |
| +// is refused, and one that re-submits the current time passes. |
| +func bookedEventRules(existing Event, in EventInput, b Booking) error { |
| + const moved = "This is a booked meeting. Its time is changed by the person who booked it, or by cancelling and letting them rebook." |
| + switch { |
| + case in.StartLocal != existing.StartLocal: |
| + return ValidationError{"start", moved} |
| + case in.EndLocal != existing.EndLocal: |
| + return ValidationError{"end", moved} |
| + case in.TZ != existing.TZ: |
| + return ValidationError{"tz", moved} |
| + case in.AllDay != existing.AllDay: |
| + return ValidationError{"all_day", moved} |
| + case in.Kind != existing.Kind: |
| + return ValidationError{"kind", moved} |
| + case in.RRule != existing.RRule: |
| + return ValidationError{"repeat", "This is a booked meeting; it cannot repeat."} |
| + } |
| + switch b.State { |
| + case BookingHeld: |
| + if in.Status == StatusConfirmed { |
| + return ValidationError{"status", "Only the person who booked this can confirm it."} |
| + } |
| + if in.Status != StatusTentative && in.Status != StatusCancelled { |
| + return ValidationError{"status", "A held booking is tentative until the person who booked it confirms it."} |
| + } |
| + case BookingConfirmed: |
| + if in.Status != StatusConfirmed && in.Status != StatusCancelled { |
| + return ValidationError{"status", "A booked meeting is confirmed or cancelled."} |
| + } |
| + default: // cancelled, expired |
| + if in.Status == StatusConfirmed { |
| + return ValidationError{"status", "This booking was cancelled. Make a new event instead."} |
| + } |
| + if in.Status != StatusCancelled { |
| + return ValidationError{"status", "This booking was cancelled."} |
| + } |
| + } |
| + return nil |
| +} |
diff --git a/internal/calendar/store_booking_confirm.go b/internal/calendar/store_booking_confirm.go| new file mode 100644 |
| index 0000000..9c6296c |
| --- /dev/null |
| +++ b/internal/calendar/store_booking_confirm.go |
| @@ -0,0 +1,159 @@ |
| +package calendar |
| + |
| +import ( |
| + "context" |
| + "fmt" |
| + "time" |
| + |
| + "gorm.io/gorm" |
| +) |
| + |
| +// The confirm and reschedule transitions (booking spec §5.4, §5.6, |
| +// §5.7). Each is a conditional UPDATE naming the state — and, for a |
| +// reschedule, the revision — it expects, checking RowsAffected. Zero |
| +// rows is "somebody moved it first": the caller re-reads and renders, |
| +// never retries. |
| + |
| +// ConfirmBooking moves held → confirmed. Guarded on the hold not having |
| +// lapsed AND the meeting not having started (B15); the second guard is |
| +// read from the event inside the transaction. The attendee row, the |
| +// sequence bump and the owed host mail commit with it; the host's own |
| +// default reminder is created after, as its own act — the booking is |
| +// correct without it. |
| +func (s *Store) ConfirmBooking(ctx context.Context, bookingID int64, now time.Time) (changed bool, err error) { |
| + var b Booking |
| + var e Event |
| + err = s.g.WithContext(ctx).Transaction(func(tx *gorm.DB) error { |
| + if err := tx.First(&b, bookingID).Error; err != nil { |
| + return fmt.Errorf("calendar: read booking: %w", err) |
| + } |
| + if err := tx.First(&e, b.EventID).Error; err != nil { |
| + return fmt.Errorf("calendar: read booked event: %w", err) |
| + } |
| + res := tx.Model(&Booking{}). |
| + Where("id = ? AND state = ? AND hold_expires_at > ? AND ? < ?", bookingID, BookingHeld, now.Unix(), now.Unix(), e.StartUTC). |
| + Updates(map[string]any{"state": BookingConfirmed, "confirmed_at": now.Unix(), "updated_at": now.UTC()}) |
| + if res.Error != nil { |
| + return fmt.Errorf("calendar: confirm booking: %w", res.Error) |
| + } |
| + if res.RowsAffected == 0 { |
| + return nil |
| + } |
| + changed = true |
| + if err := tx.Model(&Event{}).Where("id = ?", e.ID). |
| + Updates(map[string]any{"status": StatusConfirmed, "sequence": gorm.Expr("sequence + 1"), "updated_at": now.UTC()}).Error; err != nil { |
| + return fmt.Errorf("calendar: confirm booked event: %w", err) |
| + } |
| + if err := tx.First(&e, e.ID).Error; err != nil { |
| + return err |
| + } |
| + addr, err := s.openBookerAddress(b) |
| + if err != nil { |
| + return err |
| + } |
| + if err := s.setAttendeesOn(tx, e.ID, []string{addr}); err != nil { |
| + return err |
| + } |
| + return queueBookingMail(tx, bookingID, MailConfirmed, []string{ToHost}, e.Sequence, now) |
| + }) |
| + if err != nil || !changed { |
| + return false, err |
| + } |
| + // The host's default reminder, after commit. Its own function, its |
| + // own failure mode; a booking without a reminder is still a |
| + // booking. |
| + var host Person |
| + if err := s.g.WithContext(ctx).Raw(`SELECT people.* FROM people JOIN booking_pages ON booking_pages.person_id = people.id WHERE booking_pages.id = ?`, b.PageID).Scan(&host).Error; err == nil && host.ID != 0 { |
| + _ = s.EnsureDefaultReminder(ctx, host, e) |
| + } |
| + return true, nil |
| +} |
| + |
| +// RescheduleBooking moves a confirmed booking's event. Inside holdMu, |
| +// with the same write-lock-then-re-check shape as a hold (B12). The |
| +// event UPDATE names the revision the form saw, so a resubmitted form |
| +// affects zero rows (B11, r2 A10). All five time columns move together |
| +// with tz = the host's CURRENT zone, the zone the slot was computed in |
| +// (B21). Every reminder RULE on the event gets its queue row rebuilt in |
| +// the same transaction (B24, B26). |
| +func (s *Store) RescheduleBooking(ctx context.Context, bookingID, startUTC, endUTC int64, rev int, now time.Time) (changed bool, err error) { |
| + s.holdMu.Lock() |
| + defer s.holdMu.Unlock() |
| + |
| + v, err := s.BookingByID(ctx, bookingID, false) |
| + if err != nil { |
| + return false, err |
| + } |
| + if !v.Live() { |
| + return false, nil |
| + } |
| + if v.Reschedules >= MaxReschedules { |
| + return false, ErrOverCap |
| + } |
| + dur := int((v.Event.EndUTC - v.Event.StartUTC) / 60) |
| + if endUTC-startUTC != v.Event.EndUTC-v.Event.StartUTC { |
| + return false, ErrSlotTaken // the form asked for a different length |
| + } |
| + loc, err := Zone(v.Host.Zone) |
| + if err != nil { |
| + return false, err |
| + } |
| + d := time.Unix(startUTC, 0).In(loc) |
| + day := time.Date(d.Year(), d.Month(), d.Day(), 0, 0, 0, 0, loc) |
| + dayEnd := day.AddDate(0, 0, 1) |
| + |
| + slots, err := s.FreeSlots(ctx, v.Page, v.Host, now, day, dayEnd, v.EventID, dur) |
| + if err != nil { |
| + return false, err |
| + } |
| + if !hasSlot(slots, startUTC, endUTC) { |
| + return false, ErrSlotTaken |
| + } |
| + if s.afterSlotCheckForTest != nil { |
| + s.afterSlotCheckForTest() |
| + } |
| + |
| + err = s.g.WithContext(ctx).Transaction(func(tx *gorm.DB) error { |
| + if err := tx.Exec(`UPDATE booking_pages SET updated_at = updated_at WHERE id = ?`, v.PageID).Error; err != nil { |
| + return fmt.Errorf("calendar: take write lock: %w", err) |
| + } |
| + again, err := s.freeSlotsOn(tx, v.Page, v.Host, now, day, dayEnd, v.EventID, dur) |
| + if err != nil { |
| + return err |
| + } |
| + if !hasSlot(again, startUTC, endUTC) { |
| + return ErrSlotTaken |
| + } |
| + res := tx.Model(&Event{}). |
| + Where("id = ? AND sequence = ? AND deleted_at IS NULL AND status = ?", v.EventID, rev, StatusConfirmed). |
| + Updates(map[string]any{ |
| + "start_local": FormatLocal(time.Unix(startUTC, 0).In(loc), false), |
| + "end_local": FormatLocal(time.Unix(endUTC, 0).In(loc), false), |
| + "tz": v.Host.Zone, |
| + "start_utc": startUTC, |
| + "end_utc": endUTC, |
| + "sequence": gorm.Expr("sequence + 1"), |
| + "updated_at": now.UTC(), |
| + }) |
| + if res.Error != nil { |
| + return fmt.Errorf("calendar: move booked event: %w", res.Error) |
| + } |
| + if res.RowsAffected == 0 { |
| + return nil |
| + } |
| + changed = true |
| + if err := tx.Model(&Booking{}). |
| + Where("id = ? AND state = ?", bookingID, BookingConfirmed). |
| + Updates(map[string]any{"reschedules": gorm.Expr("reschedules + 1"), "updated_at": now.UTC()}).Error; err != nil { |
| + return fmt.Errorf("calendar: count reschedule: %w", err) |
| + } |
| + if err := s.rescheduleAllForTx(tx, v.EventID); err != nil { |
| + return err |
| + } |
| + if err := deleteUnsentMail(tx, bookingID, MailRescheduled); err != nil { |
| + return err |
| + } |
| + return queueBookingMail(tx, bookingID, MailRescheduled, []string{ToBooker, ToHost}, rev+1, now) |
| + }) |
| + return changed, err |
| +} |
diff --git a/internal/calendar/store_booking_flow.go b/internal/calendar/store_booking_flow.go| new file mode 100644 |
| index 0000000..7e3819c |
| --- /dev/null |
| +++ b/internal/calendar/store_booking_flow.go |
| @@ -0,0 +1,257 @@ |
| +package calendar |
| + |
| +import ( |
| + "context" |
| + "crypto/rand" |
| + "encoding/hex" |
| + "errors" |
| + "fmt" |
| + "time" |
| + |
| + "gorm.io/gorm" |
| + |
| + "amadan.net/oficina/calendar/internal/calendar/seal" |
| +) |
| + |
| +var ( |
| + // ErrSlotTaken says the slot the booker chose is no longer free — |
| + // somebody else held it, or the host's calendar filled it. |
| + ErrSlotTaken = errors.New("calendar: slot taken") |
| + // ErrOverCap says one of the abuse caps refused (booking spec §7.1). |
| + ErrOverCap = errors.New("calendar: too many open holds") |
| +) |
| + |
| +// HoldInput is what a stranger's form supplies. Never bound from the |
| +// request: the handler validates each field and builds this. |
| +type HoldInput struct { |
| + Page BookingPageView |
| + Host Person |
| + StartUTC, EndUTC int64 |
| + Name, Address string |
| + Note, BookerZone string |
| + IPFP string |
| +} |
| + |
| +// FreeSlots is the host's windows minus everything the host can see, |
| +// for one page, over [from, to). excludeEventID (0 = none) is the |
| +// booking being rescheduled; durationMin (0 = the page's) is the |
| +// EVENT's own length on a reschedule. |
| +// |
| +// It reads through the STRICT range reader and fails closed: a busy |
| +// series that could not be fully expanded is an error here, never a |
| +// free afternoon (booking spec B23). |
| +func (s *Store) FreeSlots(ctx context.Context, page BookingPageView, host Person, now, from, to time.Time, excludeEventID int64, durationMin int) ([]Slot, error) { |
| + return s.freeSlotsOn(s.g.WithContext(ctx), page, host, now, from, to, excludeEventID, durationMin) |
| +} |
| + |
| +// freeSlotsOn is FreeSlots on a caller's transaction, so the hold and |
| +// the reschedule can re-check the slot behind the write lock (B12). |
| +func (s *Store) freeSlotsOn(tx *gorm.DB, page BookingPageView, host Person, now, from, to time.Time, excludeEventID int64, durationMin int) ([]Slot, error) { |
| + skip := map[int64]bool{} |
| + if excludeEventID != 0 { |
| + skip[excludeEventID] = true |
| + } |
| + // A hold that lapsed but has not been swept yet is not busy: its |
| + // slot is free the moment it lapses (spec §4.4). |
| + var lapsed []int64 |
| + if err := tx.Model(&Booking{}). |
| + Where("state = ? AND hold_expires_at <= ?", BookingHeld, now.Unix()). |
| + Pluck("event_id", &lapsed).Error; err != nil { |
| + return nil, fmt.Errorf("calendar: lapsed holds: %w", err) |
| + } |
| + for _, id := range lapsed { |
| + skip[id] = true |
| + } |
| + busy, err := s.busyOn(tx, host, from.Unix(), to.Unix(), skip) |
| + if err != nil { |
| + return nil, err |
| + } |
| + rule := ruleFor(page, host) |
| + if durationMin > 0 { |
| + rule.DurationMin = durationMin |
| + } |
| + return ComputeSlots(rule, busy, now, from, to) |
| +} |
| + |
| +func ruleFor(page BookingPageView, host Person) SlotRule { |
| + return SlotRule{ |
| + DurationMin: page.DurationMin, BufferMin: page.BufferMin, |
| + NoticeMin: page.NoticeMin, HorizonDays: page.HorizonDays, |
| + Zone: host.Zone, Windows: page.Windows, |
| + } |
| +} |
| + |
| +// newToken is the management capability: 32 bytes from crypto/rand as |
| +// 64 hex characters (B14). Distinct from the eight-character page code, |
| +// which is not a secret. |
| +func newToken() string { |
| + b := make([]byte, 32) |
| + if _, err := rand.Read(b); err != nil { |
| + panic("calendar: no randomness for a booking token: " + err.Error()) |
| + } |
| + return hex.EncodeToString(b) |
| +} |
| + |
| +// HoldBooking is the hold transition (booking spec §5.2, §5.7). |
| +// |
| +// Inside holdMu: the caps, then a slot check for the RESPONSE, then one |
| +// transaction that first takes SQLite's write lock with a no-op write, |
| +// re-runs the slot check through the transaction, and only then |
| +// inserts. The mutex orders booking requests against each other; the |
| +// write lock orders them against an ordinary CreateEvent by the host, |
| +// which can land between a read outside the transaction and the |
| +// insert (r7 f2). |
| +// |
| +// Whatever happens to the mail afterwards, the hold stands (B16). |
| +func (s *Store) HoldBooking(ctx context.Context, in HoldInput, now time.Time) (Booking, error) { |
| + s.holdMu.Lock() |
| + defer s.holdMu.Unlock() |
| + |
| + addr := normalizeAddress(in.Address) |
| + addrFP, err := s.seal.Fingerprint(seal.PurposeAttendeeLookup, addr) |
| + if err != nil { |
| + return Booking{}, fmt.Errorf("calendar: fingerprint booker: %w", err) |
| + } |
| + counts, err := s.HoldCountsFor(ctx, in.Page.ID, in.IPFP, addrFP, now) |
| + if err != nil { |
| + return Booking{}, err |
| + } |
| + if counts.overCap() { |
| + return Booking{}, ErrOverCap |
| + } |
| + |
| + loc, err := Zone(in.Host.Zone) |
| + if err != nil { |
| + return Booking{}, err |
| + } |
| + d := time.Unix(in.StartUTC, 0).In(loc) |
| + day := time.Date(d.Year(), d.Month(), d.Day(), 0, 0, 0, 0, loc) |
| + dayEnd := day.AddDate(0, 0, 1) |
| + |
| + slots, err := s.FreeSlots(ctx, in.Page, in.Host, now, day, dayEnd, 0, 0) |
| + if err != nil { |
| + return Booking{}, err |
| + } |
| + if !hasSlot(slots, in.StartUTC, in.EndUTC) { |
| + return Booking{}, ErrSlotTaken |
| + } |
| + if s.afterSlotCheckForTest != nil { |
| + s.afterSlotCheckForTest() |
| + } |
| + |
| + token := newToken() |
| + sealed, err := s.sealBooker(in, token) |
| + if err != nil { |
| + return Booking{}, err |
| + } |
| + |
| + var b Booking |
| + err = s.g.WithContext(ctx).Transaction(func(tx *gorm.DB) error { |
| + // The write lock, before any read this decision depends on. |
| + if err := tx.Exec(`UPDATE booking_pages SET updated_at = updated_at WHERE id = ?`, in.Page.ID).Error; err != nil { |
| + return fmt.Errorf("calendar: take write lock: %w", err) |
| + } |
| + again, err := s.freeSlotsOn(tx, in.Page, in.Host, now, day, dayEnd, 0, 0) |
| + if err != nil { |
| + return err |
| + } |
| + if !hasSlot(again, in.StartUTC, in.EndUTC) { |
| + return ErrSlotTaken |
| + } |
| + |
| + calID, err := s.defaultCalendarOn(tx, in.Host.ID) |
| + if err != nil { |
| + return err |
| + } |
| + e, err := s.buildEvent(in.Host.ID, EventInput{ |
| + CalendarID: calID, |
| + Title: in.Page.Title + " — " + in.Name, |
| + Notes: in.Note, |
| + StartLocal: FormatLocal(time.Unix(in.StartUTC, 0).In(loc), false), |
| + EndLocal: FormatLocal(time.Unix(in.EndUTC, 0).In(loc), false), |
| + TZ: in.Host.Zone, |
| + Kind: KindMeeting, |
| + Status: StatusTentative, |
| + }) |
| + if err != nil { |
| + return err |
| + } |
| + if err := tx.Create(&e).Error; err != nil { |
| + return fmt.Errorf("calendar: create held event: %w", err) |
| + } |
| + |
| + expires := now.Add(HoldTTL).Unix() |
| + if in.StartUTC < expires { |
| + expires = in.StartUTC // B15: never confirmable after it starts |
| + } |
| + ipfp := in.IPFP |
| + b = Booking{ |
| + PageID: in.Page.ID, EventID: e.ID, |
| + TokenHash: HashFeedToken(token), TokenSealed: sealed.token, |
| + State: BookingHeld, |
| + TitleSealed: sealed.title, |
| + DescriptionSealed: sealed.description, |
| + BookerNameSealed: sealed.name, BookerAddressSealed: sealed.address, |
| + BookerAddressFP: addrFP, NoteSealed: sealed.note, |
| + BookerZone: in.BookerZone, |
| + HoldExpiresAt: expires, |
| + IPFP: &ipfp, IPForgetAt: now.Add(IPForgetAfter).Unix(), |
| + CreatedAt: now.UTC(), UpdatedAt: now.UTC(), |
| + } |
| + if err := tx.Create(&b).Error; err != nil { |
| + if isUniqueViolation(err) { |
| + return fmt.Errorf("calendar: token collision: %w", err) |
| + } |
| + return fmt.Errorf("calendar: create booking: %w", err) |
| + } |
| + return queueBookingMail(tx, b.ID, MailHold, []string{ToBooker}, 0, now) |
| + }) |
| + if err != nil { |
| + return Booking{}, err |
| + } |
| + return b, nil |
| +} |
| + |
| +type sealedBooker struct { |
| + token, title, description, name, address, note []byte |
| +} |
| + |
| +func (s *Store) sealBooker(in HoldInput, token string) (sealedBooker, error) { |
| + var out sealedBooker |
| + var err error |
| + seal1 := func(dst *[]byte, purpose, plain, what string) { |
| + if err != nil { |
| + return |
| + } |
| + if *dst, err = s.seal.SealString(purpose, plain); err != nil { |
| + err = fmt.Errorf("calendar: seal booking %s: %w", what, err) |
| + } |
| + } |
| + seal1(&out.token, seal.PurposeBookingToken, token, "token") |
| + seal1(&out.title, seal.PurposeBookingTitle, in.Page.Title, "title") |
| + seal1(&out.description, seal.PurposeBookingDescription, in.Page.Description, "description") |
| + seal1(&out.name, seal.PurposeBookerName, in.Name, "name") |
| + seal1(&out.address, seal.PurposeBookerAddress, normalizeAddress(in.Address), "address") |
| + seal1(&out.note, seal.PurposeBookerNote, in.Note, "note") |
| + return out, err |
| +} |
| + |
| +func hasSlot(slots []Slot, startUTC, endUTC int64) bool { |
| + for _, s := range slots { |
| + if s.StartUTC == startUTC && s.EndUTC == endUTC { |
| + return true |
| + } |
| + } |
| + return false |
| +} |
| + |
| +// defaultCalendarOn is DefaultCalendarID on a transaction. |
| +func (s *Store) defaultCalendarOn(tx *gorm.DB, personID int64) (int64, error) { |
| + var cal Calendar |
| + err := tx.Where("owner_id = ? AND kind = ? AND archived_at IS NULL", personID, "personal"). |
| + Order("id").First(&cal).Error |
| + if err != nil { |
| + return 0, fmt.Errorf("calendar: host has no personal calendar: %w", err) |
| + } |
| + return cal.ID, nil |
| +} |
diff --git a/internal/calendar/store_booking_mail.go b/internal/calendar/store_booking_mail.go| new file mode 100644 |
| index 0000000..0580bba |
| --- /dev/null |
| +++ b/internal/calendar/store_booking_mail.go |
| @@ -0,0 +1,157 @@ |
| +package calendar |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "fmt" |
| + "time" |
| + |
| + "gorm.io/gorm" |
| +) |
| + |
| +// The booking mail queue: one row per (booking, kind, recipient, |
| +// revision), claimed with an identity and a lease, acknowledged |
| +// conditionally on that identity — the reminder queue's own shape |
| +// (booking spec §2.5, B13, B22). No body is stored; it renders at send |
| +// time from the booking's current state. |
| + |
| +// queueBookingMail owes one message to each recipient, inside the |
| +// caller's transaction, due now. The unique index makes a repeat of the |
| +// same (booking, kind, recipient, revision) a no-op rather than a second |
| +// message. |
| +func queueBookingMail(tx *gorm.DB, bookingID int64, kind string, recipients []string, revision int, now time.Time) error { |
| + for _, to := range recipients { |
| + row := BookingMail{ |
| + BookingID: bookingID, Kind: kind, Recipient: to, Revision: revision, |
| + DueAt: now.Unix(), CreatedAt: now.UTC(), |
| + } |
| + if err := tx.Where(BookingMail{BookingID: bookingID, Kind: kind, Recipient: to, Revision: revision}). |
| + FirstOrCreate(&row).Error; err != nil { |
| + return fmt.Errorf("calendar: queue booking mail: %w", err) |
| + } |
| + } |
| + return nil |
| +} |
| + |
| +// deleteUnsentMail forgets every message a booking still owed. Called |
| +// by the transitions that make those messages false — cancel, expire — |
| +// so a booker does not get "confirmed" after "cancelled". A row already |
| +// claimed and handed to the sender cannot be recalled; the spec says so. |
| +func deleteUnsentMail(tx *gorm.DB, bookingID int64, kinds ...string) error { |
| + q := tx.Where("booking_id = ? AND sent_at IS NULL", bookingID) |
| + if len(kinds) > 0 { |
| + q = q.Where("kind IN ?", kinds) |
| + } |
| + if err := q.Delete(&BookingMail{}).Error; err != nil { |
| + return fmt.Errorf("calendar: forget owed mail: %w", err) |
| + } |
| + return nil |
| +} |
| + |
| +// ClaimBookingMail takes the earliest claimable row: one transaction, |
| +// a conditional UPDATE that writes a fresh claim id and advances due_at |
| +// to the lease end, then a re-read of THAT row. For booking mail due_at |
| +// means only "eligible from", so advancing it on claim is what makes a |
| +// crash after a claim harmless — the row comes due again when the lease |
| +// ends, and NextDueAt reads nothing but due_at. |
| +func (s *Store) ClaimBookingMail(ctx context.Context, now int64) (BookingMail, bool, error) { |
| + var cand BookingMail |
| + err := s.g.WithContext(ctx). |
| + Where("sent_at IS NULL AND due_at <= ?", now). |
| + Order("due_at ASC, id ASC"). |
| + First(&cand).Error |
| + if errors.Is(err, gorm.ErrRecordNotFound) { |
| + return BookingMail{}, false, nil |
| + } |
| + if err != nil { |
| + return BookingMail{}, false, fmt.Errorf("calendar: find owed mail: %w", err) |
| + } |
| + claimID := newClaimID() |
| + var row BookingMail |
| + err = s.g.WithContext(ctx).Transaction(func(tx *gorm.DB) error { |
| + res := tx.Model(&BookingMail{}). |
| + Where("id = ? AND sent_at IS NULL AND due_at <= ?", cand.ID, now). |
| + Updates(map[string]any{ |
| + "claimed_at": now, "claim_id": claimID, |
| + "due_at": now + int64(claimLease.Seconds()), |
| + }) |
| + if res.Error != nil { |
| + return fmt.Errorf("calendar: claim booking mail: %w", res.Error) |
| + } |
| + if res.RowsAffected == 0 { |
| + return errClaimLost |
| + } |
| + return tx.First(&row, cand.ID).Error |
| + }) |
| + if errors.Is(err, errClaimLost) { |
| + return BookingMail{}, false, nil |
| + } |
| + if err != nil { |
| + return BookingMail{}, false, err |
| + } |
| + return row, true, nil |
| +} |
| + |
| +// MarkBookingMailSent acknowledges a send, conditional on the claim. |
| +func (s *Store) MarkBookingMailSent(ctx context.Context, id int64, claimID string, now int64) error { |
| + err := s.g.WithContext(ctx).Model(&BookingMail{}). |
| + Where("id = ? AND claim_id = ? AND sent_at IS NULL", id, claimID). |
| + Update("sent_at", now).Error |
| + if err != nil { |
| + return fmt.Errorf("calendar: mark booking mail sent: %w", err) |
| + } |
| + return nil |
| +} |
| + |
| +// MarkBookingMailFailed counts a real send error. due_at already points |
| +// at the lease end, so the retry time needs no writing. At |
| +// MaxReminderAttempts the row is given up on: sent_at = MailGaveUp, and |
| +// the caller logs a stage and a code, never an address. |
| +func (s *Store) MarkBookingMailFailed(ctx context.Context, id int64, claimID string) (gaveUp bool, err error) { |
| + var row BookingMail |
| + if err := s.g.WithContext(ctx).First(&row, id).Error; err != nil { |
| + return false, fmt.Errorf("calendar: read booking mail: %w", err) |
| + } |
| + attempts := row.Attempts + 1 |
| + updates := map[string]any{"attempts": attempts} |
| + if attempts >= MaxReminderAttempts { |
| + updates["sent_at"] = MailGaveUp |
| + gaveUp = true |
| + } |
| + res := s.g.WithContext(ctx).Model(&BookingMail{}). |
| + Where("id = ? AND claim_id = ?", id, claimID).Updates(updates) |
| + if res.Error != nil { |
| + return false, fmt.Errorf("calendar: mark booking mail failed: %w", res.Error) |
| + } |
| + return gaveUp && res.RowsAffected == 1, nil |
| +} |
| + |
| +// DeferBookingMail puts a row back without an attempt: the sender was |
| +// busy and nothing was tried (B22). |
| +func (s *Store) DeferBookingMail(ctx context.Context, id int64, claimID string, now int64) error { |
| + err := s.g.WithContext(ctx).Model(&BookingMail{}). |
| + Where("id = ? AND claim_id = ?", id, claimID). |
| + Updates(map[string]any{"due_at": now + int64(deferFor.Seconds()), "claimed_at": nil, "claim_id": ""}).Error |
| + if err != nil { |
| + return fmt.Errorf("calendar: defer booking mail: %w", err) |
| + } |
| + return nil |
| +} |
| + |
| +// NextBookingMailDue is the earliest owed row's due_at — always a |
| +// claimable instant, because claims advance it. |
| +func (s *Store) NextBookingMailDue(ctx context.Context) time.Time { |
| + var at *int64 |
| + err := s.g.WithContext(ctx).Raw(`SELECT MIN(due_at) FROM booking_mails WHERE sent_at IS NULL`).Scan(&at).Error |
| + if err != nil || at == nil { |
| + return time.Time{} |
| + } |
| + return time.Unix(*at, 0) |
| +} |
| + |
| +// UnsentBookingMailForTest lists what a booking still owes. |
| +func (s *Store) UnsentBookingMailForTest(bookingID int64) []BookingMail { |
| + var rows []BookingMail |
| + _ = s.g.Where("booking_id = ? AND sent_at IS NULL", bookingID).Order("id").Find(&rows).Error |
| + return rows |
| +} |
diff --git a/internal/calendar/store_booking_read.go b/internal/calendar/store_booking_read.go| new file mode 100644 |
| index 0000000..82463a6 |
| --- /dev/null |
| +++ b/internal/calendar/store_booking_read.go |
| @@ -0,0 +1,159 @@ |
| +package calendar |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "fmt" |
| + |
| + "gorm.io/gorm" |
| + |
| + "amadan.net/oficina/calendar/internal/calendar/seal" |
| +) |
| + |
| +// BookingView is a booking with its sealed columns opened and the rows |
| +// around it loaded. Event is the ROW, deliberately not an EventView: |
| +// public code reads its status, times, sequence and deletion, and must |
| +// never reach the host's notes, location or attendees, so those are |
| +// not opened here at all (booking spec §5.5). |
| +type BookingView struct { |
| + Booking |
| + Page BookingPageView |
| + Host Person |
| + HostName string // opened NameSealed, or the handle |
| + Event Event |
| + EventUID string |
| + |
| + // Title and Description are the booking's own snapshot (B20). |
| + Title, Description string |
| + Name, Address, Note string |
| + |
| + // Token is the opened management token: set only by BookingByID |
| + // with withToken, and only to render a mail. |
| + Token string |
| +} |
| + |
| +// BookingByToken is the public reader. A wrong token is ErrNotFound, |
| +// like a wrong feed token — never "expired", never 403. |
| +func (s *Store) BookingByToken(ctx context.Context, token string) (BookingView, error) { |
| + var b Booking |
| + err := s.g.WithContext(ctx).Where("token_hash = ?", HashFeedToken(token)).First(&b).Error |
| + if errors.Is(err, gorm.ErrRecordNotFound) { |
| + return BookingView{}, ErrNotFound |
| + } |
| + if err != nil { |
| + return BookingView{}, fmt.Errorf("calendar: read booking by token: %w", err) |
| + } |
| + return s.openBooking(ctx, b, false) |
| +} |
| + |
| +// BookingByID is the internal reader, for the drain and the handlers |
| +// that already hold a row. withToken opens the management token, which |
| +// only a mail renderer needs. |
| +func (s *Store) BookingByID(ctx context.Context, id int64, withToken bool) (BookingView, error) { |
| + var b Booking |
| + err := s.g.WithContext(ctx).First(&b, id).Error |
| + if errors.Is(err, gorm.ErrRecordNotFound) { |
| + return BookingView{}, ErrNotFound |
| + } |
| + if err != nil { |
| + return BookingView{}, fmt.Errorf("calendar: read booking: %w", err) |
| + } |
| + return s.openBooking(ctx, b, withToken) |
| +} |
| + |
| +func (s *Store) openBooking(ctx context.Context, b Booking, withToken bool) (BookingView, error) { |
| + v := BookingView{Booking: b} |
| + |
| + var page BookingPage |
| + if err := s.g.WithContext(ctx).First(&page, b.PageID).Error; err != nil { |
| + return v, fmt.Errorf("calendar: read booking's page: %w", err) |
| + } |
| + pv, err := s.viewOf(ctx, page) |
| + if err != nil { |
| + return v, err |
| + } |
| + v.Page = pv |
| + |
| + if err := s.g.WithContext(ctx).First(&v.Host, page.PersonID).Error; err != nil { |
| + return v, fmt.Errorf("calendar: read booking's host: %w", err) |
| + } |
| + if name, err := s.seal.OpenString(seal.PurposeMemberName, v.Host.NameSealed); err == nil && name != "" { |
| + v.HostName = name |
| + } else if v.Host.Handle != nil { |
| + v.HostName = *v.Host.Handle |
| + } |
| + |
| + // The event row INCLUDING a soft-deleted one: a host-deleted booking |
| + // still exports its CANCEL (spec §5.8). |
| + if err := s.g.WithContext(ctx).Unscoped().First(&v.Event, b.EventID).Error; err != nil { |
| + return v, fmt.Errorf("calendar: read booking's event: %w", err) |
| + } |
| + if uid, err := s.seal.OpenString(seal.PurposeEventUID, v.Event.UIDSealed); err == nil { |
| + v.EventUID = uid |
| + } |
| + |
| + open := func(purpose string, ct []byte, what string) (string, error) { |
| + out, err := s.seal.OpenString(purpose, ct) |
| + if err != nil { |
| + return "", fmt.Errorf("calendar: open booking %s (id %d): %w", what, b.ID, err) |
| + } |
| + return out, nil |
| + } |
| + if v.Title, err = open(seal.PurposeBookingTitle, b.TitleSealed, "title"); err != nil { |
| + return v, err |
| + } |
| + if v.Description, err = open(seal.PurposeBookingDescription, b.DescriptionSealed, "description"); err != nil { |
| + return v, err |
| + } |
| + if v.Name, err = open(seal.PurposeBookerName, b.BookerNameSealed, "name"); err != nil { |
| + return v, err |
| + } |
| + if v.Address, err = open(seal.PurposeBookerAddress, b.BookerAddressSealed, "address"); err != nil { |
| + return v, err |
| + } |
| + if v.Note, err = open(seal.PurposeBookerNote, b.NoteSealed, "note"); err != nil { |
| + return v, err |
| + } |
| + if withToken { |
| + if v.Token, err = open(seal.PurposeBookingToken, b.TokenSealed, "token"); err != nil { |
| + return v, err |
| + } |
| + } |
| + return v, nil |
| +} |
| + |
| +// bookingFor finds the booking an event belongs to, if any. Used by the |
| +// host's own edits to learn the event is booked (B18). |
| +func bookingFor(tx *gorm.DB, eventID int64) (Booking, bool, error) { |
| + var b Booking |
| + err := tx.Where("event_id = ?", eventID).First(&b).Error |
| + if errors.Is(err, gorm.ErrRecordNotFound) { |
| + return Booking{}, false, nil |
| + } |
| + if err != nil { |
| + return Booking{}, false, fmt.Errorf("calendar: booking for event: %w", err) |
| + } |
| + return b, true, nil |
| +} |
| + |
| +// bookingIDForEvent is bookingFor without the row, for callers that |
| +// only need the fact. |
| +func (s *Store) bookingIDForEvent(ctx context.Context, eventID int64) (int64, bool) { |
| + b, ok, err := bookingFor(s.g.WithContext(ctx), eventID) |
| + if err != nil || !ok { |
| + return 0, false |
| + } |
| + return b.ID, true |
| +} |
| + |
| +// Effective state of a booking, read from the booking AND its event: |
| +// a host who cancelled or deleted the event has cancelled the booking |
| +// whether or not the transition recorded it (spec §5.3). |
| +func (v BookingView) Cancelled() bool { |
| + return v.State == BookingCancelled || v.Event.Status == StatusCancelled || v.Event.DeletedAt != nil |
| +} |
| + |
| +// Live reports a confirmed booking whose event still stands. |
| +func (v BookingView) Live() bool { |
| + return v.State == BookingConfirmed && !v.Cancelled() |
| +} |
diff --git a/internal/calendar/store_booking_sweep.go b/internal/calendar/store_booking_sweep.go| new file mode 100644 |
| index 0000000..946cf41 |
| --- /dev/null |
| +++ b/internal/calendar/store_booking_sweep.go |
| @@ -0,0 +1,173 @@ |
| +package calendar |
| + |
| +import ( |
| + "context" |
| + "fmt" |
| + "time" |
| + |
| + "gorm.io/gorm" |
| +) |
| + |
| +// The sweep's booking half: expiring holds, forgetting IPs, and the two |
| +// NextDueAt sources those imply (booking spec §8). |
| + |
| +// ExpireHolds moves every lapsed hold to expired, soft-deletes its |
| +// tentative event, drops its reminder rows and forgets any mail it was |
| +// owed. Bounded to limit rows a pass; the rest come due on the next |
| +// wake, which NextDueAt already points at. |
| +func (s *Store) ExpireHolds(ctx context.Context, now time.Time, limit int) (int, error) { |
| + var lapsed []Booking |
| + err := s.g.WithContext(ctx). |
| + Where("state = ? AND hold_expires_at <= ?", BookingHeld, now.Unix()). |
| + Order("hold_expires_at ASC").Limit(limit).Find(&lapsed).Error |
| + if err != nil { |
| + return 0, fmt.Errorf("calendar: find lapsed holds: %w", err) |
| + } |
| + n := 0 |
| + for _, b := range lapsed { |
| + err := s.g.WithContext(ctx).Transaction(func(tx *gorm.DB) error { |
| + res := tx.Model(&Booking{}). |
| + Where("id = ? AND state = ? AND hold_expires_at <= ?", b.ID, BookingHeld, now.Unix()). |
| + Updates(map[string]any{"state": BookingExpired, "updated_at": now.UTC()}) |
| + if res.Error != nil { |
| + return fmt.Errorf("calendar: expire hold: %w", res.Error) |
| + } |
| + if res.RowsAffected == 0 { |
| + return nil // confirmed under us; not ours to expire |
| + } |
| + n++ |
| + return retireHeldEvent(tx, b.EventID, b.ID, now) |
| + }) |
| + if err != nil { |
| + return n, err |
| + } |
| + } |
| + return n, nil |
| +} |
| + |
| +// retireHeldEvent is what an expired or abandoned hold leaves behind: |
| +// no event, no reminder rows, no owed mail. |
| +func retireHeldEvent(tx *gorm.DB, eventID, bookingID int64, now time.Time) error { |
| + if err := tx.Model(&Event{}).Where("id = ? AND deleted_at IS NULL", eventID). |
| + Update("deleted_at", now.UTC()).Error; err != nil { |
| + return fmt.Errorf("calendar: retire held event: %w", err) |
| + } |
| + if err := deleteReminderRowsFor(tx, eventID); err != nil { |
| + return err |
| + } |
| + return deleteUnsentMail(tx, bookingID) |
| +} |
| + |
| +// deleteReminderRowsFor drops the queue rows of every reminder on an |
| +// event. The rules stay: a cancelled meeting's reminder preference is |
| +// still the person's, and a queue row is what fires. |
| +func deleteReminderRowsFor(tx *gorm.DB, eventID int64) error { |
| + err := tx.Exec(`DELETE FROM reminder_queues WHERE reminder_id IN (SELECT id FROM reminders WHERE event_id = ?)`, eventID).Error |
| + if err != nil { |
| + return fmt.Errorf("calendar: drop reminder rows: %w", err) |
| + } |
| + return nil |
| +} |
| + |
| +// ForgetOldIPs nulls the IP fingerprint of bookings past ip_forget_at. |
| +// The fingerprint's purpose — the abuse caps — ends with the attempt |
| +// window; a permanent network-linkage column is a leak the estate does |
| +// not otherwise have. |
| +func (s *Store) ForgetOldIPs(ctx context.Context, now time.Time, limit int) (int, error) { |
| + var ids []int64 |
| + err := s.g.WithContext(ctx).Model(&Booking{}). |
| + Where("ip_fp IS NOT NULL AND ip_forget_at <= ?", now.Unix()). |
| + Order("ip_forget_at ASC").Limit(limit).Pluck("id", &ids).Error |
| + if err != nil { |
| + return 0, fmt.Errorf("calendar: find forgettable ips: %w", err) |
| + } |
| + if len(ids) == 0 { |
| + return 0, nil |
| + } |
| + res := s.g.WithContext(ctx).Model(&Booking{}).Where("id IN ?", ids).Update("ip_fp", nil) |
| + if res.Error != nil { |
| + return 0, fmt.Errorf("calendar: forget ips: %w", res.Error) |
| + } |
| + return int(res.RowsAffected), nil |
| +} |
| + |
| +// NextHoldExpiry is the earliest lapse among open holds. |
| +func (s *Store) NextHoldExpiry(ctx context.Context) time.Time { |
| + var at *int64 |
| + err := s.g.WithContext(ctx).Raw(`SELECT MIN(hold_expires_at) FROM bookings WHERE state = ?`, BookingHeld).Scan(&at).Error |
| + if err != nil || at == nil { |
| + return time.Time{} |
| + } |
| + return time.Unix(*at, 0) |
| +} |
| + |
| +// NextIPForget is the earliest fingerprint still to be forgotten. |
| +func (s *Store) NextIPForget(ctx context.Context) time.Time { |
| + var at *int64 |
| + err := s.g.WithContext(ctx).Raw(`SELECT MIN(ip_forget_at) FROM bookings WHERE ip_fp IS NOT NULL`).Scan(&at).Error |
| + if err != nil || at == nil { |
| + return time.Time{} |
| + } |
| + return time.Unix(*at, 0) |
| +} |
| + |
| +// HoldCounts is what the abuse caps compare against (booking spec §7.1). |
| +type HoldCounts struct { |
| + OpenIP, OpenPage, OpenAddress int |
| + AttemptsIP, AttemptsAddress int |
| +} |
| + |
| +// The caps themselves. |
| +const ( |
| + MaxOpenHoldsPerIP = 5 |
| + MaxOpenHoldsPerPage = 20 |
| + MaxOpenHoldsPerAddress = 2 |
| + MaxAttemptsPerIP = 20 |
| + MaxAttemptsPerAddress = 6 |
| + attemptWindow = 24 * time.Hour |
| +) |
| + |
| +// HoldCountsFor reads the five counts for one prospective hold. Every |
| +// state counts toward attempts — that is why a hold whose mail failed |
| +// is a row and not a rollback. |
| +func (s *Store) HoldCountsFor(ctx context.Context, pageID int64, ipFP, addressFP string, now time.Time) (HoldCounts, error) { |
| + var c HoldCounts |
| + open := func(where string, args ...any) (int, error) { |
| + var n int64 |
| + err := s.g.WithContext(ctx).Model(&Booking{}). |
| + Where("state = ? AND hold_expires_at > ?", BookingHeld, now.Unix()). |
| + Where(where, args...).Count(&n).Error |
| + return int(n), err |
| + } |
| + attempts := func(where string, args ...any) (int, error) { |
| + var n int64 |
| + err := s.g.WithContext(ctx).Model(&Booking{}). |
| + Where("created_at > ?", now.Add(-attemptWindow).UTC()). |
| + Where(where, args...).Count(&n).Error |
| + return int(n), err |
| + } |
| + var err error |
| + if c.OpenIP, err = open("ip_fp = ?", ipFP); err != nil { |
| + return c, fmt.Errorf("calendar: count holds: %w", err) |
| + } |
| + if c.OpenPage, err = open("page_id = ?", pageID); err != nil { |
| + return c, fmt.Errorf("calendar: count holds: %w", err) |
| + } |
| + if c.OpenAddress, err = open("booker_address_fp = ?", addressFP); err != nil { |
| + return c, fmt.Errorf("calendar: count holds: %w", err) |
| + } |
| + if c.AttemptsIP, err = attempts("ip_fp = ?", ipFP); err != nil { |
| + return c, fmt.Errorf("calendar: count attempts: %w", err) |
| + } |
| + if c.AttemptsAddress, err = attempts("booker_address_fp = ?", addressFP); err != nil { |
| + return c, fmt.Errorf("calendar: count attempts: %w", err) |
| + } |
| + return c, nil |
| +} |
| + |
| +// overCap reports whether any cap refuses this hold. |
| +func (c HoldCounts) overCap() bool { |
| + return c.OpenIP >= MaxOpenHoldsPerIP || c.OpenPage >= MaxOpenHoldsPerPage || |
| + c.OpenAddress >= MaxOpenHoldsPerAddress || |
| + c.AttemptsIP >= MaxAttemptsPerIP || c.AttemptsAddress >= MaxAttemptsPerAddress |
| +} |
diff --git a/internal/calendar/store_drain.go b/internal/calendar/store_drain.go| index b5d8a64..daf30c5 100644 |
| --- a/internal/calendar/store_drain.go |
| +++ b/internal/calendar/store_drain.go |
| @@ -2,6 +2,8 @@ package calendar |
| import ( |
| "context" |
| + "crypto/rand" |
| + "encoding/hex" |
| "errors" |
| "fmt" |
| "time" |
| @@ -16,6 +18,10 @@ import ( |
| type DueReminder struct { |
| Queue ReminderQueue |
| + // ClaimID is the identity this worker holds on the row. Every |
| + // completion passes it back and is conditional on it. |
| + ClaimID string |
| + |
| EventID int64 |
| Title string |
| Location string |
| @@ -40,21 +46,41 @@ type DueReminder struct { |
| // duplicate message, which is the trade this app makes on purpose. |
| const claimLease = 5 * time.Minute |
| -// ClaimDueReminder takes the earliest unclaimed due reminder and marks |
| -// it claimed, returning everything needed to send it. |
| +// deferFor is how long a row waits after the sender said "busy". Short: |
| +// nothing was tried, and capacity comes back in seconds. |
| +const deferFor = 30 * time.Second |
| + |
| +var errClaimLost = errors.New("calendar: claim lost") |
| + |
| +// newClaimID is sixteen random bytes as hex — an identity, not a secret. |
| +func newClaimID() string { |
| + b := make([]byte, 16) |
| + if _, err := rand.Read(b); err != nil { |
| + panic("calendar: no randomness for a claim id: " + err.Error()) |
| + } |
| + return hex.EncodeToString(b) |
| +} |
| + |
| +// ClaimDueReminder takes the earliest claimable reminder and marks it |
| +// claimed, returning everything needed to send it. |
| // |
| -// The claim is a conditional UPDATE rather than a read followed by a |
| -// write: two drains running at once — a tick and a page view — would |
| -// otherwise both read the same row and both send it. |
| +// The claim is one transaction: a conditional UPDATE writing a fresh |
| +// claim id, then a re-read of the SAME row, and the payload is built |
| +// from the re-read — never from the candidate this worker looked at |
| +// first. A reschedule can land between the candidate read and the |
| +// claim; the claim still succeeds on the rewritten row, and a payload |
| +// built from the candidate would announce the OLD time under a valid |
| +// claim (booking spec B22, r6 f2). |
| func (s *Store) ClaimDueReminder(ctx context.Context, now int64) (DueReminder, bool, error) { |
| cutoff := now - int64(claimLease.Seconds()) |
| - var q ReminderQueue |
| + var cand ReminderQueue |
| err := s.g.WithContext(ctx). |
| Where("fired_at IS NULL AND due_at <= ?", now). |
| - Where("claimed_at IS NULL OR claimed_at < ?", cutoff). |
| + Where("retry_at IS NULL OR retry_at <= ?", now). |
| + Where("claimed_at IS NULL OR claimed_at <= ?", cutoff). |
| Order("due_at ASC"). |
| - First(&q).Error |
| + First(&cand).Error |
| if errors.Is(err, gorm.ErrRecordNotFound) { |
| return DueReminder{}, false, nil |
| } |
| @@ -62,30 +88,41 @@ func (s *Store) ClaimDueReminder(ctx context.Context, now int64) (DueReminder, b |
| return DueReminder{}, false, fmt.Errorf("calendar: find due reminder: %w", err) |
| } |
| - // Claim it, conditional on it still looking the way it did. A |
| - // competing drain that got there first changes claimed_at, and this |
| - // update then affects no rows and this pass moves on. |
| - res := s.g.WithContext(ctx).Model(&ReminderQueue{}). |
| - Where("id = ? AND fired_at IS NULL", q.ID). |
| - Where("claimed_at IS NULL OR claimed_at < ?", cutoff). |
| - Updates(map[string]any{"claimed_at": now}) |
| - if res.Error != nil { |
| - return DueReminder{}, false, fmt.Errorf("calendar: claim reminder: %w", res.Error) |
| - } |
| - if res.RowsAffected == 0 { |
| + claimID := newClaimID() |
| + var q ReminderQueue |
| + err = s.g.WithContext(ctx).Transaction(func(tx *gorm.DB) error { |
| + res := tx.Model(&ReminderQueue{}). |
| + Where("id = ? AND fired_at IS NULL", cand.ID). |
| + Where("retry_at IS NULL OR retry_at <= ?", now). |
| + Where("claimed_at IS NULL OR claimed_at <= ?", cutoff). |
| + Updates(map[string]any{"claimed_at": now, "claim_id": claimID}) |
| + if res.Error != nil { |
| + return fmt.Errorf("calendar: claim reminder: %w", res.Error) |
| + } |
| + if res.RowsAffected == 0 { |
| + return errClaimLost |
| + } |
| + return tx.First(&q, cand.ID).Error |
| + }) |
| + if errors.Is(err, errClaimLost) { |
| return DueReminder{}, false, nil |
| } |
| + if err != nil { |
| + return DueReminder{}, false, err |
| + } |
| var rem Reminder |
| if err := s.g.WithContext(ctx).First(&rem, q.ReminderID).Error; err != nil { |
| return DueReminder{}, false, fmt.Errorf("calendar: read reminder: %w", err) |
| } |
| var e Event |
| - if err := s.g.WithContext(ctx).Where("id = ? AND deleted_at IS NULL", rem.EventID). |
| - First(&e).Error; err != nil { |
| - // The event went away between queueing and firing. Drop the |
| - // row rather than send a reminder for something that no longer |
| - // exists. |
| + err = s.g.WithContext(ctx). |
| + Where("id = ? AND deleted_at IS NULL AND status <> ?", rem.EventID, StatusCancelled). |
| + First(&e).Error |
| + if err != nil { |
| + // The event went away, or was cancelled, between queueing and |
| + // firing. Drop the row rather than send a reminder for |
| + // something that is not happening. |
| _ = s.g.WithContext(ctx).Delete(&ReminderQueue{}, q.ID).Error |
| return DueReminder{}, false, nil |
| } |
| @@ -115,6 +152,7 @@ func (s *Store) ClaimDueReminder(ctx context.Context, now int64) (DueReminder, b |
| return DueReminder{ |
| Queue: q, |
| + ClaimID: claimID, |
| EventID: e.ID, |
| Title: title, |
| Location: loc, |
| @@ -125,26 +163,52 @@ func (s *Store) ClaimDueReminder(ctx context.Context, now int64) (DueReminder, b |
| }, true, nil |
| } |
| -// MarkReminderSent records the fire, and how late it was. |
| +// CompleteReminder records the fire and queues the occurrence AFTER the |
| +// one just sent, in one transaction conditional on the claim. |
| +// |
| +// Conditional, because a reschedule may have rewritten the row while |
| +// this worker was sending: the row now names a different occurrence, |
| +// the claim id is gone, and this worker must neither mark it fired nor |
| +// advance it past a time it never announced. Zero rows means exactly |
| +// that, and the worker stops; its message went out and described the |
| +// old time, which the booking spec states rather than hides. |
| // |
| // LateBySec is written rather than discarded because an instance that |
| // hibernates fires late sometimes, and a product that hides that is a |
| // product whose users conclude reminders are unreliable without ever |
| // being told why. |
| -func (s *Store) MarkReminderSent(ctx context.Context, queueID, now int64) error { |
| - var q ReminderQueue |
| - if err := s.g.WithContext(ctx).First(&q, queueID).Error; err != nil { |
| - return fmt.Errorf("calendar: read queue row: %w", err) |
| - } |
| - late := now - q.DueAt |
| +func (s *Store) CompleteReminder(ctx context.Context, due DueReminder, now int64) error { |
| + late := now - due.Queue.DueAt |
| if late < 0 { |
| late = 0 |
| } |
| + return s.g.WithContext(ctx).Transaction(func(tx *gorm.DB) error { |
| + res := tx.Model(&ReminderQueue{}). |
| + Where("id = ? AND claim_id = ?", due.Queue.ID, due.ClaimID). |
| + Updates(map[string]any{"fired_at": now, "late_by_sec": late}) |
| + if res.Error != nil { |
| + return fmt.Errorf("calendar: mark reminder sent: %w", res.Error) |
| + } |
| + if res.RowsAffected == 0 { |
| + return nil |
| + } |
| + // Queue the occurrence AFTER the one just sent. Not "the next |
| + // occurrence from now": a reminder fires before its event |
| + // starts, so that search returns the one just sent and the |
| + // drain re-sends it until its deadline. |
| + return s.rescheduleAfterTx(tx, due.Queue.ReminderID, due.StartUTC) |
| + }) |
| +} |
| + |
| +// DeferReminder puts a row back without counting an attempt: the sender |
| +// was busy and nothing was tried. retry_at, never due_at — due_at IS |
| +// the meeting time minus the offset. |
| +func (s *Store) DeferReminder(ctx context.Context, queueID int64, claimID string, now int64) error { |
| err := s.g.WithContext(ctx).Model(&ReminderQueue{}). |
| - Where("id = ?", queueID). |
| - Updates(map[string]any{"fired_at": now, "late_by_sec": late}).Error |
| + Where("id = ? AND claim_id = ?", queueID, claimID). |
| + Updates(map[string]any{"retry_at": now + int64(deferFor.Seconds()), "claimed_at": nil, "claim_id": nil}).Error |
| if err != nil { |
| - return fmt.Errorf("calendar: mark reminder sent: %w", err) |
| + return fmt.Errorf("calendar: defer reminder: %w", err) |
| } |
| return nil |
| } |
| @@ -157,29 +221,23 @@ func (s *Store) MarkReminderSent(ctx context.Context, queueID, now int64) error |
| // residency. |
| const MaxReminderAttempts = 5 |
| -// FailReminder records an attempt and gives up after enough of them. |
| +// FailReminder records an attempt and gives up after enough of them, |
| +// conditional on the claim. |
| // |
| // occurrenceStartUTC is the start of the occurrence that just failed, |
| // needed only on the giving-up path so the NEXT one can be queued. |
| // |
| -// Two things here are deliberate and both were bugs: |
| -// |
| // The claim is REFRESHED rather than cleared. Clearing it put the row |
| // straight back at the head of ClaimDueReminder's `ORDER BY due_at |
| // ASC`, so the very next claim in the same pass returned the same row, |
| // the drain's `seen` guard tripped, and the pass returned early — with |
| -// every other due reminder still unsent. One undeliverable address |
| -// therefore held up the whole team's reminders, for up to ~30 hours on |
| -// a 6h tick, and the log line blamed a double claim rather than the |
| -// address. Leaving the lease in place makes the five-minute claim do |
| -// the backoff it was already written to do, and the pass moves on to |
| -// the next person's reminder. |
| +// every other due reminder still unsent. Leaving the lease in place |
| +// makes the five-minute claim do the backoff it was already written to |
| +// do, and the pass moves on to the next person's reminder. |
| // |
| // Giving up RESCHEDULES the rule. See Reminder.UndeliverableAt: the |
| -// marker lives on the rule so the queue row is free to move on. For a |
| -// single event there is no next occurrence and rescheduleAfter deletes |
| -// the row, which is the same end state as before. |
| -func (s *Store) FailReminder(ctx context.Context, queueID, occurrenceStartUTC int64) error { |
| +// marker lives on the rule so the queue row is free to move on. |
| +func (s *Store) FailReminder(ctx context.Context, queueID int64, claimID string, occurrenceStartUTC int64) error { |
| var q ReminderQueue |
| if err := s.g.WithContext(ctx).First(&q, queueID).Error; err != nil { |
| return fmt.Errorf("calendar: read queue row: %w", err) |
| @@ -187,29 +245,23 @@ func (s *Store) FailReminder(ctx context.Context, queueID, occurrenceStartUTC in |
| now := time.Now().Unix() |
| attempts := q.Attempts + 1 |
| - if attempts < MaxReminderAttempts { |
| - err := s.g.WithContext(ctx).Model(&ReminderQueue{}). |
| - Where("id = ?", queueID). |
| - Updates(map[string]any{"attempts": attempts, "claimed_at": now}).Error |
| - if err != nil { |
| - return fmt.Errorf("calendar: record reminder failure: %w", err) |
| + return s.g.WithContext(ctx).Transaction(func(tx *gorm.DB) error { |
| + res := tx.Model(&ReminderQueue{}). |
| + Where("id = ? AND claim_id = ?", queueID, claimID). |
| + Updates(map[string]any{"attempts": attempts, "claimed_at": now}) |
| + if res.Error != nil { |
| + return fmt.Errorf("calendar: record reminder failure: %w", res.Error) |
| } |
| - return nil |
| - } |
| - |
| - // Given up on. Record it against the rule, where it survives the |
| - // queue row being reused, then let the series carry on. |
| - if err := s.g.WithContext(ctx).Model(&Reminder{}). |
| - Where("id = ?", q.ReminderID). |
| - Update("undeliverable_at", now).Error; err != nil { |
| - return fmt.Errorf("calendar: mark reminder undeliverable: %w", err) |
| - } |
| - if err := s.g.WithContext(ctx).Model(&ReminderQueue{}). |
| - Where("id = ?", queueID). |
| - Updates(map[string]any{"attempts": attempts, "claimed_at": now}).Error; err != nil { |
| - return fmt.Errorf("calendar: record reminder failure: %w", err) |
| - } |
| - return s.rescheduleAfter(ctx, q.ReminderID, occurrenceStartUTC) |
| + if res.RowsAffected == 0 || attempts < MaxReminderAttempts { |
| + return nil |
| + } |
| + if err := tx.Model(&Reminder{}). |
| + Where("id = ?", q.ReminderID). |
| + Update("undeliverable_at", now).Error; err != nil { |
| + return fmt.Errorf("calendar: mark reminder undeliverable: %w", err) |
| + } |
| + return s.rescheduleAfterTx(tx, q.ReminderID, occurrenceStartUTC) |
| + }) |
| } |
| // UndeliverableReminders lists the ones that gave up, for the settings |
diff --git a/internal/calendar/store_event.go b/internal/calendar/store_event.go| index b8be0aa..9f583f7 100644 |
| --- a/internal/calendar/store_event.go |
| +++ b/internal/calendar/store_event.go |
| @@ -9,8 +9,6 @@ import ( |
| "fmt" |
| "time" |
| - "gorm.io/gorm" |
| - |
| "amadan.net/oficina/calendar/internal/calendar/seal" |
| ) |
| @@ -165,99 +163,6 @@ func (s *Store) buildEvent(personID int64, in EventInput) (Event, error) { |
| return e, nil |
| } |
| -// UpdateEvent replaces the editable fields of an existing event. |
| -func (s *Store) UpdateEvent(ctx context.Context, personID, eventID int64, in EventInput) error { |
| - existing, err := s.Event(ctx, personID, eventID) |
| - if err != nil { |
| - return err |
| - } |
| - if !existing.Writable() { |
| - return ErrReadOnly |
| - } |
| - if err := ValidateEventInput(&in); err != nil { |
| - return err |
| - } |
| - startUTC, err := ToUTC(in.StartLocal, in.TZ, in.AllDay) |
| - if err != nil { |
| - return err |
| - } |
| - endUTC, err := ToUTC(in.EndLocal, in.TZ, in.AllDay) |
| - if err != nil { |
| - return err |
| - } |
| - title, err := s.seal.SealString(seal.PurposeEventTitle, in.Title) |
| - if err != nil { |
| - return fmt.Errorf("calendar: seal title: %w", err) |
| - } |
| - loc, err := s.seal.SealString(seal.PurposeEventLocation, in.Location) |
| - if err != nil { |
| - return fmt.Errorf("calendar: seal location: %w", err) |
| - } |
| - notes, err := s.seal.SealString(seal.PurposeEventNotes, in.Notes) |
| - if err != nil { |
| - return fmt.Errorf("calendar: seal notes: %w", err) |
| - } |
| - |
| - // An explicit column allowlist, never a struct update: the fields |
| - // absent from this list — calendar_id, uid, created_by, series_id — |
| - // are exactly the ones a hidden form field must not be able to |
| - // move. |
| - updates := map[string]any{ |
| - "title_sealed": title, |
| - "location_sealed": loc, |
| - "notes_sealed": notes, |
| - "all_day": in.AllDay, |
| - "start_local": in.StartLocal, |
| - "end_local": in.EndLocal, |
| - "tz": in.TZ, |
| - "start_utc": startUTC, |
| - "end_utc": endUTC, |
| - "rrule": in.RRule, |
| - "exdates": in.Exdates, |
| - "kind": in.Kind, |
| - "status": in.Status, |
| - // SEQUENCE is what tells a re-importing client that this is a |
| - // newer version of an event it already has. |
| - "sequence": existing.Sequence + 1, |
| - "updated_at": time.Now().UTC(), |
| - } |
| - if until, ok := RuleUntilUTC(in.RRule, in.StartLocal, in.TZ, in.AllDay); ok { |
| - updates["until_utc"] = until |
| - } else { |
| - updates["until_utc"] = nil |
| - } |
| - if err := s.g.WithContext(ctx).Model(&Event{}). |
| - Where("id = ?", eventID).Updates(updates).Error; err != nil { |
| - return fmt.Errorf("calendar: update event: %w", err) |
| - } |
| - return nil |
| -} |
| - |
| -// DeleteEvent soft-deletes an event and, when it is a series master, |
| -// its overrides with it — an override of a series that no longer exists |
| -// is a row nothing can render and nothing can reach. |
| -func (s *Store) DeleteEvent(ctx context.Context, personID, eventID int64) error { |
| - existing, err := s.Event(ctx, personID, eventID) |
| - if err != nil { |
| - return err |
| - } |
| - if !existing.Writable() { |
| - return ErrReadOnly |
| - } |
| - now := time.Now().UTC() |
| - return s.g.WithContext(ctx).Transaction(func(tx *gorm.DB) error { |
| - if err := tx.Model(&Event{}).Where("id = ?", eventID). |
| - Update("deleted_at", now).Error; err != nil { |
| - return fmt.Errorf("calendar: delete event: %w", err) |
| - } |
| - if err := tx.Model(&Event{}).Where("series_id = ?", eventID). |
| - Update("deleted_at", now).Error; err != nil { |
| - return fmt.Errorf("calendar: delete overrides: %w", err) |
| - } |
| - return nil |
| - }) |
| -} |
| - |
| // newUID mints an iCalendar UID. Random rather than derived from the |
| // content, because a UID must survive every edit the content makes. |
| func newUID() string { |
diff --git a/internal/calendar/store_event_write.go b/internal/calendar/store_event_write.go| new file mode 100644 |
| index 0000000..f79bccc |
| --- /dev/null |
| +++ b/internal/calendar/store_event_write.go |
| @@ -0,0 +1,152 @@ |
| +package calendar |
| + |
| +import ( |
| + "context" |
| + "fmt" |
| + "time" |
| + |
| + "gorm.io/gorm" |
| + |
| + "amadan.net/oficina/calendar/internal/calendar/seal" |
| +) |
| + |
| +// The two writes to an existing event, split from store_event.go so |
| +// neither file passes the 300-line cap. Both enforce the booking flow's |
| +// authority over a booked event (booking spec B18). |
| + |
| +// UpdateEvent replaces the editable fields of an existing event. |
| +func (s *Store) UpdateEvent(ctx context.Context, personID, eventID int64, in EventInput) error { |
| + existing, err := s.Event(ctx, personID, eventID) |
| + if err != nil { |
| + return err |
| + } |
| + if !existing.Writable() { |
| + return ErrReadOnly |
| + } |
| + if err := ValidateEventInput(&in); err != nil { |
| + return err |
| + } |
| + // A BOOKED event's scheduling belongs to the booking flow (booking |
| + // spec B18): the comparison against the stored row happens inside |
| + // the transaction below, so a stale form cannot slip a time past it. |
| + return s.g.WithContext(ctx).Transaction(func(tx *gorm.DB) error { |
| + return s.updateEventOn(tx, existing.Event, in, time.Now().UTC()) |
| + }) |
| +} |
| + |
| +func (s *Store) updateEventOn(tx *gorm.DB, existing Event, in EventInput, now time.Time) error { |
| + var stored Event |
| + if err := tx.Where("id = ? AND deleted_at IS NULL", existing.ID).First(&stored).Error; err != nil { |
| + return ErrNotFound |
| + } |
| + booking, booked, err := bookingFor(tx, stored.ID) |
| + if err != nil { |
| + return err |
| + } |
| + if booked { |
| + if err := bookedEventRules(stored, in, booking); err != nil { |
| + return err |
| + } |
| + } |
| + startUTC, err := ToUTC(in.StartLocal, in.TZ, in.AllDay) |
| + if err != nil { |
| + return err |
| + } |
| + endUTC, err := ToUTC(in.EndLocal, in.TZ, in.AllDay) |
| + if err != nil { |
| + return err |
| + } |
| + title, err := s.seal.SealString(seal.PurposeEventTitle, in.Title) |
| + if err != nil { |
| + return fmt.Errorf("calendar: seal title: %w", err) |
| + } |
| + loc, err := s.seal.SealString(seal.PurposeEventLocation, in.Location) |
| + if err != nil { |
| + return fmt.Errorf("calendar: seal location: %w", err) |
| + } |
| + notes, err := s.seal.SealString(seal.PurposeEventNotes, in.Notes) |
| + if err != nil { |
| + return fmt.Errorf("calendar: seal notes: %w", err) |
| + } |
| + |
| + // An explicit column allowlist, never a struct update: the fields |
| + // absent from this list — calendar_id, uid, created_by, series_id — |
| + // are exactly the ones a hidden form field must not be able to |
| + // move. |
| + // |
| + // A note on the sequence: the bump is an expression, not a value |
| + // read earlier. Two writers reading 3 and both writing 4 lose a |
| + // revision the ICS relies on (B11). |
| + updates := map[string]any{ |
| + "title_sealed": title, |
| + "location_sealed": loc, |
| + "notes_sealed": notes, |
| + "all_day": in.AllDay, |
| + "start_local": in.StartLocal, |
| + "end_local": in.EndLocal, |
| + "tz": in.TZ, |
| + "start_utc": startUTC, |
| + "end_utc": endUTC, |
| + "rrule": in.RRule, |
| + "exdates": in.Exdates, |
| + "kind": in.Kind, |
| + "status": in.Status, |
| + // SEQUENCE is what tells a re-importing client that this is a |
| + // newer version of an event it already has. |
| + "sequence": gorm.Expr("sequence + 1"), |
| + "updated_at": now, |
| + } |
| + if until, ok := RuleUntilUTC(in.RRule, in.StartLocal, in.TZ, in.AllDay); ok { |
| + updates["until_utc"] = until |
| + } else { |
| + updates["until_utc"] = nil |
| + } |
| + if err := tx.Model(&Event{}). |
| + Where("id = ?", stored.ID).Updates(updates).Error; err != nil { |
| + return fmt.Errorf("calendar: update event: %w", err) |
| + } |
| + // The host cancelling a booked meeting cancels the booking and |
| + // tells the booker (booking spec §5.5). |
| + if booked && in.Status == StatusCancelled && stored.Status != StatusCancelled { |
| + return cancelBookingsForEvent(tx, stored.ID, now) |
| + } |
| + return nil |
| +} |
| + |
| +// DeleteEvent soft-deletes an event and, when it is a series master, |
| +// its overrides with it — an override of a series that no longer exists |
| +// is a row nothing can render and nothing can reach. |
| +func (s *Store) DeleteEvent(ctx context.Context, personID, eventID int64) error { |
| + existing, err := s.Event(ctx, personID, eventID) |
| + if err != nil { |
| + return err |
| + } |
| + if !existing.Writable() { |
| + return ErrReadOnly |
| + } |
| + now := time.Now().UTC() |
| + return s.g.WithContext(ctx).Transaction(func(tx *gorm.DB) error { |
| + // A booked event's deletion is a cancellation the booker is |
| + // told about, and its CANCEL ICS must outrank the REQUEST, so |
| + // the soft-deleted row takes one more revision (booking spec |
| + // §5.5). |
| + updates := map[string]any{"deleted_at": now} |
| + if _, booked, err := bookingFor(tx, eventID); err != nil { |
| + return err |
| + } else if booked { |
| + updates["sequence"] = gorm.Expr("sequence + 1") |
| + if err := cancelBookingsForEvent(tx, eventID, now); err != nil { |
| + return err |
| + } |
| + } |
| + if err := tx.Model(&Event{}).Where("id = ?", eventID). |
| + Updates(updates).Error; err != nil { |
| + return fmt.Errorf("calendar: delete event: %w", err) |
| + } |
| + if err := tx.Model(&Event{}).Where("series_id = ?", eventID). |
| + Update("deleted_at", now).Error; err != nil { |
| + return fmt.Errorf("calendar: delete overrides: %w", err) |
| + } |
| + return nil |
| + }) |
| +} |
diff --git a/internal/calendar/store_range.go b/internal/calendar/store_range.go| index b214922..e3d4665 100644 |
| --- a/internal/calendar/store_range.go |
| +++ b/internal/calendar/store_range.go |
| @@ -5,6 +5,8 @@ import ( |
| "errors" |
| "fmt" |
| "sort" |
| + |
| + "gorm.io/gorm" |
| ) |
| // eventRow is the join every range query reads: the event plus the two |
| @@ -36,7 +38,7 @@ type eventRow struct { |
| // Skipping pass 2 is the classic vanishing-event bug; skipping the |
| // dedupe is the classic duplicated one. |
| func (s *Store) EventsInRange(ctx context.Context, personID, from, to int64) ([]EventView, error) { |
| - return s.eventsInRange(ctx, personID, from, to, false) |
| + return s.eventsInRange(s.g.WithContext(ctx), personID, from, to, false) |
| } |
| // ErrIncompleteExpansion says a busy series in the window could not be |
| @@ -56,11 +58,13 @@ var ErrIncompleteExpansion = errors.New("calendar: a busy series could not be fu |
| // rejects or ran out of steps (booking spec §4.1, B23). The rule is |
| // still stored verbatim, per ruling 4. |
| func (s *Store) EventsInRangeStrict(ctx context.Context, personID, from, to int64) ([]EventView, error) { |
| - return s.eventsInRange(ctx, personID, from, to, true) |
| + return s.eventsInRange(s.g.WithContext(ctx), personID, from, to, true) |
| } |
| -func (s *Store) eventsInRange(ctx context.Context, personID, from, to int64, strict bool) ([]EventView, error) { |
| - rows, err := s.rangeRows(ctx, personID, from, to) |
| +// eventsInRange runs on any *gorm.DB — the store's own, or a caller's |
| +// transaction — so a hold can re-read busy time behind the write lock. |
| +func (s *Store) eventsInRange(db *gorm.DB, personID, from, to int64, strict bool) ([]EventView, error) { |
| + rows, err := s.rangeRows(db, personID, from, to) |
| if err != nil { |
| return nil, err |
| } |
| @@ -91,7 +95,7 @@ func (s *Store) eventsInRange(ctx context.Context, personID, from, to int64, str |
| } |
| } |
| } |
| - overrides, err := s.overridesFor(ctx, personID, masterIDs) |
| + overrides, err := s.overridesFor(db, personID, masterIDs) |
| if err != nil { |
| return nil, err |
| } |
| @@ -188,9 +192,9 @@ func (s *Store) eventsInRange(ctx context.Context, personID, from, to int64, str |
| // rangeRows reads the candidate events: anything overlapping the window, |
| // plus every series master that could still be producing in it. |
| -func (s *Store) rangeRows(ctx context.Context, personID, from, to int64) ([]eventRow, error) { |
| +func (s *Store) rangeRows(db *gorm.DB, personID, from, to int64) ([]eventRow, error) { |
| var rows []eventRow |
| - err := s.g.WithContext(ctx). |
| + err := db. |
| Model(&Event{}). |
| Select("events.*, grants.role as role, calendars.name_sealed as calendar_name, calendars.hue as calendar_hue"). |
| Joins("JOIN calendars ON calendars.id = events.calendar_id AND calendars.archived_at IS NULL"). |
| @@ -227,13 +231,13 @@ func (s *Store) rangeRows(ctx context.Context, personID, from, to int64) ([]even |
| // overridesFor reads every override of the given series, keyed by |
| // series and ORIGINAL occurrence. |
| -func (s *Store) overridesFor(ctx context.Context, personID int64, seriesIDs []int64) (map[string]eventRow, error) { |
| +func (s *Store) overridesFor(db *gorm.DB, personID int64, seriesIDs []int64) (map[string]eventRow, error) { |
| out := map[string]eventRow{} |
| if len(seriesIDs) == 0 { |
| return out, nil |
| } |
| var rows []eventRow |
| - err := s.g.WithContext(ctx). |
| + err := db. |
| Model(&Event{}). |
| Select("events.*, grants.role as role, calendars.name_sealed as calendar_name, calendars.hue as calendar_hue"). |
| Joins("JOIN calendars ON calendars.id = events.calendar_id"). |
diff --git a/internal/calendar/store_reminder.go b/internal/calendar/store_reminder.go| index a8074fe..6489979 100644 |
| --- a/internal/calendar/store_reminder.go |
| +++ b/internal/calendar/store_reminder.go |
| @@ -99,43 +99,36 @@ func (s *Store) RescheduleReminder(ctx context.Context, reminderID int64) error |
| return s.rescheduleAfter(ctx, reminderID, 0) |
| } |
| -// RescheduleAfterFiring queues the occurrence following the one that |
| -// just fired. |
| -// |
| -// It exists because "the next occurrence" and "the next occurrence |
| -// after the one I just sent" are different questions, and answering the |
| -// first after a fire is a resend loop. |
| -// |
| -// A reminder fires BEFORE its occurrence starts. So at the moment of |
| -// firing, that occurrence is still in the future — and a search for |
| -// "the first occurrence starting after now" returns the one just sent, |
| -// queued with a due time already in the past, immediately claimable |
| -// again. The queue then re-sends the same reminder as fast as the drain |
| -// can loop until its 45-second deadline: a burst of identical emails |
| -// for one meeting, repeated at every wake. |
| -// |
| -// Found by TestARecurringReminderRequeuesItself, which hung rather than |
| -// failed — the drain was busy sending the same reminder for 45 seconds. |
| -func (s *Store) RescheduleAfterFiring(ctx context.Context, reminderID, firedOccurrenceStartUTC int64) error { |
| - return s.rescheduleAfter(ctx, reminderID, firedOccurrenceStartUTC) |
| -} |
| - |
| // rescheduleAfter queues the first occurrence starting strictly after |
| // `after`, or after now when `after` is zero. |
| func (s *Store) rescheduleAfter(ctx context.Context, reminderID, after int64) error { |
| + return s.rescheduleAfterTx(s.g.WithContext(ctx), reminderID, after) |
| +} |
| + |
| +// rescheduleAfterTx is rescheduleAfter on a caller's transaction, so a |
| +// reschedule of a booked meeting can rebuild its reminder rows in the |
| +// SAME transaction as the move (booking spec B24): a crash between the |
| +// two would otherwise leave a row that sends a reminder for a time the |
| +// meeting no longer has. |
| +// |
| +// Any rewrite of a queue row clears its claim and its retry: whoever |
| +// held it was announcing a time that is gone, and must find zero rows |
| +// when they try to complete. |
| +func (s *Store) rescheduleAfterTx(tx *gorm.DB, reminderID, after int64) error { |
| var rem Reminder |
| - if err := s.g.WithContext(ctx).First(&rem, reminderID).Error; err != nil { |
| + if err := tx.First(&rem, reminderID).Error; err != nil { |
| if errors.Is(err, gorm.ErrRecordNotFound) { |
| return nil |
| } |
| return fmt.Errorf("calendar: read reminder: %w", err) |
| } |
| var e Event |
| - if err := s.g.WithContext(ctx). |
| - Where("id = ? AND deleted_at IS NULL", rem.EventID).First(&e).Error; err != nil { |
| + if err := tx.Where("id = ? AND deleted_at IS NULL AND status <> ?", rem.EventID, StatusCancelled). |
| + First(&e).Error; err != nil { |
| if errors.Is(err, gorm.ErrRecordNotFound) { |
| - // The event is gone; so is any reason to warn about it. |
| - return s.g.WithContext(ctx).Where("reminder_id = ?", reminderID).Delete(&ReminderQueue{}).Error |
| + // The event is gone, or cancelled; so is any reason to |
| + // warn about it. |
| + return tx.Where("reminder_id = ?", reminderID).Delete(&ReminderQueue{}).Error |
| } |
| return fmt.Errorf("calendar: read event for reminder: %w", err) |
| } |
| @@ -151,7 +144,7 @@ func (s *Store) rescheduleAfter(ctx context.Context, reminderID, after int64) er |
| // Nothing left to warn about: a finished series, or a single |
| // event already past. Drop the queue row rather than leave a |
| // due time in the past that every drain re-examines. |
| - return s.g.WithContext(ctx).Where("reminder_id = ?", reminderID).Delete(&ReminderQueue{}).Error |
| + return tx.Where("reminder_id = ?", reminderID).Delete(&ReminderQueue{}).Error |
| } |
| q := ReminderQueue{ |
| @@ -161,12 +154,14 @@ func (s *Store) rescheduleAfter(ctx context.Context, reminderID, after int64) er |
| } |
| // Upsert on the unique reminder_id, so rescheduling is idempotent |
| // and two callers cannot leave two pending rows for one rule. |
| - err := s.g.WithContext(ctx). |
| + err := tx. |
| Where(ReminderQueue{ReminderID: reminderID}). |
| Assign(map[string]any{ |
| "occurrence_key": q.OccurrenceKey, |
| "due_at": q.DueAt, |
| "claimed_at": nil, |
| + "claim_id": nil, |
| + "retry_at": nil, |
| "fired_at": nil, |
| "attempts": 0, |
| "late_by_sec": 0, |
| @@ -207,13 +202,21 @@ func (s *Store) ExcludeOccurrence(ctx context.Context, personID, eventID int64, |
| // rescheduleAllFor recomputes every reminder on an event, after the |
| // event's own times or rule changed. |
| func (s *Store) rescheduleAllFor(ctx context.Context, eventID int64) error { |
| + return s.rescheduleAllForTx(s.g.WithContext(ctx), eventID) |
| +} |
| + |
| +// rescheduleAllForTx enumerates the event's reminder RULES — not its |
| +// queue rows, because a reminder that already fired has none — and |
| +// upserts one queue row per rule from the event's current times |
| +// (booking spec B26). |
| +func (s *Store) rescheduleAllForTx(tx *gorm.DB, eventID int64) error { |
| var ids []int64 |
| - if err := s.g.WithContext(ctx).Model(&Reminder{}). |
| + if err := tx.Model(&Reminder{}). |
| Where("event_id = ?", eventID).Pluck("id", &ids).Error; err != nil { |
| return fmt.Errorf("calendar: list reminders to reschedule: %w", err) |
| } |
| for _, id := range ids { |
| - if err := s.RescheduleReminder(ctx, id); err != nil { |
| + if err := s.rescheduleAfterTx(tx, id, 0); err != nil { |
| return err |
| } |
| } |
diff --git a/internal/calendar/store_reminder_next.go b/internal/calendar/store_reminder_next.go| index 8538dc3..b8d6076 100644 |
| --- a/internal/calendar/store_reminder_next.go |
| +++ b/internal/calendar/store_reminder_next.go |
| @@ -52,13 +52,35 @@ func (s *Store) nextOccurrenceAfter(e Event, now int64, offsetMin int) (int64, s |
| // Zero means nothing is scheduled, and the platform hibernates |
| // indefinitely. |
| func (s *Store) NextDueAt(ctx context.Context) time.Time { |
| - var q ReminderQueue |
| - err := s.g.WithContext(ctx). |
| - Where("fired_at IS NULL"). |
| - Order("due_at ASC"). |
| - First(&q).Error |
| - if err != nil { |
| + var due time.Time |
| + consider := func(t time.Time) { |
| + if t.IsZero() { |
| + return |
| + } |
| + if due.IsZero() || t.Before(due) { |
| + due = t |
| + } |
| + } |
| + consider(s.nextClaimableReminder(ctx)) |
| + consider(s.NextHoldExpiry(ctx)) |
| + consider(s.NextBookingMailDue(ctx)) |
| + consider(s.NextIPForget(ctx)) |
| + return due |
| +} |
| + |
| +// nextClaimableReminder is the earliest instant a reminder row may |
| +// actually be CLAIMED: its due time, or its retry time if deferred, or |
| +// the end of its lease if somebody holds it — whichever is latest for |
| +// that row, and then the earliest across rows. Advertising a bare |
| +// due_at woke the instance for a row nobody could take for five |
| +// minutes (booking spec §8, r4 A6). |
| +func (s *Store) nextClaimableReminder(ctx context.Context) time.Time { |
| + var at *int64 |
| + err := s.g.WithContext(ctx).Raw(` |
| + SELECT MIN(MAX(due_at, COALESCE(retry_at, 0), COALESCE(claimed_at + ?, 0))) |
| + FROM reminder_queues WHERE fired_at IS NULL`, int64(claimLease.Seconds())).Scan(&at).Error |
| + if err != nil || at == nil { |
| return time.Time{} |
| } |
| - return time.Unix(q.DueAt, 0) |
| + return time.Unix(*at, 0) |
| } |
diff --git a/internal/calendar/store_slots.go b/internal/calendar/store_slots.go| index d90ff6a..d2904cc 100644 |
| --- a/internal/calendar/store_slots.go |
| +++ b/internal/calendar/store_slots.go |
| @@ -3,6 +3,8 @@ package calendar |
| import ( |
| "context" |
| "time" |
| + |
| + "gorm.io/gorm" |
| ) |
| // BusyFor is everything the host can see (B2) that occupies time, as |
| @@ -19,11 +21,18 @@ import ( |
| // freed. This function does not know about bookings and does not build |
| // that set. |
| func (s *Store) BusyFor(ctx context.Context, host Person, from, to int64, skip map[int64]bool) ([]Busy, error) { |
| + return s.busyOn(s.g.WithContext(ctx), host, from, to, skip) |
| +} |
| + |
| +// busyOn is BusyFor on a caller's transaction. It reads through the |
| +// STRICT range reader: a busy series that could not be fully expanded |
| +// is an error here, never a free afternoon (booking spec B23). |
| +func (s *Store) busyOn(tx *gorm.DB, host Person, from, to int64, skip map[int64]bool) ([]Busy, error) { |
| hostLoc, err := Zone(host.Zone) |
| if err != nil { |
| return nil, err |
| } |
| - events, err := s.EventsInRange(ctx, host.ID, from-86400, to+86400) |
| + events, err := s.eventsInRange(tx, host.ID, from-86400, to+86400, true) |
| if err != nil { |
| return nil, err |
| } |
diff --git a/internal/calendar/testing.go b/internal/calendar/testing.go| index 761f35a..2f5521d 100644 |
| --- a/internal/calendar/testing.go |
| +++ b/internal/calendar/testing.go |
| @@ -199,3 +199,9 @@ func ZoneCacheSizeForTest() int { |
| }) |
| return n |
| } |
| + |
| +// SetAfterSlotCheckForTest installs a callback that runs between a |
| +// hold's response-time slot check and its transaction, so a test can |
| +// commit an event over the slot and prove the in-transaction re-check |
| +// behind the write lock catches it. nil removes it. |
| +func (a *App) SetAfterSlotCheckForTest(f func()) { a.store.afterSlotCheckForTest = f } |
diff --git a/internal/calendartest/booking_flow_test.go b/internal/calendartest/booking_flow_test.go| new file mode 100644 |
| index 0000000..402d708 |
| --- /dev/null |
| +++ b/internal/calendartest/booking_flow_test.go |
| @@ -0,0 +1,283 @@ |
| +package calendartest |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "sync" |
| + "testing" |
| + "time" |
| + |
| + calendar "amadan.net/oficina/calendar/internal/calendar" |
| +) |
| + |
| +// hostWithPage is a host with a handle, a published every-day 9–5 page |
| +// of 30-minute slots, no buffer and no notice. The store's own zone |
| +// for the host is UTC, which keeps the arithmetic legible. |
| +func hostWithPage(t *testing.T) (*calendar.App, calendar.Person, calendar.BookingPageView) { |
| + t.Helper() |
| + app := newApp(t) |
| + host := aHost(t, app, "host@example.com") |
| + st := app.StoreForTest() |
| + ctx := context.Background() |
| + if err := st.SaveHandle(ctx, host.ID, "paul"); err != nil { |
| + t.Fatal(err) |
| + } |
| + ws, problems := calendar.ParseAvailability("every day 9-5") |
| + if len(problems) != 0 { |
| + t.Fatal(problems) |
| + } |
| + p, err := st.CreateBookingPage(ctx, host.ID, calendar.BookingPageInput{ |
| + Title: "Chat", Description: "Half an hour.", Availability: "every day 9-5", |
| + DurationMin: 30, BufferMin: 0, NoticeMin: 0, HorizonDays: 30}, ws) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := st.PublishBookingPage(ctx, host.ID, p.ID, true); err != nil { |
| + t.Fatal(err) |
| + } |
| + page, err := st.BookingPage(ctx, host.ID, p.ID) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return app, host, page |
| +} |
| + |
| +func slotsFor(t *testing.T, app *calendar.App, page calendar.BookingPageView, host calendar.Person, now time.Time, days int) []calendar.Slot { |
| + t.Helper() |
| + slots, err := app.StoreForTest().FreeSlots(context.Background(), page, host, now, now, now.AddDate(0, 0, days), 0, 0) |
| + if err != nil { |
| + t.Fatalf("FreeSlots: %v", err) |
| + } |
| + if len(slots) == 0 { |
| + t.Fatal("no free slots") |
| + } |
| + return slots |
| +} |
| + |
| +func holdInput(page calendar.BookingPageView, host calendar.Person, s calendar.Slot, address, ip string) calendar.HoldInput { |
| + return calendar.HoldInput{Page: page, Host: host, StartUTC: s.StartUTC, EndUTC: s.EndUTC, |
| + Name: "Doc", Address: address, Note: "About the thing", BookerZone: "UTC", IPFP: ip} |
| +} |
| + |
| +func TestAHoldIsATentativeEventTitledForTheBooker(t *testing.T) { |
| + app, host, page := hostWithPage(t) |
| + st := app.StoreForTest() |
| + ctx := context.Background() |
| + now := time.Now() |
| + slot := slotsFor(t, app, page, host, now, 3)[0] |
| + |
| + b, err := st.HoldBooking(ctx, holdInput(page, host, slot, "doc@tito.io", "ip1"), now) |
| + if err != nil { |
| + t.Fatalf("hold: %v", err) |
| + } |
| + ev, err := st.Event(ctx, host.ID, b.EventID) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if ev.Status != calendar.StatusTentative || ev.Title != "Chat — Doc" || ev.Kind != calendar.KindMeeting { |
| + t.Errorf("held event = %q %q %q", ev.Status, ev.Title, ev.Kind) |
| + } |
| + if b.HoldExpiresAt != now.Add(calendar.HoldTTL).Unix() && b.HoldExpiresAt != slot.StartUTC { |
| + t.Errorf("hold expires %d, want min(now+30m, start)", b.HoldExpiresAt) |
| + } |
| + owed := st.UnsentBookingMailForTest(b.ID) |
| + if len(owed) != 1 || owed[0].Kind != calendar.MailHold || owed[0].Recipient != calendar.ToBooker { |
| + t.Errorf("owed mail = %+v, want one hold to the booker", owed) |
| + } |
| + v, err := st.BookingByID(ctx, b.ID, true) |
| + if err != nil || v.Token == "" || v.Title != "Chat" || v.Name != "Doc" || v.Address != "doc@tito.io" { |
| + t.Errorf("opened view = %+v err %v", v, err) |
| + } |
| + if got, err := st.BookingByToken(ctx, v.Token); err != nil || got.ID != b.ID { |
| + t.Errorf("BookingByToken: %v", err) |
| + } |
| + if _, err := st.BookingByToken(ctx, "0000"); !calendar.NotFound(err) { |
| + t.Errorf("wrong token: %v, want ErrNotFound", err) |
| + } |
| + if _, err := app.DBForTest().Exec("PRAGMA wal_checkpoint(TRUNCATE)"); err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, needle := range []string{v.Token, "doc@tito.io", "About the thing", "Doc"} { |
| + if found, where := app.GrepDatabaseForTest(t, needle); found && needle != "Doc" { |
| + t.Errorf("%q is plaintext in %s", needle, where) |
| + } |
| + } |
| + // The same slot is gone for the next stranger. |
| + if _, err := st.HoldBooking(ctx, holdInput(page, host, slot, "other@example.com", "ip2"), now); !errors.Is(err, calendar.ErrSlotTaken) { |
| + t.Errorf("second hold: %v, want ErrSlotTaken", err) |
| + } |
| +} |
| + |
| +func TestTwentyStrangersOneSlotOneWinner(t *testing.T) { |
| + app, host, page := hostWithPage(t) |
| + st := app.StoreForTest() |
| + now := time.Now() |
| + slot := slotsFor(t, app, page, host, now, 3)[0] |
| + var wg sync.WaitGroup |
| + var mu sync.Mutex |
| + won, taken := 0, 0 |
| + for i := 0; i < 20; i++ { |
| + wg.Add(1) |
| + go func(i int) { |
| + defer wg.Done() |
| + _, err := st.HoldBooking(context.Background(), holdInput(page, host, slot, "s@example.com", "ip"+string(rune('a'+i))), now) |
| + mu.Lock() |
| + defer mu.Unlock() |
| + switch { |
| + case err == nil: |
| + won++ |
| + case errors.Is(err, calendar.ErrSlotTaken): |
| + taken++ |
| + default: |
| + t.Errorf("hold %d: %v", i, err) |
| + } |
| + }(i) |
| + } |
| + wg.Wait() |
| + if won != 1 || taken != 19 { |
| + t.Errorf("won %d taken %d, want 1 and 19", won, taken) |
| + } |
| +} |
| + |
| +// An ordinary CreateEvent by the host between the response-time slot |
| +// check and the transaction: the in-transaction re-check behind the |
| +// write lock catches it (B12, r7 f2). |
| +func TestAHostEventLandingMidHoldIsCaught(t *testing.T) { |
| + app, host, page := hostWithPage(t) |
| + st := app.StoreForTest() |
| + ctx := context.Background() |
| + now := time.Now() |
| + slot := slotsFor(t, app, page, host, now, 3)[0] |
| + calID, _ := st.DefaultCalendarID(ctx, host.ID) |
| + app.SetAfterSlotCheckForTest(func() { |
| + _, err := st.CreateEvent(ctx, host.ID, calendar.EventInput{CalendarID: calID, Title: "Squatter", |
| + StartLocal: time.Unix(slot.StartUTC, 0).UTC().Format("2006-01-02T15:04"), |
| + EndLocal: time.Unix(slot.EndUTC, 0).UTC().Format("2006-01-02T15:04"), |
| + TZ: "UTC", Kind: "meeting", Status: "confirmed"}) |
| + if err != nil { |
| + t.Error(err) |
| + } |
| + app.SetAfterSlotCheckForTest(nil) |
| + }) |
| + _, err := st.HoldBooking(ctx, holdInput(page, host, slot, "doc@tito.io", "ip"), now) |
| + if !errors.Is(err, calendar.ErrSlotTaken) { |
| + t.Fatalf("hold over a squatter: %v, want ErrSlotTaken", err) |
| + } |
| + var n int64 |
| + if err := app.DBForTest().QueryRow(`SELECT COUNT(*) FROM bookings`).Scan(&n); err != nil || n != 0 { |
| + t.Errorf("bookings = %d, want none", n) |
| + } |
| +} |
| + |
| +func TestConfirmIsAConditionalUpdateWithGuards(t *testing.T) { |
| + app, host, page := hostWithPage(t) |
| + st := app.StoreForTest() |
| + ctx := context.Background() |
| + now := time.Now() |
| + slot := slotsFor(t, app, page, host, now, 3)[0] |
| + b, _ := st.HoldBooking(ctx, holdInput(page, host, slot, "doc@tito.io", "ip"), now) |
| + |
| + changed, err := st.ConfirmBooking(ctx, b.ID, now) |
| + if err != nil || !changed { |
| + t.Fatalf("confirm: changed=%v err=%v", changed, err) |
| + } |
| + if changed, _ := st.ConfirmBooking(ctx, b.ID, now); changed { |
| + t.Error("a second confirm changed something") |
| + } |
| + v, _ := st.BookingByID(ctx, b.ID, false) |
| + if v.State != calendar.BookingConfirmed || v.Event.Status != calendar.StatusConfirmed || v.Event.Sequence != 1 { |
| + t.Errorf("after confirm: %s / %s / seq %d", v.State, v.Event.Status, v.Event.Sequence) |
| + } |
| + if att, _ := st.AttendeesFor(ctx, b.EventID); len(att) != 1 || att[0] != "doc@tito.io" { |
| + t.Errorf("attendees = %v", att) |
| + } |
| + owed := st.UnsentBookingMailForTest(b.ID) |
| + if len(owed) != 2 || owed[1].Kind != calendar.MailConfirmed || owed[1].Recipient != calendar.ToHost || owed[1].Revision != 1 { |
| + t.Errorf("owed = %+v", owed) |
| + } |
| + if rems, _ := st.RemindersFor(ctx, host.ID, b.EventID); len(rems) != 1 { |
| + t.Errorf("default reminder rows = %d, want 1 (host default is 10 minutes)", len(rems)) |
| + } |
| + |
| + // Lapsed: no change, and still held — the sweep moves it. |
| + b2, _ := st.HoldBooking(ctx, holdInput(page, host, slotsFor(t, app, page, host, now, 3)[1], "b@example.com", "ip"), now) |
| + if changed, _ := st.ConfirmBooking(ctx, b2.ID, now.Add(calendar.HoldTTL+time.Minute)); changed { |
| + t.Error("confirm after the hold lapsed changed something") |
| + } |
| + // Started: no change (B15). |
| + if _, err := app.DBForTest().Exec(`UPDATE bookings SET hold_expires_at = ? WHERE id = ?`, now.Add(time.Hour).Unix(), b2.ID); err != nil { |
| + t.Fatal(err) |
| + } |
| + startedAt := time.Unix(slotsFor(t, app, page, host, now, 3)[0].StartUTC, 0) |
| + if changed, _ := st.ConfirmBooking(ctx, b2.ID, startedAt.Add(2*time.Hour)); changed { |
| + t.Error("confirm after the meeting started changed something") |
| + } |
| +} |
| + |
| +func TestRescheduleNamesItsRevisionAndCanReturnHome(t *testing.T) { |
| + app, host, page := hostWithPage(t) |
| + st := app.StoreForTest() |
| + ctx := context.Background() |
| + now := time.Now() |
| + slots := slotsFor(t, app, page, host, now, 3) |
| + b, _ := st.HoldBooking(ctx, holdInput(page, host, slots[0], "doc@tito.io", "ip"), now) |
| + st.ConfirmBooking(ctx, b.ID, now) |
| + |
| + changed, err := st.RescheduleBooking(ctx, b.ID, slots[2].StartUTC, slots[2].EndUTC, 1, now) |
| + if err != nil || !changed { |
| + t.Fatalf("reschedule: changed=%v err=%v", changed, err) |
| + } |
| + v, _ := st.BookingByID(ctx, b.ID, false) |
| + if v.Event.StartUTC != slots[2].StartUTC || v.Event.Sequence != 2 || v.Reschedules != 1 || v.Event.TZ != host.Zone { |
| + t.Errorf("after reschedule: start %d seq %d n %d tz %q", v.Event.StartUTC, v.Event.Sequence, v.Reschedules, v.Event.TZ) |
| + } |
| + if got, _ := calendar.ToUTC(v.Event.StartLocal, v.Event.TZ, false); got != v.Event.StartUTC { |
| + t.Error("start_local, tz and start_utc disagree after the move") |
| + } |
| + // The same form again, with its stale revision: nothing. |
| + if changed, _ := st.RescheduleBooking(ctx, b.ID, slots[3].StartUTC, slots[3].EndUTC, 1, now); changed { |
| + t.Error("a resubmitted form with a stale rev moved the meeting") |
| + } |
| + // Back to the original slot: its own event is not busy against it. |
| + if changed, err := st.RescheduleBooking(ctx, b.ID, slots[0].StartUTC, slots[0].EndUTC, 2, now); err != nil || !changed { |
| + t.Errorf("moving back: changed=%v err=%v", changed, err) |
| + } |
| + for i := 0; i < 3; i++ { |
| + st.RescheduleBooking(ctx, b.ID, slots[4+i].StartUTC, slots[4+i].EndUTC, 3+i, now) |
| + } |
| + if _, err := st.RescheduleBooking(ctx, b.ID, slots[8].StartUTC, slots[8].EndUTC, 6, now); !errors.Is(err, calendar.ErrOverCap) { |
| + t.Errorf("sixth reschedule: %v, want ErrOverCap", err) |
| + } |
| +} |
| + |
| +func TestCancelForgetsOwedMailAndIsConditional(t *testing.T) { |
| + app, host, page := hostWithPage(t) |
| + st := app.StoreForTest() |
| + ctx := context.Background() |
| + now := time.Now() |
| + slots := slotsFor(t, app, page, host, now, 3) |
| + b, _ := st.HoldBooking(ctx, holdInput(page, host, slots[0], "doc@tito.io", "ip"), now) |
| + st.ConfirmBooking(ctx, b.ID, now) |
| + st.RescheduleBooking(ctx, b.ID, slots[1].StartUTC, slots[1].EndUTC, 1, now) |
| + |
| + changed, err := st.CancelBooking(ctx, b.ID, calendar.CancelledByBooker, now) |
| + if err != nil || !changed { |
| + t.Fatalf("cancel: %v %v", changed, err) |
| + } |
| + v, _ := st.BookingByID(ctx, b.ID, false) |
| + if v.State != calendar.BookingCancelled || v.CancelledBy != calendar.CancelledByBooker || v.Event.Status != calendar.StatusCancelled || v.Event.Sequence != 3 { |
| + t.Errorf("after cancel: %+v seq %d", v.State, v.Event.Sequence) |
| + } |
| + owed := st.UnsentBookingMailForTest(b.ID) |
| + if len(owed) != 2 || owed[0].Kind != calendar.MailCancelled || owed[1].Kind != calendar.MailCancelled { |
| + t.Errorf("owed after cancel = %+v, want only the two cancelled rows", owed) |
| + } |
| + var n int64 |
| + app.DBForTest().QueryRow(`SELECT COUNT(*) FROM reminder_queues WHERE reminder_id IN (SELECT id FROM reminders WHERE event_id = ?)`, b.EventID).Scan(&n) |
| + if n != 0 { |
| + t.Errorf("reminder queue rows after cancel = %d", n) |
| + } |
| + if changed, _ := st.CancelBooking(ctx, b.ID, calendar.CancelledByBooker, now); changed { |
| + t.Error("a second cancel changed something") |
| + } |
| +} |
diff --git a/internal/calendartest/booking_host_test.go b/internal/calendartest/booking_host_test.go| new file mode 100644 |
| index 0000000..04dee40 |
| --- /dev/null |
| +++ b/internal/calendartest/booking_host_test.go |
| @@ -0,0 +1,186 @@ |
| +package calendartest |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "testing" |
| + "time" |
| + |
| + calendar "amadan.net/oficina/calendar/internal/calendar" |
| +) |
| + |
| +// The host's authority over a booked event (booking spec §5.5, B18): the |
| +// form may change what the booker never sees; everything that schedules |
| +// the meeting belongs to the booking flow. |
| + |
| +func confirmedBooking(t *testing.T) (*calendar.App, calendar.Person, calendar.Booking, calendar.EventInput) { |
| + t.Helper() |
| + app, host, page := hostWithPage(t) |
| + st := app.StoreForTest() |
| + ctx := context.Background() |
| + now := time.Now() |
| + slot := slotsFor(t, app, page, host, now, 3)[0] |
| + b, err := st.HoldBooking(ctx, holdInput(page, host, slot, "doc@tito.io", "ip"), now) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := st.ConfirmBooking(ctx, b.ID, now); err != nil { |
| + t.Fatal(err) |
| + } |
| + ev, _ := st.Event(ctx, host.ID, b.EventID) |
| + // What a faithful re-submit of the host's form carries. |
| + in := calendar.EventInput{CalendarID: ev.CalendarID, Title: ev.Title, Location: ev.Location, Notes: ev.Notes, |
| + AllDay: ev.AllDay, StartLocal: ev.StartLocal, EndLocal: ev.EndLocal, TZ: ev.TZ, |
| + RRule: ev.RRule, Kind: ev.Kind, Status: ev.Status} |
| + return app, host, b, in |
| +} |
| + |
| +func TestTheHostMayChangeWhatTheBookerNeverSees(t *testing.T) { |
| + app, host, b, in := confirmedBooking(t) |
| + st := app.StoreForTest() |
| + ctx := context.Background() |
| + in.Title, in.Notes, in.Location = "Renamed", "private", "Room 9" |
| + if err := st.UpdateEvent(ctx, host.ID, b.EventID, in); err != nil { |
| + t.Fatalf("title-only edit refused: %v", err) |
| + } |
| + v, _ := st.BookingByID(ctx, b.ID, false) |
| + if v.Title != "Chat" { |
| + t.Errorf("the booking's snapshot changed with the event title: %q", v.Title) |
| + } |
| +} |
| + |
| +func TestTheHostMayNotMoveKindOrRepeatABookedMeeting(t *testing.T) { |
| + app, host, b, in := confirmedBooking(t) |
| + st := app.StoreForTest() |
| + ctx := context.Background() |
| + cases := []struct { |
| + name string |
| + mod func(*calendar.EventInput) |
| + }{ |
| + {"start", func(i *calendar.EventInput) { i.StartLocal = i.StartLocal[:11] + "23:00" }}, |
| + {"end", func(i *calendar.EventInput) { i.EndLocal = i.EndLocal[:11] + "23:30" }}, |
| + {"tz", func(i *calendar.EventInput) { i.TZ = "Asia/Tokyo" }}, |
| + {"kind", func(i *calendar.EventInput) { i.Kind = calendar.KindNote }}, |
| + {"repeat", func(i *calendar.EventInput) { i.RRule = "FREQ=WEEKLY" }}, |
| + {"tentative", func(i *calendar.EventInput) { i.Status = calendar.StatusTentative }}, |
| + } |
| + for _, c := range cases { |
| + t.Run(c.name, func(t *testing.T) { |
| + edit := in |
| + c.mod(&edit) |
| + err := st.UpdateEvent(ctx, host.ID, b.EventID, edit) |
| + if _, ok := calendar.Invalid(err); !ok { |
| + t.Errorf("edit to %s: err = %v, want a ValidationError", c.name, err) |
| + } |
| + }) |
| + } |
| + // All-day needs date-form strings to pass the validator first. |
| + edit := in |
| + edit.AllDay, edit.StartLocal, edit.EndLocal = true, in.StartLocal[:10], in.EndLocal[:10] |
| + if _, ok := calendar.Invalid(st.UpdateEvent(ctx, host.ID, b.EventID, edit)); !ok { |
| + t.Error("all-day edit of a booked meeting was accepted") |
| + } |
| +} |
| + |
| +func TestTheHostCancellingTellsTheBookerAndCannotRevive(t *testing.T) { |
| + app, host, b, in := confirmedBooking(t) |
| + st := app.StoreForTest() |
| + ctx := context.Background() |
| + in.Status = calendar.StatusCancelled |
| + if err := st.UpdateEvent(ctx, host.ID, b.EventID, in); err != nil { |
| + t.Fatalf("host cancel: %v", err) |
| + } |
| + v, _ := st.BookingByID(ctx, b.ID, false) |
| + if v.State != calendar.BookingCancelled || v.CancelledBy != calendar.CancelledByHost { |
| + t.Errorf("after host cancel: %s by %q", v.State, v.CancelledBy) |
| + } |
| + owed := st.UnsentBookingMailForTest(b.ID) |
| + if len(owed) != 1 || owed[0].Kind != calendar.MailCancelled || owed[0].Recipient != calendar.ToBooker { |
| + t.Errorf("owed = %+v, want one cancelled to the booker", owed) |
| + } |
| + in.Status = calendar.StatusConfirmed |
| + if _, ok := calendar.Invalid(st.UpdateEvent(ctx, host.ID, b.EventID, in)); !ok { |
| + t.Error("reviving a cancelled booking was accepted") |
| + } |
| +} |
| + |
| +func TestTheHostDeletingBumpsTheRevisionAndTellsTheBooker(t *testing.T) { |
| + app, host, b, _ := confirmedBooking(t) |
| + st := app.StoreForTest() |
| + ctx := context.Background() |
| + if err := st.DeleteEvent(ctx, host.ID, b.EventID); err != nil { |
| + t.Fatal(err) |
| + } |
| + v, err := st.BookingByID(ctx, b.ID, false) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if v.State != calendar.BookingCancelled || v.Event.DeletedAt == nil || v.Event.Sequence != 2 || !v.Cancelled() { |
| + t.Errorf("after delete: %s deleted=%v seq %d", v.State, v.Event.DeletedAt != nil, v.Event.Sequence) |
| + } |
| + // A HELD booking on a deleted event just expires, telling nobody. |
| + _, _, page := hostWithPage(t) |
| + _ = page |
| +} |
| + |
| +func TestAHeldBookingKeepsItsTentativeStatusUnderTheHost(t *testing.T) { |
| + app, host, page := hostWithPage(t) |
| + st := app.StoreForTest() |
| + ctx := context.Background() |
| + now := time.Now() |
| + slot := slotsFor(t, app, page, host, now, 3)[0] |
| + b, _ := st.HoldBooking(ctx, holdInput(page, host, slot, "doc@tito.io", "ip"), now) |
| + ev, _ := st.Event(ctx, host.ID, b.EventID) |
| + in := calendar.EventInput{CalendarID: ev.CalendarID, Title: "Renamed", AllDay: ev.AllDay, |
| + StartLocal: ev.StartLocal, EndLocal: ev.EndLocal, TZ: ev.TZ, Kind: ev.Kind, Status: ev.Status} |
| + if err := st.UpdateEvent(ctx, host.ID, b.EventID, in); err != nil { |
| + t.Errorf("title-only edit of a held event: %v", err) |
| + } |
| + in.Status = calendar.StatusConfirmed |
| + if _, ok := calendar.Invalid(st.UpdateEvent(ctx, host.ID, b.EventID, in)); !ok { |
| + t.Error("the host promoted a hold to confirmed") |
| + } |
| + if err := st.DeleteEvent(ctx, host.ID, b.EventID); err != nil { |
| + t.Fatal(err) |
| + } |
| + v, _ := st.BookingByID(ctx, b.ID, false) |
| + if v.State != calendar.BookingExpired || len(st.UnsentBookingMailForTest(b.ID)) != 0 { |
| + t.Errorf("held booking after host delete: %s, owed %d", v.State, len(st.UnsentBookingMailForTest(b.ID))) |
| + } |
| +} |
| + |
| +func TestAReminderThatFiredIsRebuiltByAReschedule(t *testing.T) { |
| + app, host, page := hostWithPage(t) |
| + st := app.StoreForTest() |
| + ctx := context.Background() |
| + now := time.Now() |
| + slots := slotsFor(t, app, page, host, now, 5) |
| + b, _ := st.HoldBooking(ctx, holdInput(page, host, slots[0], "doc@tito.io", "ip"), now) |
| + st.ConfirmBooking(ctx, b.ID, now) |
| + // Fire the default reminder by hand: claim it at its due time and |
| + // complete it. Its queue row is then GONE (single event). |
| + var due int64 |
| + app.DBForTest().QueryRow(`SELECT due_at FROM reminder_queues WHERE reminder_id IN (SELECT id FROM reminders WHERE event_id = ?)`, b.EventID).Scan(&due) |
| + d, ok, err := st.ClaimDueReminder(ctx, due) |
| + if err != nil || !ok { |
| + t.Fatalf("claim: %v %v", ok, err) |
| + } |
| + if err := st.CompleteReminder(ctx, d, due); err != nil { |
| + t.Fatal(err) |
| + } |
| + var n int64 |
| + app.DBForTest().QueryRow(`SELECT COUNT(*) FROM reminder_queues WHERE reminder_id = ?`, d.Queue.ReminderID).Scan(&n) |
| + if n != 0 { |
| + t.Fatalf("queue rows after firing a single event's reminder = %d, want 0", n) |
| + } |
| + // Move the meeting to a later slot: the RULE gets a new row (B26). |
| + last := slots[len(slots)-1] |
| + if changed, err := st.RescheduleBooking(ctx, b.ID, last.StartUTC, last.EndUTC, 1, now); err != nil || !changed { |
| + t.Fatalf("reschedule: %v %v", changed, err) |
| + } |
| + app.DBForTest().QueryRow(`SELECT COUNT(*) FROM reminder_queues WHERE reminder_id = ? AND fired_at IS NULL`, d.Queue.ReminderID).Scan(&n) |
| + if n != 1 { |
| + t.Errorf("queue rows after reschedule = %d, want the rule's row back", n) |
| + } |
| + _ = errors.Is |
| +} |
diff --git a/internal/calendartest/booking_mail_test.go b/internal/calendartest/booking_mail_test.go| new file mode 100644 |
| index 0000000..fcbf3fb |
| --- /dev/null |
| +++ b/internal/calendartest/booking_mail_test.go |
| @@ -0,0 +1,238 @@ |
| +package calendartest |
| + |
| +import ( |
| + "context" |
| + "testing" |
| + "time" |
| + |
| + calendar "amadan.net/oficina/calendar/internal/calendar" |
| +) |
| + |
| +// The two queues' identities and the sweep's arithmetic (booking spec |
| +// §2.5, §6, §7.1, §8, B22). |
| + |
| +func TestBookingMailClaimsHaveAnIdentityAndALease(t *testing.T) { |
| + app, host, page := hostWithPage(t) |
| + st := app.StoreForTest() |
| + ctx := context.Background() |
| + now := time.Now() |
| + b, _ := st.HoldBooking(ctx, holdInput(page, host, slotsFor(t, app, page, host, now, 3)[0], "doc@tito.io", "ip"), now) |
| + |
| + row, ok, err := st.ClaimBookingMail(ctx, now.Unix()) |
| + if err != nil || !ok || row.BookingID != b.ID || row.ClaimID == "" { |
| + t.Fatalf("claim: %+v %v %v", row, ok, err) |
| + } |
| + // Claimed: not claimable again inside the lease, and NextDueAt says |
| + // the lease end, not now. |
| + if _, ok, _ := st.ClaimBookingMail(ctx, now.Unix()+10); ok { |
| + t.Error("a claimed row was claimed again inside its lease") |
| + } |
| + if next := st.NextBookingMailDue(ctx); next.Unix() != now.Unix()+300 { |
| + t.Errorf("NextBookingMailDue = %v, want the lease end", next) |
| + } |
| + // A stale claim id cannot acknowledge it. |
| + if err := st.MarkBookingMailSent(ctx, row.ID, "not-the-claim", now.Unix()); err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(st.UnsentBookingMailForTest(b.ID)) != 1 { |
| + t.Error("a stale claim id acknowledged the row") |
| + } |
| + // Deferral keeps attempts at zero and is claimable again in 30s. |
| + if err := st.DeferBookingMail(ctx, row.ID, row.ClaimID, now.Unix()); err != nil { |
| + t.Fatal(err) |
| + } |
| + if again, ok, _ := st.ClaimBookingMail(ctx, now.Unix()+31); !ok || again.Attempts != 0 { |
| + t.Errorf("after defer: ok=%v attempts=%d", ok, again.Attempts) |
| + } else { |
| + row = again |
| + } |
| + // Failures count, and the fifth gives up. |
| + var gaveUp bool |
| + for i := 0; i < calendar.MaxReminderAttempts; i++ { |
| + gaveUp, err = st.MarkBookingMailFailed(ctx, row.ID, row.ClaimID) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + if !gaveUp || len(st.UnsentBookingMailForTest(b.ID)) != 0 { |
| + t.Errorf("after five failures: gaveUp=%v unsent=%d", gaveUp, len(st.UnsentBookingMailForTest(b.ID))) |
| + } |
| +} |
| + |
| +func TestExpiredHoldsFreeTheirSlotAndForgetTheirMail(t *testing.T) { |
| + app, host, page := hostWithPage(t) |
| + st := app.StoreForTest() |
| + ctx := context.Background() |
| + now := time.Now() |
| + // A slot well past the hold's own lapse, so it is still offerable |
| + // once the hold has expired. |
| + slot := slotsFor(t, app, page, host, now, 3)[4] |
| + b, _ := st.HoldBooking(ctx, holdInput(page, host, slot, "doc@tito.io", "ip"), now) |
| + if exp := st.NextHoldExpiry(ctx); exp.Unix() != b.HoldExpiresAt { |
| + t.Errorf("NextHoldExpiry %v, want %d", exp, b.HoldExpiresAt) |
| + } |
| + later := time.Unix(b.HoldExpiresAt, 0).Add(time.Second) |
| + // Lapsed but unswept: already not busy. |
| + free, err := st.FreeSlots(ctx, page, host, later, later, later.AddDate(0, 0, 3), 0, 0) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if !containsSlot(free, slot) { |
| + t.Errorf("a lapsed, unswept hold still blocks its slot") |
| + } |
| + n, err := st.ExpireHolds(ctx, later, 200) |
| + if err != nil || n != 1 { |
| + t.Fatalf("expire: n=%d err=%v", n, err) |
| + } |
| + if _, err := st.Event(ctx, host.ID, b.EventID); !calendar.NotFound(err) { |
| + t.Error("the held event survived expiry") |
| + } |
| + if len(st.UnsentBookingMailForTest(b.ID)) != 0 { |
| + t.Error("an expired hold still owes mail") |
| + } |
| + if v, _ := st.BookingByID(ctx, b.ID, false); v.State != calendar.BookingExpired { |
| + t.Errorf("state %s, want expired", v.State) |
| + } |
| + if !st.NextHoldExpiry(ctx).IsZero() { |
| + t.Error("NextHoldExpiry after the last hold expired is not zero") |
| + } |
| +} |
| + |
| +func TestCapsCountEveryAttemptButOnlyOpenHolds(t *testing.T) { |
| + app, host, page := hostWithPage(t) |
| + st := app.StoreForTest() |
| + ctx := context.Background() |
| + now := time.Now() |
| + slots := slotsFor(t, app, page, host, now, 5) |
| + for i := 0; i < 3; i++ { |
| + if _, err := st.HoldBooking(ctx, holdInput(page, host, slots[i], "s@example.com", "same-ip"), now); err != nil && i < 2 { |
| + t.Fatal(err) |
| + } |
| + } |
| + // The third from one address is over the per-address cap of 2. |
| + _, err := st.HoldBooking(ctx, holdInput(page, host, slots[3], "s@example.com", "same-ip"), now) |
| + if err != calendar.ErrOverCap { |
| + t.Errorf("third open hold for one address: %v, want ErrOverCap", err) |
| + } |
| + c, _ := st.HoldCountsFor(ctx, page.ID, "same-ip", "", now) |
| + if c.OpenIP != 2 || c.AttemptsIP != 2 { |
| + t.Errorf("counts = %+v", c) |
| + } |
| + st.ExpireHolds(ctx, now.Add(calendar.HoldTTL+time.Minute), 200) |
| + c, _ = st.HoldCountsFor(ctx, page.ID, "same-ip", "", now.Add(calendar.HoldTTL+time.Minute)) |
| + if c.OpenIP != 0 || c.AttemptsIP != 2 { |
| + t.Errorf("counts after expiry = %+v, want open 0 attempts 2", c) |
| + } |
| +} |
| + |
| +func TestIPFingerprintsAreForgottenOnSchedule(t *testing.T) { |
| + app, host, page := hostWithPage(t) |
| + st := app.StoreForTest() |
| + ctx := context.Background() |
| + now := time.Now() |
| + b, _ := st.HoldBooking(ctx, holdInput(page, host, slotsFor(t, app, page, host, now, 3)[0], "doc@tito.io", "ip"), now) |
| + if at := st.NextIPForget(ctx); at.Unix() != b.IPForgetAt { |
| + t.Errorf("NextIPForget %v, want %d", at, b.IPForgetAt) |
| + } |
| + if n, _ := st.ForgetOldIPs(ctx, now, 500); n != 0 { |
| + t.Error("forgot an IP early") |
| + } |
| + if n, _ := st.ForgetOldIPs(ctx, time.Unix(b.IPForgetAt, 0), 500); n != 1 { |
| + t.Error("did not forget the IP at ip_forget_at") |
| + } |
| + if !st.NextIPForget(ctx).IsZero() { |
| + t.Error("NextIPForget after forgetting is not zero") |
| + } |
| +} |
| + |
| +func TestNextDueAtIsTheEarliestClaimableOfFourSources(t *testing.T) { |
| + app, host, page := hostWithPage(t) |
| + st := app.StoreForTest() |
| + ctx := context.Background() |
| + now := time.Now() |
| + if !st.NextDueAt(ctx).IsZero() { |
| + t.Fatal("an empty instance has work") |
| + } |
| + b, _ := st.HoldBooking(ctx, holdInput(page, host, slotsFor(t, app, page, host, now, 3)[0], "doc@tito.io", "ip"), now) |
| + // Owed mail is due now; that wins. |
| + if d := st.NextDueAt(ctx); d.Unix() != now.Unix() { |
| + t.Errorf("with owed mail NextDueAt = %v, want now", d) |
| + } |
| + row, _, _ := st.ClaimBookingMail(ctx, now.Unix()) |
| + st.MarkBookingMailSent(ctx, row.ID, row.ClaimID, now.Unix()) |
| + // Then the hold's expiry. |
| + if d := st.NextDueAt(ctx); d.Unix() != b.HoldExpiresAt { |
| + t.Errorf("with a hold NextDueAt = %v, want its expiry %d", d, b.HoldExpiresAt) |
| + } |
| +} |
| + |
| +// A reminder's claim is an identity, not a timestamp: a worker whose |
| +// row was rewritten by a reschedule — even if another worker re-claimed |
| +// it in the same second — cannot mark it, fail it or advance it. |
| +func TestAReminderClaimOvertakenByARescheduleCompletesNothing(t *testing.T) { |
| + app, host, page := hostWithPage(t) |
| + st := app.StoreForTest() |
| + ctx := context.Background() |
| + now := time.Now() |
| + slots := slotsFor(t, app, page, host, now, 5) |
| + b, _ := st.HoldBooking(ctx, holdInput(page, host, slots[0], "doc@tito.io", "ip"), now) |
| + st.ConfirmBooking(ctx, b.ID, now) |
| + var due int64 |
| + app.DBForTest().QueryRow(`SELECT due_at FROM reminder_queues WHERE reminder_id IN (SELECT id FROM reminders WHERE event_id = ?)`, b.EventID).Scan(&due) |
| + |
| + old, ok, _ := st.ClaimDueReminder(ctx, due) |
| + if !ok { |
| + t.Fatal("no claim") |
| + } |
| + // The booker moves the meeting: the row is rewritten, the claim cleared. |
| + last := slots[len(slots)-1] |
| + if changed, err := st.RescheduleBooking(ctx, b.ID, last.StartUTC, last.EndUTC, 1, now); err != nil || !changed { |
| + t.Fatalf("reschedule: %v %v", changed, err) |
| + } |
| + // The old worker finishes: nothing happens. |
| + if err := st.CompleteReminder(ctx, old, due); err != nil { |
| + t.Fatal(err) |
| + } |
| + var fired *int64 |
| + var rowDue int64 |
| + app.DBForTest().QueryRow(`SELECT fired_at, due_at FROM reminder_queues WHERE reminder_id = ?`, old.Queue.ReminderID).Scan(&fired, &rowDue) |
| + if fired != nil { |
| + t.Error("an overtaken claim marked the replacement reminder fired") |
| + } |
| + if rowDue == due { |
| + t.Error("the queue row still points at the old time") |
| + } |
| + // A deferral leaves due_at alone and the meeting time intact. |
| + // Drain the reschedule's own mail so the reminder is the only work. |
| + for { |
| + m, ok, _ := st.ClaimBookingMail(ctx, now.Unix()) |
| + if !ok { |
| + break |
| + } |
| + st.MarkBookingMailSent(ctx, m.ID, m.ClaimID, now.Unix()) |
| + } |
| + // And the IP fingerprint, which is otherwise the earlier source. |
| + st.ForgetOldIPs(ctx, time.Unix(b.IPForgetAt, 0), 500) |
| + nu, ok, _ := st.ClaimDueReminder(ctx, rowDue) |
| + if !ok { |
| + t.Fatal("the rebuilt row is not claimable at its due time") |
| + } |
| + st.DeferReminder(ctx, nu.Queue.ID, nu.ClaimID, rowDue) |
| + var after int64 |
| + app.DBForTest().QueryRow(`SELECT due_at FROM reminder_queues WHERE id = ?`, nu.Queue.ID).Scan(&after) |
| + if after != rowDue { |
| + t.Error("a deferral rewrote due_at") |
| + } |
| + if next := st.NextDueAt(ctx); next.Unix() != rowDue+30 { |
| + t.Errorf("NextDueAt after a deferral = %v, want retry_at", next) |
| + } |
| +} |
| + |
| +func containsSlot(slots []calendar.Slot, want calendar.Slot) bool { |
| + for _, s := range slots { |
| + if s.StartUTC == want.StartUTC { |
| + return true |
| + } |
| + } |
| + return false |
| +} |