import type { Db, Document, Filter, Sort } from 'mongodb'; import { Collection, type ListResponse, type HourlyRollupDocument, type ParticipantDocument, ParticipantKind, type ParticipantListItem, ParticipantStatus, type ParticipantsSummary } from '@openvidu-dashboard/typings'; import { buildPage, cursorOrThrow } from '../api/cursor.js'; import { assertRankedRange, rankedPage } from '../api/ranked.js'; import { rankedSortLimits } from '../api/ranked-limits.js'; import { markStale, type WriterFreshness } from '../status/freshness.js'; import { toParticipantListItem } from './participants-mappers.js'; import { PARTICIPANT_LIST_PROJECTION, type ParticipantsFilter, participantsListPlan } from './participants-query.js'; import type { AgentsListParams, ParticipantsListParams, ParticipantsSummaryParams } from './participants-schemas.js'; export interface ParticipantsServiceOptions { /** Longest range, in milliseconds, whose list `total` is exact (`DASHBOARD_LIST_TOTAL_MAX_RANGE`). */ totalMaxRangeMs: number; /** The stale window of the writer's batch interval. */ freshness: WriterFreshness; now: () => Date; } const JOINED_AT = 'joinedAt'; const HOUR_MS = 3_600_000; const KINDS = Object.values(ParticipantKind); type CountByKind = Record; /** Every kind at 0, plus the given counts; a kind newer than this dashboard is left out. */ function byKind(counts: readonly { _id: string; n: number }[]): CountByKind { const result = Object.fromEntries(KINDS.map((kind) => [kind, 0])) as CountByKind; for (const { _id, n } of counts) { if (Object.hasOwn(result, _id)) { result[_id as ParticipantKind] = n; } } return result; } /** `_id` of the global hourly rollup of `bucket`: RFC 3339 UTC without fractional seconds. */ function globalHourlyId(bucket: number): string { return `global:${new Date(bucket).toISOString().replace('.000Z', 'Z')}`; } /** Participants and agents lists and their summary. */ export class ParticipantsService { constructor( private readonly db: Db, private readonly options: ParticipantsServiceOptions ) {} private get participants() { return this.db.collection(Collection.PARTICIPANTS); } private exactRange(from: Date, to: Date): boolean { return to.getTime() - from.getTime() <= this.options.totalMaxRangeMs; } /** STANDARD participants and, apart, the other kinds: active now and joined in the range. */ async summary(params: ParticipantsSummaryParams): Promise { const exact = this.exactRange(params.from, params.to); const [active, sessions] = await Promise.all([ this.countByKind({ status: ParticipantStatus.ACTIVE, kind: { $in: KINDS } }), exact ? this.countByKind({ joinedAt: { $gte: params.from, $lt: params.to } }) : this.rollupSessions(params.from, params.to) ]); const activeTotal = active.reduce((sum, { n }) => sum + n, 0); const activeByKind = byKind(active); const sessionsByKind = byKind(sessions); return { active: { participants: activeByKind.STANDARD, services: activeTotal - activeByKind.STANDARD, byKind: activeByKind }, sessions: { participants: sessionsByKind.STANDARD, byKind: sessionsByKind }, approximate: !exact }; } /** Sessions per kind matching `match`. */ private countByKind(match: Filter): Promise<{ _id: string; n: number }[]> { return this.participants .aggregate<{ _id: string; n: number }>([{ $match: match }, { $group: { _id: '$kind', n: { $sum: 1 } } }]) .toArray(); } /** Sessions per kind joined in the global hourly buckets that start inside `[from, to)`. */ private async rollupSessions(from: Date, to: Date): Promise<{ _id: string; n: number }[]> { const first = Math.ceil(from.getTime() / HOUR_MS) * HOUR_MS; const last = Math.ceil(to.getTime() / HOUR_MS) * HOUR_MS - HOUR_MS; if (last < first) { return []; } const [sums] = await this.db .collection(Collection.ANALYTICS_HOURLY) .aggregate>([ { $match: { _id: { $gte: globalHourlyId(first), $lte: globalHourlyId(last) } } }, { $group: { _id: null, ...Object.fromEntries(KINDS.map((kind) => [kind, { $sum: `$participants.byKind.${kind}` }])) } } ]) .toArray(); return KINDS.map((kind) => ({ _id: kind, n: sums?.[kind] ?? 0 })); } list(params: ParticipantsListParams): Promise> { return this.page(params, params); } /** The participants list of kind `AGENT`. */ agents(params: AgentsListParams): Promise> { return this.page(params, { ...params, kind: [ParticipantKind.AGENT] }); } private async page( params: { cursor?: string | undefined; limit: number; from: Date; to: Date; order: 'asc' | 'desc' }, filter: ParticipantsFilter ): Promise> { const cursor = cursorOrThrow(params.cursor, filter.sort, params.order); const plan = participantsListPlan(filter, cursor); if (plan.kind === 'sid') { const [docs, context] = await Promise.all([ this.find(plan.filter, { _id: 1 }, 1), this.options.freshness.staleContext() ]); return { items: docs.map((doc) => markStale(toParticipantListItem(doc), doc, context)), nextCursor: null, prevCursor: null, total: docs.length }; } const hint = plan.hint === undefined ? {} : { hint: plan.hint }; if (plan.kind === 'ranked') { if (filter.room === undefined) { assertRankedRange( params, rankedSortLimits(this.options.totalMaxRangeMs).participantRankedSortMaxRangeMs, filter.sort ); } const [ranked, total, context] = await Promise.all([ rankedPage(this.participants, { match: plan.match, rank: plan.rank, projection: PARTICIPANT_LIST_PROJECTION, ...hint, sort: filter.sort, order: params.order, cursor, limit: params.limit, now: this.options.now() }), this.exactRange(params.from, params.to) ? this.participants.countDocuments(plan.countFilter as Filter, hint) : undefined, this.options.freshness.staleContext() ]); return { ...ranked, items: ranked.items.map((doc) => markStale(toParticipantListItem(doc), doc, context)), ...(total === undefined ? {} : { total }) }; } const [fetched, total, context] = await Promise.all([ this.find(plan.filter, plan.sort, params.limit + 1, plan.hint), this.exactRange(params.from, params.to) ? this.participants.countDocuments(plan.countFilter as Filter, hint) : undefined, this.options.freshness.staleContext() ]); const page = buildPage( fetched, params.limit, plan.page, cursor !== undefined, (doc) => ({ value: doc.joinedAt, id: doc._id }), { sort: JOINED_AT, order: params.order } ); return { ...page, items: page.items.map((doc) => markStale(toParticipantListItem(doc), doc, context)), ...(total === undefined ? {} : { total }) }; } private find(filter: Document, sort: Sort, limit: number, hint?: string): Promise { return this.participants .find(filter as Filter, { projection: PARTICIPANT_LIST_PROJECTION, ...(hint === undefined ? {} : { hint }) }) .sort(sort) .limit(limit) .toArray(); } }