Skip to content

Restaurant Menu Core Publishing Implementation Plan

For Codex: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Let a DailyMark tenant create, edit, preview, publish, serve, and roll back an immutable static restaurant menu on a DailyMark subdomain.

Architecture: Tenant-scoped draft entities live in PostgreSQL under FORCE RLS. A pure renderer consumes an immutable snapshot, a BullMQ processor uploads the versioned bundle and atomically writes a host manifest, and a new read-only apps/menu service serves files without querying mutable menu data.

Tech Stack: NestJS 11, TypeORM/PostgreSQL, type-rls, Redis/BullMQ, S3 API, Bun, Next.js 16, React 19, Ant Design 6, Vitest/Bun test, Playwright.

Global Constraints

  • Follow the umbrella spec at docs/superpowers/specs/2026-08-09-restaurant-qr-menu-platform-design.md and program constraints at docs/superpowers/plans/2026-08-09-restaurant-qr-menu-program-plan.md.
  • New tables use explicit migration 1723300000000-RestaurantMenus.ts, FORCE RLS, UTC timestamps, and both runtime/CLI entity registration.
  • Role.Member may edit drafts; only Role.Admin and Role.Owner may publish or roll back.
  • No draft write or failed build may alter the active host manifest.
  • The renderer accepts only MenuSnapshotV1; it never queries repositories.
  • Default public host is ${slug}.dailymark.me; custom-domain verification is implemented by the domains/QR plan.
  • Obtain approval before installing AWS SDK dependencies or running the migration.

File and Interface Ledger

Shared contracts

  • Create packages/menu-contracts/package.json — workspace package metadata.
  • Create packages/menu-contracts/tsconfig.json — strict TypeScript config.
  • Create packages/menu-contracts/src/index.tsMenuSnapshotV1, PublicationManifestV1, RenderedFile, HostManifestV1.
ts
export interface RenderedFile {
  path: string;
  contentType: string;
  body: Uint8Array;
  cacheControl: string;
  sha256: string;
}

export interface MenuSnapshotV1 {
  schemaVersion: 1;
  publicationId: string;
  tenantId: string;
  menuId: string;
  primaryLocale: 'ru' | 'en' | 'sr';
  locales: Array<'ru' | 'en' | 'sr'>;
  currency: string;
  generatedAt: string;
  sections: SnapshotSection[];
}

export interface PublicationManifestV1 {
  schemaVersion: 1;
  publicationId: string;
  rendererVersion: string;
  files: Array<{ path: string; sha256: string; contentType: string }>;
}

export interface HostManifestV1 {
  schemaVersion: 1;
  normalizedHost: string;
  publicationId: string;
  bundlePrefix: string;
  activatedAt: string;
}

API boundaries

  • Create apps/api/src/menu/storage/object-store.tsObjectStore interface and token.
  • Create apps/api/src/menu/storage/s3-object-store.ts — production S3 implementation.
  • Create apps/api/src/menu/storage/memory-object-store.ts — deterministic tests.
  • Create apps/api/src/menu/publish/menu-snapshot.tsbuildMenuSnapshot(...).
  • Create apps/api/src/menu/publish/menu-renderer.tsrenderMenu(snapshot): RenderedFile[].
  • Create apps/api/src/menu/publish/host-key.tshostManifestKey(host): string shared algorithm.
  • Create apps/api/src/menu/publish/publish.service.ts — enqueue, status, rollback.
  • Create apps/api/src/menu/publish/publish.processor.ts — build/upload/verify/activate.
ts
export interface ObjectStore {
  put(key: string, body: Uint8Array, options: PutOptions): Promise<void>;
  get(key: string): Promise<StoredObject | null>;
  head(key: string): Promise<StoredHead | null>;
  deletePrefix(prefix: string): Promise<void>;
  signUpload(key: string, contentType: string, maxBytes: number): Promise<SignedUpload>;
}

export function hostManifestKey(normalizedHost: string): string;
export function buildMenuSnapshot(input: SnapshotSource): MenuSnapshotV1;
export function renderMenu(snapshot: MenuSnapshotV1): RenderedFile[];

Task 1: Shared Contracts, Storage Configuration, and Object Store

Files:

  • Create: packages/menu-contracts/package.json
  • Create: packages/menu-contracts/tsconfig.json
  • Create: packages/menu-contracts/src/index.ts
  • Create: apps/api/src/config/namespaces/menu.config.ts
  • Create: apps/api/src/config/services/menu-config.service.ts
  • Modify: apps/api/src/config/namespaces/index.ts
  • Modify: apps/api/src/config/config.module.ts
  • Modify: apps/api/package.json
  • Modify: bun.lock
  • Create: apps/api/src/menu/storage/object-store.ts
  • Create: apps/api/src/menu/storage/memory-object-store.ts
  • Create: apps/api/src/menu/storage/s3-object-store.ts
  • Test: apps/api/src/config/namespaces/menu.config.spec.ts
  • Test: apps/api/src/menu/storage/memory-object-store.spec.ts

Interfaces:

  • Consumes: existing global LocalConfigModule and zod environment validation.

  • Produces: MenuConfigService, MENU_OBJECT_STORE, ObjectStore, and package @dailymark/menu-contracts for every later task.

  • [ ] Step 1: Obtain dependency approval and add exact packages

Run after approval:

bash
bun add --cwd apps/api @aws-sdk/client-s3 @aws-sdk/s3-request-presigner

Expected: apps/api/package.json and bun.lock contain the two AWS SDK packages; no unrelated upgrade appears.

  • [ ] Step 2: Write failing configuration and memory-store tests
ts
it('rejects a non-https public base outside test', () => {
  expect(() => menuSchema.parse({ MENU_PUBLIC_BASE_URL: 'http://bucket' }))
    .toThrow();
});

it('returns immutable bytes by key', async () => {
  const store = new MemoryObjectStore();
  await store.put('bundles/p1/index.html', bytes('ok'), {
    contentType: 'text/html', cacheControl: 'public,max-age=31536000,immutable',
  });
  expect(text((await store.get('bundles/p1/index.html'))!.body)).toBe('ok');
});
  • [ ] Step 3: Run the narrow tests and verify failure

Run:

bash
bun test apps/api/src/config/namespaces/menu.config.spec.ts apps/api/src/menu/storage/memory-object-store.spec.ts

Expected: FAIL because menuSchema and MemoryObjectStore do not exist.

  • [ ] Step 4: Implement contracts and configuration

Define exact environment keys:

ts
export const menuConfig = registerAs('menu', () => ({
  bucket: process.env.MENU_S3_BUCKET ?? 'dailymark-menu-dev',
  region: process.env.MENU_S3_REGION ?? 'auto',
  endpoint: process.env.MENU_S3_ENDPOINT,
  accessKeyId: process.env.MENU_S3_ACCESS_KEY_ID,
  secretAccessKey: process.env.MENU_S3_SECRET_ACCESS_KEY,
  publicBaseUrl: process.env.MENU_PUBLIC_BASE_URL ?? 'http://localhost:3010',
  rootDomain: process.env.MENU_ROOT_DOMAIN ?? 'dailymark.me',
}));

MenuConfigService exposes typed getters and enabled only when bucket credentials are complete. S3ObjectStore maps put/get/head/deletePrefix/signUpload to AWS SDK commands. MemoryObjectStore copies input/output byte arrays so tests cannot mutate stored values.

  • [ ] Step 5: Run tests, typecheck, and format check

Run:

bash
bun test apps/api/src/config/namespaces/menu.config.spec.ts apps/api/src/menu/storage/memory-object-store.spec.ts
bun run --cwd apps/api typecheck
bun run --cwd apps/api format:check

Expected: PASS; no environment secret appears in snapshots or logs.

  • [ ] Step 6: Commit
bash
git add packages/menu-contracts apps/api/package.json bun.lock apps/api/src/config apps/api/src/menu/storage
git commit -m "feat(menu): add publication storage contract"

Task 2: Core Entities, Domain Entity, and FORCE RLS Migration

Files:

  • Create: apps/api/src/menu/entities/menu.entity.ts
  • Create: apps/api/src/menu/entities/menu-location.entity.ts
  • Create: apps/api/src/menu/entities/menu-section.entity.ts
  • Create: apps/api/src/menu/entities/menu-item.entity.ts
  • Create: apps/api/src/menu/entities/menu-item-variant.entity.ts
  • Create: apps/api/src/menu/entities/menu-translation.entity.ts
  • Create: apps/api/src/menu/entities/media-asset.entity.ts
  • Create: apps/api/src/menu/entities/menu-domain.entity.ts
  • Create: apps/api/src/menu/entities/menu-publication.entity.ts
  • Create: apps/api/src/menu/entities/menu-audit-event.entity.ts
  • Create: apps/api/src/db/migrations/1723300000000-RestaurantMenus.ts
  • Modify: apps/api/src/db/data-source.ts
  • Create: apps/api/src/menu/menu.module.ts
  • Modify: apps/api/src/app.module.ts
  • Test: apps/api/test/menu-schema.e2e.spec.ts

Interfaces:

  • Consumes: Location, TenantContext, TypeORM, and RLSModule.forFeature.

  • Produces: core repositories and the schema used by CRUD, renderer, domains, imports, AI, rich media, and analytics.

  • [ ] Step 1: Write a failing migration/RLS e2e test

ts
it('isolates menu rows by rls.tenant_id', async () => {
  await asTenant(ds, TENANT_A, () => insertMenu(ds, TENANT_A, 'a'));
  const visible = await asTenant(ds, TENANT_B, () => ds.query('select id from menus'));
  expect(visible).toEqual([]);
});

it('enforces globally unique normalized domains', async () => {
  await insertDomain(ds, TENANT_A, 'cafe.dailymark.me');
  await expect(insertDomain(ds, TENANT_B, 'cafe.dailymark.me'))
    .rejects.toMatchObject({ code: '23505' });
});
  • [ ] Step 2: Start test DB, run existing migrations, and verify failure

Run:

bash
bun run test:db:up
bun run test:db:migrate
bun test apps/api/test/menu-schema.e2e.spec.ts

Expected: FAIL with relation menus not found.

  • [ ] Step 3: Implement focused entities

Use UUID primary keys, explicit tenant_id, sort_index, timestamptz, and string unions. The critical publication fields are:

ts
@Entity({ name: 'menu_publications' })
export class MenuPublication {
  @PrimaryGeneratedColumn('uuid') id: string;
  @Column({ name: 'tenant_id', type: 'uuid' }) tenantId: string;
  @Column({ name: 'menu_id', type: 'uuid' }) menuId: string;
  @Column({ type: 'integer' }) version: number;
  @Column({ type: 'jsonb' }) snapshot: MenuSnapshotV1;
  @Column({ name: 'bundle_prefix', nullable: true }) bundlePrefix: string | null;
  @Column({ type: 'jsonb', nullable: true }) manifest: PublicationManifestV1 | null;
  @Column({ type: 'varchar' }) state: PublicationState;
  @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) createdAt: Date;
}
  • [ ] Step 4: Implement migration, indexes, constraints, and policies

Create unique constraints for (tenant_id, menu_id, version), (menu_id, sort_index) where appropriate, and global menu_domains.normalized_host. Apply this policy to every tenant business table:

sql
ALTER TABLE menus ENABLE ROW LEVEL SECURITY;
ALTER TABLE menus FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON menus
USING (tenant_id = current_setting('rls.tenant_id', true)::uuid)
WITH CHECK (tenant_id = current_setting('rls.tenant_id', true)::uuid);

The down() migration drops policies/tables in reverse dependency order.

  • [ ] Step 5: Register entities in CLI and runtime modules

Add all ten entities to apps/api/src/db/data-source.ts, RLSModule.forFeature([...]) in MenuModule, import MenuModule from AppModule, and use ordinary TypeOrmModule only where an exact public identity lookup is explicitly designed.

  • [ ] Step 6: Obtain migration approval, run up/down/up, and run test

Run after approval:

bash
DATABASE_URL=postgres://app:app@localhost:54332/checklist_test bun run --cwd apps/api migration:run
bun test apps/api/test/menu-schema.e2e.spec.ts
DATABASE_URL=postgres://app:app@localhost:54332/checklist_test bun run --cwd apps/api migration:revert
DATABASE_URL=postgres://app:app@localhost:54332/checklist_test bun run --cwd apps/api migration:run

Expected: test PASS and round trip completes without orphan types/tables.

  • [ ] Step 7: Commit
bash
git add apps/api/src/menu apps/api/src/db/data-source.ts apps/api/src/db/migrations/1723300000000-RestaurantMenus.ts apps/api/src/app.module.ts apps/api/test/menu-schema.e2e.spec.ts
git commit -m "feat(menu): establish tenant-isolated menu schema"

Task 3: Draft CRUD, Location Assignment, and RBAC API

Files:

  • Create: apps/api/src/menu/menu.dto.ts
  • Create: apps/api/src/menu/menu.service.ts
  • Create: apps/api/src/menu/menu.controller.ts
  • Modify: apps/api/src/menu/menu.module.ts
  • Test: apps/api/src/menu/menu.service.spec.ts
  • Test: apps/api/test/menu.e2e.spec.ts

Interfaces:

  • Consumes: core repositories, RequestUser, roleAtLeast, Role, and existing Location repository.

  • Produces: REST endpoints /menus, /menus/:id, /menus/:id/sections, /menus/:id/items, /menus/:id/preview-source and MenuDraftDetail.

  • [ ] Step 1: Write failing service tests for slug, location, ordering, and tenant scope

ts
it('creates the menu and default host atomically', async () => {
  const result = await service.create(TENANT, ACTOR, {
    name: 'Dinner', slug: 'warm-corner', primaryLocale: 'ru', currency: 'RSD', locationIds: [LOCATION],
  });
  expect(result.defaultHost).toBe('warm-corner.dailymark.me');
});
  • [ ] Step 2: Run narrow test and verify failure

Run: bun test apps/api/src/menu/menu.service.spec.ts

Expected: FAIL because MenuService does not exist.

  • [ ] Step 3: Implement validated DTOs and transaction-safe service methods

Use class-validator DTOs with ISO-4217 three-letter currency, locale enum, non-empty names, bounded arrays, and slug regex. Expose exact methods:

ts
create(tenantId: string, actorId: string, dto: CreateMenuDto): Promise<MenuDraftDetail>;
get(tenantId: string, menuId: string): Promise<MenuDraftDetail>;
update(tenantId: string, actorId: string, menuId: string, dto: UpdateMenuDto): Promise<MenuDraftDetail>;
replaceSections(tenantId: string, actorId: string, menuId: string, dto: ReplaceSectionsDto): Promise<MenuDraftDetail>;
archive(tenantId: string, actorId: string, menuId: string): Promise<void>;

Create menu, location joins, default MenuDomain, and audit event in one transaction. Catch unique host violation and return HTTP 409 without revealing another tenant.

  • [ ] Step 4: Implement guarded controller

Apply @UseGuards(CsrfGuard, AuthGuard) to authenticated routes. Allow member draft edits. Require roleAtLeast(user.role, Role.Admin) for archive and return 403 otherwise. Obtain tenantId and actorId only from req.user.

  • [ ] Step 5: Add e2e tests

Cover owner/admin/member CRUD, member archive denial, invalid location from another tenant, duplicate host conflict, CSRF rejection, and cross-tenant UUID probing returning 404.

  • [ ] Step 6: Run tests and API gates
bash
bun test apps/api/src/menu/menu.service.spec.ts
bun test apps/api/test/menu.e2e.spec.ts
bun run --cwd apps/api typecheck
bun run --cwd apps/api lint

Expected: PASS and zero lint errors.

  • [ ] Step 7: Commit
bash
git add apps/api/src/menu apps/api/test/menu.e2e.spec.ts
git commit -m "feat(menu): add tenant draft management"

Task 4: Deterministic Snapshot and Static Renderer

Files:

  • Create: apps/api/src/menu/publish/menu-snapshot.ts
  • Create: apps/api/src/menu/publish/menu-renderer.ts
  • Create: apps/api/src/menu/publish/html.ts
  • Create: apps/api/src/menu/publish/host-key.ts
  • Test: apps/api/src/menu/publish/menu-snapshot.spec.ts
  • Test: apps/api/src/menu/publish/menu-renderer.spec.ts
  • Test fixture: apps/api/src/menu/publish/__fixtures__/snapshot-v1.ts
  • Golden outputs: apps/api/src/menu/publish/__fixtures__/golden/

Interfaces:

  • Consumes: fully loaded SnapshotSource from repositories.

  • Produces: MenuSnapshotV1, deterministic RenderedFile[], and host-manifest key algorithm used by gateway.

  • [ ] Step 1: Write failing deterministic and safety tests

ts
it('renders identical bytes for identical snapshots', () => {
  expect(renderMenu(FIXTURE)).toEqual(renderMenu(structuredClone(FIXTURE)));
});

it('escapes tenant text and emits no executable tenant markup', () => {
  const files = renderMenu(withItemName('<script>alert(1)</script>'));
  expect(text(file(files, 'ru/index.html').body)).toContain('&lt;script&gt;');
});
  • [ ] Step 2: Run and verify failure

Run: bun test apps/api/src/menu/publish/menu-snapshot.spec.ts apps/api/src/menu/publish/menu-renderer.spec.ts

Expected: FAIL because renderer modules do not exist.

  • [ ] Step 3: Implement canonical snapshot sorting and serialization

Sort sections/items/variants by sortIndex, then stable ID. Convert Date to UTC ISO strings. Never include mutable database timestamps that do not affect output. Include only reviewed translations and approved/publishable media.

  • [ ] Step 4: Implement static files and cache headers

Generate ru/index.html, en/index.html, sr/index.html for enabled locales, assets/menu.css, assets/menu.js, and manifest.json. HTML references only relative versioned bundle paths. Use:

ts
const IMMUTABLE = 'public,max-age=31536000,immutable';
const files = locales.map(locale => renderedHtml(locale, IMMUTABLE));
return files.map(file => ({ ...file, sha256: sha256(file.body) }));

menu.js provides locale switch, search/filter extension points, reduced-motion behavior, and no analytics code yet.

  • [ ] Step 5: Assert budgets and golden output

Tests gzip critical files and assert JavaScript ≤50 KB and HTML+CSS+JS ≤150 KB. Golden changes require an intentional renderer-version bump.

  • [ ] Step 6: Run tests and typecheck
bash
bun test apps/api/src/menu/publish
bun run --cwd apps/api typecheck

Expected: PASS with stable checksums on two consecutive runs.

  • [ ] Step 7: Commit
bash
git add apps/api/src/menu/publish packages/menu-contracts
git commit -m "feat(menu): render deterministic static bundles"

Task 5: Publish Queue, Verification, Activation, and Rollback

Files:

  • Create: apps/api/src/menu/publish/publish.constants.ts
  • Create: apps/api/src/menu/publish/publish.service.ts
  • Create: apps/api/src/menu/publish/publish.processor.ts
  • Create: apps/api/src/menu/publish/publication-validator.ts
  • Create: apps/api/src/menu/publish/publish.controller.ts
  • Modify: apps/api/src/menu/menu.module.ts
  • Test: apps/api/src/menu/publish/publication-validator.spec.ts
  • Test: apps/api/src/menu/publish/publish.processor.spec.ts
  • Test: apps/api/test/menu-publication.e2e.spec.ts

Interfaces:

  • Consumes: ObjectStore, renderer, repositories, BullMQ, and admin-or-owner request identity.

  • Produces: POST /menus/:id/publications, GET /menus/:id/publications, POST /menus/:id/publications/:publicationId/rollback.

  • [ ] Step 1: Write failing validator and failure-atomicity tests

ts
it('does not activate when read-back checksum fails', async () => {
  store.corruptNextRead('bundles/p1/ru/index.html');
  await expect(processor.process(jobFor('p1'))).rejects.toThrow('checksum');
  expect(await store.get(hostManifestKey('warm-corner.dailymark.me'))).toBeNull();
});
  • [ ] Step 2: Run and verify failure

Run: bun test apps/api/src/menu/publish/publication-validator.spec.ts apps/api/src/menu/publish/publish.processor.spec.ts

Expected: FAIL because validator and processor do not exist.

  • [ ] Step 3: Implement publication readiness and immutable snapshot transaction

Block invalid price, missing primary-locale name, unreviewed factual fields, missing rich-media fallback, inactive default domain, or duplicate build for the same revision. In one transaction allocate next version, save snapshot/state building, and audit enqueue.

  • [ ] Step 4: Implement queue registration and processor

Register menu-publish only when QUEUE_DISABLED !== '1'. Use job ID menu-publish:${publicationId}. Upload all files to public/bundles/${publicationId}/, upload manifest.json, read every head/checksum, then write host manifest last.

  • [ ] Step 5: Implement idempotent rollback

Rollback accepts only a verified, active, or superseded publication for the same menu. It writes the prior HostManifestV1, updates database active states in a transaction, records audit, and returns the now-active version. Repeating the same rollback returns success without another state transition.

  • [ ] Step 6: Run unit/e2e tests
bash
bun test apps/api/src/menu/publish
bun test apps/api/test/menu-publication.e2e.spec.ts
bun run --cwd apps/api typecheck

Expected: PASS for success, duplicate job, upload failure, checksum failure, activation retry, and rollback.

  • [ ] Step 7: Commit
bash
git add apps/api/src/menu apps/api/test/menu-publication.e2e.spec.ts
git commit -m "feat(menu): publish and roll back immutable versions"

Task 6: Read-Only Public Menu Gateway

Files:

  • Create: apps/menu/package.json
  • Create: apps/menu/tsconfig.json
  • Create: apps/menu/Dockerfile
  • Create: apps/menu/src/config.ts
  • Create: apps/menu/src/host.ts
  • Create: apps/menu/src/store.ts
  • Create: apps/menu/src/server.ts
  • Create: apps/menu/src/main.ts
  • Test: apps/menu/src/host.spec.ts
  • Test: apps/menu/src/server.spec.ts
  • Modify: package.json
  • Modify: bun.lock

Interfaces:

  • Consumes: HostManifestV1, PublicationManifestV1, hostManifestKey algorithm, and read-only S3 credentials.

  • Produces: HTTP GET/HEAD static delivery with no database dependency.

  • [ ] Step 1: Write failing host and server tests

ts
it('normalizes host and strips a valid port', () => {
  expect(normalizeRequestHost('Warm-Corner.DailyMark.Me:443')).toBe('warm-corner.dailymark.me');
});

it('serves active index with revalidation and assets immutable', async () => {
  const response = await app.request('https://warm-corner.dailymark.me/ru/');
  expect(response.headers.get('cache-control')).toBe('no-cache');
});
  • [ ] Step 2: Run and verify failure

Run: bun test --cwd apps/menu

Expected: FAIL because gateway modules do not exist.

  • [ ] Step 3: Implement strict host and path resolution

Reject missing/multiple Host, IP literals, invalid IDNA, encoded slash, dot traversal, hidden source prefixes, and unknown host manifests. Map / to /${primaryLocale}/index.html; map directory paths to index.html; support only GET/HEAD.

  • [ ] Step 4: Implement cache and security headers

Index HTML: Cache-Control: no-cache, ETag from object checksum. Hashed/versioned assets: public,max-age=31536000,immutable. Always set CSP, X-Content-Type-Options: nosniff, Referrer-Policy: strict-origin-when-cross-origin, and deny framing unless the preview route uses an explicit management-only origin.

  • [ ] Step 5: Run gateway tests and build
bash
bun test --cwd apps/menu
bun run --cwd apps/menu typecheck
bun run --cwd apps/menu build

Expected: PASS; build produces a standalone Bun entrypoint.

  • [ ] Step 6: Commit
bash
git add apps/menu package.json bun.lock packages/menu-contracts
git commit -m "feat(menu): serve published bundles by host"

Task 7: Management UI for Menus, Editor, Preview, and Publications

Files:

  • Create: apps/web/ui/app/menus/menuClient.ts
  • Create: apps/web/ui/app/menus/menuTypes.ts
  • Create: apps/web/ui/app/menus/MenusScreen.tsx
  • Create: apps/web/ui/app/menus/MenuEditorScreen.tsx
  • Create: apps/web/ui/app/menus/MenuPreview.tsx
  • Create: apps/web/ui/app/menus/PublicationsPanel.tsx
  • Create: apps/web/ui/app/menus/menuView.ts
  • Test: apps/web/ui/app/menus/menuView.test.ts
  • Story: apps/web/ui/app/menus/MenuEditorScreen.stories.tsx
  • Create: apps/web/app/[locale]/app/menus/page.tsx
  • Create: apps/web/app/[locale]/app/menus/[menuId]/page.tsx
  • Modify: apps/web/ui/app/AppShell.tsx
  • Modify: apps/web/messages/ru.json
  • Modify: apps/web/messages/en.json
  • Modify: apps/web/messages/sr.json

Interfaces:

  • Consumes: core REST endpoints and existing apiRequest<T>.

  • Produces: owner/admin/member draft workflow; publication/rollback actions hidden and server-denied for member.

  • [ ] Step 1: Write failing pure view-state tests

ts
it('blocks publish when primary names or prices are invalid', () => {
  expect(publicationReadiness(invalidDraft).blocking.map(x => x.code))
    .toEqual(['missing_primary_name', 'invalid_price']);
});
  • [ ] Step 2: Run and verify failure

Run: bun test --cwd apps/web ui/app/menus/menuView.test.ts

Expected: FAIL because publicationReadiness does not exist.

  • [ ] Step 3: Implement typed client and pure view functions

menuClient uses apiRequest for list/get/create/update/replace/publish/rollback. menuView.ts maps backend readiness codes to translated UI keys and never duplicates authoritative validation rules as permission decisions.

  • [ ] Step 4: Implement mobile-first screens

Menus list shows location, host, current version, health, and review count. Editor uses focused section/item components, sortable order, locale tabs, variants, factual warning, autosave state, and preview. Publications panel shows build state, manifest health, diff summary, and rollback confirmation.

  • [ ] Step 5: Add route/nav/copy/story states

Add menus navigation and RU/EN/SR messages. Storybook covers empty, draft, blocking warnings, building, published, failed, and read-only member states with the existing Ant Design theme and mobile viewport.

  • [ ] Step 6: Run web gates
bash
bun test --cwd apps/web ui/app/menus/menuView.test.ts
bun run --cwd apps/web typecheck
bun run --cwd apps/web lint
bun run --cwd apps/web build-storybook

Expected: PASS, including Storybook a11y checks for the new stories.

  • [ ] Step 7: Commit
bash
git add apps/web/app apps/web/ui/app/menus apps/web/ui/app/AppShell.tsx apps/web/messages
git commit -m "feat(menu): add draft and publication workspace"

Task 8: End-to-End Acceptance, Resilience, and Operations Documentation

Files:

  • Create: apps/web/playwright.config.ts
  • Create: apps/web/e2e/menu-public.spec.ts
  • Create: apps/api/test/menu-rbac.e2e.spec.ts
  • Create: docs-internal/menu-publishing-operations.md
  • Modify: .env.dev.example
  • Modify: .env.staging.example

Interfaces:

  • Consumes: the complete core workstream.

  • Produces: authoritative acceptance evidence and operator runbook.

  • [ ] Step 1: Write failing acceptance tests

The test creates a tenant menu, publishes version 1, edits/publishes version 2, verifies stable host content, rolls back to version 1, stops the management API fixture, and verifies the gateway still serves version 1.

ts
expect(await page.locator('[data-menu-version]').getAttribute('data-menu-version')).toBe('1');
expect(await request.get(publicUrl)).toMatchObject({ status: 200 });
  • [ ] Step 2: Run and verify failure before final wiring

Run:

bash
bun apps/web/node_modules/playwright/cli.js test apps/web/e2e/menu-public.spec.ts
bun test apps/api/test/menu-rbac.e2e.spec.ts

Expected: FAIL on the first missing deployment/configuration behavior.

  • [ ] Step 3: Complete environment examples and runbook

Document exact config keys, bucket policy boundaries, publication state diagnosis, dead-letter replay preconditions, active-manifest inspection, safe rollback, orphan-prefix cleanup, and recovery when DB and object manifest disagree.

  • [ ] Step 4: Run full relevant verification
bash
bun run test:db:up
bun run test:all
bun run typecheck
bun run lint
bun run format:check
bun apps/web/node_modules/playwright/cli.js test apps/web/e2e/menu-public.spec.ts
bun run test:db:down

Expected: all existing and new suites pass. If the gateway requires a local object-store fixture, its setup/teardown is part of the test command and leaves no process running.

  • [ ] Step 5: Capture acceptance evidence

Record test output, bundle gzip sizes, checksums, accessibility result, version 1→2→1 content proof, and management-API outage proof in the workstream issue.

  • [ ] Step 6: Commit
bash
git add apps/web/playwright.config.ts apps/web/e2e/menu-public.spec.ts apps/api/test/menu-rbac.e2e.spec.ts docs-internal/menu-publishing-operations.md .env.dev.example .env.staging.example
git commit -m "test(menu): prove static publication resilience"

Workstream Acceptance Checklist

  • [ ] Core schema, migration round trip, FORCE RLS, and cross-tenant tests pass.
  • [ ] Member edits but cannot publish/rollback; admin/owner can.
  • [ ] Same snapshot and renderer version produce identical bytes/checksums.
  • [ ] Upload/build/checksum failure preserves current active host manifest.
  • [ ] Version activation and rollback are retry-safe.
  • [ ] Gateway serves by host without database/API dependency.
  • [ ] Stable host shows version 1, then 2, then rolled-back 1.
  • [ ] Management UI covers empty/loading/error/building/published/failed/read-only states in RU/EN/SR.
  • [ ] Critical bundle budgets and Storybook accessibility checks pass.
  • [ ] Runbook and environment contract are reviewed by operations.