feat(notification): add admin rest management and deletion
Add revision-safe service and template APIs, channel mapping, and validated admin controls. Ref: IT-1601
This commit is contained in:
@@ -0,0 +1,109 @@
|
||||
# Service and template-definition API — IT-1601
|
||||
|
||||
The owner approved these AI-client endpoints on 2026-10-17. They use the existing application API response convention and camelCase JSON. No application authentication was added, following the owner's managed-tunnel access decision. Restrict access at the deployment boundary. Tunnel and Kubernetes configuration have **not** been verified by this implementation. Existing administration, language-specific API and worker behavior remain intact.
|
||||
|
||||
## Reads
|
||||
|
||||
| Method and route | Result |
|
||||
| --- | --- |
|
||||
| `GET /api/v1/services/all` | All services, including each revision and exact supported codes. |
|
||||
| `GET /api/v1/services/{serviceId}` | Service ID, current name, service `revision`, exact `supportedLanguageCodes` array, and `languages` objects with stable `id`, exact `code`, and current `versionCount`. |
|
||||
| `GET /api/v1/services?name={encodedExactName}` | The same single-service object, using an exact, case-sensitive name lookup. This is not a service-list/search endpoint. A missing/blank name returns 400. |
|
||||
| `GET /api/v1/services/{serviceId}/email-template-definitions?key={encodedKey}` | Whole definition: `id`, `serviceId`, `serviceName`, `key`, `revision`, shared `variables`, every existing `translations` version, and definition `created`/`updated` timestamps. |
|
||||
|
||||
Missing services or definitions return 404. Service IDs remain stable through renames. Name, key and language codes are never trimmed or case-folded. The exact `key` is a required query parameter on both definition endpoints; a missing/blank key returns 400. Encode it as a query value (for example `?key=Welcome%2Fchild` for `Welcome/child`, or `?key=Welcome%252Fchild` for the literal key `Welcome%2Fchild`). Normal query decoding preserves that distinction without custom raw-path parsing. Encode spaces, Unicode, percent signs, plus signs, `#` and `?` using the HTTP client's query builder. Proxy/tunnel configuration is outside the completed validation.
|
||||
|
||||
A variable is `{ "name": "Name", "required": true }`. A translation contains `id`, `languageCode`, `subject`, `htmlBody`, `textBody`, `created`, and `updated`. Reads include only persisted versions; an empty definition returns `translations: []`. Missing service languages do not create placeholder versions.
|
||||
|
||||
`GET /api/v1/services/{serviceId}/email-template-definitions/all` returns complete definition DTOs for only that service, including definitions with zero versions; an existing empty service returns `[]` and a missing service returns 404.
|
||||
|
||||
## Service and catalog management
|
||||
|
||||
`POST /api/v1/services` accepts `{ "name": "ExampleService", "languages": [{ "code": "en" }, { "code": "uk" }] }`. It returns 201 with a Location to the service read and the complete service DTO. Names are exact and unique. The required languages array may be empty; new services cannot reference persisted language IDs.
|
||||
|
||||
`PUT /api/v1/services/{serviceId}` accepts the last-read nonempty `revision`, `name`, and a required full `languages` array. Retain each existing language's ID from the read DTO; omit/empty the ID for a new code. The returned service DTO includes the new revision and generated language IDs. Omitted unused catalog rows are removed; omitting a used language returns 409 with no changes. A stale revision returns 409; a missing service returns 404. Existing migrated nonstandard codes remain valid unchanged, and the existing stricter syntax validation applies only to added/renamed codes. Renames do not rewrite independently configured channel service names.
|
||||
|
||||
`DELETE /api/v1/services/{serviceId}/languages/{languageId}?revision={serviceRevision}` removes an unused catalog row only. Used languages return 409; use the explicit destructive endpoint when the versions should be deleted:
|
||||
|
||||
```text
|
||||
POST /api/v1/services/{serviceId}/languages/{languageId}/delete-with-versions
|
||||
```
|
||||
|
||||
```json
|
||||
{ "revision": "22222222-2222-2222-2222-222222222222", "expectedVersionCount": 2, "confirmDelete": true }
|
||||
```
|
||||
|
||||
All three body properties are required. The nonnegative count and service revision must still match under the existing mutation lock. Missing/false confirmation or invalid values return 400; count/revision changes return 409 without deletion. Success returns 200 with `ApiResponse<Unit>` and deletes that language plus exactly its current versions, retaining definitions/shared contracts. Reread the service and explicitly reconfirm after a conflict. These API operations reuse the same safeguards as the existing separate admin confirmation page; no modal UX was introduced.
|
||||
|
||||
## Revision-guarded deletion
|
||||
|
||||
- `DELETE /api/v1/services/{serviceId}/email-template-definitions?key={encodedKey}&revision={lastReadDefinitionRevision}` removes the selected definition and all of its versions atomically.
|
||||
- `DELETE /api/v1/services/{serviceId}?revision={lastReadServiceRevision}` requires **every definition** to have been removed first, including zero-version definitions; otherwise it returns 409. It removes the remaining catalog languages and service atomically.
|
||||
|
||||
Both endpoints return HTTP 200 with the existing `ApiResponse<Unit>` success envelope (`data: {}`). A missing resource returns 404, missing/empty/malformed revision or invalid key returns 400, and stale revision returns 409. Every check and mutation runs under the common transaction advisory lock. Concurrent definition creation cannot race past the service's nonempty check. These operations do not remove email channels, whose service name is a separate identifier without a service foreign key.
|
||||
|
||||
## Atomic upsert
|
||||
|
||||
`POST /api/v1/services/{serviceId}/email-template-definitions/upsert?key={encodedKey}`
|
||||
|
||||
```json
|
||||
{
|
||||
"revision": null,
|
||||
"variables": [{ "name": "Name", "required": true }],
|
||||
"translations": [
|
||||
{
|
||||
"languageCode": "en",
|
||||
"subject": "Hello {{Name}}",
|
||||
"htmlBody": "<p>Hello {{Name}}</p>",
|
||||
"textBody": "Hello {{Name}}"
|
||||
}
|
||||
],
|
||||
"removeLanguageCodes": []
|
||||
}
|
||||
```
|
||||
|
||||
- A null or omitted `revision` means **create only**. An existing service/key returns 409. A nonempty GUID revision means **update only** and must equal the last-read revision. A missing definition or stale token returns 409; an empty GUID or malformed revision returns 400. The route supplies the stable service ID and the required query parameter supplies the exact key.
|
||||
- `variables` and `translations` are required JSON arrays, including when empty; missing/null arrays and null elements return 400. Each variable needs a unique nonblank `name` and an explicitly supplied boolean `required`. Variables replace the entire shared contract, including for translations omitted from the request. `variables: []` clears it.
|
||||
- Sent translations add or replace only their exact codes. Every sent version requires nonblank subject, HTML body and text body. Duplicate translation codes return 400. Existing IDs/created timestamps are retained for updates; new versions receive new identities. Omitted versions retain all their content and timestamps.
|
||||
- Optional `removeLanguageCodes` names the exact versions to delete. It defaults to an empty list. It must contain unique nonblank codes, cannot overlap sent translations, and cannot name codes absent from the service catalog. Removing an already absent version for a supported code is a no-op. Removing the final version keeps the definition and contract. Supplying empty content is an error; use the explicit removal list.
|
||||
- Both sent and removed codes must exactly match the current service catalog. `RU`, `ru` and `ru-RU` are distinct. No language is added to the catalog by this API.
|
||||
- A successful create returns HTTP **201 Created** with a `Location` pointing to the definition GET route (including the correctly encoded `key` query value); a successful update returns HTTP **200 OK**. Both return the **complete saved definition and its new revision**. Any successful upsert advances the revision, including a valid request that leaves content unchanged. Use that revision on the next update.
|
||||
|
||||
The service/catalog lookup, validation, revision check, explicit removals, translation merge and shared-contract replacement run under the existing PostgreSQL transaction/advisory lock. There is no read/merge outside the lock and no nested mutation runner. Invalid or conflicting requests save nothing. Concurrent create requests have one winner; updates sharing a revision have one winner. Administration, service catalog changes and legacy language writes advance definition revisions and therefore invalidate stale AI-client revisions.
|
||||
|
||||
## Email channels
|
||||
|
||||
- `GET /api/v1/email-channels/all` enumerates **all** channels, including channels whose exact `serviceName` has no service row.
|
||||
- Existing `GET /api/v1/email-channels?serviceName={exactName}`, GET by ID, POST, PUT by ID and DELETE by ID remain available. Create returns 201/Location and its existing GUID response; update/delete return 200/Unit.
|
||||
- Create supports SMTP (`channelType: 1`). Request/response `settings` explicitly maps `host`, `port`, `username`, `password`, `useSsl`, `fromEmail`, and `fromName`; reads no longer lose derived SMTP fields through an abstract JSON type. Host/fromEmail and a valid port are required. The optional empty username/password support unauthenticated development SMTP.
|
||||
- Existing PUT behavior is retained: it changes settings, priority, limits, warning threshold and active state, **not** serviceName or channel type. Service names have no service FK and no rename cascade. Read all channels separately when performing an explicitly authorized cleanup; deleting a service does not delete its channels.
|
||||
- SMTP HTTP lifecycle tests cover create/read/update/filter/all/delete. The repository ID lookup is a no-tracking domain projection so an update can attach its mapped entity without an EF tracking conflict. No SMTP send or owner-channel cleanup is implied by these tests.
|
||||
|
||||
## Responses
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"id": "11111111-1111-1111-1111-111111111111",
|
||||
"name": "ExampleService",
|
||||
"revision": "22222222-2222-2222-2222-222222222222",
|
||||
"supportedLanguageCodes": ["en", "uk"],
|
||||
"languages": [
|
||||
{ "id": "33333333-3333-3333-3333-333333333333", "code": "en", "versionCount": 1 },
|
||||
{ "id": "44444444-4444-4444-4444-444444444444", "code": "uk", "versionCount": 0 }
|
||||
]
|
||||
},
|
||||
"error": null
|
||||
}
|
||||
```
|
||||
|
||||
Errors use `success: false`, null `data`, and `error: { "code": "InvalidRequest|NotFound|Conflict", "message": "..." }`. New-endpoint JSON/model-binding errors use that envelope too. Invalid content produces 400, absent read resources produce 404, and duplicate/stale/missing-definition writes produce 409. Read the current definition after a 409, reconcile the intended changes, and send its current revision; do not retry by discarding the revision.
|
||||
|
||||
The revision is an optimistic-concurrency token, not a historical snapshot or restore handle. No history storage, restore API/UI, or migration was introduced for this extension.
|
||||
|
||||
## Verification scope
|
||||
|
||||
Disposable PostgreSQL integration tests cover service lookup/rename, whole-definition reads, create/update, required arrays/boolean presence, exact codes, explicit removals, empty definitions, retention of omitted versions and their IDs/content/timestamps, shared contract replacement, invalid-save atomicity, duplicate/stale conflicts, concurrent requests, encoded keys and creation-Location round trips, and legacy-write revision invalidation. The full existing suite remains the regression gate for administration, migrations and the worker. Separate local HTTP checks exercise the host Web against task-owned infrastructure; no owner database or running Rider environment is used. Deployment/tunnel/Kubernetes validation is not implied by these tests.
|
||||
|
||||
On 2026-10-17 the solution build and all 148 tests passed after the REST CRUD, 201/Location and administration follow-ups (32 service/worker tests and 116 PostgreSQL integration tests, including 29 new definition-API cases and 3 administration-navigation checks). A separate host Web at `127.0.0.1:52656`, backed by the task-owned PostgreSQL container on port `58519`, passed service reads, exact encoded-key round trips (slash/literal `%2F`/Unicode), 201-created Location round trips and 200-updates, omitted-version retention, duplicate/stale conflicts, required-boolean rejection with no changes, final-version removal retaining the definition, required-key errors, and exact-name case checks. Its OpenAPI document exposed the typed response schemas and required request fields. The task Web and disposable database were stopped/removed afterward. No owner database, containers or Rider process were modified. Existing Microsoft.OpenApi NU1903 dependency warnings remain.
|
||||
@@ -9,12 +9,14 @@ This is internal administration for the independently released HrynCo Notificati
|
||||
- `email_template_definitions`: ID, service ID, key, one shared variables JSON array, revision and timestamps; unique `(service_id, key)`.
|
||||
- `email_templates`: existing language-version ID, definition ID, service ID, service-language ID, subject, HTML body, text body and timestamps; unique `(definition_id, service_language_id)`.
|
||||
|
||||
Composite foreign keys from a version's `(definition_id, service_id)` and `(service_language_id, service_id)` enforce that its language and definition belong to the same service. Service and service-language deletion are restricted by foreign keys; deleting a definition cascades to its versions. The administration does not expose service/definition deletion. Removing a version never removes its definition. Zero-version definitions retain their key, ID and shared variables.
|
||||
Composite foreign keys from a version's `(definition_id, service_id)` and `(service_language_id, service_id)` enforce that its language and definition belong to the same service. Service and service-language deletion are restricted by foreign keys; deleting a definition cascades to its versions. Service and template lists expose Delete links to separate confirmation pages. The owner-authorized REST API has explicit revision-guarded definition/service DELETE operations; service deletion requires all definitions to have been removed first. Removing a version never removes its definition. Zero-version definitions retain their key, ID and shared variables.
|
||||
|
||||
Codes are case-sensitive exact identifiers. `ru`, `ru-RU`, `en` and `EN` remain distinct. Repository and API reads use exact codes without trimming, case folding or locale substitution. The worker first requests the exact code; only after a miss does it retry `LanguageCode.Trim().ToLowerInvariant()` when that string differs ordinally. It never scans case-insensitively, substitutes another region/language or modifies stored codes. New/edited catalog codes allow letters, numbers and hyphens, up to ten characters, with no blank code or empty hyphen segment. This is syntax validation, not a supported-language allowlist. Migration retains existing nonblank codes exactly. Unchanged migrated identifiers such as `en_US` remain valid during service edits; stricter syntax validation applies only to newly added or actually renamed code rows.
|
||||
|
||||
## Administration
|
||||
|
||||
The dark navbar includes **API documentation** at the right, opening `/scalar/v1` in a new tab with an explicit accessible new-tab label and safe opener attributes. All four administration data tables (Services, service templates, Email Channels and embedded Service Languages) explicitly use the shared `admin-data-table` style: contrasting blue headers, outer borders, vertical column separators and vertically centered column headings. Vendor tables are not globally overridden. The template list retains its white filter controls and compact Action column; channel actions and the existing responsive table wrappers remain available. Navbar items and filter controls are vertically centered. When a data-table control receives keyboard focus, only its nearest responsive wrapper's horizontal scroll position is adjusted to expose the full control and focus ring, including newly added language rows. This helper never scrolls the document or vertical ancestors and does not run for pointer-originated focus. Long service names wrap inside list cells and the selected-service heading/breadcrumb rather than widening the page.
|
||||
|
||||
`/admin/services` lists services. Its create/edit form manages Name and an inline table of language codes. Successful creation or editing, including a rename, returns to `/admin/services/{serviceId}` and its template list; validation/save errors retain the service form and entered values. The service page's **Edit Service** action uses the primary blue outline style. There is no separate language editor. Existing code rows retain their IDs when renamed. A transaction-local staging step frees old codes before final assignment so valid code swaps and replacements respect uniqueness without changing version identities. Intermediate codes are never committed or visible to other transactions. Renaming a service or code changes the identifiers used by template API clients and senders; publishers and independently configured email-channel service names must be aligned by the administrator.
|
||||
|
||||
Ordinary removal of an unused language row is saved with the service. For a used language, the form displays its affected version count and blocks ordinary removal. The server repeats this check using the current database count. After a validation or save error, usage counts are reloaded by persisted language ID while preserving entered names/codes and the original revision. If counts cannot be loaded, their display is unavailable and ordinary removal stays disabled.
|
||||
@@ -33,7 +35,11 @@ The language tabs and their Subject/HTML/Text fields form one bordered sheet, wi
|
||||
|
||||
Service and template editor breadcrumbs appear above the editor card, followed by a prominent semantic page heading in its blue header. The shared editor layout keeps breadcrumbs optional so the channel editor continues to work without a breadcrumb section. In the template editor, the selected Service is plain semantic context with a prominent wrapping name beside Template Key; its smaller Edit Service and language codes link remains a separate keyboard-focusable action. Long service names wrap in both the context and template breadcrumb. The hidden service identity and routes are unchanged.
|
||||
|
||||
Preview uses the selected version and sample values from the shared contract. HTML variables are encoded as in the worker, while subject and text stay plain. The iframe is sandboxed without permissions and has a restrictive CSP; scripts, forms, top navigation and external resources are blocked. Save errors preserve entered content and active language; navigation warns about unsaved edits.
|
||||
Preview uses the selected version and sample values from the shared contract. HTML variables are encoded as in the worker, while subject and text stay plain. The owner approved `sandbox="allow-same-origin"` without `allow-scripts` for automatic HTML-preview height on 2026-10-17. This trades the prior opaque origin for trusted parent-side DOM measurement; it does not permit template scripts. The existing restrictive CSP remains unchanged. Scripts, forms, top/pop-up navigation and external resources stay blocked; the trusted parent also prevents preview link navigation and submits. No JavaScript is inserted into the template HTML. On load, content/data-image/width changes and viewport resize, the parent measures content height; resetting height before measurement lets short/empty content shrink. Old ResizeObservers are disconnected when the document changes or page hides. Save errors preserve entered content and active language; navigation warns about unsaved edits.
|
||||
|
||||
The Services and scoped Templates lists offer compact Edit/Delete links. Their separate confirmation pages require antiforgery-protected POST and explicit ConfirmDelete. Definition deletion shows its selected service, key, current translation count and exact codes; it deletes the definition/shared contract/versions while retaining the service/catalog/channels. Service deletion is blocked while any definition exists, including empty definitions, and explicitly retains channels. Revision conflicts reload the current count/revision, clear the checkbox, and require fresh confirmation; definition cancel/success retain the key filter. These routes reuse the revision-protected REST commands and validate selected-service ownership.
|
||||
|
||||
Empty Services uses the existing administration card/empty-state pattern with Create First Service. An empty selected service keeps its table/filter header and offers Create First Template; a filter miss instead offers Clear filter. Template load failures remain errors and never claim an empty catalog. The Email Channels empty-state view is unchanged.
|
||||
|
||||
Every template/service mutation takes the same PostgreSQL transaction advisory lock (`1601`). This makes catalog membership, affected counts and uniqueness checks consistent across API and administration writers. Group and service editors also compare revision tokens, rejecting stale edits rather than overwriting changes. The lock serializes infrequent administration writes; reads and email sends do not take it.
|
||||
|
||||
@@ -56,6 +62,10 @@ Legacy PUT requests have no revision field and retain last-successful-write beha
|
||||
|
||||
The worker still uses the selected language's subject/HTML/text and validates required variables from the shared parent contract. The message envelope, SMTP/channel selection, quotas, retries and ACK/NACK behavior are unchanged. Exact stored versions take priority, so a request for `RU` selects `RU` when both `RU` and `ru` exist. If the exact version is absent, one differing trimmed/lowercase code is tried for compatibility with existing senders (`RU` or ` ru ` can resolve `ru`). A missing `RU-ru` may retry `ru-ru`; it does not search for `ru-RU` or fall back to `ru`. Blank codes are rejected before querying. API reads and writes remain exact, and storage/migration never normalize codes.
|
||||
|
||||
## Whole-definition API for AI clients
|
||||
|
||||
The owner-approved extension provides service read/exact-name lookup and exact supported codes, whole-definition read by stable service ID and key, and one atomic revision-guarded upsert. Shared variables are replaced; supplied translations add/update, omitted translations remain, and an explicit removal list deletes versions without deleting the definition. Validation and merging execute under the same mutation lock as administration/legacy writers. See the [API contract and examples](email-template-definition-api.md) for routes, DTOs, response/error rules, encoding and validation scope. No application authentication or history/restore mechanism is added; the owner's managed-tunnel access decision is recorded without claiming tunnel/Kubernetes verification.
|
||||
|
||||
## Migration and rollout gate
|
||||
|
||||
The current `20261017072410_EmailTemplateLocalizations` EF migration accepts two explicitly recognized source layouts: the flat legacy schema, or the known interim three-entity schema recorded as `20261017070315_EmailTemplateLocalizations`. It checks column names/types/nullability and the expected index/constraint definitions before modifying the schema. An unknown or hybrid layout/history is rejected; the migrator never guesses, drops the database, or rewrites migration history to adopt it. The operation locks the source tables and executes transactionally.
|
||||
@@ -94,3 +104,7 @@ Chromium administration checks on 2026-10-17 included a 1440px desktop viewport
|
||||
|
||||
|
||||
The service-scoped navigation and all-language tab revision was validated separately on 2026-10-17 with temporary host Web at `127.0.0.1:52655` and a fresh, task-owned PostgreSQL database (no restored owner content). Chromium desktop and a 390px touch viewport covered Services name navigation, separate Edit Service, selected-service template list, blank-definition creation, both configured tabs, partial-content errors with no persisted versions, retained input/active tab, keyboard switching, unchanged selected-language preview and HTML isolation/escaping, successful save, and clearing each version through to No versions while keeping the template. The narrow page had no horizontal overflow. The temporary Web and browser tab were stopped afterward. This is browser validation of the Notification Service administration, not validation of an installed Invemory Android application.
|
||||
|
||||
The later administration styling pass checked all four `admin-data-table` views at 1440, 390 and 320 pixels using temporary Web `127.0.0.1:52656` and task-owned PostgreSQL `58519`. Shared header colors/borders/vertical alignment, long service-name wrapping, filter-miss headers, compact actions, and table-local scrolling passed without page overflow. Keyboard focus exposed the complete channel/template/language actions and newly added language rows. The navbar remained 52px tall with matching brand/link centers, and API documentation opened a rendered Scalar page in a separate tab with a null opener; link contrast was 14.63:1. The full solution build and 148 tests passed (32 service/worker and 116 integration tests). Temporary resources were removed afterward. Remove behavior and the separate DeleteLanguage confirmation page were retained; no modal UX was introduced.
|
||||
|
||||
The final preview/deletion browser pass used the same task-owned host and database. At 1440px, long content grew to 1047px and an empty language tab shrank to 80px; at 390px the corresponding measurements were 997px and 80px. At 320px responsive text/data-image content grew from 1206px to 1653px when the image height changed, with document width remaining 320px. Preview script execution stayed blocked, and parent-side event checks cancelled top/self links and form submission while retaining about:srcdoc. At 320px the definition confirmation rejected an unchecked submission, then confirmed deletion returned to the scoped empty-template state; nonempty service deletion was blocked, and confirmed empty-service deletion reached the empty Services state. The table header/filter and first-create links remained visible with no page overflow. A separate read-only owner-Web browser check confirmed Invemory-Prod and its eight imported definitions; no owner data was changed.
|
||||
|
||||
Reference in New Issue
Block a user