APPLICATION_LOGIC — staff-service
Sibling: DOMAIN_MODEL · API_CONTRACTS · EVENT_SCHEMAS · SECURITY_MODEL
Strategic anchors: 02 §3 Application Architecture · 04 Event-Driven Architecture · standards/ERROR_CODES
The application layer orchestrates the domain. It owns the use cases, ports (interfaces toward infrastructure), policies (cross-cutting), and event handlers. It does not contain HTTP, SQL, Pub/Sub, or KMS code — those live in src/infrastructure/.
1. Layer Layout
src/application/
├── commands/
│ ├── staff/
│ │ ├── create-staff.use-case.ts
│ │ ├── update-staff.use-case.ts
│ │ ├── change-position.use-case.ts
│ │ ├── set-clock-pin.use-case.ts
│ │ ├── terminate-staff.use-case.ts
│ │ ├── reactivate-staff.use-case.ts
│ │ └── add-certification.use-case.ts
│ ├── shift/
│ │ ├── upsert-shift-pattern.use-case.ts
│ │ ├── generate-shifts.use-case.ts
│ │ ├── create-ad-hoc-shift.use-case.ts
│ │ ├── cancel-shift.use-case.ts
│ │ ├── assign-staff.use-case.ts
│ │ ├── unassign-staff.use-case.ts
│ │ ├── swap-assignments.use-case.ts
│ │ └── promote-standby.use-case.ts
│ ├── clock/
│ │ ├── punch-clock.use-case.ts
│ │ ├── manager-override-punch.use-case.ts
│ │ └── auto-close-shifts.use-case.ts
│ ├── leave/
│ │ ├── submit-leave.use-case.ts
│ │ ├── decide-leave.use-case.ts
│ │ └── cancel-leave.use-case.ts
│ └── handoff/
│ └── add-handoff-note.use-case.ts
├── queries/
│ ├── list-staff.query.ts
│ ├── get-shift-grid.query.ts
│ ├── get-capacity-snapshot.query.ts
│ ├── attendance-report.query.ts
│ └── leave-calendar.query.ts
├── ports/ # interfaces only — no implementations
│ ├── staff.repository.ts
│ ├── shift.repository.ts
│ ├── assignment.repository.ts
│ ├── clock.repository.ts
│ ├── leave.repository.ts
│ ├── handoff.repository.ts
│ ├── certification.repository.ts
│ ├── outbox.port.ts
│ ├── idempotency.store.ts
│ ├── pin-hmac.service.ts
│ ├── property-client.ts
│ ├── iam-client.ts
│ ├── ai-suggestion.consumer.ts
│ ├── notification.publisher.ts
│ └── clock.cache.ts
├── handlers/ # inbound event handlers (Pub/Sub → use cases)
│ ├── on-iam-user-registered.handler.ts
│ ├── on-tenant-membership-created.handler.ts
│ ├── on-tenant-membership-removed.handler.ts
│ ├── on-property-deactivated.handler.ts
│ └── on-ai-shift-optimization.handler.ts
├── policies/
│ ├── rbac.policy.ts
│ ├── property-access.policy.ts
│ └── pin-rate-limit.policy.ts
└── dto/
└── … (request/response shapes; never leaked into domain)
2. Selected Port Interfaces
// ports/clock.repository.ts
export interface ClockRepository {
appendEntry(entry: ClockEntry): Promise<void>; // throws ClockSequenceError on I-Clock-* violations
findOpenEntryForStaff(tenantId: TenantId, staffId: StaffId): Promise<ClockEntry | null>;
listForShift(tenantId: TenantId, shiftId: ShiftId): Promise<ClockEntry[]>;
listInRange(tenantId: TenantId, staffId: StaffId, from: ISODateTime, to: ISODateTime): Promise<ClockEntry[]>;
}
// ports/pin-hmac.service.ts
export interface PinHmacService {
hashPin(rawPin: string, staffId: StaffId, tenantId: TenantId): Promise<Uint8Array>; // KMS-backed pepper
verify(rawPin: string, staffId: StaffId, tenantId: TenantId, hmac: Uint8Array): Promise<boolean>;
}
// ports/outbox.port.ts
export interface OutboxPort {
// Always called within the same DB transaction as the write that triggered the event.
appendInTx<T>(tx: Tx, envelope: EventEnvelope<T>): Promise<void>;
}
// ports/iam-client.ts
export interface IamClient {
revokeAllSessionsForUser(tenantId: TenantId, userId: UserId, reason: string, correlationId: string): Promise<void>;
resolveUserByEmail(tenantId: TenantId, email: string): Promise<{ userId: UserId } | null>;
}
// ports/property-client.ts
export interface PropertyClient {
getPropertyTz(tenantId: TenantId, propertyId: PropertyId): Promise<IanaTz>;
isActive(tenantId: TenantId, propertyId: PropertyId): Promise<boolean>;
}
// ports/clock.cache.ts (Redis-backed)
export interface ClockCache {
setCapacitySnapshot(key: string, dto: CapacityDto, ttlSeconds: number): Promise<void>;
getCapacitySnapshot(key: string): Promise<CapacityDto | null>;
invalidateForProperty(tenantId: TenantId, propertyId: PropertyId): Promise<void>;
incrPinFailures(staffId: StaffId): Promise<number>; // returns new value
resetPinFailures(staffId: StaffId): Promise<void>;
}
// ports/idempotency.store.ts
export interface IdempotencyStore {
acquire(key: string, ttlSeconds: number): Promise<{ acquired: true } | { acquired: false; cachedResponse?: unknown }>;
saveResult(key: string, result: unknown): Promise<void>;
}
All ports are referenced by use cases via constructor injection. The infra layer wires the implementations in NestJS modules.
3. Use Case Specs (selected)
3.1 CreateStaff
Purpose. Provision a Staff record. Optionally invite a user (delegates to iam-service) or accept that the staff is PIN-only (no userId).
Inputs (DTO).
{
homePropertyId: PropertyId;
propertyAccess?: PropertyId[]; // defaults to [homePropertyId]
givenName: string;
familyName: string;
email?: string;
managerEmailForNotifications?: string;
phoneE164?: string;
positionId: PositionId;
departmentId: DepartmentId;
employmentType: EmploymentType;
employmentStartedAt: ISODate;
spokenLanguages?: SpokenLanguage[];
skillIds?: SkillId[];
initialPin?: string; // 6 digits; optional; if provided, hashed & stored
inviteUser?: boolean; // default true if email present
emergencyContact?: EmergencyContact;
}
Flow.
- RBAC: actor must hold
staff.staff.writeon the home property (see SECURITY_MODEL §2 RBAC). - Validate: I-Staff-2 (
email or managerEmail), property exists & active (viaPropertyClient),positionIdanddepartmentIdmatch tenant + propertyAccess. - Generate
staffCode(StaffCodeGenerator); on collision, retry once withseq+1, then failSTAFF.CODE_COLLISION. - If
inviteUser && email, calliam-client.inviteUser(...); persistuserIdif returned synchronously (else fill oniam.user.registered.v1). - If
initialPin, hash viaPinHmacService.hashPin; persist HMAC +clockInPinSetAt. - Open Tx →
staffRepository.insert(...)→outbox.appendInTx(staff.created.v1)→ commit. - Return DTO with
staffId,staffCode,pendingInviteboolean.
Errors.
PROPERTY.NOT_FOUND (404) · STAFF.CONTACT_MISSING (422) · STAFF.CODE_COLLISION (409) · STAFF.PIN_INVALID_FORMAT (400) · COMMON.RBAC_DENIED (403).
Idempotency. Required (Idempotency-Key header); response cached for 24 h.
Events emitted. melmastoon.staff.created.v1.
3.2 PunchClock
Purpose. Append a single clock entry. Used both for self-punch (Electron with PIN, web/mobile JWT) and as the building block for offline-replay and manager-override.
Inputs.
{
// Identity branches:
authMode: 'jwt' | 'pin';
staffId?: StaffId; // required for JWT mode (resolved from token + tenant)
tenantId: TenantId; // header
propertyId: PropertyId; // header
// PIN-mode:
pin?: string; // 6 digits
// Common:
kind: 'in' | 'out' | 'break_start' | 'break_end';
occurredAtUtc?: ISODateTime; // defaults to now; max 5 min skew vs server
shiftIdHint?: ShiftId; // optional: client knows the scheduled shift
source: ClockSource; // 'electron_pin' | 'electron_jwt' | 'mobile_jwt' | 'web_jwt' | 'offline_replay'
deviceId?: DeviceId;
offlineQueueAgeSeconds?: number; // present iff source='offline_replay'
}
Flow.
- Resolve
staffId:- JWT mode: from token claim + tenant scope.
- PIN mode: query
staffRepository.findByPinCandidate(tenantId, propertyId, pinHmacCandidates). The PIN HMAC is keyed per staff, so this is a constant-time per-staff lookup driven by candidate enumeration cap (M0: ≤ 250 staff per property → acceptable; see SECURITY_MODEL §4).
- PIN-mode: if found →
PinHmacService.verify; else increment shared counter and rejectPIN_INCORRECT. After 5 failures (per staff), emitPIN_LOCKEDand setclockInPinLockedUntil = now + 15min. - RBAC:
staff.clock.write_selffor self-punch,staff.clock.write_otherfor manager-override branch. - Multi-property gate:
clockRepository.findOpenEntryForStaff(...)— if open at a different property → rejectMULTI_PROPERTY_ACTIVE. - Sequence gate: per I-Clock-{1,2,3}.
- Resolve
shiftId: prefershiftIdHintif it covers the punch time (with 30-min before/after grace); else find a matching scheduled shift; else null (and onkind='in'set thestaffing_gap_detected.v1candidate flag for §6.2). - Open Tx → append
ClockEntry→ ifkind='in'and matched shift wasscheduled→ setShift.status='in_progress'+startedAtand emitstaff.shift.started.v1. Ifkind='out'and no remaining openinfor any primary on the shift → setstatus='completed'+endedAtand emitstaff.shift.ended.v1→ outboxstaff.clock.{in|out|break_started|break_ended}.v1→ commit. - Invalidate
clockCache.invalidateForProperty.
Errors. PIN_INCORRECT, PIN_LOCKED, MULTI_PROPERTY_ACTIVE, CLOCK_SEQUENCE_INVALID, RBAC_DENIED.
Idempotency. Required for offline-replay (key = client-generated ULID, persisted in Electron queue); ignored for live JWT punches but the (staffId, occurredAtUtc, kind) tuple is unique-indexed at the DB layer to prevent duplicates from double-tap.
Events emitted. One of staff.clock.in.v1 / .out.v1 / .break_started.v1 / .break_ended.v1; possibly also staff.shift.started.v1 or staff.shift.ended.v1.
3.3 GenerateShifts
Materialize concrete Shift rows from a ShiftPattern over a date window per property.
Inputs. { propertyId, patternId, fromDate, toDate, dryRun?: boolean }.
Flow. Load pattern → PatternMaterializer.materialize(pattern, propertyTz, fromDate, toDate) → for each candidate shift, compute TimeWindow, dedupe against existing shifts (unique (patternId, localWindow.date) if pattern-derived) → if dryRun, return preview; else open Tx → bulk insert + outbox one staff.shift.scheduled.v1 per row → commit.
Idempotency. Required; key includes (patternId, fromDate, toDate) so safe to retry.
3.4 AssignStaff
Assign a Staff to a Shift with role primary | standby | on_call.
Conflict checks (via ConflictEvaluator).
- I-Asn-2 (no overlapping primary).
- Active leave intersecting the shift window →
LEAVE_COLLISION. - Required certification for the position is expired →
CERTIFICATION_EXPIRED(warn-only ifforce=true). - Property access — staff's
propertyAccessmust includeShift.propertyId.
Idempotency. Required.
Events emitted. staff.shift.assigned.v1.
3.5 SwapAssignments
Atomic swap of two assignments (e.g., two staff trade shifts). Both assignments must be active, both staff must pass ConflictEvaluator against the other's shift. Single Tx; emits staff.shift.swapped.v1 once with both assignment IDs and chained swappedFromAssignmentId references.
3.6 TerminateStaff
Set employmentStatus='terminated', employmentEndedAt. Cascade:
- Auto-close any open clock-in (
source='system_auto', emitsclock.out.v1). - Soft-unassign all future assignments (preserve audit, set
unassignedAt). - Cancel future shifts where this staff was the only primary, marking with
cancelReason='primary_terminated'. - Call
iam-client.revokeAllSessionsForUser(...)ifuserIdpresent (fire-and-forget with retry). - Outbox
staff.terminated.v1withcascadeSummary.
Idempotent at the use-case level.
3.7 DecideLeave
Approve or reject a LeaveRequest.
- On approve:
- Compute colliding active assignments via
LeaveCollisionResolver. - If any and
forceUnassign=false→ reject use-case withLEAVE_COLLISIONand return the list. - Else: in Tx, mark
LeaveRequest.status='approved', soft-unassign collisions, setStaff.employmentStatus='on_leave'if leave window overlaps now, outboxstaff.leave.approved.v1(and astaff.shift.unassigned.v1per cascaded assignment — implicit via the assignment write-emits-outbox path).
- Compute colliding active assignments via
- On reject: simple state transition + outbox
staff.leave.rejected.v1.
4. Event-Driven Handlers (inbound)
4.1 OnIamUserRegistered
Subject: melmastoon.iam.user.registered.v1.
If a Staff exists with matching tenantId + email and userId is null → set userId, employmentStatus='active' (from pending_invite), outbox staff.updated.v1. Otherwise, no-op.
4.2 OnTenantMembershipCreated
Subject: melmastoon.tenant.membership.created.v1.
If payload.user_type=='staff' and no Staff exists for (tenantId, userId) → create a default-shell Staff (status pending_invite, no position, default home property = first active property of tenant) for the manager to complete. Emits staff.created.v1.
4.3 OnTenantMembershipRemoved
Subject: melmastoon.tenant.membership.removed.v1.
Cascade-terminate any active/on_leave/suspended Staff for (tenantId, userId) via the TerminateStaff use case (system actor).
4.4 OnPropertyDeactivated
Subject: melmastoon.property.deactivated.v1.
Cancel all scheduled future shifts for the property; emits one staff.shift.cancelled.v1 per shift with cancelReason='property_deactivated'. Active staff at the property are not auto-terminated; manager must decide.
4.5 OnAiShiftOptimization
Subject: melmastoon.ai.suggestion.shift_optimization.v1.
Persist the suggestion as an advisory ShiftSuggestion row (separate table — not an aggregate). Surface via GET /api/v1/shift-suggestions. Never auto-apply. Suggestions older than 14 days are TTL-purged.
All inbound handlers use the inbox pattern (per 04 §10) keyed on eventId for exactly-once consumption per consumer.
5. Cross-Cutting Policies
| Policy | Where | Notes |
|---|---|---|
| Tenant scope guard | TenantScopeMiddleware → sets app.tenant_id | All DB calls run under RLS |
| RBAC | RbacPolicy decorator on every controller | Maps to capability in SECURITY_MODEL §2 |
| Property-access ABAC | PropertyAccessPolicy | Manager scope is per-property |
| PIN attempt rate limit | PinRateLimitPolicy | Redis token bucket: 10/min/property |
| Idempotency | IdempotencyInterceptor (24 h TTL) | All POST and PATCH |
| Outbox + Tx | OutboxPort.appendInTx | Append in same Tx as state change |
| OCC | version column on every aggregate | Optimistic; retry-once on collision |
| Audit log | AuditWriter infra port | Every command appends an audit row |
6. Saga Participation
staff-service does not own a saga. It participates in two flows as a passive consumer + emitter:
6.1 IAM Termination Cascade
staff.terminated.v1 ──▶ iam-service (RPC: revokeAllSessions)
──▶ notification-service (manager + staff notify)
──▶ housekeeping-service / maintenance-service (rescind open assignments)
Compensation: none — termination is final at the staff layer. If a re-hire happens, staff.reactivated.v1 is the new fact, not a compensation.
6.2 Staffing Gap Detector (scheduled)
A scheduled job (auto-close-shifts.use-case) runs every minute:
- For each
Shiftwithstatus='scheduled'whosewindow.startUtc <= now + gapWarnMin(default 15) and no active primary clocked in → emitstaff.shift.staffing_gap_detected.v1. - For each
Shiftwithstatus='in_progress'whosewindow.endUtc + autoCloseGraceMin < now→ auto-close (forceclock.out.v1for any still-clocked-in primary, transition tocompleted, emitstaff.shift.ended.v1). - Idempotent via the per-shift
staffing_gap_emitted_atcolumn.
7. Selected Error Codes (application-mapped)
See DOMAIN_MODEL §6 for the full mapping. The application layer additionally raises:
| Code | HTTP | When |
|---|---|---|
MELMASTOON.STAFF.IDEMPOTENCY_REUSE_MISMATCH | 409 | Same Idempotency-Key reused with a different request body |
MELMASTOON.STAFF.PROPERTY_INACTIVE | 422 | Target property is deactivated |
MELMASTOON.STAFF.PIN_INVALID_FORMAT | 400 | PIN must be exactly 6 digits, not all-same |
MELMASTOON.COMMON.RBAC_DENIED | 403 | Capability missing |
MELMASTOON.COMMON.OCC_CONFLICT | 409 | version mismatch |
MELMASTOON.COMMON.RATE_LIMITED | 429 | Per-policy rate limit exceeded |