From 90e8a22de4c2eb8dd7a906c791bc62c870bf1105 Mon Sep 17 00:00:00 2001 From: Ronni Baslund Date: Sun, 7 Jun 2026 09:39:42 +0200 Subject: [PATCH] feat(scheduling): calendar_failed badge + admin "retry now" action Surface pending/calendar_failed booking states in the admin bookings list with proper status badges (failed shows the last calendar error as a tooltip), and add an operator "Retry now" action. The retry re-drives the same Stalwart calendar write (confirm + attendee email on success); for a terminal calendar_failed booking it re-claims the slot lock atomically first and refuses if the time was taken in the meantime, so a manual retry can never double-book. --- apps/portal/pages/admin/scheduling.vue | 34 ++++++++++- .../scheduling/bookings/bookings.service.ts | 58 +++++++++++++++++++ .../scheduling/scheduling-admin.controller.ts | 11 ++++ 3 files changed, 102 insertions(+), 1 deletion(-) diff --git a/apps/portal/pages/admin/scheduling.vue b/apps/portal/pages/admin/scheduling.vue index 25cb198..d7e7639 100644 --- a/apps/portal/pages/admin/scheduling.vue +++ b/apps/portal/pages/admin/scheduling.vue @@ -42,6 +42,7 @@ interface Booking { attendeeName: string attendeeEmail: string eventTypeId: string + lastCalendarError?: string } const toast = useToast() @@ -323,6 +324,31 @@ function cancelBookingAdmin(b: Booking) { }) } +// Badge tone for a booking status (Badge supports ok/warn/bad/neutral). +function bookingTone(status: string): 'ok' | 'warn' | 'bad' | 'neutral' { + if (status === 'confirmed') return 'ok' + if (status === 'pending') return 'warn' + if (status === 'calendar_failed') return 'bad' + return 'neutral' +} + +// "Retry now" — re-drive the Stalwart calendar write for a pending / +// calendar_failed booking. On success it confirms + emails the attendee. +const retryingId = ref(null) +async function retryBookingAdmin(b: Booking) { + retryingId.value = b._id + try { + const updated = (await request(`${base.value}/bookings/${b._id}/retry`, { method: 'POST' })) as Booking + toast.ok(updated.status === 'confirmed' ? 'Booking confirmed' : 'Retry queued', updated.status === 'confirmed' ? 'The calendar event was written and the attendee emailed.' : 'The calendar write failed again; the background worker will keep retrying.') + await loadHostData() + await loadOverview() + } catch (err) { + toastErr(err, 'Retry failed') + } finally { + retryingId.value = null + } +} + // ── Create event type ── const etOpen = ref(false) const etBusy = ref(false) @@ -751,9 +777,15 @@ const maskSecret = (s: string) => (s.length > 12 ? `${s.slice(0, 9)}…${s.slice
{{ fmtDateTime(b.startUtc) }} – {{ fmtDateTime(b.endUtc) }}
- {{ b.status }} + {{ statusLabel[b.status] ?? b.status }} Reschedule Cancel + {{ retryingId === b._id ? 'Retrying…' : 'Retry now' }}
diff --git a/services/platform-api/src/scheduling/bookings/bookings.service.ts b/services/platform-api/src/scheduling/bookings/bookings.service.ts index 7475412..ab9208a 100644 --- a/services/platform-api/src/scheduling/bookings/bookings.service.ts +++ b/services/platform-api/src/scheduling/bookings/bookings.service.ts @@ -363,6 +363,64 @@ export class BookingsService { .exec() } + /** + * Operator-triggered "retry now" from the admin bookings list. Works on a + * still-'pending' booking (which already holds its slot lock) or a terminal + * 'calendar_failed' one (whose lock was released on terminal failure — we + * re-claim the slot atomically first and refuse if it was taken in the + * meantime, so we never double-book). Resets the attempt budget and re-drives + * the shared write path; on success the booking is confirmed and the attendee + * emailed, otherwise it returns to 'pending' for the background worker. + */ + async adminRetryCalendarWrite(tenantId: Types.ObjectId, bookingId: string): Promise { + const booking = await this.getForTenant(tenantId, bookingId) + if (booking.status === 'confirmed') return booking + if (booking.status === 'cancelled' || booking.status === 'rescheduled') { + throw new BadRequestException(`Cannot retry a ${booking.status} booking.`) + } + + const tenant = await this.tenantModel.findById(booking.tenantId).exec() + const host = await this.hostModel.findById(booking.hostId).exec() + const eventType = await this.eventTypeModel.findById(booking.eventTypeId).exec() + if (!tenant || !host || !eventType) { + throw new NotFoundException('The host or event type for this booking no longer exists.') + } + const ctx: BookingContext = { + tenant: { _id: tenant._id, slug: tenant.slug, name: tenant.name, brandColor: tenant.brandColor }, + host, + eventType, + } + + // A terminal failure released the slot lock — re-claim it before retrying so + // we can't double-book a slot that was taken after this booking failed. + if (booking.status === 'calendar_failed') { + try { + await this.lockModel.create({ + tenantId: booking.tenantId, + hostId: booking.hostId, + startUtc: booking.startUtc, + endUtc: booking.endUtc, + expiresAt: null, + bookingId: booking._id, + holdToken: null, + }) + } catch (err: any) { + if (err?.code === 11000) { + throw new ConflictException('That time was taken after this booking failed — reschedule it to a free slot instead.') + } + throw err + } + } + + // Fresh attempt budget, then re-drive the shared write path. + booking.calendarWriteAttempts = 0 + booking.lastCalendarError = undefined + booking.status = 'pending' + await booking.save() + await this.attemptCalendarWrite(ctx, booking) + return booking + } + // ── Manage / cancel / reschedule ─────────────────────────────────────────── async getByManageToken(token: string): Promise { const booking = await this.bookingModel.findOne({ manageToken: token }).exec() diff --git a/services/platform-api/src/scheduling/scheduling-admin.controller.ts b/services/platform-api/src/scheduling/scheduling-admin.controller.ts index 258738a..853b66a 100644 --- a/services/platform-api/src/scheduling/scheduling-admin.controller.ts +++ b/services/platform-api/src/scheduling/scheduling-admin.controller.ts @@ -199,6 +199,17 @@ export class SchedulingAdminController { return this.bookings.rescheduleResolved(booking, new Date(dto.startUtc), ctx) } + // Operator "retry now" for a pending / calendar_failed booking's calendar write. + @Post('bookings/:bookingId/retry') + async retryBooking( + @Param('slug') slug: string, + @Param('bookingId') bookingId: string, + @CurrentUser() jwt: AuthentikJwtPayload, + ) { + const tenantId = await this.gate(slug, jwt) + return this.bookings.adminRetryCalendarWrite(tenantId, bookingId) + } + // Load a tenant-scoped booking + its full booking context (tenant + host + event type). private async resolveBookingCtx(tenantId: Types.ObjectId, bookingId: string) { const booking = await this.bookings.getForTenant(tenantId, bookingId)