Files
2026-06-28 21:59:59 +03:30

415 lines
32 KiB
Markdown

# Backend Phase 1 — Config, reference & platform signals
> **Mission:** lay the platform backbone every later phase reads from or writes to. This phase creates
> the **first marketplace EF migration baseline** (b0 deleted the old starter migrations) and stands up
> the cross-cutting tables nothing in the marketplace can run without: typed runtime config
> (`platform_configs`), the immutable audit trail (`audit_logs`) wired to the SaveChanges interceptor,
> the analytics event log (`system_events`), the shared `iranian_holidays` calendar that shifts payout
> dates, in-app `notifications`, and the internal `support_alerts` staff worklist. It ships the typed
> cached config accessor, the holiday calendar, the audit/event/notification/alert capabilities, seeds
> the canonical config keys (fee rate, VAT, dispute window, deadlines, payout interval, EVV tolerance,
> BNPL flags, cancellation tiers) and a sample holiday set — so b2…b15 read real values instead of
> hardcoding money-critical constants.
>
> **Track:** backend · **Depends on:** [backend-phase-0](backend-phase-0.md) (the cross-cutting seams, `ICurrentUser`, the audit-field SaveChanges interceptor, `IFieldEncryptor`, `ICacheService`) · **Unlocks:** every phase that reads config/holidays or raises notifications/alerts — i.e. all of b2…b15
> **Before you start, read [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md).** It is not optional.
---
## 1. Context — where this sits
This is the **second** backend phase and the foundation for the rest of the chain. b0 cleaned the
starter skeleton, stood up the REST surface, wired rate limiting + request logging + current-user/
audit-field stamping, and **defined the cross-cutting seams as DI-registered mocks**. It deliberately
shipped **no domain tables** — that starts here. Everything b2…b15 does (auth, profiles, catalog,
booking, payments, payouts, reviews, admin) reads config rows, shifts dates off holidays, writes audit
rows, raises notifications, or raises support alerts. Those four mechanisms are built **once, here**, so
no later phase reinvents them.
This is also where the **first marketplace migration baseline** is born: b0 removed the three pre-2024
starter migrations and their snapshot. The migration you generate in this phase is the new `Persistence`
baseline that every subsequent phase adds incrementally onto.
**What already exists (do not rebuild) — built by [backend-phase-0](backend-phase-0.md):**
- The cross-cutting **seams** with in-memory mocks, DI-registered via `ServiceConfiguration/` extensions
and selected by config: **`ICacheService`** (in-memory `IMemoryCache`; your config accessor caches
through it), **`IFieldEncryptor`** (`Encrypt`/`Decrypt`/`Hash`; reuse for any encrypted column),
**`IDateTimeProvider`** (`DateTimeOffset UtcNow`; never call `DateTime.Now`), **`IObjectStorage`**,
and **`INotificationDispatcher`** (b0 shipped a **no-op/log stub****this phase supersedes it** with a
real in-app `notifications` write; see §3.6 and §4).
- **`ICurrentUser`** (Application contract, Scoped, null-object outside HTTP) and the **SaveChanges
interceptor** that stamps `CreatedAt/ModifiedAt/CreatedById/ModifiedById`. b0 left a **clean extension
point on that interceptor for b1 to add audit-log-row writing** — you extend it here, you do not write
a parallel interceptor.
- The full working spine: ASP.NET Core Identity + JWE/JWT + phone-OTP, dynamic-permission RBAC, the CQRS
pipeline (`ValidateCommandBehavior`, `MetricsBehaviour`, and the now-registered `LoggingBehavior`),
`OperationResult<T>`, Mapster, FluentValidation, the `BaseController` + MVC/versioning/Swagger stack,
the REST controller surface under `Baya.Web.Api/Controllers/V1/`, rate-limiting policies, and
`Baya.Tests.Setup` (in-memory SQLite). Identity tables live in the **`usr`** schema.
- The 12-project Clean-Arch solution `Baya.sln`. The `swagger.json` envelope snapshot is published so the
frontend's f0 type pipeline already knows the `OperationResult`/`ApiResult` shape.
**What is NOT yet built (you build it here):** any marketplace table, any migration, the typed config
accessor, the holiday calendar, audit-row writing, system-event emission, notifications, support alerts.
## 2. Required reading (do this first)
**Operating rules & conventions**
- [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md) and
[`../_shared/backend-conventions-checklist.md`](../_shared/backend-conventions-checklist.md).
- [`server/CLAUDE.md`](../../../server/CLAUDE.md) — *Project map*, *Persistence*, *Startup wiring*,
*Features* (the `Features/<Area>/{Commands|Queries}/<Name>/` layout); and
[`server/CONVENTIONS.md`](../../../server/CONVENTIONS.md) — §6 persistence (configs per entity, audit
fields, soft-delete filters, EF projection + pagination), §9 logging (no PII), §12 service registration.
**Product / domain truth (the business rules are decisions, read them — don't infer from code)**
- [`product/data-model/12-audit-config-and-reference.md`](../../../product/data-model/12-audit-config-and-reference.md)
— the canonical field list and roles of `audit_logs`, `system_events`, `platform_configs`, and the
**new** `iranian_holidays` table (exact columns) + the full config-key list to seed.
- [`product/data-model/11-notifications.md`](../../../product/data-model/11-notifications.md) — the roles
of `notifications` (N:1 → `users`, `data_json` typed payload, 90-day read-retention) and
`support_alerts` (internal-only, polymorphic `entity_type`/`entity_id` + nullable typed FKs).
- [`product/business/14-notifications-and-admin.md`](../../../product/business/14-notifications-and-admin.md)
*why* in-app-only/polled, the 90-day retention rule, the admin/backoffice spine these rows feed, the
append-only audit requirement, and the Shamsi/holiday reasoning back-office depends on.
- [`product/business/13-tax-invoicing-and-legal.md`](../../../product/business/13-tax-invoicing-and-legal.md)
*why* `vat_rate` (10%, configurable, applies to the **commission line** only) and the BNPL
merchant-of-record config flags exist; finance must be able to prove the exact rate at any past moment
(this is the reason every config change is audited).
**Contract conventions you must honour**
- [`../../contracts/conventions/api-conventions.md`](../../contracts/conventions/api-conventions.md) — the
`OperationResult` envelope, snake_case routes, mandatory list pagination (`page`/`page_size`),
localisation (`name_fa` returned for reference data), status codes.
- [`../../contracts/conventions/money-and-types.md`](../../contracts/conventions/money-and-types.md) — IRR
`BIGINT`/`long`, no floats. `platform_fee_rate`/`vat_rate` are **rates** (`DECIMAL`), not money; the
amounts they later multiply are IRR `long`.
**Prior handoff**
- [`../../shared-working-context/backend/handoff/after-backend-phase-0.md`](../../shared-working-context/backend/handoff/after-backend-phase-0.md)
and [`../../shared-working-context/reports/backend-phase-0-report.md`](../../shared-working-context/reports/backend-phase-0-report.md)
— exactly what b0 left you (seam names/files, the interceptor extension point, the REST pattern).
**Code to mirror**
- The seam interfaces + their mock implementations and `ServiceConfiguration/` registration from b0
(mirror that style for `IHolidayCalendar`, `IAnalyticsSink`, and the real `INotificationDispatcher`).
- The SaveChanges interceptor b0 extended — you add audit-row writing to its diff-collection path.
- The `BaseController` REST pattern from b0's controller — mirror it for the new admin controllers.
## 3. Scope — build this
> Layout follows `Features/<Area>/{Commands|Queries}/<Name>/`. Suggested areas:
> **`Configuration`**, **`Audit`**, **`Analytics`**, **`Holidays`**, **`Notifications`**,
> **`SupportAlerts`**. Each entity gets exactly one `IEntityTypeConfiguration<T>` under
> `Persistence/Configuration/<Area>Config/`. Reads use `AsNoTracking()` + `.Select(...)` projection +
> pagination; commands return `OperationResult`. Put these tables in a dedicated **`ref`/`ops` schema**
> (mirror how Identity uses `usr`) — keep them off the `usr` schema.
### 3.1 Entities & the first marketplace migration baseline
Create these EF entities + configurations, then generate **one** migration that becomes the new
`Persistence` baseline. (b0 already deleted the old migrations/snapshot.)
- **`platform_configs`** — typed key-value runtime parameters. Columns: `id` (BIGINT PK), `key`
(NVARCHAR, **UNIQUE**), `value` (NVARCHAR — the raw string), `data_type` (NVARCHAR — `decimal` / `int` /
`bool` / `string` / `json`; **tells the app how to parse `value`**), `description` (NVARCHAR, nullable),
plus the standard audit fields. **Every change to a row here is audited** (§3.3). Soft-delete is **not**
appropriate — configs are updated in place, the audit trail is the history.
- **`audit_logs`** — immutable, append-only. Columns: `id` (BIGINT PK), `entity_type` (NVARCHAR),
`entity_id` (NVARCHAR — string so it's polymorphic across PK types), `action` (NVARCHAR —
`created`/`updated`/`deleted`), `changed_fields_json` (NVARCHAR(MAX) — `{ field: { old, new } }` for
fast filtering), `actor_user_id` (FK→`users`, nullable for system actions), `occurred_at`
(DATETIMEOFFSET, from `IDateTimeProvider`). **No `ModifiedAt`/`IsDeleted` — append-only, never updated
or deleted.** Index `(entity_type, entity_id)` and `occurred_at`. (Month-partitioning + cold-storage
archival is **(DEFERRED)** — note the index design now; the archive store seam lands later.)
- **`system_events`** — high-volume analytics, **NOT compliance**. Columns: `id` (BIGINT PK), `name`
(NVARCHAR — event name), `props_json` (NVARCHAR(MAX) — arbitrary properties), `user_id` (FK→`users`,
nullable), `occurred_at` (DATETIMEOFFSET). Append-only; no audit fields needed beyond `occurred_at`.
- **`iranian_holidays`** — exact columns from the data-model doc: `id` (BIGINT PK), `holiday_date`
(DATE, index it — lookups are by date), `name_fa` (NVARCHAR(200)), `type` (NVARCHAR(20) —
`official`/`religious`/`national`), `is_bank_closed` (BIT). Unique on `holiday_date`.
- **`notifications`** — in-app, per user. Columns: `id` (BIGINT PK), `user_id` (FK→`users`, indexed),
`type` (NVARCHAR — drives front-end rendering/deep-link), `title` (NVARCHAR), `body` (NVARCHAR,
nullable), `data_json` (NVARCHAR(MAX) — **typed deep-link payload**, a versioned contract, not an
arbitrary blob), `is_read` (BIT, default 0), `read_at` (DATETIMEOFFSET, nullable), `created_at`. Index
`(user_id, is_read, created_at)` to serve unread-first paging and the unread-count query cheaply.
- **`support_alerts`** — internal staff worklist, **never user-facing**. Columns: `id` (BIGINT PK),
`type` (NVARCHAR — `low_rating`/`evv_no_show`/`evv_location_mismatch`/`verification_expired`/
`payment_anomaly`/`fraud_signal`), `severity` (NVARCHAR — `low`/`medium`/`high`), `status` (NVARCHAR —
`open`/`assigned`/`resolved`), `entity_type` (NVARCHAR) + `entity_id` (NVARCHAR) — **polymorphic,
validated at the application layer, no DB FK** — plus **nullable typed FKs** `booking_id`
(FK→`bookings`, nullable) and `review_id` (FK→`reviews`, nullable) for the common cases (declare the FK
columns now; the `bookings`/`reviews` tables arrive in b9/b14 — gate the relationship config so the
migration is additive-safe, or add the FK constraints in the phase that creates the target table and
note it in your handoff), `owner_user_id` (FK→`users`, nullable — the assigned admin), `resolution_note`
(NVARCHAR, nullable), `resolved_at` (DATETIMEOFFSET, nullable), plus standard audit fields.
> **Migration discipline:** generate the migration with a descriptive name (e.g.
> `InitialMarketplaceBaseline`) into `Baya.Infrastructure.Persistence/Migrations`. It must apply cleanly
> against a fresh DB and be the snapshot every later phase builds on. Run `dotnet ef database update`
> (or the project's migration entry point) to confirm.
### 3.2 Config — `IPlatformConfig` (Application contract) + cached accessor
- **`IPlatformConfig`** in `Application/Contracts/` with: `Task<T> GetConfig<T>(string key, …)` (parses by
the row's `data_type`, **cached** through `ICacheService` with a sensible TTL and key scheme), and
`Task SetConfig(string key, string value, …)` (admin path — writes the row **and** an audit entry; see
§3.3) and `Task<…> GetConfigChangeHistory(string key, …)` (reads the audit trail filtered to that key's
rows). Implement in Infrastructure.
- **Cache invalidation:** `SetConfig` must evict the cache key it changes so the next `GetConfig<T>` reads
the new value — but **see the money-correctness rule in §5**: changing a rate must never retroactively
alter already-computed amounts.
- **Commands/queries:** `UpdatePlatformConfig` (command, admin-only, validated, audited),
`ListPlatformConfigs` (query, paged), `GetConfigChangeHistory` (query, paged).
### 3.3 Audit — interceptor-driven write + trail query
- **Extend the b0 SaveChanges interceptor** (do not write a new one) so that on `SavingChanges` it
inspects tracked entries for entities marked **auditable** (a marker interface, e.g. `IAuditable`, or an
explicit allow-list — `platform_configs` is auditable; `system_events`/`notifications` are **not**) and
appends an `audit_logs` row per change with `entity_type`, `entity_id`, `action`, a computed
`changed_fields_json` (old/new per modified property; redact encrypted PII — never write plaintext into
the diff), and `actor_user_id` from `ICurrentUser`. The audit write happens in the **same transaction**
as the change.
- **`WriteAuditLog`** is the interceptor-driven mechanism above; also expose an explicit
`IAuditLogger.WriteAsync(...)` Application contract for cases a handler needs to record a non-entity
state change (e.g. an action with no row diff).
- **Query `GetAuditTrail(entity_type, entity_id)`** — paged, `AsNoTracking()` + projection, admin-only.
### 3.4 Analytics — `IAnalyticsSink` + `EmitSystemEvent`
- **`IAnalyticsSink`** (Application contract, **seam introduced here**) with
`Task EmitAsync(string name, object props, …)`. Mock implementation = **insert a `system_events` row**.
Calls are **fire-and-forget** from the caller's perspective (do not block or fail the user's operation
if the sink errors — log and continue). `EmitSystemEvent` is the thin command/helper that calls the
sink. **Never** route compliance-relevant facts here — those go to `audit_logs`.
### 3.5 Holidays — `IHolidayCalendar` + seed
- **`IHolidayCalendar`** (Application contract, **seam introduced here**) with
`Task<bool> IsHoliday(DateOnly date, …)`, `Task<bool> IsBankClosed(DateOnly date, …)`, and
`Task<DateOnly> NextBusinessDay(DateOnly date, …)`. Mock implementation = the **seeded
`iranian_holidays` table** (cache the lookups through `ICacheService`). `NextBusinessDay` skips
`is_bank_closed` days (and weekend rules per the Iranian banking week) — this is what payout scheduling
(b13) calls.
- **Admin CRUD** for the calendar: `ListHolidays` (query, paged/by-range), `UpsertHoliday`,
`DeleteHoliday` (commands, admin-only, audited if you make the table auditable — at minimum log via the
interceptor allow-list if finance cares; otherwise standard audit fields suffice).
### 3.6 Notifications — real in-app write (supersedes the b0 stub)
- **Replace** the b0 no-op `INotificationDispatcher` mock with a **real in-app implementation** that writes
a `notifications` row (channel concept retained: in-app **now**; SMS/push are **(DEFERRED)** to later
channels behind the same dispatcher — do **not** build them). Keep the seam interface stable so other
domains (booking, payments, reviews, verification) call `DispatchAsync(...)` unchanged.
- **`CreateNotification(user_id, type, data_json)`** — the internal command other domains call (via the
dispatcher) to mint a typed in-app record with a deep-link payload.
- **Queries:** `ListMyNotifications` (paged, **unread-first** ordering, tenant-scoped to the caller),
`GetUnreadCount` (cheap count for the polling bell — index-backed).
- **Commands:** `MarkNotificationRead` (single, sets `is_read`+`read_at`), `MarkAllRead` (bulk for the
caller).
- **Retention job `PurgeOldReadNotifications`** — hard-deletes notifications where `is_read = 1` **AND**
`created_at` (or `read_at`) older than 90 days; **never deletes unread**. There is no scheduler in the
codebase yet — introduce the job behind a small **`IJobScheduler`/hosted-service seam** (mock = a
`BackgroundService` running on an interval, or an endpoint/CLI trigger), register it, and record it in
the mock registry. Real Hangfire/Quartz is **(DEFERRED)**.
### 3.7 Support alerts — raise / assign / resolve / list
- **`RaiseSupportAlert(type, entity_type, entity_id, severity, booking_id?, review_id?)`** — the internal
command review/EVV/verification/payment flows call later. Validate the polymorphic
`(entity_type, entity_id)` at the application layer; prefer setting the typed FK when the entity is a
booking/review.
- **`AssignSupportAlert(alert_id, owner_user_id)`** and **`ResolveSupportAlert(alert_id, note)`** — owner +
resolution trail; forward-only status (`open``assigned``resolved`).
- **`ListSupportAlerts`** — admin-only query, paged, filter by `type`/`status`/`owner`. **These are on
admin-only routes and never joined into any user-facing endpoint.**
### 3.8 Admin REST surface
Add the controllers (sealed, `BaseController`, inject `ISender`, `base.OperationResult(...)`,
`[controller]`/`[action]` tokens, narrowest fitting admin policy, lists paginated). Suggested actions
(final route casing is snake_case per conventions):
- **Config:** `GET get_platform_configs`, `POST update_platform_config`, `GET get_config_change_history`.
- **Holidays:** `GET get_holidays`, `POST upsert_holiday`, `POST delete_holiday`.
- **Audit:** `GET get_audit_trail`.
- **Support alerts (admin):** `GET get_support_alerts`, `POST assign_support_alert`,
`POST resolve_support_alert`.
- **Notifications (current user):** `GET get_notifications`, `GET get_unread_count`,
`POST mark_notification_read`, `POST mark_all_read`.
> **Not exposed via REST:** `CreateNotification`, `RaiseSupportAlert`, `EmitSystemEvent`,
> `WriteAuditLog` — these are **internal application commands** other domains invoke through their
> contracts/the dispatcher, never user-callable HTTP endpoints.
### 3.9 Seed data
- **`platform_configs` keys (seed all, with `data_type`):** `platform_fee_rate` (decimal — platform
commission %), `vat_rate` (decimal, **0.10**), `dispute_window_hours` (int, **72**),
`booking_payment_deadline_minutes` (int, **30**), `nurse_response_deadline_hours` (int),
`nurse_payout_interval_days` (int), `evv_location_tolerance_meters` (int),
`min_rating_for_support_alert` (decimal/int), `bnpl_merchant_of_record` (string),
`bnpl_provider_commission_rate` (decimal), `bnpl_settlement_timing` (string), and the
**cancellation-tier defaults** (the tiered cancellation policy thresholds/percentages — store as
`json` or as discrete tier keys; b9/b11 consume them). Seed via the migration or an idempotent seeder.
- **`iranian_holidays`:** seed a representative sample set (Nowruz block, a few official/religious/national
days) with correct `is_bank_closed` flags so `IsBankClosed`/`NextBusinessDay` are testable. The full,
maintained, partly-lunar-Hijri calendar feed is **(DEFERRED)** behind the `IHolidayCalendar` "make it
real" path.
### 3.10 Out of scope (DEFERRED — do not build here)
- `roles`/`user_roles` admin RBAC tables and the role-scope authorization grid → **(DEFERRED to b15)**;
use the existing dynamic-permission RBAC + the narrowest fitting policy for now.
- The verification queue, refund tooling, payout tooling, invoices, partner centers, tickets → their own
phases (b6, b11, b13, b15). This phase builds only the **shared signals** they all use.
- `audit_logs` month-partitioning + cold-storage archival (`IArchiveStore`); `system_events`
warehouse/exporter; SMS/push notification channels; real Hangfire/Quartz scheduler; the external
Iranian-holiday sync feed → all **(DEFERRED)** behind their seams.
## 4. Mocks & seams in this phase
This phase **introduces** three seams and **supersedes** one b0 stub. Reuse the b0 seams
(`ICacheService`, `IFieldEncryptor`, `IDateTimeProvider`, `IObjectStorage`) as-is.
| Seam | New / changed | Mock behaviour | Registry |
| --- | --- | --- | --- |
| `IHolidayCalendar` | **introduced (b1)** | reads the seeded `iranian_holidays` table; cached via `ICacheService` | update row (was listed for b1) |
| `IAnalyticsSink` | **introduced (b1)** | inserts a `system_events` row; fire-and-forget | **add new row** |
| `INotificationDispatcher` | **superseded** (b0 stub → real) | writes a real `notifications` row (in-app channel); SMS/push deferred | update row (b0/15 → in-app real here) |
| `IJobScheduler` (retention) | **introduced (b1)** | in-proc `BackgroundService`/interval runner for `PurgeOldReadNotifications`; real Hangfire/Quartz deferred | **add new row** |
| `ICacheService` | reuse (b0) | in-memory `IMemoryCache` — config + holiday lookups cache through it | n/a (already 🟡) |
| `IFieldEncryptor` | reuse (b0) | used to redact PII in `changed_fields_json` | n/a (already 🟡) |
Record each introduced/changed seam in
[`mocks-registry.md`](../../shared-working-context/reports/mocks-registry.md) (seam, file, what's faked,
config keys, how to make it real, status 🟡). Selection stays **by registration/config**, never by an
`if (mock)` branch in a handler.
## 5. Critical rules you must not get wrong
- **`audit_logs` is append-only / immutable.** There is **NO update or delete of `audit_logs` rows in app
code** — ever. No `Update`/`Remove` on the set, no soft-delete column, no admin edit endpoint. It is the
system of record for compliance; enforce append-only at the repository/configuration level.
- **EVERY `platform_configs` change is audited.** Finance must be able to **prove the exact commission/VAT
rate in effect at any moment**. `SetConfig`/`UpdatePlatformConfig` must write the row **and** an
`audit_logs` entry in the **same transaction** — never mutate a config without the audit entry.
- **Config-as-rows = money correctness.** Commission %, VAT rate, dispute window, payout interval, EVV
tolerance, cancellation tiers all flow from `platform_configs`. **Read them at compute time (cached),
never hardcode**, and respect `data_type` when parsing. **Changing a rate must NOT retroactively alter
already-computed bookings/ledger** — later phases snapshot the rate they used onto the booking/invoice
at compute time; this phase must not encourage live re-reading of a rate for an already-priced row.
- **`iranian_holidays` drives payout date shifting.** If PAYA/SATNA banks are closed
(`is_bank_closed = 1`), a weekly payout shifts to the next business day via `NextBusinessDay`. Get the
calendar and the "next open bank day" logic right or payouts (b13) mis-schedule. Holidays are partly
movable/lunar-Hijri — the table is maintained, the calendar is a seam.
- **`system_events` is analytics, NOT compliance.** Never rely on it for audit/dispute evidence — it can
be sampled, dropped, or exported. Compliance facts go to `audit_logs`. Emission is fire-and-forget and
must never fail or slow the user's operation.
- **Notifications retention purges only READ notifications older than 90 days — NEVER unread.** The purge
predicate is `is_read = 1 AND <age> > 90d`. Unread notifications are never auto-deleted.
- **`support_alerts` are internal-only.** They must **never** appear in any user-facing endpoint, query,
or join. Keep them on admin-only routes with admin authorization. Never leak alert content into a
user-facing `notification`.
- **`data_json` is a typed contract.** Front-end navigation/deep-linking depends on its shape — version it
and validate it; do not dump arbitrary blobs. Same discipline for `support_alerts` polymorphic
`(entity_type, entity_id)`: validate at the application layer (no DB FK), prefer the typed FK
(`booking_id`/`review_id`) when available.
- **Tenancy.** A user sees only their **own** notifications. `ListMyNotifications`/`GetUnreadCount`/
`MarkAllRead` are always scoped to `ICurrentUser` — never a `user_id` from the request body.
- **Encrypted PII never enters `changed_fields_json`.** When the interceptor diffs an auditable entity,
redact any encrypted/PII property — write a marker (e.g. `"<redacted>"`), never the plaintext.
- **Money is IRR `BIGINT`/`long`, no floats.** Rates (`platform_fee_rate`, `vat_rate`,
`bnpl_provider_commission_rate`) are `DECIMAL` rate values; the IRR amounts they later multiply are
`long`. No money column or float appears in this phase's tables, but keep the rule visible for the
phases that read these rates.
## 6. Definition of Done
The shared [definition-of-done.md](../_shared/definition-of-done.md), plus:
- [ ] The **first marketplace migration baseline** is generated and applies cleanly to a fresh DB; all six
tables (`platform_configs`, `audit_logs`, `system_events`, `iranian_holidays`, `notifications`,
`support_alerts`) exist with the columns/indexes/uniques above.
- [ ] `IPlatformConfig.GetConfig<T>` returns a correctly **typed** value parsed by `data_type` and is
cached; `SetConfig`/`UpdatePlatformConfig` writes the row **and** an `audit_logs` row in one
transaction; `GetConfigChangeHistory` returns that audit entry.
- [ ] The SaveChanges interceptor (extended from b0) writes an `audit_logs` row on an auditable-entity
change with correct `changed_fields_json` and `actor_user_id`; no path updates/deletes `audit_logs`.
- [ ] `IHolidayCalendar.IsBankClosed`/`IsHoliday`/`NextBusinessDay` answer correctly over the seeded
calendar; the seed set is present.
- [ ] `IAnalyticsSink.EmitAsync` inserts a `system_events` row and is fire-and-forget.
- [ ] The **real** `INotificationDispatcher` writes a `notifications` row (b0 stub removed/replaced);
`CreateNotification``ListMyNotifications` (unread-first, tenant-scoped) + `GetUnreadCount` work;
`MarkNotificationRead`/`MarkAllRead` flip `is_read`; `PurgeOldReadNotifications` deletes only
read>90d.
- [ ] `RaiseSupportAlert``ListSupportAlerts` (admin) works; `AssignSupportAlert`/`ResolveSupportAlert`
record owner + resolution; support alerts appear on no user-facing route.
- [ ] All seeded config keys (§3.9) and the sample holidays are present after migration/seed.
- [ ] Admin + notification controllers reachable via Swagger, returning the `OperationResult` envelope;
lists paginated; admin routes authorized.
- [ ] `dotnet build Baya.sln` zero new warnings; `dotnet test Baya.sln` green (the handler + integration
tests this phase adds included). The **Project map** in `server/CLAUDE.md` is updated (new areas,
new schema, the three new seams + where they're registered).
## 7. How to test (what a human can verify after this phase)
Run `dotnet ef database update` (fresh DB) then `dotnet run` the API and use Swagger; back the unit/
integration assertions with `Baya.Tests.Setup` (SQLite).
1. **Migration creates all tables.** Apply the migration to a clean DB → all six tables + indexes/uniques
exist; the seeded config keys and sample holidays are present (query them).
2. **Typed config read.** `GetConfig<decimal>("vat_rate")``0.10m`; `GetConfig<int>("dispute_window_hours")`
`72`; `GetConfig<int>("booking_payment_deadline_minutes")``30`. A second read hits the cache.
3. **Config change is audited.** `POST update_platform_config { key:"platform_fee_rate", value:"0.18" }`
→ succeeds; `GET get_config_change_history?key=platform_fee_rate` shows the change (old→new, actor,
timestamp); a follow-up `GetConfig<decimal>("platform_fee_rate")` returns the new value (cache evicted).
Confirm no path can update/delete the `audit_logs` row.
4. **Holiday calendar.** `IsBankClosed(<a seeded bank-closed date>)``true`; `IsHoliday(<non-holiday>)`
`false`; `NextBusinessDay(<a seeded holiday>)` → the next open bank day.
5. **System event.** `EmitSystemEvent("nurse_search_performed", {...})` → a `system_events` row exists;
failure of the sink does not surface to the caller.
6. **Notifications round-trip.** `CreateNotification(user, "booking_confirmed", {...})` then
`GET get_notifications` (as that user) shows it unread-first; `GET get_unread_count` returns `1`;
`POST mark_notification_read` → unread count `0`. As a **different** user, the notification is not
visible (tenancy).
7. **Retention.** Insert a read notification dated >90 days, run `PurgeOldReadNotifications` → it is gone;
an unread >90-day notification and a read <90-day notification both **remain**.
8. **Support alerts.** `RaiseSupportAlert("low_rating", "review", "42", review_id:42)` then
`GET get_support_alerts` (admin) shows it `open`; `POST assign_support_alert``assigned` with owner;
`POST resolve_support_alert``resolved` with note. Confirm it appears on **no** user-facing endpoint.
## 8. Hand off & document (close the phase)
- **Docs:** update the **Project map** in [`server/CLAUDE.md`](../../../server/CLAUDE.md) — the new feature
areas (`Configuration`/`Audit`/`Analytics`/`Holidays`/`Notifications`/`SupportAlerts`), the new
`ref`/`ops` schema, and the three new seams + where they register (`ServiceConfiguration/`). Add a short
note in `server/CONVENTIONS.md` if you established a reusable pattern (the typed config accessor, the
auditable-entity marker, the retention-job seam). If you discovered/decided any config-key default the
`product/` docs don't capture, record it in
[`product/data-model/12-audit-config-and-reference.md`](../../../product/data-model/12-audit-config-and-reference.md)
(don't invent rules — record decisions) and regenerate the HTML view per `product/CLAUDE.md`.
- **Contract:** write
[`../../contracts/domains/config-reference.md`](../../contracts/domains/config-reference.md) (use
[`_TEMPLATE.md`](../../contracts/domains/_TEMPLATE.md)) covering the admin config/holiday/audit/
support-alert endpoints and the current-user notification endpoints — request/response shapes, the
`data_json`/`changed_fields_json` payload contracts, enums (`data_type`, holiday `type`, alert
`type`/`status`/`severity`, notification `type`), pagination, status codes. Re-publish the `swagger.json`
snapshot per [`../../contracts/openapi/README.md`](../../contracts/openapi/README.md). Mark it **live as
of backend-phase-1**, frontend consumer **f14** (notification center) / **f15** (admin config/holidays/
audit/alerts).
- **Handoff & report:** write
`shared-working-context/backend/handoff/after-backend-phase-1.md` (the baseline migration is live; config
is read via `IPlatformConfig`; holidays via `IHolidayCalendar`; audit rows write automatically on
auditable entities; `INotificationDispatcher` now writes real in-app notifications; `RaiseSupportAlert`
exists for later domains to call — list the internal contracts b2…b15 should depend on, not re-create),
append to `shared-working-context/backend/STATUS.md`, write
`shared-working-context/reports/backend-phase-1-report.md` (what was built, what is now testable and
exactly how — §7, what is mocked + how to make it real, contracts produced, follow-ups: the FK
constraints for `support_alerts.booking_id`/`review_id` to be added in b9/b14), and update
`shared-working-context/reports/mocks-registry.md` (`IHolidayCalendar`, `IAnalyticsSink`, the retention
`IJobScheduler` → 🟡; flip `INotificationDispatcher` to in-app-real 🟡).
- **Memory:** save a `project` memory note for the non-obvious decisions this phase fixes (config-as-rows
read-at-compute-time + rate-snapshot rule; the auditable-entity marker + append-only enforcement; the
notification retention predicate; the support-alert internal-only boundary; the new `ref`/`ops` schema)
with a one-line `MEMORY.md` pointer.