backend phase 1: config, reference & platform signals
Lay the cross-cutting platform backbone every later phase reads from. Adds the first marketplace EF migration baseline (new `ops` schema) and the mechanisms b2..b15 reuse: typed runtime config, an append-only audit trail, an analytics event log, the holiday/bank-closure calendar, in-app notifications, and the internal support-alert worklist. Schema & migration - New `ops` schema + migration InitialMarketplaceBaseline with 6 tables: PlatformConfigs (IAuditable), AuditLogs (append-only), SystemEvents, IranianHolidays, Notifications, SupportAlerts — with indexes/uniques and FKs to usr.Users. Seeded 12 config keys + 7 sample holidays via HasData. Domain / Application - IAuditable marker + [AuditRedacted] attribute; entities + string-code constant holders (config data_type, holiday type, alert type/severity/status). - Facade contracts: IPlatformConfig, IHolidayCalendar, IAnalyticsSink, IAuditLogger, INotificationService, ISupportAlertService; DTOs + PagedResult<T>; evolved the INotificationDispatcher.Notification record to carry Type + DataJson; Pagination helper. - 14 CQRS commands/queries (+ validators) wiring the endpoints to the facades. Infrastructure - DB-backed facade implementations in Persistence/Services/; real in-app INotificationDispatcher (removes the b0 log stub); notification-retention hosted service (purge is_read=1 AND age>90d). - Extended AuditFieldInterceptor to also append an old/new-diff audit_logs row for every IAuditable change in the same transaction (PII redacted). - Registered all facades + hosted service in AddPersistenceServices; removed the dispatcher registration from AddCrossCuttingSeams. API - 5 controllers: admin PlatformConfig/Holidays/Audit/SupportAlerts ([Authorize(DynamicPermission)]) + current-user Notifications ([Authorize]), all tenant-scoped and paginated. 16 Swagger paths total. Money-correctness & safety rules honoured - Config read at compute time (cached, parsed by data_type), never hardcoded; every config change is audited in the same transaction; audit_logs is append-only (no update/delete path); support alerts are admin-only; notifications are tenant-scoped; analytics is fire-and-forget. Tests & docs - 18 new foundation tests over in-memory SQLite (config typing + audit, holidays, notifications + tenancy + retention, support alerts, analytics); build clean (0 new code warnings), 22 tests green; migration applied to the dev DB and swagger.v1.json refreshed. - Updated server Project map + CONVENTIONS, product data-model doc 12 (seeded config defaults), config-reference contract, mock registry, backend handoff/ STATUS/report. Follow-ups: add FK constraints for SupportAlerts.BookingId (b9) and ReviewId (b14) when those tables land. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,113 @@
|
||||
# Contract — Config, Reference & Platform Signals (backend phase b1)
|
||||
|
||||
> Admin config/holiday/audit/support-alert endpoints + the current-user notification endpoints. Assumes
|
||||
> [`../conventions/api-conventions.md`](../conventions/api-conventions.md) +
|
||||
> [`../conventions/money-and-types.md`](../conventions/money-and-types.md). Machine schema (authoritative
|
||||
> for exact field/param casing): [`../openapi/swagger.v1.json`](../openapi/README.md).
|
||||
|
||||
**Status:** live as of backend-phase-1 · **Frontend consumer:** frontend-phase-f14 (notification center) / frontend-phase-f15 (admin config/holidays/audit/alerts)
|
||||
|
||||
All responses are the standard `OperationResult`→`ApiResult` envelope (camelCase body, snake_case URLs).
|
||||
Lists carry `{ items, total, page, pageSize }`. Pagination inputs are `page` (1-based) + `page_size`
|
||||
(default 50, max 100) — bound from the query string; derive exact casing from `swagger.v1.json`.
|
||||
|
||||
## Enums used
|
||||
- **config `data_type`**: `decimal` | `int` | `bool` | `string` | `json` — how to parse a config `value`.
|
||||
- **holiday `type`**: `official` | `religious` | `national`.
|
||||
- **support-alert `type`**: `low_rating` | `evv_no_show` | `evv_location_mismatch` | `verification_expired` | `payment_anomaly` | `fraud_signal`.
|
||||
- **support-alert `severity`**: `low` | `medium` | `high`.
|
||||
- **support-alert `status`**: `open` | `assigned` | `resolved` (forward-only).
|
||||
- **notification `type`**: open string code the front-end renders/deep-links on (e.g. `booking_confirmed`); its shape is the `data_json` contract (below), versioned per type.
|
||||
|
||||
---
|
||||
|
||||
## Admin — Platform config (`platform_config` controller, `[Authorize(DynamicPermission)]`)
|
||||
|
||||
### `GET api/v1/platform_config/get_platform_configs`
|
||||
- **Purpose:** list config rows. **Auth:** admin (DynamicPermission). **Rate-limited:** no.
|
||||
- **Query:** `page`, `page_size`.
|
||||
- **200 `data`:** `PagedResult<PlatformConfigDto>` — `{ items:[{ key, value, dataType, description }], total, page, pageSize }`.
|
||||
|
||||
### `POST api/v1/platform_config/update_platform_config`
|
||||
- **Purpose:** update one existing config row; writes an `audit_logs` entry in the same transaction and evicts the cache. **Auth:** admin.
|
||||
- **Body:** `{ "key": "platform_fee_rate", "value": "0.18" }`.
|
||||
- **200 `data`:** `true` (empty-body success).
|
||||
- **Failures:** `400` validation (empty key); `404` key does not exist. **Notes:** value is the raw string parsed per the row's `data_type`; changing a rate never retroactively re-prices already-computed rows.
|
||||
|
||||
### `GET api/v1/platform_config/get_config_change_history`
|
||||
- **Purpose:** the audited change history for one key (from the append-only trail). **Auth:** admin.
|
||||
- **Query:** `key` (required), `page`, `page_size`.
|
||||
- **200 `data`:** `PagedResult<ConfigChangeDto>` — `{ items:[{ id, action, changedFieldsJson, actorUserId, occurredAt }], … }`, newest first.
|
||||
|
||||
---
|
||||
|
||||
## Admin — Holidays (`holidays` controller, `[Authorize(DynamicPermission)]`)
|
||||
|
||||
### `GET api/v1/holidays/get_holidays`
|
||||
- **Query:** `from` (date, optional), `to` (date, optional), `page`, `page_size`.
|
||||
- **200 `data`:** `PagedResult<HolidayDto>` — `{ items:[{ id, holidayDate, nameFa, type, isBankClosed }], … }`, by date.
|
||||
|
||||
### `POST api/v1/holidays/upsert_holiday`
|
||||
- **Body:** `{ "holidayDate": "2026-03-21", "nameFa": "نوروز", "type": "national", "isBankClosed": true }`.
|
||||
- **200 `data`:** `true`. **Failures:** `400` (bad `type`, empty `nameFa`, default date). **Notes:** upsert keyed on `holidayDate`.
|
||||
|
||||
### `POST api/v1/holidays/delete_holiday`
|
||||
- **Body:** `{ "holidayDate": "2026-03-21" }`. **200:** `true`; **404** if no holiday on that date.
|
||||
|
||||
---
|
||||
|
||||
## Admin — Audit (`audit` controller, `[Authorize(DynamicPermission)]`)
|
||||
|
||||
### `GET api/v1/audit/get_audit_trail`
|
||||
- **Purpose:** the immutable trail for one entity. **Auth:** admin.
|
||||
- **Query:** `entity_type` (e.g. `PlatformConfig`), `entity_id` (string), `page`, `page_size`.
|
||||
- **200 `data`:** `PagedResult<AuditLogDto>` — `{ items:[{ id, entityType, entityId, action, changedFieldsJson, actorUserId, occurredAt }], … }`, newest first. **Notes:** read-only; there is no write/update/delete endpoint for audit rows.
|
||||
|
||||
---
|
||||
|
||||
## Admin — Support alerts (`support_alerts` controller, `[Authorize(DynamicPermission)]`, never user-facing)
|
||||
|
||||
### `GET api/v1/support_alerts/get_support_alerts`
|
||||
- **Query:** `type?`, `status?`, `owner_user_id?`, `page`, `page_size`.
|
||||
- **200 `data`:** `PagedResult<SupportAlertDto>` — `{ items:[{ id, type, severity, status, entityType, entityId, bookingId, reviewId, ownerUserId, resolutionNote, resolvedAt, createdAt }], … }`.
|
||||
|
||||
### `POST api/v1/support_alerts/assign_support_alert`
|
||||
- **Body:** `{ "alertId": 42, "ownerUserId": 7 }`. **200:** `true` (open → assigned); **404** if missing or already resolved.
|
||||
|
||||
### `POST api/v1/support_alerts/resolve_support_alert`
|
||||
- **Body:** `{ "alertId": 42, "note": "handled" }`. **200:** `true` (→ resolved); **404** if missing or already resolved.
|
||||
|
||||
---
|
||||
|
||||
## Current user — Notifications (`notifications` controller, `[Authorize]`, tenant-scoped)
|
||||
|
||||
Every endpoint is scoped to the signed-in caller (`ICurrentUser`) — never a body-supplied user id.
|
||||
|
||||
### `GET api/v1/notifications/get_notifications`
|
||||
- **Query:** `page`, `page_size`.
|
||||
- **200 `data`:** `PagedResult<NotificationDto>` — `{ items:[{ id, type, title, body, dataJson, isRead, readAt, createdAt }], … }`, **unread-first** then newest-first.
|
||||
|
||||
### `GET api/v1/notifications/get_unread_count`
|
||||
- **200 `data`:** `{ count }` — cheap index-backed count for the polling bell.
|
||||
|
||||
### `POST api/v1/notifications/mark_notification_read`
|
||||
- **Body:** `{ "notificationId": 100 }`. **200:** `true`; **404** if it isn't the caller's or doesn't exist.
|
||||
|
||||
### `POST api/v1/notifications/mark_all_read`
|
||||
- **No body. 200:** `true`.
|
||||
|
||||
> **Not exposed via REST** (internal contracts other backend domains call): `CreateNotification` (via
|
||||
> `INotificationDispatcher.DispatchAsync`), `RaiseSupportAlert` (`ISupportAlertService.RaiseAsync`),
|
||||
> `EmitSystemEvent` (`IAnalyticsSink.EmitAsync`), `WriteAuditLog` (`IAuditLogger.WriteAsync`). The
|
||||
> notification retention purge runs on a background hosted service, not an endpoint.
|
||||
|
||||
## Shared shapes
|
||||
- **`PlatformConfigDto`**: `key` (string), `value` (string, raw — parse per `dataType`), `dataType` (enum), `description` (string, nullable).
|
||||
- **`ConfigChangeDto`**: `id` (long), `action` (`created`/`updated`/`deleted`), `changedFieldsJson` (string, nullable — `{ "Field": { "old": …, "new": … } }`; encrypted/PII fields redacted as `"<redacted>"`), `actorUserId` (int, nullable), `occurredAt` (UTC ISO-8601).
|
||||
- **`HolidayDto`**: `id` (long), `holidayDate` (date), `nameFa` (string), `type` (enum), `isBankClosed` (bool).
|
||||
- **`AuditLogDto`**: `id` (long), `entityType` (string), `entityId` (string), `action`, `changedFieldsJson` (nullable), `actorUserId` (nullable), `occurredAt`.
|
||||
- **`NotificationDto`**: `id` (long), `type` (string code), `title` (string), `body` (string, nullable), `dataJson` (string, nullable — a **typed, versioned deep-link payload**; shape depends on `type`, e.g. `{"booking_id": 1}`), `isRead` (bool), `readAt` (UTC, nullable), `createdAt` (UTC).
|
||||
- **`SupportAlertDto`**: `id`, `type`, `severity`, `status`, `entityType` (string), `entityId` (string), `bookingId` (long, nullable), `reviewId` (long, nullable), `ownerUserId` (int, nullable), `resolutionNote` (string, nullable), `resolvedAt` (UTC, nullable), `createdAt` (UTC).
|
||||
|
||||
## Changelog
|
||||
- b1 — initial contract (config, holidays, audit, support alerts, notifications).
|
||||
Reference in New Issue
Block a user