import { AdmissionPolicy, OccupancySnapshot } from "../domain/admission/admission.mjs"; import { MusterContext } from "../domain/ports/common.mjs"; import { SubjectBridge } from "../domain/ports/subject.bridge.mjs"; import { ScheduleBridge } from "../domain/ports/schedule.bridge.mjs"; import { AdmissionBridge } from "../domain/ports/admission.bridge.mjs"; import { CheckInMethodValue } from "../domain/enums/check-in-method.enum.mjs"; import { OutcomePolicy } from "../domain/ports/outcome.policy.mjs"; import { GeoPoint } from "../domain/value-objects/geo-point.vo.mjs"; import { DeviceInfo } from "../domain/value-objects/device-info.vo.mjs"; import { AttendanceSessionDocument, SessionCompanion } from "../models/attendance-session.model.mjs"; import { AttendanceRecordDocument } from "../models/attendance-record.model.mjs"; import { ProposedSessionChanges } from "../models/attendance-correction.model.mjs"; import { AttendanceRecordRepository } from "./attendance-record.repository.mjs"; import { PluginType, Repository } from "@classytic/mongokit"; import { Model, Types } from "mongoose"; import { EventTransport } from "@classytic/primitives/events"; import { OutboxStore } from "@classytic/primitives/outbox"; //#region src/repositories/attendance-session.repository.d.ts interface CheckInInput { subjectRef: string; subjectModel: string; scope?: string; /** * People entering WITH the subject (gym guests, a collecting parent, a * contractor's crew). Counted into occupancy — a party of member + 2 guests * consumes 3 capacity slots. Defaults to `companions?.length ?? 0`. */ companionCount?: number; /** Optional companion identities (liability / contact tracing). */ companions?: SessionCompanion[]; /** * House rules for this admission. Supply it and `checkIn` ENFORCES capacity * (against live occupancy, inside the transaction), the companion cap, and * any declarative `Condition` rules — throwing `AdmissionDeniedError` on * refusal. Omit it and muster stays a pure recorder (unchanged behaviour). * * The standing RIGHT to enter and metered allowances ("3 guest visits per * month") are `@classytic/access`'s job — check/consume there, then pass the * per-visit policy here. */ admission?: AdmissionPolicy; /** * Extra domain facts exposed to `admission.rules` by dotted path — tier, * membershipStatus, escortPresent, roomType, anything. Never persisted; * pass durable data via `metadata`. */ admissionFacts?: Record; /** * Modality discriminator — 'corporate' | 'class' | 'gym' | 'appointment' | * 'visitor' | …. Host-defined. Defaults to `'attendance'` when omitted. * Used by `outcomePolicy` to derive outcomes without per-kind branching * at the call site. */ sessionKind?: string; method: CheckInMethodValue; /** Claimed wall-clock time. Defaults to "now" on the server. */ occurredAt?: Date; scheduledStart?: Date; scheduledEnd?: Date; geo?: GeoPoint; device?: DeviceInfo; note?: string; metadata?: Record; /** * Device-replay dedupe key (`SN:PIN:timestamp` from a biometric fleet, or * any caller-stable key). A retry with the same key returns the ORIGINAL * session instead of double-writing — see `muster_record_idempotency`. */ idempotencyKey?: string; } interface CheckOutInput { method: CheckInMethodValue; occurredAt?: Date; geo?: GeoPoint; device?: DeviceInfo; note?: string; /** Device-replay dedupe key — see {@link CheckInInput.idempotencyKey}. */ idempotencyKey?: string; } /** * punch() input — a direction-less device tap. Devices (single-button * biometric terminals, RFID turnstiles) don't know in-vs-out; `punch` * resolves it from the subject's open-session state. */ interface PunchInput { subjectRef: string; subjectModel: string; scope?: string; sessionKind?: string; method: CheckInMethodValue; /** Device wall-clock time (offline batches upload PAST punches). */ occurredAt?: Date; geo?: GeoPoint; device?: DeviceInfo; note?: string; metadata?: Record; /** STRONGLY recommended for device feeds — see {@link CheckInInput.idempotencyKey}. */ /** * ── ADMISSION, on the DEVICE path ───────────────────────────────────────── * * These were absent, so `punch()` could never refuse anyone. A manual `checkIn` * enforced capacity, companion limits and house rules; the same person tapping a * turnstile bypassed all of it, because the punch→check-in conversion simply did not * carry them. * * That is the gap that matters for door control: the device path is the ONE path where * nobody is watching. Verified on a running deployment — a subject with no * `facility:sauna` entitlement punched into `sauna` and got `200 checked_in`. * * A caller that omits them still gets today's behaviour (record, do not judge), so this * is additive: an unpoliced deployment is unchanged, a policed one is now policed * everywhere. */ admission?: AdmissionPolicy; /** * Domain facts the rules read by dotted path — `hasZoneGrant`, `membershipStatus`, * `tier`. The HOST resolves these (it owns `@classytic/access`); the kernel only * evaluates the declared rules against them, so muster never learns what an * entitlement is. */ admissionFacts?: Record; /** The arriving party beyond the subject — capacity counts `1 + companions`. */ companions?: SessionCompanion[]; companionCount?: number; idempotencyKey?: string; } interface PunchResult { /** * What the punch resolved to: * - `checked_in` — no open session existed; one was opened. * - `checked_out` — the open session was closed. * - `duplicate` — the idempotency key was already consumed (device * replay); `session` is the ORIGINAL result, nothing was written. * - `debounced` — a distinct-timestamp double-tap inside the debounce * window (finger bounce); nothing was written. */ action: 'checked_in' | 'checked_out' | 'duplicate' | 'debounced'; session: AttendanceSessionDocument; } interface BreakInput { method?: CheckInMethodValue; occurredAt?: Date; geo?: GeoPoint; device?: DeviceInfo; note?: string; } interface CancelSessionInput { reason: string; occurredAt?: Date; } interface AttendanceSessionRepositoryConfig { model: Model; plugins?: PluginType[]; recordRepo: AttendanceRecordRepository; /** Custom-id prefix. Default: 'ATT'. */ idPrefix?: string; idPartition?: 'yearly' | 'monthly' | 'daily'; eventTransport?: EventTransport | undefined; outbox?: OutboxStore | undefined; subjectBridge?: SubjectBridge | undefined; scheduleBridge?: ScheduleBridge | undefined; /** * Server-side house rules per scope. Wired, admission stops being opt-in by * the caller — see {@link AdmissionBridge}. */ admissionBridge?: AdmissionBridge | undefined; /** * Optional host-supplied outcome resolver. Called at checkout with the * final session snapshot. Return value is persisted to `session.outcome` * in the same transaction as the checkout write. */ outcomePolicy?: OutcomePolicy | undefined; /** * When `true`, multi-write domain verbs (`checkIn`, `checkOut`, * `breakStart`, `breakEnd`, `cancel`, `applyCorrection`) fall back to * NON-transactional execution on standalone Mongo deployments. Default * `false`: the verb throws on standalone Mongo so partial writes can't * happen. Hosts running dev with standalone Mongo opt in by setting this * to `true` AND accepting the partial-write risk that follows. */ allowNonTransactional?: boolean | undefined; /** * IANA zone the `businessDate` (roster day) is derived in — validated at * engine boot. Default `'UTC'`. A Dhaka deployment passes `'Asia/Dhaka'` * so a 23:30-local night-shift check-in lands on THAT local day. */ timezone?: string | undefined; /** * `punch()` double-tap window in ms (default 60 000): a second punch this * close to the session's start (or to a just-closed session's end) is * finger bounce, not a 40-second shift — returned as `debounced`, no write. */ punchDebounceMs?: number | undefined; logger?: { error(message: string, ...args: unknown[]): void; } | undefined; } /** * The period a summary covers. * * **Prefer the civil-date form** (`{ from: '2026-08-01', to: '2026-08-31' }`) — a * period is a calendar span, and muster resolves it against the business zone it * already derives `businessDate` in. Passing instants makes the CALLER own that * boundary, and every caller that owns it can get it wrong differently. */ interface SummaryRange { from: Date | string; to: Date | string; } declare class AttendanceSessionRepository extends Repository { private readonly eventTransport; private readonly outbox; private readonly pkgLogger; private readonly recordRepo; private readonly subjectBridge; private readonly scheduleBridge; private readonly admissionBridge; private readonly outcomePolicy; private readonly allowNonTransactional; private readonly timezone; private readonly punchDebounceMs; constructor(config: AttendanceSessionRepositoryConfig); /** * checkIn — create a new open session and append the first check_in * record. Rejects when the subject already has an open session in the * same scope (prevents double clock-in). * * If `SubjectBridge.verify()` is wired, the subject is validated before * writing. If `ScheduleBridge.resolveWindow()` is wired and the caller * didn't supply `scheduledStart`/`scheduledEnd`, we populate them from * the bridge. */ /** * LIVE occupancy of a scope — how many are inside RIGHT NOW. * * Domain math, not a `count` alias: `people` sums subjects PLUS their * companions, which is the number a capacity rule must compare against (a * member with two guests is three bodies). Reads the partial * `muster_live_occupancy` index. * * @param filter `scope` / `sessionKind`; omit both for the whole tenant. */ occupancy(filter: { scope?: string | undefined; sessionKind?: string | undefined; }, ctx: MusterContext): Promise; /** * Live occupancy grouped BY scope — the whole-facility dashboard ("pool 12, * spa 3, floor 41") in ONE aggregation instead of a query per room. * Scope-less sessions group under `''`. Descending by people. */ occupancyByScope(filter: { sessionKind?: string | undefined; }, ctx: MusterContext): Promise>; /** * Resolve a summary range to instants using THIS repository's business zone. * * Civil dates (`'2026-08-01'`) are what a period actually is — a calendar span * — so they are the preferred form; instants are accepted so existing callers * (payrun's AttendanceBridge) keep working verbatim. * * A mixed pair is refused rather than guessed: `{ from: '2026-08-01', to: someDate }` * has no single obvious meaning, and picking one silently would produce a range * nobody asked for. */ private resolveSummaryRange; /** * Worked / break / scheduled minutes for ONE subject over a period. * * The projection payroll needs, and the reason it lives here: muster owns the * sessions, the break accounting and the business-date boundaries, so computing this * anywhere else means re-deriving all three. The consumer (`@classytic/payrun`'s * `AttendanceBridge`) stays a two-line adapter, and muster gains no knowledge of pay. * * ## Only CLOSED sessions count * * An open session has no `actualEnd`, so its elapsed time is unbounded and would grow * while payroll ran. Counting it would make a payslip depend on WHEN the run executed * — the same value computed twice, differently. A shift still open at period end is a * missing-punch exception for an operator to correct, not minutes to pay. * * `breakMs` is subtracted, never allowed to push worked below zero: corrections can * currently make accumulated breaks exceed the elapsed interval (a known kernel gap), * and a NEGATIVE worked total would silently reduce someone's pay. */ summarizeForSubject(subjectRef: string, subjectModel: string, range: SummaryRange, ctx: MusterContext): Promise<{ workedMinutes: number; breakMinutes: number; scheduledMinutes: number; sessions: number; }>; /** * Shared occupancy aggregation. `groupBy: null` collapses to one total; * a field expression groups. * * Goes through mongokit's `aggregatePipeline` — NOT `Model.aggregate` — so * the POLICY HOOKS prepend the `$match` for tenant scope and soft-delete * themselves. Hand-rolling `{ organizationId }` here would both hardcode * the tenant field (muster's tenant field is configurable via * `injectTenantField`) and skip mongoose's casting, so a hex-string * `organizationId` would silently match nothing against an ObjectId column. * `repoOptionsFromCtx` forwards `organizationId` + `session`, so the guard * inside `checkIn` reads its own transaction's view. */ private occupancyAggregate; checkIn(input: CheckInInput, ctx: MusterContext): Promise; /** * checkOut — close an open session. FSM-guarded. Writes actualEnd, * computes durationMs, emits `session.closed`, appends check_out record. */ checkOut(sessionId: string, input: CheckOutInput, ctx: MusterContext): Promise; private checkOutUnit; /** * punch — a DIRECTION-LESS device tap (single-button biometric terminal, * RFID turnstile, ZKTeco-style fleet). Resolves what the tap means from * the subject's state: * * no open session → checkIn (`checked_in`) * open session, outside debounce window → checkOut (`checked_out`) * open session, inside debounce window → no-op (`debounced`) * session CLOSED within debounce window → no-op (`debounced`) * (a triple-tap must not open a ghost 1-second session) * idempotency key already consumed → no-op (`duplicate`) * * Concurrency: two concurrent DISTINCT first-punches race on the * open-session unique index — the loser reloads and resolves as the * SECOND punch (debounced/checked_out). Concurrent SAME-KEY deliveries * race on the record idempotency index — exactly ONE session and ONE * record are written, and every caller resolves to that same session. * Honest limit (rule 33): a concurrent same-key loser may report * `checked_in` rather than `duplicate` (it can't tell it wasn't the * writer); `duplicate` is guaranteed for SEQUENTIAL replays. Gateways * summarising a batch should count by session id, not by action. */ punch(input: PunchInput, ctx: MusterContext): Promise; private punchAgainstOpen; private toCheckInInput; private findOpenSession; private findJustClosedSession; /** * Resolve the session a consumed idempotency key belongs to — the * duplicate-delivery return path. */ private findSessionByRecordKey; /** * breakStart — append a break_start record. No FSM transition; session * stays `open`. Rejects when the session is already on break. */ breakStart(sessionId: string, input: BreakInput, ctx: MusterContext): Promise; /** * breakEnd — append a break_end record and accumulate break duration * onto the session. Rejects when the session is not on break. */ breakEnd(sessionId: string, input: BreakInput, ctx: MusterContext): Promise; /** * cancel — FSM-guarded transition to `cancelled`. Void an open session * (mistaken check-in, admin retraction). Unlike `checkOut`, cancel does * not set `actualEnd` or compute duration — the session is erased from * reporting. */ cancel(sessionId: string, input: CancelSessionInput, ctx: MusterContext): Promise; /** * applyCorrection — internal entrypoint used by * `AttendanceCorrectionRepository.approve()` once a correction is * authorised. Mutates the session according to `changes`, appends a * `correction_applied` record, and emits `session.corrected`. NEVER * transitions status; corrections can edit metadata but a closed session * stays closed. * * Pass `emitSessionCorrected: false` to defer the `session.corrected` * emission — `approve()` uses this so it can fire `correction.approved` * first (causation order: the approval causes the session correction). * When deferred, the caller must emit via the returned `emitCorrected()`. */ applyCorrection(sessionId: string | Types.ObjectId, correctionId: Types.ObjectId, correctionNumber: string, changes: ProposedSessionChanges, ctx: MusterContext, options?: { emitSessionCorrected?: boolean; }): Promise<{ session: AttendanceSessionDocument; record: AttendanceRecordDocument; emitCorrected: () => Promise; }>; /** * loadSession — accept either the public `sessionNumber` * (ATT-2026-0001) OR a 24-char ObjectId hex. Public routes will use the * former via Arc's adapter; callers holding an already-fetched document * will naturally pass `String(session._id)`. */ private loadSession; private appendRecord; private findLastRecord; /** * Resolve which original event-log record a correction supersedes. Returns * the record's _id, or undefined when the correction only edits fields that * have no underlying event entry (outcome / note / metadata). * * If the proposed change should target a record but none exists (e.g. the * correction edits actualEnd on a session that was never checked out), * returns undefined — the schema permits null/undefined for that case. */ private resolveCorrectionOf; private emitRecord; private emitDomain; /** * dispatch — unmanaged-session guard FIRST, then outbox.save, then * publish (deferred via the pending queue when one is attached). * * Why guard before outbox: an outbox row written with the host's session * commits when the host commits. If the publish path threw later, a host * that catches the error would still have a stale outbox row. Throwing * up front keeps the database clean even when the host swallows the * error inside its transaction body. * * Discipline by case: * - `ctx.session` set without a queue → throw `UnmanagedSessionError` * BEFORE writing to the outbox or publishing. Repository verbs that * don't go through `runUnit` (today: `request`, `reject`) also call * `assertManagedSession(ctx)` at their start so the throw lands * before ANY mutation. * - `ctx[PENDING_EVENTS]` set → outbox.save in-tx, then append (caller * flushes post-commit). * - Neither set → outbox.save standalone, then publish immediately. */ private dispatch; /** * Internal: build the `RunUnitConfig` bag every domain verb passes to * `runUnit(...)`. Single source of truth for the connection, transport, * logger, and the `allowNonTransactional` toggle. */ private get unitConfig(); } //#endregion export { AttendanceSessionRepository, AttendanceSessionRepositoryConfig, BreakInput, CancelSessionInput, CheckInInput, CheckOutInput, PunchInput, PunchResult, SummaryRange };