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
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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user