Add revision-protected reads, atomic upsert and deletion with structured actions. Preserve omitted translations and fix insertion of new push languages. Ref: IT-1627
20 KiB
Channel template model — IT-1627
Current compatibility decision — 2026-10-31 (supersedes earlier checkpoints)
The owner subsequently required independently deliverable Notification Service
changes. Request version now selects the format: omitted or 1 accepts the
original title/body/route request and emits the original version=1 data payload with
route=/home, without template lookup or actions. Version 2 requires templateKey,
languageCode and templateData and emits localized buttons. Unsupported versions
are explicit validation errors, never a fallback. One Firebase channel serves both.
The earlier no-legacy decision below is historical and no longer applies.
An action metadata entry is still required for each selected action, but its
objectId is optional. Section-only actions use {}; no synthetic object IDs.
For Invemory, the agreed first button label is «Перейти в розділ», localized in the
template, and the key determines contacts/containers/basket navigation. No mutation
or direct object-opening behavior is added in this increment.
Push Channel test-send has a format selector. Legacy preserves its existing simple Firebase notification test (token only), distinct from the production version=1 data-envelope flow. Version 2 emits the actual action data envelope with one button and matching primary action, requiring the actual device binding ID, action key and label. It never invents a binding or object ID. Unknown versions and missing binding are rejected before provider calls. Test fields are not channel configuration; token is cleared after send, and token/binding are cleared on modal close.
Version 2 requires a client that supports this payload. Current Invemory Android only accepts version=1 and /home; producer capability routing and client updates remain outside this Notification Service increment. Keeping version 1 as the default permits independent service delivery without changing existing producers.
Scope and sequence
The owner agreed on 2026-10-30 to implement the model first, then adapt the Notification Service administration. The service page is called Templates; the Template editor has Email and Push channel navigation, each containing language tabs. A key may have Email only, Push only, or both. Presence never selects a delivery channel automatically. Transport rendering and Invemory producers are later work.
The model and administration increments supply persistence and Email/Push editing, while preserving existing email API behavior. The action increment adds push rendering/forwarding; Invemory producers and native action handling are still pending.
Owner decision: action metadata — 2026-10-31
The template-data object has two properties: binding values and structured action metadata supplied by the producer. Actions belong to the individual template; there is no service-level action registry or registration endpoint. Template authors know the action keys. Notification Service validates structure without implementing consumer-specific behavior. The receiving application interprets actions and checks current access. Localized button labels belong to the template language version. The final owner-approved metadata is only an objectId; the dictionary key identifies the action. There is no action type field, arbitrary parameters, or translation-key registry. Authoring actions requires knowledge of the consuming application's keys.
The owner explicitly rejected legacy push-format compatibility branches. This is a new template format; existing Email behavior remains unchanged. Work proceeds through contract/editor/model, rendering/forwarding, then client handling coordinated with IT-1628. Saving or previewing buttons does not itself add native Android actions. Read/unread state and automatic business mutations are not introduced.
For subsequent owner-authorized local dev migrations, do not create backups, per the owner's explicit preference. This does not change production rollout rules.
Storage details
- templates: shared identity, service ID, exact key and timestamps. The composite service/key alternate key is unique. Service deletion is restricted.
- email_template_definitions: retained email channel identity, revision, variables, and translations. Its service/key now references the shared template.
- push_template_definitions: independent channel identity, revision and variables; unique service/key referencing the same shared template. No email is required.
- push_templates: language versions with plain-text title/body. Unique definition/language; composite foreign keys ensure that the definition and language belong to the same service. Content remains text because placeholders are rendered before the existing final-payload size limits are checked.
Each channel owns its variable contract, shared across that channel's languages. A missing channel has no definition row. An existing channel with no translations has a definition row and an empty version collection. The frontend must distinguish these states and must not create a channel merely by opening its tab.
Compatibility and mutations
Existing email creation paths create or reuse the shared key under the existing transaction advisory lock 1601. Existing IDs, keys, revisions, variables and language content are unchanged by migration. Email APIs continue to expose only email definitions and translations. Creating email for an existing push-only key reuses that catalog entry.
Deleting an email definition preserves a matching push definition and its shared key. If no push channel exists, that compatibility operation also removes the now-unused catalog entry. Deleting an email translation retains its definition. Service deletion checks the shared catalog, including push-only keys.
Service language usage counts include both channels. Ordinary removal rejects any used language; explicitly confirmed deletion removes both channels' translations atomically and retains their definitions. Service/catalog edits refresh both channels' revisions, so editors cannot silently save against an outdated catalog.
Migration and rollout
AddChannelTemplates creates the shared catalog from existing email definitions, retaining their IDs as the initial shared template IDs, and adds push tables and foreign keys. It does not touch the existing email content or transport payloads. No owner or production database is migrated as part of source implementation.
Stop administration writes and the migrator's development seeding while upgrading; apply the migration and run the matching Web/Migrator build before resuming writes. The previous Web binary cannot create email keys against the new foreign key. Existing email reads and sends remain compatible. Use the existing backup and explicit migration procedure in email-template-localizations.md.
Downgrade preserves existing email rows but refuses to discard push definitions, including empty ones. Export and explicitly remove push data before an intentional rollback; otherwise restore the verified backup with its matching application.
Verification
ChannelTemplateModelTests uses disposable PostgreSQL databases to cover migration and rerun, independent channel contracts, email-only/push-only/combined keys, adding email to a push-only key, email deletion preserving push, language deletion, duplicate language rejection, cross-service language rejection, and rollback guards. Existing migration/API/worker tests provide email regression coverage.
Administration increment — 2026-10-31
After separate owner authorization on 2026-10-31, AddChannelTemplates was applied to the local Development database at localhost:5433/notification_service using update-development-database.bat. A custom-format backup was taken first with Web stopped. Before/after data-only dumps confirmed unchanged services, service languages, email definitions and translations. All eight email definitions have shared catalog rows. The matching task Web build was restarted on localhost:5188; production was not accessed.
The service table has one row per exact key and separate Email/Push columns. Each channel shows its configured-language statuses or Not configured. Existing email URLs and key filters remain supported. The editor has service/key context followed by Email/Push navigation, then channel-specific variables and language tabs.
Channel navigation uses normal server-rendered links styled as tabs, with aria-current identifying the selected channel. Opening a missing channel shows a Create Email Template or Create Push Template action; GET requests never persist anything. The new-template page defaults to an unsaved Email form and can switch to Push before saving. A shared key is fixed when adding a channel to an existing key. A channel may retain zero translations.
Push editing supplies Title and Body, independent variables, exact catalog-language validation, revision checks and unknown/incomplete placeholder validation. Blank Title and Body omit/remove that version; a partial version rejects the whole save. Failed saves retain entered values, active language and original revision. Removing Push has a separate antiforgery-protected confirmation and preserves Email; Email confirmation explicitly states that Push is retained.
Both editors reuse the existing language keyboard behavior and sample-variable preview script. Interpolation is single-pass: sample values containing placeholders are not interpreted again. Push preview writes plain text; email HTML remains encoded and sandboxed. A modal Keep editing / Discard changes choice guards links when fields are dirty, including after validation errors. Language switching stays inside the page and retains entered values. Saving affects only the selected channel. The existing browser unload warning remains supplementary.
Validation on 2026-10-31: all 67 service/worker and 166 PostgreSQL integration tests passed. Chrome against a temporary host and disposable database covered creating Push first, adding Email under the same key, independent content and variables, validation with retained input, language keyboard navigation, preview, and keeping unsaved edits through the navigation dialog. Desktop 1440px and narrow 390/320px layouts were checked; the table scrolls locally without page overflow. This was administration browser validation, not installed Android or end-to-end push delivery validation. Owner databases were not migrated. Cognitive complexity was structurally reviewed; numeric analyzer scores were not measured.
The table has a minimum readable width and scrolls within its wrapper at narrow widths. The shared stylesheet is fingerprinted to avoid cached pre-change layouts. No editor is added to the Invemory web or installed Android application.
Push template actions and rendering — 2026-10-31
SendPushMessageData now requires templateKey, languageCode and templateData
instead of ready-made title/body/route. Delivery identity, expiry and registration
fields remain. templateData has exactly two model properties:
{
"bindings": { "Name": "Alex" },
"actions": { "openNeed": { "objectId": "need-42" } }
}
Each language version stores actions as JSON, edited alongside title/body:
{
"defaultAction": "openNeed",
"buttons": [{ "action": "openNeed", "label": "View need" }]
}
Labels belong to that language and may reference declared text bindings. The order
of buttons is their display order. This first editor supports up to three buttons,
unique action keys and nonempty labels up to 100 template characters. {} means
no actions. Clear title/body and reset actions to {} to remove a translation.
Missing action metadata fails the send; it never silently creates a dead button.
Only actions selected in the template are forwarded. IDs are opaque strings;
Notification Service neither constructs URLs nor interprets consumer actions.
The worker resolves the exact language then English, checks required bindings,
substitutes once, and truncates title/body/label to 100/500/100 UTF-16 code units
without splitting a text element. Missing optional bindings become empty text;
empty final content fails. User-supplied placeholder text is not reinterpreted.
FCM receives data payload version 2 with title/body and an actions JSON string
containing the resolved default action and buttons (action, objectId, label).
The serialized data dictionary is capped at 3500 UTF-8 bytes as a conservative
application budget; oversized payloads fail before contacting Firebase.
The RabbitMQ command type remains Notification.SendPush.v1; its payload contract
is replaced directly, with no old-title/body acceptance branch per owner decision.
AddPushTemplateActions adds a JSONB column with {} for existing versions. It was
applied to the local dev database without a backup, per owner instruction. Matching
Web is running on localhost:5188. This is not a production deployment. Do not send
this new contract from Invemory until its producers and installed client handling
are updated together. Native version 2/action handling remains separate work;
provider serialization tests do not demonstrate Android display or navigation.
Validation: 76 service/worker tests and 168 PostgreSQL integration tests passed. New tests cover language fallback, single-pass substitution, Unicode truncation, missing bindings/action IDs, malformed action definitions, forwarding and localized editor persistence/errors. Rider found no errors in inspected production files; remaining findings were style suggestions and the existing Firebase Token warning. Cognitive complexity was structurally reviewed, not numerically measured. Reuse review compared the existing email renderer and shared editor: push keeps a separate single-pass plain-text renderer because email uses HTML encoding and repeated replacement; channel editing, preview layout and revision handling are reused.
Browser validation against the updated local dev Web covered the Ukrainian action label and primary-action preview. The 390px layout kept page width within the viewport and the button remained visible and clickable as a preview-only control. Server-side invalid JSON retention and save/reload were verified by integration tests; an attempted browser submission lost its automation connection, so no successful browser save is claimed for this increment. No QA push template was persisted into the owner's catalog. No installed Android/real FCM send was tested.
Compatibility increment verification — 2026-10-31
The complete solution passed 84 service/worker tests and 172 PostgreSQL integration checks. Added coverage deserializes original requests with omitted/explicit version, compares the seven-field legacy FCM envelope, proves legacy never queries templates, rejects unsupported versions before provider calls, renders section actions without objectId, and exercises both test-send formats through the antiforgery-protected admin endpoint using a recording Firebase boundary. The unchanged authorization and saved-credential behavior remain covered. No new migration is required.
Chrome against temporary host Web on 127.0.0.1:52658 verified default legacy mode, version-2 fields, server rejection of missing binding before Firebase access, token clearing, switching modes, and the scrollable 390px modal without horizontal page overflow. The host was stopped afterward; the owner's Rider Web on 5188 was left running. No real Firebase send was performed. No phone was connected through ADB, and the existing Android source accepts only version 1; device display/tap behavior for version 2 remains unverified and needs the separate client implementation.
Review reused existing version, sender, channel credential resolver, provider client, and admin test endpoint rather than introducing another channel type. Compared the legacy input validator and FCM builder against Git HEAD and the current Android allowlist. Rider reported no errors in inspected changed production files; warnings remain for existing Firebase Token deprecation, defensive JSON null checks and style. Cognitive complexity was reviewed structurally against the limit of 10; numeric scores were not measured. All changes remain uncommitted for owner review/testing.
Push template management REST API (IT-1627, 2026-10-31)
Push definitions now have the same service-scoped management operations as Email:
| Method | Route | Purpose |
|---|---|---|
| GET | /api/v1/services/{serviceId}/push-template-definitions?key={key} |
Read one exact key |
| GET | /api/v1/services/{serviceId}/push-template-definitions/all |
List Push definitions |
| POST | /api/v1/services/{serviceId}/push-template-definitions/upsert?key={key} |
Create or atomically patch a definition |
| DELETE | /api/v1/services/{serviceId}/push-template-definitions?key={key}&revision={revision} |
Delete only Push, preserving Email |
Requests and responses use camelCase and the existing success/data/error envelope.
Create with omitted/null revision; an existing key returns 409. Update with the
last read nonempty revision; stale/missing definitions return 409. Successful
creation returns 201 with Location; update/read/delete return 200. Unknown services
or absent reads/deletes return 404; invalid requests return 400.
Example creation body:
{
"revision": null,
"variables": [{ "name": "ActorName", "required": true }],
"translations": [{
"languageCode": "uk",
"title": "Довірені контакти",
"body": "{{ActorName}} додав вас до довірених контактів.",
"actions": {
"defaultAction": "contacts",
"buttons": [{ "action": "contacts", "label": "Перейти в розділ" }]
}
}],
"removeLanguageCodes": []
}
The action key above illustrates a producer/client agreement; this endpoint neither
registers actions nor activates any Invemory producer. Object IDs are per-message
metadata, not template content. actions is a structured object, not an escaped JSON
string. Omission means no actions for a supplied translation; explicit null is invalid.
variables and translations arrays are required (empty is valid). Variables replace
the channel-wide contract. Supplied translations replace their full content, including
actions. Omitted translations remain; only removeLanguageCodes removes versions.
Removing every language retains the definition and variables. Codes exactly match the
service catalog; duplicate/unknown codes and simultaneous replace/remove are rejected.
The final merged definition is validated, including retained translations' placeholders,
so a variable change cannot invalidate an omitted translation. Invalid/stale requests
leave content and revision unchanged. The existing transaction/advisory lock covers
catalog checks, merge and revision-guarded persistence. API and administration share
validation and storage, including explicit EF insertion when adding a new language.
Existing Email endpoints remain unchanged. Both channels can independently use the same service/key. No migration, new registry, access boundary or Android change is needed for these management endpoints. Keep them within the existing private Notification Service administration network boundary.
Validation of the management API increment: 84 service/unit and 187 PostgreSQL integration tests passed (271 total). After review cleanup, all 29 focused Push API, editor and model tests passed again. Coverage includes parallel create/update conflicts, atomic invalid writes, retained translations, structured localized actions, explicit language removal and Email preservation. Rider reported no errors in inspected files; manual structural complexity review was performed, with no numeric score recorded. Reuse review compared Email definition controllers/handlers, API validation/envelopes, TemplateMutationRunner and the existing Push editor/storage path.
A separate local Web launch for additional browser validation was rejected by the automatic execution policy without further reason. No standalone-browser or installed Android validation is claimed for this increment; API requests were exercised through the ASP.NET integration host with real PostgreSQL. Changes await owner testing and remain uncommitted; the previously delivered service does not yet include these endpoints.
Owner follow-up: authorized code delivery and another Kubernetes deployment after
verification, and authorized stopping the Rider Web process. The Rider process was
stopped. A second background Web launch was rejected by the execution tool with only
blocked by policy; this is an execution-tool restriction, not a request for more
owner permission. The automated API/PostgreSQL checks remain available.