feat(scheduling): tenant scheduling overview/analytics
This commit is contained in:
@@ -0,0 +1,116 @@
|
||||
import { Injectable } from '@nestjs/common'
|
||||
import { InjectModel } from '@nestjs/mongoose'
|
||||
import { Model, Types } from 'mongoose'
|
||||
import { Booking, BookingDocument, BookingStatus } from '../../schemas/booking.schema.js'
|
||||
import { Host, HostDocument } from '../../schemas/scheduling-host.schema.js'
|
||||
|
||||
// Read-only analytics for the tenant scheduling admin (Phase 3). All figures are
|
||||
// derived from the bookings collection scoped to a single tenant; nothing here
|
||||
// mutates state. The per-day series powers a small sparkline-style chart, and the
|
||||
// per-host breakdown lets an admin see who is carrying the booking load.
|
||||
|
||||
const SERIES_DAYS = 30
|
||||
|
||||
export interface HostCount {
|
||||
hostId: string
|
||||
displayName: string
|
||||
count: number
|
||||
}
|
||||
|
||||
export interface DayPoint {
|
||||
date: string // YYYY-MM-DD (UTC)
|
||||
count: number
|
||||
}
|
||||
|
||||
export interface SchedulingOverview {
|
||||
totalBookings: number
|
||||
upcomingCount: number
|
||||
byStatus: Record<BookingStatus, number>
|
||||
byHost: HostCount[]
|
||||
last30Days: DayPoint[]
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class OverviewService {
|
||||
constructor(
|
||||
@InjectModel(Booking.name) private readonly bookingModel: Model<BookingDocument>,
|
||||
@InjectModel(Host.name) private readonly hostModel: Model<HostDocument>,
|
||||
) {}
|
||||
|
||||
async forTenant(tenantId: Types.ObjectId): Promise<SchedulingOverview> {
|
||||
const now = new Date()
|
||||
// Window start = midnight UTC, (SERIES_DAYS - 1) days ago, so the series
|
||||
// contains exactly SERIES_DAYS calendar days ending today.
|
||||
const windowStart = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()))
|
||||
windowStart.setUTCDate(windowStart.getUTCDate() - (SERIES_DAYS - 1))
|
||||
|
||||
// Run the aggregates concurrently — they're independent reads.
|
||||
const [statusAgg, hostAgg, dayAgg, upcomingCount, hosts] = await Promise.all([
|
||||
// Counts per status.
|
||||
this.bookingModel
|
||||
.aggregate<{ _id: BookingStatus; count: number }>([
|
||||
{ $match: { tenantId } },
|
||||
{ $group: { _id: '$status', count: { $sum: 1 } } },
|
||||
])
|
||||
.exec(),
|
||||
// Counts per host (any status).
|
||||
this.bookingModel
|
||||
.aggregate<{ _id: Types.ObjectId; count: number }>([
|
||||
{ $match: { tenantId } },
|
||||
{ $group: { _id: '$hostId', count: { $sum: 1 } } },
|
||||
])
|
||||
.exec(),
|
||||
// Bookings created per day over the trailing window (by createdAt).
|
||||
this.bookingModel
|
||||
.aggregate<{ _id: string; count: number }>([
|
||||
{ $match: { tenantId, createdAt: { $gte: windowStart } } },
|
||||
{
|
||||
$group: {
|
||||
_id: { $dateToString: { format: '%Y-%m-%d', date: '$createdAt', timezone: 'UTC' } },
|
||||
count: { $sum: 1 },
|
||||
},
|
||||
},
|
||||
])
|
||||
.exec(),
|
||||
// Confirmed bookings still in the future.
|
||||
this.bookingModel.countDocuments({ tenantId, status: 'confirmed', startUtc: { $gte: now } }).exec(),
|
||||
// Host display names for the breakdown labels.
|
||||
this.hostModel.find({ tenantId }).select('_id displayName').exec(),
|
||||
])
|
||||
|
||||
const byStatus: Record<BookingStatus, number> = {
|
||||
pending: 0,
|
||||
confirmed: 0,
|
||||
cancelled: 0,
|
||||
rescheduled: 0,
|
||||
calendar_failed: 0,
|
||||
}
|
||||
let totalBookings = 0
|
||||
for (const row of statusAgg) {
|
||||
if (row._id in byStatus) byStatus[row._id] = row.count
|
||||
totalBookings += row.count
|
||||
}
|
||||
|
||||
const nameById = new Map(hosts.map((h) => [h._id.toString(), h.displayName]))
|
||||
const byHost: HostCount[] = hostAgg
|
||||
.map((row) => ({
|
||||
hostId: row._id.toString(),
|
||||
displayName: nameById.get(row._id.toString()) ?? 'Unknown host',
|
||||
count: row.count,
|
||||
}))
|
||||
.sort((a, b) => b.count - a.count)
|
||||
|
||||
// Fill the day series so every day in the window has a point (zero when no
|
||||
// bookings), keeping the front-end chart simple.
|
||||
const countByDate = new Map(dayAgg.map((row) => [row._id, row.count]))
|
||||
const last30Days: DayPoint[] = []
|
||||
for (let i = 0; i < SERIES_DAYS; i++) {
|
||||
const d = new Date(windowStart)
|
||||
d.setUTCDate(d.getUTCDate() + i)
|
||||
const date = d.toISOString().slice(0, 10)
|
||||
last30Days.push({ date, count: countByDate.get(date) ?? 0 })
|
||||
}
|
||||
|
||||
return { totalBookings, upcomingCount, byStatus, byHost, last30Days }
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import { CreateEventTypeDto, UpdateEventTypeDto } from './event-types/dto/event-
|
||||
import { EventTypesService } from './event-types/event-types.service.js'
|
||||
import { CreateHostDto, SetHostActiveDto } from './hosts/dto/create-host.dto.js'
|
||||
import { HostsService } from './hosts/hosts.service.js'
|
||||
import { OverviewService } from './overview/overview.service.js'
|
||||
|
||||
// Authenticated host/admin scheduling config (OIDC via Authentik). Tenant-scoped
|
||||
// and gated exactly like the rest of platform-api: platformAdmin OR a member of
|
||||
@@ -39,6 +40,7 @@ export class SchedulingAdminController {
|
||||
private readonly availability: AvailabilityService,
|
||||
private readonly eventTypes: EventTypesService,
|
||||
private readonly bookings: BookingsService,
|
||||
private readonly overview: OverviewService,
|
||||
) {}
|
||||
|
||||
private async gate(slug: string, jwt: AuthentikJwtPayload): Promise<Types.ObjectId> {
|
||||
@@ -50,6 +52,12 @@ export class SchedulingAdminController {
|
||||
return tenant._id
|
||||
}
|
||||
|
||||
// ── Overview / analytics (read-only) ──
|
||||
@Get('overview')
|
||||
async getOverview(@Param('slug') slug: string, @CurrentUser() jwt: AuthentikJwtPayload) {
|
||||
return this.overview.forTenant(await this.gate(slug, jwt))
|
||||
}
|
||||
|
||||
// ── Hosts ──
|
||||
@Get('hosts')
|
||||
async listHosts(@Param('slug') slug: string, @CurrentUser() jwt: AuthentikJwtPayload) {
|
||||
|
||||
@@ -22,6 +22,7 @@ import { CalendarRetryWorker } from './bookings/calendar-retry.worker.js'
|
||||
import { JmapMailer } from './email/jmap-mailer.service.js'
|
||||
import { EventTypesService } from './event-types/event-types.service.js'
|
||||
import { HostsService } from './hosts/hosts.service.js'
|
||||
import { OverviewService } from './overview/overview.service.js'
|
||||
import { PublicSchedulingController } from './public/public-scheduling.controller.js'
|
||||
import { PublicSchedulingService } from './public/public-scheduling.service.js'
|
||||
import { BookingReminderWorker } from './reminders/booking-reminder.worker.js'
|
||||
@@ -66,6 +67,7 @@ import { WebhooksService } from './webhooks/webhooks.service.js'
|
||||
EventTypesService,
|
||||
SlotService,
|
||||
BookingsService,
|
||||
OverviewService,
|
||||
PublicSchedulingService,
|
||||
JmapMailer,
|
||||
BookingReminderWorker,
|
||||
|
||||
Reference in New Issue
Block a user