feat(scheduling): calendar_failed badge + admin "retry now" action
ci / typecheck (map[dir:apps/booking name:booking]) (push) Has been cancelled
ci / typecheck (map[dir:apps/portal name:portal]) (push) Has been cancelled
ci / typecheck (map[dir:apps/website name:website]) (push) Has been cancelled
ci / typecheck (map[dir:services/platform-api name:platform-api]) (push) Has been cancelled
ci / test (push) Has been cancelled

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.
This commit is contained in:
Ronni Baslund
2026-06-07 09:39:42 +02:00
parent 35bc7b6c31
commit 90e8a22de4
3 changed files with 102 additions and 1 deletions
+33 -1
View File
@@ -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<string | null>(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
<div class="mute small">{{ fmtDateTime(b.startUtc) }} {{ fmtDateTime(b.endUtc) }}</div>
</div>
<div class="itemactions">
<Badge :tone="b.status === 'confirmed' ? 'ok' : b.status === 'cancelled' ? 'bad' : 'neutral'">{{ b.status }}</Badge>
<Badge :tone="bookingTone(b.status)" :title="b.lastCalendarError || undefined">{{ statusLabel[b.status] ?? b.status }}</Badge>
<UiButton v-if="b.status === 'confirmed'" variant="ghost" @click="openReschedule(b)">Reschedule</UiButton>
<UiButton v-if="b.status === 'confirmed'" variant="ghost" @click="cancelBookingAdmin(b)">Cancel</UiButton>
<UiButton
v-if="b.status === 'calendar_failed' || b.status === 'pending'"
variant="ghost"
:disabled="retryingId === b._id"
@click="retryBookingAdmin(b)"
>{{ retryingId === b._id ? 'Retrying…' : 'Retry now' }}</UiButton>
</div>
</Card>
</div>
@@ -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<BookingDocument> {
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<BookingDocument> {
const booking = await this.bookingModel.findOne({ manageToken: token }).exec()
@@ -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)