Skip to content

Restaurant Menu Tenant Analytics Implementation Plan

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

Goal: Give each tenant trustworthy menu views, item interactions, locale/device summaries, and campaign attribution without cross-tenant leakage, cookies, fingerprinting, or collection of direct personal identifiers.

Architecture: Published static pages contain a short-lived HMAC-signed publication token and a tiny first-party collector script. The public collector validates schema/signature/origin, applies rate limits and event-id deduplication, and writes append-only tenant events. A scheduled worker builds tenant/day aggregates; authenticated APIs read aggregates under FORCE RLS, while raw events expire after 90 days and aggregates after 24 months.

Tech Stack: NestJS, TypeORM/PostgreSQL with FORCE RLS and range partitioning, Redis rate limiting, BullMQ aggregation/retention, static renderer JavaScript, Next.js, Jest, Playwright, k6 or repository-standard load runner.


Global Constraints

  • Run python3 .agent/tools/recall.py "restaurant menu tenant analytics migration retention privacy timestamp failing test" before implementation and surface the result.
  • Obtain approval before applying the migration, adding a load-test dependency, or changing Caddy/deployment configuration.
  • Collect no names, email addresses, phone numbers, precise location, full IP addresses, cookies, fingerprint IDs, free-form text, or raw user-agent strings.
  • Do not persist IP addresses. Use them only in memory/Redis for a rotating keyed rate-limit digest with maximum TTL of 15 minutes.
  • Event timestamps are server-assigned UTC; client timestamps are diagnostic offsets bounded to plus/minus 24 hours and are not authoritative.
  • Raw events live 90 days; tenant/day aggregates live 24 months; deletion and retention runs are auditable.
  • Analytics failure must never block menu rendering, navigation, or item interaction.
  • Tenant APIs expose aggregates by default; raw-event access is unavailable in the first release.
  • Campaign values are allowlisted opaque slugs up to 64 characters, never arbitrary query-string capture.

Produced and Consumed Interfaces

ts
export type MenuAnalyticsEventType =
  | 'menu_view' | 'item_view' | 'item_media_open' | 'locale_change';

export interface MenuAnalyticsEventV1 {
  schemaVersion: 1;
  eventId: string; // UUID v4 generated in the browser
  type: MenuAnalyticsEventType;
  publicationToken: string;
  itemPublicId?: string;
  locale: 'ru' | 'en' | 'sr';
  campaign?: string;
  viewport: 'small' | 'medium' | 'large';
  clientOffsetMs: number;
}

export interface SignedPublicationClaims {
  version: 1;
  tenantId: string;
  menuId: string;
  publicationId: string;
  host: string;
  issuedAt: number;
  expiresAt: number;
  keyId: string;
}

export interface MenuAnalyticsQuery {
  menuId: string;
  from: string; // inclusive YYYY-MM-DD in tenant timezone
  to: string;   // inclusive YYYY-MM-DD in tenant timezone
  locale?: 'ru' | 'en' | 'sr';
  campaign?: string;
}

The workstream consumes active publication/host manifests and renderer item public IDs. It produces append-only events, tenant/day aggregates, authenticated dashboard responses, CSV aggregate exports, and operational metrics.

Task 1: Sign Analytics Claims During Publication

Files:

  • Create: packages/menu-contracts/src/menu-analytics-v1.ts

  • Modify: packages/menu-contracts/src/index.ts

  • Create: apps/api/src/menu/analytics/publication-token.service.ts

  • Modify: apps/api/src/config/env.schema.ts

  • Modify: apps/api/src/menu/publish/publish.service.ts

  • Test: apps/api/src/menu/analytics/publication-token.spec.ts

  • [ ] Step 1: Write failing token tests

Cover canonical serialization, current/previous key verification, wrong host/publication, tampering, unknown key, expiry boundaries, clock skew of at most 60 seconds, constant-time signature comparison, and absence of secrets in logs.

  • [ ] Step 2: Run and confirm failure
bash
bun test apps/api/src/menu/analytics/publication-token.spec.ts
  • [ ] Step 3: Implement versioned HMAC tokens

Require MENU_ANALYTICS_HMAC_KEYS as a key-ID map and MENU_ANALYTICS_ACTIVE_KEY_ID. Sign base64url canonical claims with HMAC-SHA-256, issue tokens valid for 30 days, and support one previous key for rotations.

  • [ ] Step 4: Add the token to immutable publication manifests

Generate one token per publication/host activation, not per visitor, and render it only into that host's static artifact.

  • [ ] Step 5: Run token and publication tests
bash
bun test apps/api/src/menu/analytics/publication-token.spec.ts apps/api/src/menu/publish
  • [ ] Step 6: Commit
bash
git add packages/menu-contracts/src apps/api/src/menu/analytics/publication-token.service.ts apps/api/src/config/env.schema.ts apps/api/src/menu/publish/publish.service.ts
git commit -m "feat(menu): sign analytics publications"

Task 2: Persist Partitioned Events and Daily Aggregates

Files:

  • Create: apps/api/src/menu/analytics/entities/menu-analytics-event.entity.ts

  • Create: apps/api/src/menu/analytics/entities/menu-analytics-daily.entity.ts

  • Create: apps/api/src/menu/analytics/entities/menu-analytics-retention-run.entity.ts

  • Create: apps/api/src/db/migrations/1723800000000-MenuAnalytics.ts

  • Modify: apps/api/src/db/data-source.ts

  • Modify: apps/api/src/menu/menu.module.ts

  • Test: apps/api/src/menu/analytics/menu-analytics.entity.spec.ts

  • [ ] Step 1: Write failing schema and RLS tests

Cover monthly raw-event partitions, (received_month, tenant_id, event_id) deduplication, aggregate uniqueness dimensions, bounded enums/slugs, UTC timestamp defaults, insert-only raw behavior, retention audit rows, and FORCE RLS.

  • [ ] Step 2: Run and confirm failure
bash
bun test apps/api/src/menu/analytics/menu-analytics.entity.spec.ts
  • [ ] Step 3: Implement migration and entities

Partition menu_analytics_events by received_at, create current plus next-month partitions, index tenant/publication/day/type/item, create menu_analytics_daily keyed by tenant/menu/publication/day/type/item/locale/campaign/viewport, and force tenant RLS on all tenant tables.

  • [ ] Step 4: Register entities in runtime and CLI data sources

  • [ ] Step 5: After migration approval, run tests and a migration round trip

bash
bun test apps/api/src/menu/analytics/menu-analytics.entity.spec.ts
bun run test:db:up
DATABASE_URL=postgres://app:app@localhost:54332/checklist_test bun run --cwd apps/api migration:run
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
bun run test:db:down
  • [ ] Step 6: Commit
bash
git add apps/api/src/menu/analytics/entities apps/api/src/db/data-source.ts apps/api/src/db/migrations/1723800000000-MenuAnalytics.ts apps/api/src/menu/menu.module.ts
git commit -m "feat(menu): persist tenant analytics"

Task 3: Build the Public Collector

Files:

  • Create: apps/api/src/menu/analytics/menu-analytics-collector.controller.ts

  • Create: apps/api/src/menu/analytics/menu-analytics-collector.service.ts

  • Create: apps/api/src/menu/analytics/menu-analytics-event.schema.ts

  • Create: apps/api/src/menu/analytics/menu-analytics-rate-limit.service.ts

  • Modify: Caddyfile

  • Test: apps/api/src/menu/analytics/menu-analytics-collector.spec.ts

  • [ ] Step 1: Write failing collector tests

Cover valid POST /menu-analytics/events, schema size/unknown-key rejection, bad/expired signature, host mismatch, inactive publication, unknown item public ID, duplicate event ID, per-IP-digest and per-publication rate limits, bot user agents, CORS preflight, and storage outage.

  • [ ] Step 2: Run and confirm failure
bash
bun test apps/api/src/menu/analytics/menu-analytics-collector.spec.ts
  • [ ] Step 3: Implement strict validation and ingestion

Limit the body to 4 KiB, accept one event per request, validate token before tenant context creation, map the current request host to claims, validate item public IDs against the publication manifest, assign received_at in PostgreSQL, and return 202 for accepted or deduplicated events.

  • [ ] Step 4: Implement privacy-preserving limits

Hash the request IP with a rotating in-memory key only for Redis counters, discard IP immediately, classify user agent into bot or a coarse device class without persistence, and reject known bots before insert. Fail open to the menu client with 202 while emitting an ingestion-failure metric if event storage is unavailable.

  • [ ] Step 5: Route the collector host

Route only /menu-analytics/events on analytics.dailymark.me; set explicit origin reflection only after token host validation, no credentials, and a restrictive methods/header list.

  • [ ] Step 6: Run collector tests
bash
bun test apps/api/src/menu/analytics/menu-analytics-collector.spec.ts
  • [ ] Step 7: Commit
bash
git add apps/api/src/menu/analytics Caddyfile
git commit -m "feat(menu): collect privacy-safe analytics"

Task 4: Instrument Static Menus Without Cookies

Files:

  • Create: apps/api/src/menu/publish/static/analytics.js

  • Modify: apps/api/src/menu/publish/menu-renderer.ts

  • Create: apps/api/src/menu/publish/item-public-id.ts

  • Test: apps/api/src/menu/publish/analytics.spec.ts

  • Test: apps/api/src/menu/publish/__fixtures__/analytics-menu.expected.html

  • [ ] Step 1: Write failing markup and browser tests

Cover one menu view per page load, item view after 50% visibility for one second, media-open click, locale change, UUID event IDs, allowlisted campaign parsing, viewport bucket, sendBeacon/fetch fallback, disabled JS, collector failure, and no cookie/localStorage/sessionStorage access.

  • [ ] Step 2: Run and confirm failure
bash
bun test apps/api/src/menu/publish/analytics.spec.ts
  • [ ] Step 3: Implement stable public item IDs

Derive opaque IDs at publication time, include only those IDs in markup/events, and keep internal database IDs out of public HTML.

  • [ ] Step 4: Implement the collector script

Use navigator.sendBeacon with fetch(..., { keepalive: true }) fallback, IntersectionObserver, per-page in-memory deduplication, doNotTrack/globalPrivacyControl opt-out, and silent bounded failure handling.

  • [ ] Step 5: Run renderer and size tests
bash
bun test apps/api/src/menu/publish/analytics.spec.ts
bun run --cwd apps/api typecheck

Expected: analytics script is at most 4 KiB compressed and adds no blocking request before first paint.

  • [ ] Step 6: Commit
bash
git add apps/api/src/menu/publish
git commit -m "feat(menu): instrument static analytics"

Task 5: Aggregate and Retain Data Idempotently

Files:

  • Create: apps/api/src/menu/analytics/workers/menu-analytics-aggregate.processor.ts

  • Create: apps/api/src/menu/analytics/workers/menu-analytics-retention.processor.ts

  • Create: apps/api/src/menu/analytics/workers/menu-analytics-partition.processor.ts

  • Test: apps/api/src/menu/analytics/workers/menu-analytics-aggregate.spec.ts

  • Test: apps/api/src/menu/analytics/workers/menu-analytics-retention.spec.ts

  • [ ] Step 1: Write failing worker tests

Cover timezone day boundaries including DST, late events, rerun convergence, overlapping workers, empty days, aggregate replacement transaction, next-month partition creation, exact 90-day raw expiry, exact 24-month aggregate expiry, and retention audit on partial failure.

  • [ ] Step 2: Run and confirm failure
bash
bun test apps/api/src/menu/analytics/workers/menu-analytics-aggregate.spec.ts apps/api/src/menu/analytics/workers/menu-analytics-retention.spec.ts
  • [ ] Step 3: Implement aggregation

Recompute the previous three tenant-local days under a PostgreSQL advisory lock, write to a staging CTE, and upsert aggregate rows in one transaction. Counts are event counts; no unique-visitor metric is claimed without a durable visitor identifier.

  • [ ] Step 4: Implement partitions and retention

Create the next two monthly partitions ahead of time. Drop only partitions whose maximum timestamp is older than 90 days, delete aggregates older than 24 tenant-local months in bounded batches, and record rows examined/deleted plus errors.

  • [ ] Step 5: Run worker tests
bash
bun test apps/api/src/menu/analytics/workers/menu-analytics-aggregate.spec.ts apps/api/src/menu/analytics/workers/menu-analytics-retention.spec.ts
  • [ ] Step 6: Commit
bash
git add apps/api/src/menu/analytics/workers
git commit -m "feat(menu): aggregate and retain analytics"

Task 6: Expose Tenant-Scoped Analytics APIs

Files:

  • Create: apps/api/src/menu/analytics/menu-analytics.controller.ts

  • Create: apps/api/src/menu/analytics/menu-analytics-query.service.ts

  • Create: apps/api/src/menu/analytics/menu-analytics-query.dto.ts

  • Create: apps/api/src/menu/analytics/menu-analytics-export.service.ts

  • Test: apps/api/src/menu/analytics/menu-analytics-query.spec.ts

  • [ ] Step 1: Write failing query and export tests

Cover owner/admin/member authorization, cross-tenant denial, invalid/inverted/overlong ranges, tenant timezone, zero-filled days, item/locale/campaign filters, sort/pagination, CSV formula injection, UTF-8 BOM policy, and a 100,000-row export cap.

  • [ ] Step 2: Run and confirm failure
bash
bun test apps/api/src/menu/analytics/menu-analytics-query.spec.ts
  • [ ] Step 3: Implement aggregate-only endpoints
text
GET /menus/:menuId/analytics/summary
GET /menus/:menuId/analytics/timeseries
GET /menus/:menuId/analytics/items
GET /menus/:menuId/analytics/export.csv

Cap interactive ranges at 366 days, use indexed aggregate queries only, set tenant context before each query, neutralize CSV cells beginning with =, +, -, or @, and audit exports.

  • [ ] Step 4: Run query tests
bash
bun test apps/api/src/menu/analytics/menu-analytics-query.spec.ts
  • [ ] Step 5: Commit
bash
git add apps/api/src/menu/analytics
git commit -m "feat(menu): expose tenant analytics APIs"

Task 7: Build the Analytics Dashboard

Files:

  • Create: apps/web/app/[locale]/app/menus/[menuId]/analytics/page.tsx

  • Create: apps/web/ui/app/menus/analytics/AnalyticsFilters.tsx

  • Create: apps/web/ui/app/menus/analytics/AnalyticsSummary.tsx

  • Create: apps/web/ui/app/menus/analytics/AnalyticsTimeseries.tsx

  • Create: apps/web/ui/app/menus/analytics/AnalyticsItemsTable.tsx

  • Create: apps/web/ui/app/menus/analytics/menuAnalyticsClient.ts

  • Test: apps/web/ui/app/menus/analytics/AnalyticsTimeseries.test.tsx

  • Test: apps/web/e2e/menu-analytics.spec.ts

  • Modify: apps/web/messages/ru.json

  • Modify: apps/web/messages/en.json

  • Modify: apps/web/messages/sr.json

  • [ ] Step 1: Write failing component and journey tests

Cover empty/loading/error states, date/locale/campaign filters, timezone labels, summary math, accessible chart table alternative, item sorting, CSV export, role denial, responsive layout, and RU/EN/SR strings.

  • [ ] Step 2: Run and confirm failure
bash
bun test --cwd apps/web ui/app/menus/analytics/AnalyticsTimeseries.test.tsx
  • [ ] Step 3: Implement dashboard components

Label metrics as views/interactions rather than people, state the timezone and retention window, preserve filters in the URL, and provide a semantic table for every chart.

  • [ ] Step 4: Run UI tests
bash
bun test --cwd apps/web ui/app/menus/analytics/AnalyticsTimeseries.test.tsx
bun apps/web/node_modules/playwright/cli.js test apps/web/e2e/menu-analytics.spec.ts
  • [ ] Step 5: Commit
bash
git add apps/web/app apps/web/ui/app/menus/analytics apps/web/e2e/menu-analytics.spec.ts apps/web/messages
git commit -m "feat(menu): add tenant analytics dashboard"

Task 8: Prove Privacy, Load, Deletion, and Operations

Files:

  • Create: test/menu-analytics.e2e.test.ts

  • Create: test/menu-analytics-privacy.e2e.test.ts

  • Create: test/load/menu-analytics-collector.js

  • Create: docs-internal/menu-analytics-operations.md

  • [ ] Step 1: Write failing privacy and deletion tests

Prove no forbidden fields/storage APIs, signed-host binding, tenant isolation, retention boundaries, tenant deletion of raw/aggregate data, export audit, key rotation, collector outage isolation, and log redaction.

  • [ ] Step 2: Add load assertions

At the approved staging capacity, assert collector p95 below 150 ms, error rate below 1%, deduplication under retry, bounded Redis keys, no API-pool starvation, and no dropped partition inserts.

  • [ ] Step 3: Implement tenant analytics deletion workflow

Delete tenant raw events from each live partition and aggregates in bounded transactions, retain only the existing legally required deletion audit fact, and make retries convergent.

  • [ ] Step 4: Write the operations runbook

Document signing-key rotation, partition calendar, aggregation replay, retention verification, load limits, collector disable switch, Redis/database incident modes, deletion/export requests, privacy field audit, and dashboard diagnostics.

  • [ ] Step 5: Run the acceptance suite
bash
bun test apps/api/src/menu/analytics
bun test apps/api/src/menu/publish/analytics.spec.ts
bun apps/web/node_modules/playwright/cli.js test apps/web/e2e/menu-analytics.spec.ts
bun test test/menu-analytics.e2e.test.ts test/menu-analytics-privacy.e2e.test.ts
  • [ ] Step 6: Obtain staging approval and run the load profile
bash
k6 run test/load/menu-analytics-collector.js
  • [ ] Step 7: Commit
bash
git add test/menu-analytics.e2e.test.ts test/menu-analytics-privacy.e2e.test.ts test/load/menu-analytics-collector.js docs-internal/menu-analytics-operations.md
git commit -m "test(menu): prove analytics privacy and load"

Workstream Acceptance Checklist

  • [ ] Publication tokens are versioned, host-bound, expiring, rotatable, and tamper-evident.
  • [ ] The collector accepts only bounded allowlisted data and stores no direct identifier.
  • [ ] IP addresses and raw user agents are never persisted.
  • [ ] Events deduplicate and aggregate idempotently across retries, late arrival, DST, and worker overlap.
  • [ ] FORCE RLS and API tests prove tenant isolation for events, aggregates, exports, and deletion.
  • [ ] Raw events expire after 90 days and aggregates after 24 months with audited evidence.
  • [ ] Static menus remain functional when analytics is blocked or unavailable.
  • [ ] Dashboard semantics, accessibility, privacy, load, retention, deletion, and runbook checks pass.