Skip to main content

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.

  1. RBAC: actor must hold staff.staff.write on the home property (see SECURITY_MODEL §2 RBAC).
  2. Validate: I-Staff-2 (email or managerEmail), property exists & active (via PropertyClient), positionId and departmentId match tenant + propertyAccess.
  3. Generate staffCode (StaffCodeGenerator); on collision, retry once with seq+1, then fail STAFF.CODE_COLLISION.
  4. If inviteUser && email, call iam-client.inviteUser(...); persist userId if returned synchronously (else fill on iam.user.registered.v1).
  5. If initialPin, hash via PinHmacService.hashPin; persist HMAC + clockInPinSetAt.
  6. Open Tx → staffRepository.insert(...) → outbox.appendInTx(staff.created.v1) → commit.
  7. Return DTO with staffId, staffCode, pendingInvite boolean.

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.

  1. 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).
  2. PIN-mode: if found → PinHmacService.verify; else increment shared counter and reject PIN_INCORRECT. After 5 failures (per staff), emit PIN_LOCKED and set clockInPinLockedUntil = now + 15min.
  3. RBAC: staff.clock.write_self for self-punch, staff.clock.write_other for manager-override branch.
  4. Multi-property gate: clockRepository.findOpenEntryForStaff(...) — if open at a different property → reject MULTI_PROPERTY_ACTIVE.
  5. Sequence gate: per I-Clock-{1,2,3}.
  6. Resolve shiftId: prefer shiftIdHint if it covers the punch time (with 30-min before/after grace); else find a matching scheduled shift; else null (and on kind='in' set the staffing_gap_detected.v1 candidate flag for §6.2).
  7. Open Tx → append ClockEntry → if kind='in' and matched shift was scheduled → set Shift.status='in_progress' + startedAt and emit staff.shift.started.v1. If kind='out' and no remaining open in for any primary on the shift → set status='completed' + endedAt and emit staff.shift.ended.v1 → outbox staff.clock.{in|out|break_started|break_ended}.v1 → commit.
  8. 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 if force=true).
  • Property access — staff's propertyAccess must include Shift.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:

  1. Auto-close any open clock-in (source='system_auto', emits clock.out.v1).
  2. Soft-unassign all future assignments (preserve audit, set unassignedAt).
  3. Cancel future shifts where this staff was the only primary, marking with cancelReason='primary_terminated'.
  4. Call iam-client.revokeAllSessionsForUser(...) if userId present (fire-and-forget with retry).
  5. Outbox staff.terminated.v1 with cascadeSummary.

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 with LEAVE_COLLISION and return the list.
    • Else: in Tx, mark LeaveRequest.status='approved', soft-unassign collisions, set Staff.employmentStatus='on_leave' if leave window overlaps now, outbox staff.leave.approved.v1 (and a staff.shift.unassigned.v1 per cascaded assignment — implicit via the assignment write-emits-outbox path).
  • 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​

PolicyWhereNotes
Tenant scope guardTenantScopeMiddleware → sets app.tenant_idAll DB calls run under RLS
RBACRbacPolicy decorator on every controllerMaps to capability in SECURITY_MODEL §2
Property-access ABACPropertyAccessPolicyManager scope is per-property
PIN attempt rate limitPinRateLimitPolicyRedis token bucket: 10/min/property
IdempotencyIdempotencyInterceptor (24 h TTL)All POST and PATCH
Outbox + TxOutboxPort.appendInTxAppend in same Tx as state change
OCCversion column on every aggregateOptimistic; retry-once on collision
Audit logAuditWriter infra portEvery 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 Shift with status='scheduled' whose window.startUtc <= now + gapWarnMin (default 15) and no active primary clocked in → emit staff.shift.staffing_gap_detected.v1.
  • For each Shift with status='in_progress' whose window.endUtc + autoCloseGraceMin < now → auto-close (force clock.out.v1 for any still-clocked-in primary, transition to completed, emit staff.shift.ended.v1).
  • Idempotent via the per-shift staffing_gap_emitted_at column.

7. Selected Error Codes (application-mapped)​

See DOMAIN_MODEL §6 for the full mapping. The application layer additionally raises:

CodeHTTPWhen
MELMASTOON.STAFF.IDEMPOTENCY_REUSE_MISMATCH409Same Idempotency-Key reused with a different request body
MELMASTOON.STAFF.PROPERTY_INACTIVE422Target property is deactivated
MELMASTOON.STAFF.PIN_INVALID_FORMAT400PIN must be exactly 6 digits, not all-same
MELMASTOON.COMMON.RBAC_DENIED403Capability missing
MELMASTOON.COMMON.OCC_CONFLICT409version mismatch
MELMASTOON.COMMON.RATE_LIMITED429Per-policy rate limit exceeded