2dd5e318d7
Allow Web SMTP egress on TCP 587 and reuse the saved channel password only when connection settings match. Ref: IT-1601
112 lines
13 KiB
Markdown
112 lines
13 KiB
Markdown
# 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
|
|
|
|
In the admin channel editor, **Send Test Email** tests the form's current values without saving them. An explicitly entered password takes precedence. When the password field is blank for an existing channel, the Web server reads its saved password only if host, port, username, and SSL/TLS mode still match the saved SMTP connection. Changed connection settings require an explicit password. The saved password is not placed in the page or test response. A new channel keeps the ad-hoc test behavior, including unauthenticated SMTP when username and password are empty.
|
|
|
|
- `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.
|