Files
ersatztv/web/src/api/templates.ts
T
timothyandClaude Opus 4.8 611924c0ee feat(#253 PR2): optimistic-concurrency contract for Template and DecoTemplate
Wire the frozen ETag/If-Match/412 recipe (Block reference implementation)
onto the Template and DecoTemplate aggregates:

- ReplaceTemplateItems / ReplaceDecoTemplateItems commands gain
  Option<int> ExpectedVersion; ToCommand() on the request DTOs threads it
  through from If-Match.
- Handlers introduce the version check as a standalone Either after
  validation (never via Apply), bump Version unconditionally before
  saving, and persist through SaveChangesWithConcurrencyGuard so a losing
  writer maps to 412 instead of 500. DecoTemplate's post-commit playout
  Reset enqueue now only runs after a successful save.
- TemplateViewModel / DecoTemplateViewModel carry Version (header-only,
  not echoed in the response body), populated in Mapper.
- TemplateController / DecoTemplateController: GET items emits a strong
  ETag of the root's version; PUT parses If-Match (400 on malformed),
  threads the expected version into the command, and returns the new
  ETag from the refreshed root on success. Both PUT actions now use the
  handler's returned item list directly instead of re-querying items.
- SPA: templates.ts / decoTemplates.ts gain getXItemsWithMeta and an
  If-Match-aware replaceX; TemplateEditor / DecoTemplateEditor hold the
  ETag in a ref, read items-with-meta first on load, and open a
  "changed elsewhere" ConfirmDialog on a 412 instead of navigating away.

Tests: new ReplaceTemplateItemsHandlerConcurrencyTests /
ReplaceDecoTemplateItemsHandlerConcurrencyTests mirror the Block
concurrency contract tests (stale/matching/absent If-Match, no-op bump,
racing-save 412, non-vacuous backstop). TemplateControllerTests /
DecoTemplateControllerTests gain ETag/If-Match/412 coverage.
TemplatesScreen.test.tsx / DecoTemplatesScreen.test.tsx gain a 412
conflict-dialog test mirroring BlocksScreen's.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 18:36:35 +02:00

86 lines
3.2 KiB
TypeScript

import { ApiError, request, requestWithMeta, type ResponseWithMeta } from './client';
import type { components } from './generated/v1';
export type TemplateGroup = components['schemas']['TemplateGroupResponseModel'];
export type Template = components['schemas']['TemplateResponseModel'];
export type TemplateItem = components['schemas']['TemplateItemResponseModel'];
export type TemplateWithItems = components['schemas']['TemplateWithItemsResponseModel'];
export type CreateTemplateGroupRequest = components['schemas']['CreateTemplateGroupRequest'];
export type CreateTemplateRequest = components['schemas']['CreateTemplateRequest'];
export type ReplaceTemplateRequest = components['schemas']['ReplaceTemplateRequest'];
export type TemplateItemRequest = components['schemas']['TemplateItemRequest'];
export type CopyTemplateRequest = components['schemas']['CopyTemplateRequest'];
// Template groups
export function getTemplateGroups(): Promise<TemplateGroup[]> {
return request<TemplateGroup[]>('/api/templates/groups');
}
export function createTemplateGroup(body: CreateTemplateGroupRequest): Promise<TemplateGroup> {
return request<TemplateGroup>('/api/templates/groups', { body, method: 'POST' });
}
export function deleteTemplateGroup(id: number): Promise<void> {
return request<void>(`/api/templates/groups/${id}`, { method: 'DELETE' });
}
// Templates
export function getTemplates(): Promise<Template[]> {
return request<Template[]>('/api/templates');
}
export function getTemplate(id: number): Promise<Template> {
return request<Template>(`/api/templates/${id}`);
}
export function createTemplate(body: CreateTemplateRequest): Promise<Template> {
return request<Template>('/api/templates', { body, method: 'POST' });
}
export function deleteTemplate(id: number): Promise<void> {
return request<void>(`/api/templates/${id}`, { method: 'DELETE' });
}
export function getTemplateItems(id: number): Promise<TemplateItem[]> {
return request<TemplateItem[]>(`/api/templates/${id}/items`);
}
/** Load template items together with the template's concurrency ETag (issue #253). */
export function getTemplateItemsWithMeta(id: number): Promise<ResponseWithMeta<TemplateItem[]>> {
return requestWithMeta<TemplateItem[]>(`/api/templates/${id}/items`);
}
/**
* Replace a template. Pass the last-seen ETag as `If-Match` to reject a stale overwrite with 412;
* the resolved value carries the new ETag for a subsequent save (issue #253).
*/
export function replaceTemplate(
id: number,
body: ReplaceTemplateRequest,
ifMatch?: string | null
): Promise<ResponseWithMeta<TemplateWithItems>> {
return requestWithMeta<TemplateWithItems>(`/api/templates/${id}`, {
body,
method: 'PUT',
headers: ifMatch ? { 'If-Match': ifMatch } : undefined
});
}
export function copyTemplate(id: number, body: CopyTemplateRequest): Promise<Template> {
return request<Template>(`/api/templates/${id}/copy`, { body, method: 'POST' });
}
export function messageFromTemplateError(error: unknown, fallback = 'Unable to load templates'): string {
if (error instanceof ApiError) {
return error.detail ?? error.message;
}
if (error instanceof Error) {
return error.message;
}
return fallback;
}