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