add build development phases
This commit is contained in:
@@ -0,0 +1,414 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user