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
@@ -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)