API_CONTRACTS — reporting-service
Sibling: APPLICATION_LOGIC · DOMAIN_MODEL · EVENT_SCHEMAS · SECURITY_MODEL
Strategic anchors: 05 API Design · standards/ERROR_CODES · standards/NAMING §10 API paths
All endpoints follow the platform conventions in 05 API Design: REST under /api/v1/reports, JSON content type, ULID + prefix identifiers, RFC 7807 Problem-Details errors with the canonical MELMASTOON.<DOMAIN>.<CODE> codes, optimistic concurrency via If-Match: "v<n>" on mutating endpoints, RFC 5988 link headers for pagination, Idempotency-Key accepted on every POST that creates server state.
1. Common headers (request)
| Header | Required | Purpose |
|---|---|---|
Authorization: Bearer <jwt> | yes | Caller identity (staff, system, partner) |
X-Tenant-Id: tnt_<ulid> | yes (except platform-admin endpoints) | Tenant context; cross-checked vs JWT |
X-Property-Id: ppt_<ulid> | recommended for ops-scoped reads | Property scoping |
X-Request-Id: <ULID> | recommended | Correlation; service generates one if absent |
Idempotency-Key: <ULID> | required on POSTs that create server state | Replay-safe |
If-Match: "v<n>" | required on PATCH / DELETE | OCC |
Accept-Language: <locale> | optional | Localized error userMessageKey |
2. Common headers (response)
X-Request-Id, X-Trace-Id, ETag: "v<n>" on every aggregate read, Retry-After on 429/503, Cache-Control per endpoint, Content-Disposition only for direct artifact downloads (we typically redirect to a V4 signed URL instead).
3. Error response shape
Per standards/ERROR_CODES. Reporting-specific codes are added in the same PR that introduces them under a new REPORTING section:
| Code | HTTP | Retriable |
|---|---|---|
MELMASTOON.REPORTING.TEMPLATE_LOCKED | 409 | no |
MELMASTOON.REPORTING.TEMPLATE_VERSION_LOCKED | 409 | no |
MELMASTOON.REPORTING.TEMPLATE_NOT_FOUND | 404 | no |
MELMASTOON.REPORTING.FILTER_INVALID | 422 | no |
MELMASTOON.REPORTING.FORMAT_NOT_SUPPORTED | 422 | no |
MELMASTOON.REPORTING.RUN_TERMINAL | 409 | no |
MELMASTOON.REPORTING.RUN_NOT_FOUND | 404 | no |
MELMASTOON.REPORTING.RENDER_BUDGET_EXCEEDED | 422 | no |
MELMASTOON.REPORTING.ARTIFACT_LOCKED | 409 | no |
MELMASTOON.REPORTING.ARTIFACT_CHECKSUM_MISMATCH | 500 | no |
MELMASTOON.REPORTING.SCHEDULE_INVALID_CRON | 422 | no |
MELMASTOON.REPORTING.SCHEDULE_INVALID_TZ | 422 | no |
MELMASTOON.REPORTING.SCHEDULE_NO_RECIPIENTS | 422 | no |
MELMASTOON.REPORTING.SUBSCRIPTION_LIMIT | 429 | no |
MELMASTOON.REPORTING.REGULATORY_INCONSISTENT | 422 | no |
MELMASTOON.REPORTING.REGULATORY_ADAPTER_MISSING | 409 | no |
MELMASTOON.REPORTING.SUBMISSION_RETRY_EXHAUSTED | 409 | no |
MELMASTOON.REPORTING.PROOF_OF_DELIVERY_REQUIRED | 500 | no |
Universal codes (MELMASTOON.GENERAL.*, MELMASTOON.IDENTITY.*, MELMASTOON.TENANT.*, MELMASTOON.SYNC.*) apply.
4. Templates
4.1 POST /api/v1/reports/templates
Create a new logical template with its first version. Subsequent versions go through POST /:id/versions.
Auth: platform_admin (when tenantId omitted ⇒ shared) or tenant_admin/gm/owner.
Request:
{
"key": "reservation.daily_arrivals",
"category": "operational",
"tenantId": "tnt_01H...", // optional; omit ⇒ platform-shared
"spec": {
"columns": [
{ "key": "reservationCode", "header": { "default": "Code", "variants": { "fa-AF": "کد" } }, "type": "string" },
{ "key": "guest.fullName.latin", "header": { "default": "Guest" }, "type": "string" },
{ "key": "stayWindow.start", "header": { "default": "Arrival" }, "type": "date", "format": { "pattern": "yyyy-MM-dd" } },
{ "key": "totals.grandTotalMicro", "header": { "default": "Total" }, "type": "currency", "format": { "currency": "TENANT", "decimals": 2 }, "align": "end" }
],
"filters": [
{ "key": "propertyId", "type": "tenant_property_picker", "required": true, "multi": false },
{ "key": "stayDate", "type": "date_range", "required": true }
],
"layout": [
{ "kind": "header", "logo": "tenant", "title": { "default": "Daily Arrivals" } },
{ "kind": "kpi_band", "metrics": [
{ "label": { "default": "Arriving" }, "sourceKey": "kpi.arriving" },
{ "label": { "default": "Pre-Paid" }, "sourceKey": "kpi.prepaid" }
]},
{ "kind": "table", "columns": ["reservationCode","guest.fullName.latin","stayWindow.start","totals.grandTotalMicro"], "sourceKey": "rows" },
{ "kind": "footer", "signed": true, "pageNumbers": true }
],
"supportedFormats": ["pdf","xlsx","csv"],
"defaultLocale": "fa-AF",
"localeVariants": ["fa-AF","ps-AF","en-US"],
"regulatory": false,
"retentionClass": "operational_2y",
"dataSourceSpec": {
"primary": { "kind": "service_read", "service": "reservation", "queryName": "arrivalsForDate" }
}
}
}
Response: 201 Created
{
"templateId": "tpl_rep_01H...",
"versionId": "tpv_01H...",
"versionNumber": 1,
"publishedAt": "2026-04-22T10:00:00Z"
}
4.2 POST /api/v1/reports/templates/:id/versions
Publish a new version of an existing template. Body matches spec in §4.1.
Errors: MELMASTOON.REPORTING.TEMPLATE_VERSION_OUT_OF_ORDER, MELMASTOON.REPORTING.LAYOUT_REFERENCE_INVALID, MELMASTOON.REPORTING.LOCALE_VARIANT_MISSING.
4.3 GET /api/v1/reports/templates
List templates available to the caller. Query: category=operational|financial|compliance|regulatory|manager_dashboard, regulatory=true|false, jurisdictionCode=AF, cursor=, limit<=100.
Response:
{
"items": [
{ "templateId": "tpl_rep_01H...", "key": "reservation.daily_arrivals", "category": "operational",
"latestVersion": 4, "regulatory": false, "supportedFormats": ["pdf","xlsx","csv"], "archived": false }
],
"next": null
}
4.4 GET /api/v1/reports/templates/:id/versions
Returns version history (immutable). Useful for auditors; supports ?at=<RFC3339> to resolve "what was the latest version at this date".
4.5 DELETE /api/v1/reports/templates/:id
Archives a template. Existing scheduled runs continue to use the last published version until the schedule is updated.
5. Reports (logical instances)
5.1 POST /api/v1/reports
{
"templateId": "tpl_rep_01H...",
"templateVersionPin": null,
"displayName": { "default": "Daily Arrivals - Kabul Branch" },
"defaultFilters": { "propertyId": "ppt_01H...", "stayDate": { "preset": "today" } }
}
Response: 201 with reportId.
5.2 PATCH /api/v1/reports/:id
Updates displayName, templateVersionPin, defaultFilters. OCC via If-Match.
5.3 GET /api/v1/reports/:id
Returns Report plus recentRunIds (last 30).
6. Runs
6.1 POST /api/v1/reports/runs
Request a render. Idempotency-Key required.
Request:
{
"reportId": "rep_01H...",
"templateVersionPin": null,
"filters": { "propertyId": "ppt_01H...", "stayDate": { "from": "2026-04-22", "to": "2026-04-22" } },
"formats": ["pdf","csv"],
"locale": "fa-AF",
"correlationId": "01H..."
}
Response: 202 Accepted
{
"runId": "run_01H...",
"status": "queued",
"queuedAt": "2026-04-22T10:00:00Z",
"expectedReadyBy": "2026-04-22T10:00:30Z",
"subscribePollUrl": "/api/v1/reports/runs/run_01H...",
"subscribeStreamUrl": "/api/v1/reports/runs/run_01H.../events"
}
Errors: FILTER_INVALID, FORMAT_NOT_SUPPORTED, TEMPLATE_LOCKED, RATE_LIMITED, IDEMPOTENCY_KEY_REUSED.
6.2 GET /api/v1/reports/runs/:id
Returns the full run.
{
"runId": "run_01H...",
"reportId": "rep_01H...",
"templateId": "tpl_rep_01H...",
"templateVersionId": "tpv_01H...",
"templateVersionNumber": 4,
"status": "completed",
"queuedAt": "2026-04-22T10:00:00Z",
"startedAt": "2026-04-22T10:00:01Z",
"renderedAt": "2026-04-22T10:00:10Z",
"completedAt": "2026-04-22T10:00:11Z",
"artifacts": [
{ "artifactId": "art_01H...", "format": "pdf", "locale": "fa-AF", "sizeBytes": 187432, "sha256": "…" },
{ "artifactId": "art_01H...", "format": "csv", "locale": "fa-AF", "sizeBytes": 12031, "sha256": "…" }
],
"deliveries": [
{ "subscriptionId": "sub_01H...", "channel": "email", "status": "delivered", "deliveredAt": "2026-04-22T10:00:13Z" }
],
"version": 5
}
6.3 GET /api/v1/reports/runs/:id/events (SSE)
Server-Sent Events stream of status transitions; closes on completed/failed/cancelled. Used by the desktop and BFFs to avoid polling.
6.4 POST /api/v1/reports/runs/:id/cancel
Cancels a queued/running run. 409 RUN_TERMINAL if already terminal.
6.5 GET /api/v1/reports/runs/:id/artifacts/:artId/download
Returns 302 Found with Location: <V4 signed URL> and Cache-Control: private, max-age=60. The signed URL TTL is 15 min (SIGNED_URL_TTL_SECONDS=900).
For partner integrations that cannot follow redirects, ?inline=true returns the raw bytes streamed directly (heavier, audited at higher severity).
6.6 GET /api/v1/reports/runs
List runs. Query: reportId, status, from, to, cursor, limit<=100.
7. Schedules
7.1 POST /api/v1/reports/schedules
{
"reportId": "rep_01H...",
"cronExpr": "0 6 * * *",
"timezone": "Asia/Kabul",
"templateVersionPin": null,
"filters": { "propertyId": "ppt_01H...", "stayDate": { "preset": "today" } },
"subscriptionIds": ["sub_01H..."]
}
Errors: SCHEDULE_INVALID_CRON, SCHEDULE_INVALID_TZ, SCHEDULE_NO_RECIPIENTS, SCHEDULE_COLLISION.
7.2 PATCH /api/v1/reports/schedules/:id
Update cron, recipients, status (active/paused). To re-enable a disabled_after_failures schedule, set status="active" (audit-recorded).
7.3 DELETE /api/v1/reports/schedules/:id
Disables.
7.4 POST /internal/jobs/schedule-fire (system, GCP-Scheduler)
Cloud Scheduler endpoint. Body: { scheduleId: "sch_01H...", firedAt: "..." }. Auth: GCP service-account JWT, principal must be scheduler@<project>.iam.gserviceaccount.com. Maps to FireScheduleUseCase.
8. Subscriptions
8.1 POST /api/v1/reports/subscriptions
{
"reportId": "rep_01H...",
"recipient": { "kind": "user", "userId": "usr_01H..." },
"channel": "email",
"format": "pdf",
"locale": "fa-AF"
}
For desktop_device recipients the recipient.deviceId must be a dev_* bound to a current member; the subscription delivers via the desktop sync engine (artifact pulled into SQLite-backed local cache).
8.2 DELETE /api/v1/reports/subscriptions/:id
Cancels. Idempotent.
8.3 GET /api/v1/reports/subscriptions?reportId=…
Lists active subscriptions. Returns last delivery state per subscription.
9. Filters (saved)
9.1 POST /api/v1/reports/filters
{
"name": "Kabul, Today, Direct only",
"scope": "tenant", // 'user' or 'tenant'
"filters": { "propertyId": "ppt_01H...", "channel": ["direct"], "stayDate": { "preset": "today" } }
}
9.2 GET /api/v1/reports/filters
Lists. Query: scope, cursor, limit.
9.3 DELETE /api/v1/reports/filters/:id
10. Regulatory submissions
10.1 GET /api/v1/reports/regulatory/submissions
Lists submissions. Query: status, jurisdictionCode, from, to. Sample item:
{
"submissionId": "reg_01H...",
"runId": "run_01H...",
"jurisdictionCode": "AF",
"adapterRef": "af.tourism.daily_guests.v1",
"status": "succeeded",
"attempts": 1,
"submittedAt": "2026-04-22T22:00:01Z",
"succeededAt": "2026-04-22T22:00:09Z",
"proofOfDelivery": {
"receiptKind": "signed_xml_receipt",
"registerReference": "AF-TOUR-2026-04-22-038271",
"storedAtObjectPath": "tnt_01H.../regulatory/.../receipt.xml"
}
}
10.2 POST /api/v1/reports/regulatory/submissions/:id/retry
Manually retry. Auth: compliance_officer, gm, owner. 409 SUBMISSION_RETRY_EXHAUSTED if attempts >= maxAttempts and not manually escalated.
10.3 POST /api/v1/reports/regulatory/submissions/:id/manual-resolve
Mark as manually_resolved with operator-supplied resolutionNote. Audit-recorded; emits regulatory.submission_succeeded.v1 with proofOfDelivery.receiptKind='paper_print_signature' if the operator printed and hand-delivered.
11. Internal endpoints
| Path | Caller | Purpose |
|---|---|---|
POST /internal/events/notification-delivered | Pub/Sub push | Inbox handler for notification.delivery.recorded.v1 |
POST /internal/events/tenant-deleted | Pub/Sub push | Cascade purge |
POST /internal/events/report-completed | Pub/Sub push (self-loop) | Trigger subscription dispatch + regulatory dispatch |
POST /internal/jobs/schedule-fire | Cloud Scheduler | See §7.4 |
POST /internal/jobs/regulatory-retry | Cloud Scheduler | Triggers RetryRegulatorySubmissionUseCase |
GET /internal/health | VPC | DB + Pub/Sub + GCS readiness |
GET /internal/ready | VPC | All inbox subscriptions attached |
All /internal/events/* validate the GCP-issued OIDC token and refuse non-Pub/Sub principals. All /internal/jobs/* validate the Cloud Scheduler service account.
12. Pagination, sorting, rate limits
- Default
limit=50, maxlimit=100. Response envelope includesnextcursor (opaque). - Sort: deterministic
(updated_at DESC, id DESC)for list endpoints. - Rate limits per 05 §11: 600 req/min per tenant on read endpoints; 60 req/min on
POST /runsper tenant; 5 concurrent in-flight regulatory retries per tenant.
13. OpenAPI
Generated by NestJS Swagger from controller decorators into openapi.json and committed; CI's OpenAPI diff gate fails any breaking change without a /api/v1 → /api/v2 jump. Pact consumer tests live in bff-backoffice-service and notification-service and run on every PR via the Pact broker.
14. Cross-references
- Use cases backing each endpoint: APPLICATION_LOGIC §2
- Event payloads emitted: EVENT_SCHEMAS
- RBAC matrix: SECURITY_MODEL §2
- Sync surface used by desktop instead of these REST calls for subscribed reports: SYNC_CONTRACT