Skip to main content

API_CONTRACTS — staff-service

Sibling: APPLICATION_LOGIC · DOMAIN_MODEL · SECURITY_MODEL · EVENT_SCHEMAS

Strategic anchors: 05 API Design · standards/NAMING · standards/ERROR_CODES

All endpoints are versioned at /api/v1/… under the staff-service host. Request/response bodies are JSON; errors follow RFC 7807 Problem+JSON. Timestamps are ISO-8601 UTC unless explicitly noted as a property-local date (YYYY-MM-DD). Mutating endpoints accept Idempotency-Key and propagate traceparent. The OpenAPI spec is at openapi/staff-service.v1.yaml and is generated from this document; CI fails if they drift (pnpm -F staff-service openapi:diff).

This service is fronted by bff-backoffice-service for staff/manager UIs and bff-tenant-booking-service for any guest-facing staff lookups (rare). Direct client traffic is not allowed; the BFFs hold the only IAM principals authorized to call us.


1. Common Headers

HeaderRequiredNotes
Authorization: Bearer <jwt>yes (most)Issued by iam-service. PIN-clock endpoints accept an unauthenticated session but require X-Property-Id, X-Device-Id, and a device-scoped HMAC.
X-Tenant-Id: ten_…yesCross-checked against JWT; mismatch → 403 MELMASTOON.TENANT.MISMATCH
X-Property-Id: ppt_…yes for property-scoped endpointsValidated against staff property access
X-Device-Id: dev_…required for /clock/* and /sync/*Device-bound clock-in audit
Idempotency-Key: <ULID>yes for POST / PATCH24 h dedupe window
Accept-Language: <bcp47>recommendedDrives Problem+JSON title localization (en/ps/fa/ar/ur/tg)
traceparent: 00-…-…-01yes (set by gateway)W3C trace propagation
If-Match: "<version>"required for OCC writesRequired on all PATCH endpoints

2. Common Error Envelope

{
"type": "https://errors.melmastoon.com/STAFF/SHIFT_CONFLICT",
"title": "Cannot assign — staff has an overlapping primary shift.",
"status": 409,
"code": "MELMASTOON.STAFF.SHIFT_CONFLICT",
"detail": "Staff stf_01HZ… already assigned to shift shf_01HZ… 2026-04-22T06:00Z..14:00Z.",
"instance": "/api/v1/shifts/shf_01HZ.../assignments",
"traceId": "01HZ4WK7…",
"tenantId": "ten_01HZ…",
"violations": [
{ "field": "staffId", "rule": "double_shift", "conflictingShiftId": "shf_01HZ…" }
]
}
StatusUsed for
400Validation, malformed request
401Missing/invalid JWT or PIN
403Tenant mismatch, RBAC/ABAC denial, property access denial
404Not found (or RLS-hidden)
409OCC, illegal transition, shift conflict, multi-property active, leave collision
422Domain invariant violation that isn't a transient conflict
423PIN locked
429Rate-limited
503Downstream (iam, property) unavailable

3. Staff Endpoints

3.1 POST /api/v1/staff

Create a staff record. Supports both invite-with-email and PIN-only paths.

Request

{
"homePropertyId": "ppt_01HZ8X2K3M4N5P6Q7R8S9T0V1W",
"propertyAccess": ["ppt_01HZ8X2K3M4N5P6Q7R8S9T0V1W"],
"givenName": "Bilal",
"familyName": "Khan",
"email": "bilal@melmastoon-grand.example",
"phoneE164": "+93700123456",
"positionId": "pos_01HZ…FRONT_DESK",
"departmentId": "dpt_01HZ…FRONT_OFFICE",
"employmentType": "full_time",
"employmentStartedAt": "2026-04-15",
"spokenLanguages": [
{ "code": "ps", "proficiency": "native" },
{ "code": "en", "proficiency": "fluent" }
],
"skillIds": ["skl_01HZ…OPERA_PMS"],
"initialPin": "284917",
"inviteUser": true,
"emergencyContact": {
"fullName": "Sana Khan",
"relationship": "spouse",
"phoneE164": "+93700111222"
}
}

Response 201

{
"staffId": "stf_01HZA2B3C4D5E6F7G8H9J0K1L2",
"staffCode": "GM-DOH-FD-014",
"version": 1,
"pendingInvite": true,
"userId": null,
"createdAt": "2026-04-22T15:33:18.412Z"
}

Errors. STAFF.CONTACT_MISSING, STAFF.CODE_COLLISION, STAFF.PIN_INVALID_FORMAT, PROPERTY.NOT_FOUND, COMMON.RBAC_DENIED.


3.2 GET /api/v1/staff

List + filter staff. Cursor-paginated.

Query parameters

ParamTypeNotes
propertyIdstringrequired for non-tenant-admin actors
positionstringcomma-separated Position.code
departmentstringDepartment.code
statusenumactive (default), pending_invite, on_leave, suspended, terminated, any
querystringsubstring across staffCode, given/family name (citext)
languagestringBCP-47 code; matches any spoken language
pageSizeintdefault 50, max 200
cursorstringopaque

Response 200

{
"items": [
{
"staffId": "stf_01HZ…",
"staffCode": "GM-DOH-FD-014",
"displayName": "Bilal Khan",
"positionId": "pos_01HZ…FRONT_DESK",
"positionLabel": { "en": "Front Desk", "ps": "د مخکیني میز" },
"departmentId": "dpt_01HZ…",
"employmentStatus": "active",
"homePropertyId": "ppt_01HZ…",
"version": 3,
"updatedAt": "2026-04-22T11:01:09.000Z"
}
],
"nextCursor": null
}

3.3 GET /api/v1/staff/{staffId}

Returns the full Staff DTO including propertyAccess, spokenLanguages, skillIds, certificationIds. PII fields (emergencyContact, phoneE164) are present only for actors with staff.staff.read_pii. pinSet is a boolean — never returns the PIN HMAC.


3.4 PATCH /api/v1/staff/{staffId}

Partial update (per JSON Merge Patch). Cannot mutate tenantId, staffCode, employmentStartedAt, clockInPinHmac (use /staff/{id}/pin). OCC required. Emits staff.updated.v1.


3.5 POST /api/v1/staff/{staffId}/pin

Set or rotate PIN. Body: { "pin": "284917" }. PIN must be 6 digits, not all-same, not sequential (123456/654321).

Response 204, plus event staff.updated.v1 with changedFields: ["clockInPinHmac"].

Errors. STAFF.PIN_INVALID_FORMAT.


3.6 POST /api/v1/staff/{staffId}:change-position

Body: { "newPositionId", "newDepartmentId?", "effectiveAt": "ISO date", "reason?": "string" }. Emits staff.position_changed.v1. Future shifts where the staff is the only primary at the old position are cancelled (cascade).


3.7 POST /api/v1/staff/{staffId}:terminate

Body: { "effectiveAt": "ISO date", "reason": "string" }. Cascade per APPLICATION_LOGIC §3.6. Emits staff.terminated.v1. Cannot be reversed — use /reactivate to re-hire.


3.8 POST /api/v1/staff/{staffId}:reactivate

Re-hire a terminated staff. Body: { "newEmploymentStartedAt": "ISO date", "homePropertyId?", "positionId?", "departmentId?" }. Generates a new staffCode if the old one was reissued in the meantime. Emits staff.reactivated.v1.


3.9 POST /api/v1/staff/{staffId}/certifications

Add a certification. Body: { "type", "customLabel?", "issuedAt", "expiresAt?", "documentRef?" }. Emits staff.certification.added.v1. The TTL job emits .expired.v1 at expiresAt 00:00 UTC.


4. Position & Department Endpoints

VerbPathNotes
GET/api/v1/positionsTenant-scoped catalog
POST/api/v1/positions{ code, label, departmentId, capacitySignalKey }
PATCH/api/v1/positions/{id}Localized labels mutate frequently
GET/api/v1/departments?propertyId= required
POST/api/v1/departments

5. Shift & Pattern Endpoints

5.1 POST /api/v1/shift-patterns

{
"propertyId": "ppt_01HZ…",
"positionId": "pos_01HZ…HOUSEKEEPER",
"name": "Housekeeping Morning",
"cadence": "weekly",
"weekDays": ["mon","tue","wed","thu","fri","sat"],
"startLocal": "06:00",
"endLocal": "14:00",
"primaryHeadcount": 4,
"standbyHeadcount": 1,
"effectiveFrom": "2026-04-22"
}

5.2 POST /api/v1/shifts/generate

Materialize concrete shifts. Body:

{
"propertyId": "ppt_01HZ…",
"patternId": "shp_01HZ…",
"fromDate": "2026-04-22",
"toDate": "2026-05-22",
"dryRun": false
}

Response 200

{
"generated": 24,
"skippedExisting": 6,
"shifts": [
{ "shiftId": "shf_01HZ…", "localDate": "2026-04-22", "windowUtc": { "startUtc": "2026-04-22T01:30Z", "endUtc": "2026-04-22T09:30Z" } }
]
}

5.3 POST /api/v1/shifts (ad-hoc)

Body: { propertyId, positionId, localWindow, primaryHeadcount, standbyHeadcount?, notes? }. Emits staff.shift.scheduled.v1.

5.4 GET /api/v1/shifts

?propertyId=…&from=YYYY-MM-DD&to=YYYY-MM-DD&positionId=&status=. Paginated. Returns shifts with embedded assignment summary (assignedPrimaryCount, assignedStandbyCount).

5.5 POST /api/v1/shifts/{shiftId}:cancel

Body: { "reason": "string" }. Forbidden after in_progress. Emits staff.shift.cancelled.v1.

5.6 POST /api/v1/shifts/{shiftId}/assignments

Assign staff. Body: { "staffId", "role": "primary"|"standby"|"on_call", "force?": false }. Emits staff.shift.assigned.v1. Returns 409 with violations[] listing every conflict found.

5.7 DELETE /api/v1/shifts/{shiftId}/assignments/{assignmentId}

Soft-unassign. Body: { "reason": "string" }. Emits staff.shift.unassigned.v1.

5.8 POST /api/v1/shifts/assignments:swap

Atomic swap. Body: { "leftAssignmentId", "rightAssignmentId" }. Emits a single staff.shift.swapped.v1.

5.9 POST /api/v1/shifts/{shiftId}/assignments/{assignmentId}:promote

Promote a standby to primary (used during no-show response). Emits staff.shift.assigned.v1 with source='auto_promoted'.


6. Clock Endpoints

6.1 POST /api/v1/clock/punch (PIN or JWT)

Single punch. Body:

{
"authMode": "pin",
"pin": "284917",
"kind": "in",
"occurredAtUtc": "2026-04-22T05:59:48.221Z",
"shiftIdHint": "shf_01HZ…",
"source": "electron_pin",
"deviceId": "dev_01HZ…"
}

Response 201

{
"clockEntryId": "clk_01HZ…",
"staffId": "stf_01HZ…",
"kind": "in",
"occurredAtUtc": "2026-04-22T05:59:48.221Z",
"matchedShiftId": "shf_01HZ…",
"shiftStarted": true,
"openClockIsNowFor": "shf_01HZ…"
}

Errors. STAFF.PIN_INCORRECT, STAFF.PIN_LOCKED, STAFF.MULTI_PROPERTY_ACTIVE, STAFF.CLOCK_SEQUENCE_INVALID.

6.2 POST /api/v1/clock/punch:manager-override

Manager-initiated retroactive punch. Body adds staffId, reason. Requires staff.clock.write_other. Emits staff.clock.{in|out|...}.v1 with source='manager_override'.

6.3 GET /api/v1/clock/entries

?staffId=…&from=…&to=…&propertyId=&kind=. Paginated, append-only ordering by occurredAtUtc ASC.

6.4 GET /api/v1/clock/open

Returns the single open clock-in for a staff (across tenant): null or { clockEntryId, propertyId, shiftId?, openSince }. Used by Electron to render the "you are clocked in at X" badge.

6.5 GET /api/v1/capacity

The capacity snapshot. Cached in Redis with 30 s TTL.

?propertyId=ppt_…&position=HOUSEKEEPER&at=2026-04-22T15:00:00Z (at defaults to now)

Response 200

{
"propertyId": "ppt_01HZ…",
"asOf": "2026-04-22T15:00:00Z",
"byPosition": [
{
"positionCode": "HOUSEKEEPER",
"active": [
{ "staffId": "stf_01HZ…", "displayName": "Maryam B.", "shiftId": "shf_01HZ…", "onBreak": false, "clockInAt": "2026-04-22T06:01:11Z" }
],
"onBreak": [],
"scheduledNext": [
{ "staffId": "stf_01HZ…", "shiftId": "shf_01HZ…", "startsAtUtc": "2026-04-22T22:00:00Z" }
],
"headcountActive": 4,
"headcountRequired": 4,
"deficit": 0
}
],
"cacheTtlSeconds": 30
}

7. Leave Endpoints

7.1 POST /api/v1/leave-requests

Body: { staffId, type, windowLocal: { from, to }, reason? }. Emits staff.leave.requested.v1.

7.2 GET /api/v1/leave-requests

?staffId=…&status=…&from=…&to=…. Paginated.

7.3 POST /api/v1/leave-requests/{id}:approve

Body: { "decisionNote?", "forceUnassign?": false }. Returns 409 LEAVE_COLLISION with violations[] if forceUnassign=false and conflicts exist. Emits staff.leave.approved.v1.

7.4 POST /api/v1/leave-requests/{id}:reject

Body: { "decisionNote?": "string" }. Emits staff.leave.rejected.v1.

7.5 POST /api/v1/leave-requests/{id}:cancel

By the requester or manager+. Emits staff.leave.cancelled.v1. Forbidden after approved and the leave window has started.


8. Hand-off Notes

8.1 POST /api/v1/handoff-notes

Body: { propertyId, positionId, fromShiftId?, body, attachments? }. Append-only; emits staff.handoff.note_added.v1.

8.2 GET /api/v1/handoff-notes

?propertyId=…&positionId=…&since=ISO. Returns the most recent N (max 100) notes.

8.3 POST /api/v1/handoff-notes/{id}:ack

Body: { staffId }. Adds to acknowledgedBy[]. No event emitted.


9. Reports

9.1 GET /api/v1/reports/attendance

?propertyId=&from=YYYY-MM-DD&to=YYYY-MM-DD&staffId?&positionId?&format=json|csv.

Response 200 (json, paginated)

{
"items": [
{
"staffId": "stf_01HZ…",
"displayName": "Bilal Khan",
"totals": { "scheduledHours": 40.0, "actualHours": 39.5, "breakMinutes": 240, "lateCount": 1, "noShowCount": 0 },
"byDay": [
{ "date": "2026-04-22", "scheduledHours": 8, "actualHours": 7.75, "lateMinutes": 15 }
]
}
],
"nextCursor": null
}

The CSV variant streams via chunked transfer (max 5 minutes); job-style export for windows > 90 d goes through bff-backoffice-service's async-export pattern (returns { jobId }).

9.2 GET /api/v1/reports/staffing-gaps

?propertyId=&from=&to=&positionId?. Returns each staffing_gap_detected.v1 event projected, with the eventual resolution (resolved_at, resolution: 'standby_promoted' | 'manager_walked_in' | 'unresolved').


10. Suggestions (advisory AI)

10.1 GET /api/v1/shift-suggestions

?propertyId=&for=YYYY-MM-DD. Returns the most recent advisory ShiftSuggestion from ai-orchestrator-service. No :apply endpoint — managers transcribe to manual POST /shifts or :assign. AI usage is audited; provenance preserved per 02 §10.


11. Sync Surface

The Electron desktop never calls these endpoints directly; it talks to bff-backoffice-servicesync-service, which wraps the /sync/v1/pull and /sync/v1/push per 05 §10. The per-aggregate replication scope and conflict policy are declared in SYNC_CONTRACT.


12. Rate Limits

EndpointLimitScope
POST /clock/punch (any source)10 / minute / staffstaffId
POST /clock/punch (PIN-only)30 / minute / propertypropertyId
POST /staff and :terminate60 / minute / actoractorUserId
POST /shifts/generate5 / minute / actoractorUserId
GET /capacity600 / minute / property (cache-friendly)propertyId
GET /reports/attendance (csv)1 concurrent / actoractorUserId

Limits returned via Retry-After and X-RateLimit-* headers. Limits are tunable per-tenant via tenant-service quota.staff.*.


13. Versioning & Deprecation

  • URI versioning at /api/v1. Backwards-incompatible changes require /api/v2.
  • Field additions (non-breaking) ship in v1 with a featureFlag header opt-in for ≥ 14 d before becoming default.
  • Deprecated fields are flagged via Deprecation: true and Sunset: <RFC 1123 date> response headers (per 05 §6).