5a465d7257
Center action controls and adjust template editor field heights. Ref: IT-1601
111 lines
25 KiB
Markdown
111 lines
25 KiB
Markdown
# Email template definitions and service languages — IT-1601
|
|
|
|
This is internal administration for the independently released HrynCo Notification Service. It is excluded from public Invemory release notes. The owner approved the four-entity design and empty definitions on 2026-10-17.
|
|
|
|
## Storage and invariants
|
|
|
|
- `services`: ID, unique exact name, revision and timestamps.
|
|
- `service_languages`: ID, service ID and exact code, unique `(service_id, code)`.
|
|
- `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. 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.
|
|
|
|
The language table's Actions column is sized to its controls and aligned right for existing and newly added rows; buttons share a row on desktop and stack with complete labels on narrow screens, with table scrolling contained inside the form when needed.
|
|
|
|
The separate **Delete language and versions…** confirmation shows the current service, code and affected version count. It requires an explicit checkbox and POST with antiforgery protection. The server checks the current count and service revision under the mutation lock. A changed count/revision rejects the request, refreshes the displayed count and requires another confirmation. A confirmed deletion atomically removes every version using that service language and the catalog row, preserving every definition and its shared variables. Failure rolls the transaction back.
|
|
|
|
The administration starts at `/admin/services` (also the root redirect). A service name opens `/admin/services/{serviceId}`, which lists only that service's Email Templates, including definitions without translations. **Edit Service** is a separate action at `/admin/services/{id}/edit` for Name and the inline language table. There is no independent global Email Templates menu or extra page for a key: Template Key identifies the template edited directly from this service list. Create/edit/save/delete routes live under `/admin/services/{serviceId}/templates`; breadcrumbs, cancel and successful saves/deletes return to the selected service and retain its exact Key filter. Route IDs are bound explicitly from the route and cannot be replaced by form/query values. GET/POST validate definition ownership, and the mutation transaction also checks the selected service ID. Legacy global GET links redirect to the canonical service/template when identifiable, otherwise to Services; old global template POST routes are not retained. The language-specific API is unchanged.
|
|
|
|
The service's exact configured language codes appear near its heading. Each template row lists every catalog language with a text-and-color **Present** or **Missing** status, comparing exact ordinal codes (`en` and `EN`, or `ru` and `ru-RU`, remain distinct). The editor repeats these statuses on its language tabs. They show saved versions, including after a failed form submission; unsaved field changes do not alter the status. A definition with no versions shows all configured languages as Missing; a service with no catalog has an explicit no-languages state. Status display never creates translations. The GET Key filter, Filter and Clear controls live in the Template Key column header, which stays visible for an empty service or filter miss; the scoped header strip has a contrasting blue background. Clear returns to the same service without a key filter, while create/edit/save/cancel retain the selected key filter. The Action header and controls are centered in all four administration grids; their column widths are unchanged. The editor initially shows fourteen rows for Shared variables, fifteen for HTML Body and eight for Text Body, without changing their content or contracts.
|
|
|
|
A template belongs to the selected service; its create/edit form shows Template Key and a tab for **every** language configured in that service. Languages are added, renamed and removed only through Edit Service. A tab with Subject, HTML Body and Text Body all blank does not persist a version; clearing all three removes an existing version while retaining the definition. A partially populated tab requires all three fields and prevents the entire save if incomplete. All tabs may be blank, and a service with no configured languages may still have a key/shared-variables definition. Submitted tabs must match the current service catalog exactly, including uniqueness. Tabs retain unsaved content and support Left/Right/Home/End keyboard navigation. Shared variables appear once as a camelCase JSON array: `[{"name":"RecipientName","required":true}]`. Each variable has a unique nonblank name and a boolean `required`; unknown JSON properties and malformed shapes are rejected. Variable contracts compare Name and Required semantically, independent of variable order.
|
|
|
|
The language tabs and their Subject/HTML/Text fields form one bordered sheet, with horizontal tab scrolling on narrow screens; Template Key, shared variables and the existing preview remain outside it, and no empty sheet is shown when the service has no languages.
|
|
|
|
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 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.
|
|
|
|
The existing access boundary is preserved: there was no application-level authentication/authorization middleware or controller policy in this repository before this task, and none is introduced or removed here. Operate the administration/API behind the same restricted network/proxy boundary; no new permission role or public endpoint policy is added.
|
|
|
|
## Existing language-specific API
|
|
|
|
The routes under `/api/v1/email-templates` and their camelCase response envelopes are retained. The public `EmailTemplate` DTO remains a compatibility projection with service name, key, language code, language content, shared variables, existing version ID and timestamps.
|
|
|
|
| Operation | Mapping |
|
|
| --- | --- |
|
|
| GET collection / service filter / key filter | Flat language-version collection, as before. Empty definitions do not create synthetic language rows. |
|
|
| GET `/{service}/{key}/{code}` | Exact version content and the parent's current shared variables; missing version returns 404. |
|
|
| POST | Adds the version to an existing definition, or creates the definition for an existing service/catalog language. Missing service or unknown catalog code returns 400; it is never silently created. Duplicate version returns 409. |
|
|
| PUT `/{service}/{key}/{code}` | Updates only that version's content. It must submit the same semantic shared variable contract. Missing version returns 404. |
|
|
| POST/PUT with different shared variables | 409 with instructions to edit the shared contract in the logical template editor. No partial changes. |
|
|
| DELETE `/{service}/{key}/{code}` | Removes only the requested version, including the last one; definition/key/variables survive. Missing version returns 404. |
|
|
|
|
Legacy PUT requests have no revision field and retain last-successful-write behavior for language content. Group edits use revision tokens. Every successful legacy mutation advances the definition revision, so an already-open group editor cannot overwrite it silently. A changed shared contract makes an old legacy payload conflict.
|
|
|
|
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.
|
|
|
|
For the flat legacy schema, it audits all rows before dropping legacy columns:
|
|
|
|
1. Reject blank identities, non-array variables, malformed variable objects/types, unsupported JSON fields and duplicate variable names.
|
|
2. Sort each variable array by exact name and compare the JSON contracts for every service/key group. JSON object-property ordering is already semantic under PostgreSQL `jsonb`.
|
|
3. On a real conflict, raise a diagnostic containing service/key/language and record IDs only. Bodies, subjects and variable contents are not included in diagnostics. Resolve the source conflict explicitly; rerun only after agreement on the corrected contract.
|
|
4. Create services and their distinct **unchanged** language codes, then one definition per service/key and attach every existing version. Existing version IDs, content and timestamps remain intact.
|
|
5. Add required relationships/uniqueness constraints and drop repeated service/key/code/variables columns only after data is linked.
|
|
|
|
For the known interim schema, shared variables already belong to definitions. The migration audits their array/object/duplicate-name validity plus blank identities and orphaned references, then upgrades the existing rows in place. Existing service, definition and version IDs, content, shared variables, definition revisions and timestamps remain unchanged. It adds the previously absent service revision, derives the service-language catalog from exact existing codes, links versions to that catalog, and installs the final same-service constraints/indexes. Empty definitions and services without versions survive. Newly introduced catalog IDs and service revisions are generated once. The interim history row is retained and EF records the current migration as an additional row.
|
|
|
|
Failure or interruption rolls back schema and data; the current migration history entry is not recorded. A successful rerun is a no-op through EF migration history. Tests cover both source layouts, semantically equal contracts, true conflicts, malformed JSON shapes, interrupted conversion, recovery, exact-code preservation, unrecognized/hybrid layouts, and altered index/foreign-key definitions.
|
|
|
|
A downgrade cannot represent empty definitions, unused service languages or catalog-only services. `Down` fails before modifying data when these exist. It also always rejects an interim-origin database: downgrading the current migration cannot reconstruct the interim migration's historical model while targeting the flat legacy model. Restore a verified pre-migration backup for rollback in these cases. Flat-origin data that remains representable has a tested Down/Up path.
|
|
|
|
Before a separately authorized rollout: back up the target database, rehearse migration against a disposable restored copy, inspect/resolve reported conflicts, stop old Web/Worker writers during migration, apply the current migrator, then start matching Web and Worker builds together. Verify exact-code clients, administration and a safe email send. Never run old binaries against the new schema. No production data audit or rollout was performed for this implementation.
|
|
|
|
## Owner's Windows development setup
|
|
|
|
The owner's existing PostgreSQL, Mailpit and Seq containers are infrastructure for later testing; do not recreate or reset them as part of this task. Web runs on the host through Rider with the Development profile and its checked-in connection to `localhost:5433`. Its checked-in HTTP launch URL is `http://localhost:5188` (HTTPS profile also uses `https://localhost:7210`). Keep Docker Web stopped so there is only one Web process. RabbitMQ and Worker are needed only when exercising message delivery.
|
|
|
|
The checked-in migrator connection is intentionally empty; when the owner chooses to prepare the local schema, run the current Migrator against the intended local database by explicitly supplying `App__ConnectionString` through a Rider environment setting. The host Web does not apply migrations automatically. Take a backup first and ensure old writers are stopped. The implementation did not migrate the owner's local database or start their Rider application.
|
|
|
|
For development seeding, the migrator explicitly creates the configured service and its `en` catalog entry when missing, then idempotently creates the neutral `TestEmail` version and Mailpit channel. Existing versions/channels are not overwritten. This seeding exception is explicit development setup; legacy API calls never create catalog languages.
|
|
|
|
## Validation evidence
|
|
|
|
Automated checks use disposable PostgreSQL Testcontainers databases, not the owner's database. They cover migration rollback/recovery, API compatibility, empty definitions, shared contracts, stale edits, direct uniqueness/same-service constraints, service form save and counted deletion. Existing worker unit tests cover successful send, SMTP/error behavior and required variables; integration tests exercise exact-language loading/rendering and the bounded worker compatibility retry with the real normalized PostgreSQL repository.
|
|
|
|
On 2026-10-17 the interim upgrade was also rehearsed against a fresh disposable restore of the verified pre-migration backup (SHA256 `691CBBD35A49A73F17C7E9FB2A3C1E0BE539F0A9B4DC462E2B37B12AE220BDE3`). The current Migrator in Production mode upgraded 1 service, 1 definition and 1 version, derived 1 exact catalog code, and increased history from 3 to 4 rows while retaining the interim row. SQL assertions compared all existing template/service/definition fields, channel and usage data, and original history with the restored source; they matched. A second migrator run preserved the complete upgraded state. This rehearsal did not apply the repair to the owner's database. Builds/tests used a separate temporary artifacts directory because the owner's running Rider Web process held its normal output DLLs.
|
|
|
|
Chromium administration checks on 2026-10-17 included a 1440px desktop viewport and a narrow viewport. They used disposable PostgreSQL at `127.0.0.1:15433` and the task Web build hosted temporarily at `127.0.0.1:52652`: grouped/filter-preserving list, adding `uk`, keyboard tab navigation, unsaved tab content, selected-language preview, HTML variable encoding and script isolation, one shared variable field, malformed JSON preserving active language/input, final-version removal, ordinary removal alert/count, and confirmed deletion of a used language retaining its definition. The temporary Web was stopped afterward. No RabbitMQ-to-SMTP runtime message was sent; existing transport tests remain the evidence for that unchanged path. No Invemory website/Android application changed in this task.
|
|
|
|
|
|
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.
|