diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..d2a3d8b --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +NEXT_PUBLIC_SUPABASE_URL=http://127.0.0.1:54321 +NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=replace-with-local-publishable-key diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a6354b5..5fec45e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,10 @@ on: branches: [main] pull_request: +concurrency: + group: ci-${{ github.workflow }}-${{ github.head_ref || github.ref_name }} + cancel-in-progress: true + permissions: contents: read @@ -23,10 +27,37 @@ jobs: - run: npm run typecheck - run: npm run test - run: npm run build + env: + NEXT_PUBLIC_SUPABASE_URL: http://127.0.0.1:54321 + NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY: sb_publishable_local_ci + - run: npm audit --audit-level=moderate + + database: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 + with: + node-version: 22 + cache: npm + - run: npm ci + - run: npm run db:start + - run: npm run db:reset + - run: npm run db:lint + - run: npm run db:test + - run: npm run db:types:check + - name: Export public local client configuration + run: | + echo "NEXT_PUBLIC_SUPABASE_URL=$(npx supabase status -o env | sed -n 's/^API_URL="\(.*\)"/\1/p')" >> "$GITHUB_ENV" + echo "NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=$(npx supabase status -o env | sed -n 's/^PUBLISHABLE_KEY="\(.*\)"/\1/p')" >> "$GITHUB_ENV" + - run: npm run test:integration + - name: Stop local Supabase + if: always() + run: npm run db:stop browser: runs-on: ubuntu-latest - needs: quality + needs: [quality, database] steps: - uses: actions/checkout@v7 - uses: actions/setup-node@v7 @@ -34,6 +65,15 @@ jobs: node-version: 22 cache: npm - run: npm ci + - run: npm run db:start + - run: npm run db:reset + - name: Export public local client configuration + run: | + echo "NEXT_PUBLIC_SUPABASE_URL=$(npx supabase status -o env | sed -n 's/^API_URL="\(.*\)"/\1/p')" >> "$GITHUB_ENV" + echo "NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=$(npx supabase status -o env | sed -n 's/^PUBLISHABLE_KEY="\(.*\)"/\1/p')" >> "$GITHUB_ENV" - run: npx playwright install --with-deps chromium - run: npm run build - run: npm run test:e2e + - name: Stop local Supabase + if: always() + run: npm run db:stop diff --git a/.gitignore b/.gitignore index c760c95..606c16d 100644 --- a/.gitignore +++ b/.gitignore @@ -36,6 +36,11 @@ yarn-error.log* # env files (can opt-in for committing if needed) .env* +!.env.example + +# Supabase CLI state and local credentials +/supabase/.temp/ +/supabase/.branches/ # vercel .vercel diff --git a/README.md b/README.md index 73f03d2..2943ca4 100644 --- a/README.md +++ b/README.md @@ -1,79 +1,91 @@ # Next -> A calm, real-time queue for small service teams. +> A calm, persistent real-time queue for small service teams. -Next is a minimalist queue-management product concept for cafés, barbershops, repair desks, clinics, campus offices, and small service counters. It gives customers, staff, and a public display a clear view of one small ordered queue. +Story 2 replaces the local visual prototype state with a transactional Supabase PostgreSQL engine. Anonymous browser identities can create or join queues; a one-time queue capability grants staff membership; customer, staff, and public-display clients converge through filtered Realtime invalidations followed by authoritative revisioned snapshots. -## Story 1 status - -This repository currently contains a polished **visual prototype** with deterministic local state. It demonstrates the intended product, interaction, accessibility, responsive layout, and motion language. It does **not** synchronize separate browser clients yet. Persistent production real-time synchronization is scheduled for the next engineering story. +The application is not publicly deployed. ## Product surfaces -- `/q/north-star-cafe` — customer check-in and personal queue position -- `/q/north-star-cafe/staff` — focused staff queue controls -- `/q/north-star-cafe/display` — distance-readable public display -- `/demo` — guided entry to all three prototypes -- `/` — concise product landing page -- `/about` — privacy, accessibility, technology, and cost principles +- `/demo` — create a persistent queue and receive its staff code once +- `/q/[slug]` — customer check-in, number, position, and service state +- `/q/[slug]/staff` — capability claim and authorized queue commands +- `/q/[slug]/display` — public-safe current and upcoming numbers +- `/` and `/about` — product and project context + +## Identity and privacy + +Supabase anonymous sign-in creates a unique user UUID without email, phone, password, social identity, address, or demographics. That user uses PostgreSQL's `authenticated` role; it is different from the public publishable key and the unauthenticated `anon` database role. The session is cookie-backed through `@supabase/ssr` and normally survives refreshes and same-profile tabs. Clearing site data, using another device, or signing out loses the anonymous identity. -## Local development +Optional customer names live in `queue_entry_private`. Public Realtime tables and public snapshots contain number labels only. No tracking, analytics, advertising, or visitor profiling is installed. -Requires Node.js 22 and npm. +## Local requirements + +- Node.js 22 +- npm +- Docker Desktop or another Docker-compatible runtime with at least 7 GB available ```bash npm install +npm run db:start +npm run db:reset +``` + +Copy `.env.example` to `.env.local`, then use the local API URL and **publishable** key reported by the CLI. Do not place a secret/service-role key in the browser environment. + +```bash npm run dev ``` -Open [http://localhost:3000](http://localhost:3000). +Open [http://localhost:3000/demo](http://localhost:3000/demo). Create a queue, save the one-time staff code privately, then open the customer, staff, and display routes in separate browser contexts. The deterministic `north-star-cafe` seed is public-state visual data only and intentionally has no recoverable staff code; automated tests create isolated queues and consume their one-time code. + +The local stack is development-only, uses default local credentials, and must not be exposed to public traffic. ## Commands ```bash +npm run db:start +npm run db:stop +npm run db:status +npm run db:reset +npm run db:lint +npm run db:test +npm run db:types +npm run db:types:check +npm run test:integration +npm run test:realtime +npm run test:e2e npm run format npm run format:check npm run lint npm run typecheck npm run test -npm run test:e2e npm run build +npm audit ``` -## Architecture direction - -Queue rules live independently from React in `src/features/queue`. A deliberately small adapter contract in `src/lib/realtime` separates the UI from the future persistent provider. The current recommendation is Supabase Postgres with transactional commands and Realtime subscriptions, subject to a focused Story 2 spike and a fresh free-tier review before provisioning anything. - -See [the real-time evaluation](docs/architecture/realtime-evaluation.md) for requirements, current official quotas, alternatives, risks, and the next implementation step. - -## Design and motion - -The interface uses a warm neutral foundation, one vermilion accent, large editorial typography, tabular monospace queue numbers, generous whitespace, and borders instead of dashboard-card chrome. Motion communicates number changes, list insertion, completion, and connectivity. Every meaningful animation has a reduced-motion alternative. - -See [the motion system](docs/design/motion-system.md). - -## Accessibility - -The foundation includes semantic landmarks, one clear page heading per route, keyboard-operable controls, visible focus, 44px minimum icon targets, status text beyond color, restrained `aria-live` regions, stable-width queue numbers, high-contrast display treatment, and `prefers-reduced-motion` support. Browser tests reject serious or critical automated axe violations; automated checks complement manual keyboard, zoom, contrast, and screen-reader review. - -## Privacy +`db:reset` drops only the local database, replays every migration, and reapplies safe seed data. `db:types` regenerates `src/lib/supabase/database.types.ts`; do not edit that file manually. -No account, email, phone number, address, tracking, analytics, advertising, or third-party profiling is used. Customer first name is optional. Public surfaces prioritize queue numbers and never expose internal IDs. +## Command and authorization model -## Cost constraint +Clients have no direct mutation grants. Explicit security-definer RPCs implement create, staff claim, join, call, complete, skip, pause, reopen, and close. Each validates `auth.uid()`, uses a fixed empty `search_path`, locks queue/entry rows, increments the queue revision once, records an idempotency receipt and append-only event, and returns a fresh snapshot. RLS separately limits table reads. -Story 1 runs locally with no database or external service. No paid plan, trial, payment information, production credential, hosted analytics, or deployment is configured. The architecture recommendation is explicitly bounded by current free quotas and must fail closed rather than create charges. +One partial unique index permits at most one `SERVING` entry per queue. `(queue_id, sequence)` and `(queue_id, number_label)` are unique, queue numbers are never reused, and position is calculated from ordered waiting rows. -## Testing +## Realtime and reconnection -Vitest covers queue formatting, ordering, allowed and rejected transitions, empty states, joining, staff controls, connection states, public-display content, and reduced-motion helpers. Playwright covers primary routes, customer/staff flows, public display, mobile navigation, theme switching, reduced motion, accessibility, headings, and narrow viewport overflow. +Only `queues` and display-safe `queue_entries` are in `supabase_realtime`. Each surface subscribes with a queue-ID filter. A change is an invalidation signal: related messages are debounced for 75 ms, a snapshot RPC is fetched, stale revisions are ignored, and the new authoritative state replaces local state. The client resynchronizes after subscription, browser online, channel recovery, and a meaningful visibility return. Healthy connections do not poll. -GitHub Actions runs formatting, linting, type checking, unit/component tests, production build, and the Chromium browser suite using only standard GitHub-hosted workflow features. +## Cost boundary -## Roadmap +The intended hosted validation target is one Supabase Free project only: no card, trial, compute upgrade, paid backup, PITR, log drain, support plan, custom domain, or usage-based add-on. Current Free projects are limited to two active projects, 500 MB database size, and may pause after roughly one week of low activity. No keep-alive is used to evade pausing. Re-check [official pricing](https://supabase.com/pricing) before provisioning. -1. **Story 1 — Foundation and visual prototype:** current. -2. **Story 2 — Persistent real-time queue:** transactional command endpoint, database policies, subscriptions, reconnect/resync, conflict tests, and quota-safe deployment decision. -3. **Version 1 polish:** production QA and deployment only after Story 2 is validated. +## Documentation -The Version 1 boundary is documented in [product scope](docs/product/scope.md). +- [ADR 002](docs/architecture/adr-002-supabase-realtime.md) +- [Data model](docs/architecture/data-model.md) +- [Realtime protocol](docs/architecture/realtime-protocol.md) +- [Authorization and security](docs/security/authorization.md) +- [Motion system](docs/design/motion-system.md) +- [Product scope](docs/product/scope.md) diff --git a/docs/architecture/adr-002-supabase-realtime.md b/docs/architecture/adr-002-supabase-realtime.md new file mode 100644 index 0000000..a56cca7 --- /dev/null +++ b/docs/architecture/adr-002-supabase-realtime.md @@ -0,0 +1,41 @@ +# ADR 002: Supabase PostgreSQL and Realtime + +**Status:** implemented locally; remote Free validation recorded in the pull request and final Story 2 report + +## Context and requirements + +Next needs durable ordered queue state, anonymous privacy-preserving identity, database-enforced staff authorization, atomic concurrent commands, live multi-client updates, complete reconnect recovery, reproducible local development, and a hard $0 boundary. + +## Decision + +Use Supabase anonymous Auth, PostgreSQL, explicit transactional RPCs, RLS, and filtered Realtime Postgres Changes behind the provider-independent queue adapter. + +Anonymous Auth supplies a stable browser-scoped UUID without contact information. Anonymous users use the `authenticated` PostgreSQL role and carry an `is_anonymous` JWT claim; the publishable key only identifies the public application and does not create a user. Identity-bearing routes are dynamically rendered, browser/server clients use `@supabase/ssr` cookies, and the Next.js proxy validates/refreshes claims. See the [official anonymous Auth guidance](https://supabase.com/docs/guides/auth/auth-anonymous) and [SSR client guidance](https://supabase.com/docs/guides/auth/server-side/creating-a-client). + +RPC commands are used because queue transitions span multiple rows and require locks, authorization, revision increments, idempotency receipts, and audit events in one transaction. Direct client writes are revoked. Security-definer functions derive the actor from `auth.uid()`, never accept an actor UUID or staff Boolean, and use an explicit empty `search_path`. + +Public queue state and private ownership/names are separate tables. This matters because RLS is row-oriented and Realtime payloads must never contain private customer fields. + +## Realtime protocol + +Story 2 uses Postgres Changes because it is simple and proportional for a small portfolio queue. Only `queues` and `queue_entries` are published, and subscriptions filter by queue UUID. Supabase now recommends database-triggered Broadcast for better scalability and security; it remains the likely future transport if load grows. See [Subscribing to database changes](https://supabase.com/docs/guides/realtime/subscribing-to-database-changes). + +Realtime events are invalidations, never the authoritative state. The client subscribes, waits for `SUBSCRIBED`, fetches a snapshot, briefly debounces duplicate table changes, rejects stale revisions, and resynchronizes on online/visibility/channel recovery. This avoids fragile client-side event replay and closes the fetch/subscribe race. + +## Revision and idempotency + +Every successful mutation increments `queues.revision` exactly once. Entry rows record the revision that changed them, and `queue_events` has a unique `(queue_id, queue_revision)` index. Every mutation carries a client UUID request ID. `queue_commands` binds it to queue, actor, and command type; exact replay returns current authoritative state, while actor/type reuse is rejected. + +## Rejected approaches + +- Direct table writes: cannot safely coordinate multi-row invariants and broaden grants. +- Client-only authorization: hidden controls and local flags are not security boundaries. +- Client-side event replay: reconnect gaps and duplicate/out-of-order messages make it fragile. +- Presence or heartbeat writes: no product requirement and needless quota consumption. +- Polling while connected: unnecessary traffic; snapshot fetches are event/recovery driven. + +## Free-plan constraints and risks + +As verified on 2026-07-17, [Supabase Free pricing](https://supabase.com/pricing) lists two active projects, 500 MB database size, 5 GB egress, and pausing after roughly one week of low activity. [Realtime pricing](https://supabase.com/docs/guides/realtime/pricing) and [limits](https://supabase.com/docs/guides/realtime/limits) remain quota-bound. No keep-alive, paid add-on, trial, or public deployment is part of Story 2. + +Remaining hardening includes CAPTCHA or stronger anonymous abuse protection, cleanup of abandoned anonymous identities, capability rotation/recovery, more durable distributed attempt throttling, operational monitoring, data retention policy, and reassessment of Broadcast at scale. The current throttle is five failed attempts per user/queue/15-minute window with a 15-minute block; it is deliberately modest, not commercial brute-force protection. diff --git a/docs/architecture/data-model.md b/docs/architecture/data-model.md new file mode 100644 index 0000000..0a39cd4 --- /dev/null +++ b/docs/architecture/data-model.md @@ -0,0 +1,36 @@ +# Data model + +```mermaid +erDiagram + AUTH_USERS ||--o{ QUEUES : creates + AUTH_USERS ||--o{ QUEUE_STAFF_MEMBERSHIPS : receives + QUEUES ||--o{ QUEUE_STAFF_MEMBERSHIPS : authorizes + QUEUES ||--|| QUEUE_STAFF_ACCESS : protects + QUEUES ||--o{ QUEUE_STAFF_ACCESS_ATTEMPTS : throttles + AUTH_USERS ||--o{ QUEUE_STAFF_ACCESS_ATTEMPTS : makes + QUEUES ||--o{ QUEUE_ENTRIES : contains + QUEUE_ENTRIES ||--|| QUEUE_ENTRY_PRIVATE : separates + AUTH_USERS ||--o{ QUEUE_ENTRY_PRIVATE : owns + QUEUES ||--o{ QUEUE_COMMANDS : receives + AUTH_USERS ||--o{ QUEUE_COMMANDS : issues + QUEUES ||--o{ QUEUE_EVENTS : records + QUEUE_ENTRIES o|--o{ QUEUE_EVENTS : references + QUEUE_COMMANDS ||--o| QUEUE_EVENTS : produces +``` + +## Responsibilities + +- `queues`: public slug/name/prefix/status, next sequence, monotonic revision, creator, timestamps. +- `queue_entries`: display-safe stable number, state timestamps, and changing revision. It never stores customer identity/name or mutable position. +- `queue_entry_private`: customer UUID and optional normalized display name. +- `queue_staff_memberships`: the database authorization fact for staff commands and private-name reads. +- `queue_staff_access`: one bcrypt-compatible `pgcrypto` hash per queue; the raw code is returned once and never persisted. +- `queue_staff_access_attempts`: per-user/per-queue 15-minute failure window and block. +- `queue_commands`: request UUID, actor, queue, and command type for idempotency. +- `queue_events`: append-only transition type, entry reference, actor, request, and queue revision. + +## Constraints and indexes + +Slugs, names, prefixes, positive sequences, nonnegative revisions, labels, and state/timestamp combinations have checks. Queue sequence/label pairs are unique. `queue_entries_one_serving_idx` is a partial unique index on `queue_id where status = 'SERVING'`. Waiting order, status, customer ownership, staff membership, attempts, commands, and event revisions have deliberate indexes. `queue_events_queue_revision_idx` makes event revisions unique per queue. + +Only `queues` and `queue_entries` are published to Realtime. Private names, ownership, memberships, hashes, attempts, command receipts, and events are excluded. diff --git a/docs/architecture/realtime-protocol.md b/docs/architecture/realtime-protocol.md new file mode 100644 index 0000000..cff46f4 --- /dev/null +++ b/docs/architecture/realtime-protocol.md @@ -0,0 +1,37 @@ +# Realtime protocol + +## Subscription lifecycle + +1. Reuse or create one anonymous browser session. +2. Fetch a preliminary snapshot only to discover the queue UUID. +3. Open one deterministic `queue::changes` channel. +4. Subscribe to `queues.id = ` and `queue_entries.queue_id = `. +5. Wait for `SUBSCRIBED`. +6. Fetch and publish a fresh authoritative snapshot. +7. Treat later changes as debounced invalidation signals. + +This subscribe-then-snapshot publication order prevents an update between initial discovery and live subscription from being missed. + +## Connection states + +`CONNECTING`, `CONNECTED`, `RECONNECTING`, `OFFLINE`, and `ERROR` map to calm readable interface states. Brief channel errors are reconnecting, not alarming failures. The adapter removes its channel and event listeners when the route unmounts. + +## Invalidations and revisions + +Queue and entry messages from one transaction can arrive rapidly, so a 75 ms debounce produces one snapshot request. A refresh already in flight queues at most one follow-up. Lower revisions are ignored; equal revisions are ignored during ordinary invalidation; higher revisions replace state. Recovery refreshes may reassert an equal revision but never downgrade. Because every invalidation retrieves full state, detected revision gaps converge without event replay. + +## Resync triggers + +- initial `SUBSCRIBED` +- browser `online` +- channel resubscription/recovery +- tab visibility return after more than five seconds hidden +- any queue/entry invalidation, including a revision gap + +There is no healthy-connection polling, Presence, global schema channel, custom heartbeat write, or private-table subscription. + +## Commands and retry + +A new user intent gets a UUID request ID. Automatic retry must retain that UUID. The RPC validates actor/type reuse, performs its transaction, and returns a snapshot. Buttons show local pending feedback but do not invent an authoritative result. Typed conflicts restore the control and explain the recoverable state. Realtime is confirmation/invalidation; the RPC result may update the UI immediately. + +Development-only instrumentation reports sanitized subscription status, refresh reason, and current revision. It never logs access codes, customer names, tokens, or Realtime payloads and is omitted from production mode. diff --git a/docs/product/scope.md b/docs/product/scope.md index e9779f7..c62fca2 100644 --- a/docs/product/scope.md +++ b/docs/product/scope.md @@ -18,7 +18,7 @@ Queue numbers use a configurable prefix and monotonically increasing integer, fo ## Non-goals -Authentication in the foundation, organizations, teams, billing, subscriptions, customer accounts, email, SMS, push, AI, analytics, reports, calendars, payments, uploads, multiple locations, inventory, appointments, schedules, settings systems, role management, a marketing CMS, and native mobile apps are outside Version 1. +Permanent email/password or social accounts, organizations, teams, billing, subscriptions, customer accounts, email, SMS, push, AI, analytics, reports, calendars, payments, uploads, multiple locations, inventory, appointments, schedules, settings systems, role management, a marketing CMS, and native mobile apps are outside Version 1. Anonymous browser identity and queue-specific staff capability membership are implementation security mechanisms, not account-management features. ## Primary workflows @@ -33,4 +33,4 @@ Authentication in the foundation, organizations, teams, billing, subscriptions, Version 1 is done when all three surfaces are polished and understandable, domain invariants hold under concurrent commands, connected screens synchronize and recover, accessibility and narrow responsive QA pass, free-tier operation is verified without payment information, CI is green, production security is reviewed, and documentation does not overstate capabilities. -Story 1 satisfies the visual and domain foundation only. Production persistence and multi-client synchronization remain explicitly scheduled for Story 2. +Story 1 satisfies the visual and domain foundation. Story 2 implements persistent PostgreSQL state, anonymous identity, transactional commands, database authorization, filtered Realtime invalidation, revisioned snapshots, reconnect convergence, and local multi-client validation. Public application deployment and production operations remain explicitly out of scope. diff --git a/docs/security/authorization.md b/docs/security/authorization.md new file mode 100644 index 0000000..193112b --- /dev/null +++ b/docs/security/authorization.md @@ -0,0 +1,29 @@ +# Authorization and security + +## Identity distinctions + +- The publishable key identifies the public application and is safe in client configuration. +- Before sign-in, requests use PostgreSQL's `anon` role and receive no queue RPC grants. +- `signInAnonymously()` creates a real browser-scoped user UUID with no contact data. +- Anonymous users call the Data API as `authenticated`; RLS and RPCs derive identity from `auth.uid()`. +- The service-role key is not used by the Next.js application or browser. + +## Staff capability claim + +Queue creation generates 18 cryptographically random bytes, displays their hexadecimal representation once, and stores only `pgcrypto.crypt()` output with a bcrypt salt. Claim compares through `crypt(submitted, stored_hash) = stored_hash`, inserts a membership on success, and returns a generic failure otherwise. The code is never a URL value, event field, command value, log field, seed credential, or Realtime payload. + +Five failed attempts by the same authenticated user against the same queue within 15 minutes cause a 15-minute database block. Raw attempts are not stored or exposed. This limits rapid single-identity guessing but is not IP-wide or distributed commercial protection; anonymous identity rotation remains a limitation. CAPTCHA/Turnstile and stronger edge rate limiting are production-hardening work. + +## RLS and grants + +RLS is enabled on every public table. Authenticated clients may select display-safe queues/entries. Customers can select their own private record; staff can select private records and limited events only for queues where membership exists. Membership enumeration is restricted to the caller. Access hashes, attempts, and command receipts have no client select policy. Only `queues` and `queue_entries` are in the Realtime publication. + +All table writes are revoked from `anon` and `authenticated`. Clients cannot directly insert entries, change status/sequence/revision, grant membership, write hashes, record commands, or append events. + +## Security-definer safeguards + +Every exposed function revokes public/`anon` execution and grants only `authenticated`. Functions use `set search_path = ''`, fully qualified relations, `auth.uid()` instead of caller-supplied actors, explicit staff membership checks, row locks, domain-specific transitions, request-ID binding, and database constraints. Expected business failures return typed safe codes; SQL, constraint names, stack traces, hashes, and tokens are not returned. + +## Known limitations + +Anonymous identity cannot be recovered after site-data clearing or transferred automatically to another device. Automatic cleanup of abandoned anonymous Supabase users is not built in. Capability rotation/recovery, stronger abuse prevention, operational alerting, formal retention/deletion policy, penetration testing, and a production SLA are outside Story 2. This project claims no security certification. diff --git a/package-lock.json b/package-lock.json index 721efb8..30ca137 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,8 @@ "name": "next-queue", "version": "0.1.0", "dependencies": { + "@supabase/ssr": "0.12.3", + "@supabase/supabase-js": "2.110.7", "lucide-react": "^1.25.0", "motion": "^12.42.2", "next": "16.2.10", @@ -31,6 +33,7 @@ "eslint-config-next": "16.2.10", "jsdom": "^29.1.1", "prettier": "^3.9.5", + "supabase": "2.109.1", "tailwindcss": "^4", "typescript": "^5", "vitest": "^4.1.10" @@ -523,6 +526,21 @@ "node": ">=20.19.0" } }, + "node_modules/@ecies/ciphers": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/@ecies/ciphers/-/ciphers-0.2.6.tgz", + "integrity": "sha512-patgsRPKGkhhoBjETV4XxD0En4ui5fbX0hzayqI3M8tvNMGUoUvmyYAIWwlxBc1KX5cturfqByYdj5bYGRpN9g==", + "dev": true, + "license": "MIT", + "engines": { + "bun": ">=1", + "deno": ">=2.7.10", + "node": ">=16" + }, + "peerDependencies": { + "@noble/ciphers": "^1.0.0" + } + }, "node_modules/@emnapi/core": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", @@ -1463,6 +1481,48 @@ "node": ">= 10" } }, + "node_modules/@noble/ciphers": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", + "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/curves": { + "version": "1.9.7", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz", + "integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.8.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -1849,6 +1909,214 @@ "dev": true, "license": "MIT" }, + "node_modules/@supabase/auth-js": { + "version": "2.110.7", + "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.110.7.tgz", + "integrity": "sha512-M5Bpl4hCv6kHcOO/xM06Dyfg1mYLHljMkp1plhzG9IRZPc3czvyMsSN1XpL5+GKisOKM3lSN59zhpcm6sMVXfA==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@supabase/cli-darwin-arm64": { + "version": "2.109.1", + "resolved": "https://registry.npmjs.org/@supabase/cli-darwin-arm64/-/cli-darwin-arm64-2.109.1.tgz", + "integrity": "sha512-tkn8tfunyqIL7RE+7DVjg6Ql2cJLPkGgh9cPafp2LbXI0qDgds0TaS+UOTHQEjci8JQXXe2wS00+122ko2QI8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@supabase/cli-darwin-x64": { + "version": "2.109.1", + "resolved": "https://registry.npmjs.org/@supabase/cli-darwin-x64/-/cli-darwin-x64-2.109.1.tgz", + "integrity": "sha512-0Q5ZAoWhOyIv4ZzQOU8QbjjdB2JmznnDNyJ3VrIeuLMWoifVfUWaLZhHBNYzr4xXoxBpKrggomuAT8BaEhY3lg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@supabase/cli-linux-arm64": { + "version": "2.109.1", + "resolved": "https://registry.npmjs.org/@supabase/cli-linux-arm64/-/cli-linux-arm64-2.109.1.tgz", + "integrity": "sha512-MS1djJjq5laD99+jYJUoARfSZhzRX9aXmo5piZ1yl2JXb9IXTuhiTD5olkFKQcgNckRcAZqMf4fucQGTYC767A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@supabase/cli-linux-arm64-musl": { + "version": "2.109.1", + "resolved": "https://registry.npmjs.org/@supabase/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.109.1.tgz", + "integrity": "sha512-cXNOsSU7MS+jmslGQATTD4DfWBEIHiFi7LaBWyBxHmR7tzIiZXimUXgN26ZiQjfAxU+RZq52C3k0qhi7vmqXQw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@supabase/cli-linux-x64": { + "version": "2.109.1", + "resolved": "https://registry.npmjs.org/@supabase/cli-linux-x64/-/cli-linux-x64-2.109.1.tgz", + "integrity": "sha512-svFmamF/vIq4/oinwY50jDi869itC9/GWrPaGtsHFkK4NUBcQtl1T37WWIivGsXwbBKNC4FjZD3dGqjL7bfW1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@supabase/cli-linux-x64-musl": { + "version": "2.109.1", + "resolved": "https://registry.npmjs.org/@supabase/cli-linux-x64-musl/-/cli-linux-x64-musl-2.109.1.tgz", + "integrity": "sha512-zeD9MrZpEKJrjkSPcYp4nZeGJ9FQt0i/kiRij4ZprPP6R5l+SLi6Pk20ln+Vj/GZuWTVxX7IHJ11pgViaReAWw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@supabase/cli-windows-arm64": { + "version": "2.109.1", + "resolved": "https://registry.npmjs.org/@supabase/cli-windows-arm64/-/cli-windows-arm64-2.109.1.tgz", + "integrity": "sha512-NeKzgWAOpglnLwMTggTp5t44fbkfxACLkQNlCRx0q332HMM3WqsKQUsHv1MHc6ZbEc5bcMwaYjqPNI/aOWnP5g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@supabase/cli-windows-x64": { + "version": "2.109.1", + "resolved": "https://registry.npmjs.org/@supabase/cli-windows-x64/-/cli-windows-x64-2.109.1.tgz", + "integrity": "sha512-L9/pDLM+4IR8646aT69ZDVcnJeg1lZZsB26ZgI775S3f8jOHP41+1UH9Oq1g9CvKiAQviuaLgtwkuvi0I/cc/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@supabase/functions-js": { + "version": "2.110.7", + "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.110.7.tgz", + "integrity": "sha512-megYmexlYEoR/0qlsr4Snh9wtzAodO7MAri3NMevZrXzNvQRKlvmTcSBoKGLQEPDakgDZMqbMdf9DwoZz6qfoA==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@supabase/phoenix": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/@supabase/phoenix/-/phoenix-0.4.5.tgz", + "integrity": "sha512-aAn9H9ovVyeApKy11OWOrrOGq8DV68yWeH4ud2lN9fzn4aO8Zb5GLL9m1pUg9nLqIcT+ZDfAcsZe0E/nqdv2lw==", + "license": "MIT" + }, + "node_modules/@supabase/postgrest-js": { + "version": "2.110.7", + "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.110.7.tgz", + "integrity": "sha512-ban6YV0djhVaqVYezlOARKLIuOBSvLLhyQVZjA2nxPrtswhxHCl1+gI4giFgI9ATQAaMNbUZb4JXiuL5lEA/5g==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@supabase/realtime-js": { + "version": "2.110.7", + "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.110.7.tgz", + "integrity": "sha512-AMtZjyFA2gsmjuxopPNS/sRznLQHG0Ht5x+ytTPTOh3vAcOTUlVRLx7gW4/CONNnbb3PKOkE+HmM35HOSbmomQ==", + "license": "MIT", + "dependencies": { + "@supabase/phoenix": "0.4.5", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@supabase/ssr": { + "version": "0.12.3", + "resolved": "https://registry.npmjs.org/@supabase/ssr/-/ssr-0.12.3.tgz", + "integrity": "sha512-qWXJ/dI7CiYDKyTgIPqJ4Qkh8y5edh2LdF/nkN48z47mmEVzAO2OE+YErYeQ/UemVfonn/F4kFz+lhLqoVMdpw==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.2" + }, + "peerDependencies": { + "@supabase/supabase-js": "^2.110.5" + } + }, + "node_modules/@supabase/storage-js": { + "version": "2.110.7", + "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.110.7.tgz", + "integrity": "sha512-2tcDE8cjEDy1uKxKavBpKQod1JdMV1jDXQag48TCa+kycmJOltc0yVabC0BUlhOwAl6WykXU2aOsH3ELMtZrmQ==", + "license": "MIT", + "dependencies": { + "iceberg-js": "^0.8.1", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@supabase/supabase-js": { + "version": "2.110.7", + "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.110.7.tgz", + "integrity": "sha512-AnfO3A230Shy6RMO7cya3Wl1OcXnABJrzH8vP+fY7/RFjhzcchB7DjKkkTIAntlwekD+GkSFzEvt2tC+D4Fp8w==", + "license": "MIT", + "dependencies": { + "@supabase/auth-js": "2.110.7", + "@supabase/functions-js": "2.110.7", + "@supabase/postgrest-js": "2.110.7", + "@supabase/realtime-js": "2.110.7", + "@supabase/storage-js": "2.110.7" + }, + "engines": { + "node": ">=22.0.0" + } + }, "node_modules/@swc/helpers": { "version": "0.5.15", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", @@ -3617,6 +3885,19 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -3860,6 +4141,24 @@ "node": ">= 0.4" } }, + "node_modules/eciesjs": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/eciesjs/-/eciesjs-0.5.0.tgz", + "integrity": "sha512-s0J9SEVYAEPg7J63GFMApLYzPH9VNIQIyC6s15JpnqVc0TqcKWdbgFlnAweEBRyMmko2dcs2sfC83Hj4J43tuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ecies/ciphers": "^0.2.6", + "@noble/ciphers": "^1.3.0", + "@noble/curves": "^1.9.7", + "@noble/hashes": "^1.8.0" + }, + "engines": { + "bun": ">=1", + "deno": ">=2.7.10", + "node": ">=16" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.393", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.393.tgz", @@ -5059,6 +5358,15 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, + "node_modules/iceberg-js": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz", + "integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -5601,6 +5909,16 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/jose": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -6711,7 +7029,6 @@ "version": "8.5.19", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.19.tgz", "integrity": "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==", - "dev": true, "funding": [ { "type": "opencollective", @@ -7566,6 +7883,30 @@ } } }, + "node_modules/supabase": { + "version": "2.109.1", + "resolved": "https://registry.npmjs.org/supabase/-/supabase-2.109.1.tgz", + "integrity": "sha512-N2yP2MHTxOxXBWhfn3poudpJn4pkPosAUo7J/46FTou/l7wOwFi9tox8NSN6HljWkfM0zhwPRimNNGC9XBMoxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eciesjs": "^0.5.0", + "jose": "^6.2.3" + }, + "bin": { + "supabase": "dist/supabase.js" + }, + "optionalDependencies": { + "@supabase/cli-darwin-arm64": "2.109.1", + "@supabase/cli-darwin-x64": "2.109.1", + "@supabase/cli-linux-arm64": "2.109.1", + "@supabase/cli-linux-arm64-musl": "2.109.1", + "@supabase/cli-linux-x64": "2.109.1", + "@supabase/cli-linux-x64-musl": "2.109.1", + "@supabase/cli-windows-arm64": "2.109.1", + "@supabase/cli-windows-x64": "2.109.1" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", diff --git a/package.json b/package.json index 232590c..00f5e8b 100644 --- a/package.json +++ b/package.json @@ -12,9 +12,21 @@ "typecheck": "next typegen && tsc --noEmit", "test": "vitest run", "test:watch": "vitest", - "test:e2e": "playwright test" + "test:e2e": "playwright test", + "db:start": "supabase start", + "db:stop": "supabase stop", + "db:status": "supabase status", + "db:reset": "supabase db reset --local", + "db:test": "supabase test db --local", + "db:types": "supabase gen types typescript --local --schema public > src/lib/supabase/database.types.ts", + "db:types:check": "node scripts/check-database-types.mjs", + "db:lint": "supabase db lint --local --level warning", + "test:integration": "vitest run --config vitest.integration.config.ts", + "test:realtime": "vitest run src/lib/realtime/realtime-controller.test.ts" }, "dependencies": { + "@supabase/ssr": "0.12.3", + "@supabase/supabase-js": "2.110.7", "lucide-react": "^1.25.0", "motion": "^12.42.2", "next": "16.2.10", @@ -38,6 +50,7 @@ "eslint-config-next": "16.2.10", "jsdom": "^29.1.1", "prettier": "^3.9.5", + "supabase": "2.109.1", "tailwindcss": "^4", "typescript": "^5", "vitest": "^4.1.10" diff --git a/scripts/check-database-types.mjs b/scripts/check-database-types.mjs new file mode 100644 index 0000000..c510072 --- /dev/null +++ b/scripts/check-database-types.mjs @@ -0,0 +1,36 @@ +import { execFileSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; + +const rawGenerated = execFileSync( + process.execPath, + [ + 'node_modules/supabase/dist/supabase.js', + 'gen', + 'types', + 'typescript', + '--local', + '--schema', + 'public', + ], + { encoding: 'utf8' }, +).replaceAll('\r\n', '\n'); +const generated = execFileSync( + process.execPath, + [ + 'node_modules/prettier/bin/prettier.cjs', + '--stdin-filepath', + 'src/lib/supabase/database.types.ts', + ], + { encoding: 'utf8', input: rawGenerated }, +).replaceAll('\r\n', '\n'); +const committed = readFileSync( + 'src/lib/supabase/database.types.ts', + 'utf8', +).replaceAll('\r\n', '\n'); + +if (generated !== committed) { + console.error('Generated database types are stale. Run npm run db:types.'); + process.exit(1); +} + +console.log('Generated database types are current.'); diff --git a/src/app/about/page.tsx b/src/app/about/page.tsx index 0a22733..a7db824 100644 --- a/src/app/about/page.tsx +++ b/src/app/about/page.tsx @@ -19,19 +19,19 @@ export default function AboutPage() {

Privacy

Less data is better.

- The Version 1 product needs no account, email, phone number, - address, analytics, advertising, or third-party profiling. A first - name is optional, and public displays prioritize queue numbers. + Anonymous browser identities require no email, phone number, + address, password, analytics, advertising, or profiling. A first + name is optional, isolated from public queue state, and visible + only to its owner and authorized staff.

Technology

Built around one small domain.

- Next.js, strict TypeScript, Motion, schema-ready domain - boundaries, Vitest, Testing Library, and Playwright form the - foundation. Story 1 deliberately uses local deterministic state; - production synchronization comes next. + Next.js, strict TypeScript, Supabase PostgreSQL, transactional RPC + commands, Row Level Security, Realtime invalidation, Motion, + Vitest, pgTAP, and Playwright form the engineering foundation.

@@ -48,10 +48,9 @@ export default function AboutPage() {

Cost

Designed to remain $0.

- No paid service, trial, database, production credential, - analytics, or deployment is enabled in this foundation story. - Infrastructure will only be provisioned after its free-tier - constraints are verified. + The local stack uses Docker and the Supabase CLI. Hosted + validation is limited to one Free project with no trial, payment + method, paid add-on, analytics, or application deployment.

diff --git a/src/app/demo/page.tsx b/src/app/demo/page.tsx index 85bdabe..35cf7de 100644 --- a/src/app/demo/page.tsx +++ b/src/app/demo/page.tsx @@ -1,6 +1,9 @@ import { ArrowRight, Monitor, Smartphone, Users } from 'lucide-react'; import Link from 'next/link'; import { demoRoutes } from '@/config/product'; +import { CreateQueuePanel } from '@/features/queue/create-queue-panel'; + +export const dynamic = 'force-dynamic'; const demos = [ { @@ -12,7 +15,7 @@ const demos = [ { title: 'Staff', description: - 'Call, complete, skip, and pause with deterministic local state.', + 'Claim access, then call, complete, skip, pause, reopen, or close.', href: demoRoutes.staff, icon: Users, }, @@ -28,22 +31,22 @@ export default function DemoPage() { return (
-

Story 1 visual prototype

+

Story 2 persistent prototype

See the queue from every side.

- Open each surface to explore the intended flow, motion, and responsive - behavior. + Create a persistent queue, then open each synchronized surface in a + separate browser context.

+
{demos.map(({ title, description, href, icon: Icon }) => ( diff --git a/src/app/globals.css b/src/app/globals.css index a0a15b8..e304564 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -469,12 +469,57 @@ button { } .state-paused, +.state-connecting, .state-reconnecting { color: var(--warning); } .state-closed, -.state-offline { +.state-offline, +.state-error { + color: var(--muted); +} + +.setup-panel { + max-width: 760px; + margin: 52px auto; + padding: clamp(28px, 5vw, 52px); + border: 1px solid var(--line); + background: var(--paper-strong); +} + +.setup-panel h2 { + margin-top: 0; + font-size: clamp(1.8rem, 4vw, 3rem); + letter-spacing: -0.045em; +} + +.setup-form { + display: grid; + gap: 18px; +} + +.setup-form .field-label { + margin-top: 12px; +} + +.code-reveal { + margin-top: 26px; + padding: 24px; + border: 1px solid var(--accent); + background: color-mix(in srgb, var(--accent) 7%, var(--paper)); +} + +.code-reveal .text-input { + margin: 14px 0; + font-family: ui-monospace, Consolas, monospace; + letter-spacing: 0.08em; +} + +.live-loading { + min-height: 420px; + display: grid; + place-items: center; color: var(--muted); } diff --git a/src/app/q/[slug]/display/page.tsx b/src/app/q/[slug]/display/page.tsx index 77a9546..8e018d4 100644 --- a/src/app/q/[slug]/display/page.tsx +++ b/src/app/q/[slug]/display/page.tsx @@ -1,11 +1,10 @@ -import { InvalidQueue } from '@/components/invalid-queue'; -import { productConfig } from '@/config/product'; -import { PublicDisplay } from '@/features/queue/public-display'; +import { PublicDisplayLive } from '@/features/queue/public-display-live'; + +export const dynamic = 'force-dynamic'; export default async function DisplayPage({ params, }: PageProps<'/q/[slug]/display'>) { const { slug } = await params; - if (slug !== productConfig.demoQueueSlug) return ; - return ; + return ; } diff --git a/src/app/q/[slug]/page.tsx b/src/app/q/[slug]/page.tsx index 51a74b1..7209dfa 100644 --- a/src/app/q/[slug]/page.tsx +++ b/src/app/q/[slug]/page.tsx @@ -1,19 +1,18 @@ -import { InvalidQueue } from '@/components/invalid-queue'; import { PrototypeHeader } from '@/components/prototype-header'; -import { productConfig } from '@/config/product'; -import { CustomerPrototype } from '@/features/queue/customer-prototype'; +import { CustomerLive } from '@/features/queue/customer-live'; + +export const dynamic = 'force-dynamic'; export default async function CustomerPage({ params }: PageProps<'/q/[slug]'>) { const { slug } = await params; - if (slug !== productConfig.demoQueueSlug) return ; return (
- +
); } diff --git a/src/app/q/[slug]/staff/page.tsx b/src/app/q/[slug]/staff/page.tsx index 27ac7f1..9d868c7 100644 --- a/src/app/q/[slug]/staff/page.tsx +++ b/src/app/q/[slug]/staff/page.tsx @@ -1,21 +1,17 @@ -import { InvalidQueue } from '@/components/invalid-queue'; import { PrototypeHeader } from '@/components/prototype-header'; -import { productConfig } from '@/config/product'; -import { StaffPrototype } from '@/features/queue/staff-prototype'; +import { StaffLive } from '@/features/queue/staff-live'; + +export const dynamic = 'force-dynamic'; export default async function StaffPage({ params, }: PageProps<'/q/[slug]/staff'>) { const { slug } = await params; - if (slug !== productConfig.demoQueueSlug) return ; return (
- - + +
); } diff --git a/src/components/connection-indicator.tsx b/src/components/connection-indicator.tsx index c1182a2..5fd82f7 100644 --- a/src/components/connection-indicator.tsx +++ b/src/components/connection-indicator.tsx @@ -1,9 +1,11 @@ import type { ConnectionState } from '@/features/queue/types'; const labels: Record = { + connecting: 'Connecting', connected: 'Connected', reconnecting: 'Reconnecting', offline: 'Offline', + error: 'Connection issue', }; export function ConnectionIndicator({ state }: { state: ConnectionState }) { diff --git a/src/features/queue/create-queue-panel.tsx b/src/features/queue/create-queue-panel.tsx new file mode 100644 index 0000000..df01104 --- /dev/null +++ b/src/features/queue/create-queue-panel.tsx @@ -0,0 +1,143 @@ +'use client'; + +import { FormEvent, useMemo, useRef, useState } from 'react'; +import Link from 'next/link'; +import { SupabaseQueueAdapter } from '@/lib/realtime/supabase-adapter'; +import { QueueAdapterError } from '@/lib/realtime/errors'; + +export function CreateQueuePanel() { + const adapter = useMemo(() => { + try { + return new SupabaseQueueAdapter(); + } catch { + return undefined; + } + }, []); + const [name, setName] = useState('North Star Café'); + const [prefix, setPrefix] = useState('A'); + const [pending, setPending] = useState(false); + const [message, setMessage] = useState(''); + const [created, setCreated] = useState<{ slug: string; code: string }>(); + const codeRef = useRef(null); + + async function create(event: FormEvent) { + event.preventDefault(); + if (!adapter) { + setMessage( + 'Supabase is not configured. Follow the README local setup first.', + ); + return; + } + setPending(true); + try { + const result = await adapter.createQueue(name, prefix); + if (!result.accessCode) + throw new Error('The one-time access code was not returned.'); + setCreated({ slug: result.snapshot.queue.slug, code: result.accessCode }); + setMessage( + 'Queue created. Save the staff code now; it cannot be shown again.', + ); + } catch (error) { + setMessage( + error instanceof QueueAdapterError + ? error.message + : 'The queue could not be created.', + ); + } finally { + setPending(false); + } + } + + async function copyCode() { + if (!created) return; + try { + await navigator.clipboard.writeText(created.code); + setMessage('Staff code copied.'); + } catch { + codeRef.current?.select(); + setMessage('Select and copy the highlighted code manually.'); + } + } + + return ( +
+

Persistent setup

+

Create a queue for this test.

+
+ + setName(event.target.value)} + required + disabled={pending} + /> + + setPrefix(event.target.value)} + required + disabled={pending} + /> + +
+

+ {message || + 'This creates an anonymous private browser identity without collecting contact information.'} +

+ {created && ( +
+ One-time staff access code +

+ Anyone with this code can control the queue. Save it privately now. +

+ event.currentTarget.select()} + /> + +

+ Queue slug: {created.slug} +

+
+ + Customer view + + + Staff view + + + Public display + +
+
+ )} +
+ ); +} diff --git a/src/features/queue/customer-live.tsx b/src/features/queue/customer-live.tsx new file mode 100644 index 0000000..44b4097 --- /dev/null +++ b/src/features/queue/customer-live.tsx @@ -0,0 +1,165 @@ +'use client'; + +import { FormEvent, useState } from 'react'; +import { AnimatedQueueNumber } from '@/components/animated-queue-number'; +import { ConnectionIndicator } from '@/components/connection-indicator'; +import { InvalidQueue } from '@/components/invalid-queue'; +import { QueueStatusLabel } from '@/components/queue-status'; +import { QueueAdapterError } from '@/lib/realtime/errors'; +import { activeEntry, waitingEntries } from './transitions'; +import { useLiveQueue } from './use-live-queue'; + +export function CustomerLive({ slug }: { slug: string }) { + const { adapter, snapshot, connection, error, commit, isNotFound } = + useLiveQueue(slug); + const [displayName, setDisplayName] = useState(''); + const [pending, setPending] = useState(false); + const [message, setMessage] = useState(''); + + if (isNotFound) return ; + if (!snapshot) { + return ( +
+ {error?.message ?? 'Preparing your place in the queue…'} +
+ ); + } + + const ownEntry = snapshot.entries.find( + (entry) => entry.id === snapshot.ownEntryId, + ); + const waiting = waitingEntries(snapshot.entries); + const active = activeEntry(snapshot.entries); + const position = + ownEntry?.status === 'WAITING' + ? waiting.findIndex((entry) => entry.id === ownEntry.id) + 1 + : 0; + + async function join(event: FormEvent) { + event.preventDefault(); + if (!adapter) return; + setPending(true); + try { + const next = await adapter.joinQueue( + slug, + displayName.trim() || undefined, + ); + commit(next); + setMessage( + `You are number ${next.entries.find((entry) => entry.id === next.ownEntryId)?.numberLabel ?? ''}`, + ); + } catch (nextError) { + setMessage( + nextError instanceof QueueAdapterError + ? nextError.message + : 'Unable to join right now.', + ); + } finally { + setPending(false); + } + } + + return ( +
+
+
+

Join the queue

+ +
+

Keep your place. Keep your day.

+

+ Add a first name if you like. Your queue number is all we need. +

+ {snapshot.queue.status === 'OPEN' && !ownEntry ? ( +
+ + setDisplayName(event.target.value)} + disabled={pending} + /> +

+ Only authorized staff can see this optional name. +

+ +
+ ) : ownEntry ? ( +

+ Your place is saved to this private browser session. +

+ ) : ( +

+ {snapshot.queue.status === 'PAUSED' + ? 'Check-in is paused. Staff will reopen the queue shortly.' + : 'This queue is closed.'} +

+ )} +

+ {message} +

+
+
+
+ +

+ Your number +

+ +

+ {ownEntry?.status === 'SERVING' + ? 'It’s your turn.' + : ownEntry + ? 'We’ll update your position automatically.' + : 'Join to receive your queue number.'} +

+
+
+ + Now serving +
+ {active?.numberLabel ?? 'No one yet'} +
+ + Your position +
+ {ownEntry?.status === 'SERVING' + ? 'Now serving' + : position + ? `${position} of ${waiting.length}` + : '—'} +
+
+
+
+ ); +} diff --git a/src/features/queue/mock-data.ts b/src/features/queue/mock-data.ts index 843e092..ccd7401 100644 --- a/src/features/queue/mock-data.ts +++ b/src/features/queue/mock-data.ts @@ -10,6 +10,7 @@ export const initialQueueSnapshot: QueueSnapshot = { name: productConfig.demoQueueName, prefix: productConfig.defaultQueuePrefix, status: 'OPEN', + revision: 1, createdAt: timestamp, updatedAt: timestamp, }, @@ -20,6 +21,7 @@ export const initialQueueSnapshot: QueueSnapshot = { number: 24, displayName: 'Mara', status: 'SERVING', + revision: 1, joinedAt: '2026-07-17T13:42:00.000Z', calledAt: '2026-07-17T13:58:00.000Z', updatedAt: '2026-07-17T13:58:00.000Z', @@ -30,6 +32,7 @@ export const initialQueueSnapshot: QueueSnapshot = { number: 25 + index, displayName, status: 'WAITING' as const, + revision: 1, joinedAt: `2026-07-17T13:${50 + index}:00.000Z`, updatedAt: `2026-07-17T13:${50 + index}:00.000Z`, })), diff --git a/src/features/queue/public-display-live.tsx b/src/features/queue/public-display-live.tsx new file mode 100644 index 0000000..f2c8eeb --- /dev/null +++ b/src/features/queue/public-display-live.tsx @@ -0,0 +1,66 @@ +'use client'; + +import { AnimatedQueueNumber } from '@/components/animated-queue-number'; +import { ConnectionIndicator } from '@/components/connection-indicator'; +import { InvalidQueue } from '@/components/invalid-queue'; +import { activeEntry, waitingEntries } from './transitions'; +import { useLiveQueue } from './use-live-queue'; + +export function PublicDisplayLive({ slug }: { slug: string }) { + const { snapshot, connection, error, isNotFound } = useLiveQueue(slug); + if (isNotFound) return ; + if (!snapshot) + return ( +
+
+ {error?.message ?? 'Connecting to the queue…'} +
+
+ ); + const active = activeEntry(snapshot.entries); + const upcoming = waitingEntries(snapshot.entries).slice(0, 3); + return ( +
+
+
+

Welcome

+

{snapshot.queue.name}

+
+ +
+
+
+

+ Now serving +

+ +

+ {active + ? `Now serving ${active.numberLabel}` + : 'No one is currently being served'} +

+
+
+
+

+ Up next +

+
+ {upcoming.length ? ( + upcoming.map((entry) => ( + + {entry.numberLabel} + + )) + ) : ( + Queue is clear + )} +
+
+
+ ); +} diff --git a/src/features/queue/staff-live.tsx b/src/features/queue/staff-live.tsx new file mode 100644 index 0000000..0bfbc7d --- /dev/null +++ b/src/features/queue/staff-live.tsx @@ -0,0 +1,315 @@ +'use client'; + +import { AnimatePresence, motion, useReducedMotion } from 'motion/react'; +import { FormEvent, useRef, useState } from 'react'; +import { AnimatedQueueNumber } from '@/components/animated-queue-number'; +import { ConnectionIndicator } from '@/components/connection-indicator'; +import { InvalidQueue } from '@/components/invalid-queue'; +import { QueueStatusLabel } from '@/components/queue-status'; +import { QueueAdapterError } from '@/lib/realtime/errors'; +import { activeEntry, waitingEntries } from './transitions'; +import { useLiveQueue } from './use-live-queue'; +import type { QueueSnapshot } from './types'; + +export function StaffLive({ slug }: { slug: string }) { + const { adapter, snapshot, connection, error, commit, isNotFound } = + useLiveQueue(slug); + const [accessCode, setAccessCode] = useState(''); + const [pending, setPending] = useState(''); + const [message, setMessage] = useState('Ready.'); + const messageRef = useRef(null); + const reduced = useReducedMotion(); + + if (isNotFound) return ; + if (!snapshot) + return ( +
+ {error?.message ?? 'Preparing the staff board…'} +
+ ); + + async function claim(event: FormEvent) { + event.preventDefault(); + if (!adapter) return; + setPending('claim'); + try { + const next = await adapter.claimStaffAccess(slug, accessCode); + setAccessCode(''); + commit(next); + setMessage('Staff access confirmed.'); + } catch (nextError) { + setMessage( + nextError instanceof QueueAdapterError + ? nextError.message + : 'That access code could not be verified.', + ); + } finally { + setPending(''); + } + } + + if (snapshot.role !== 'staff') { + return ( +
+

Staff access

+

Enter the queue access code.

+

+ The code grants control of this queue. It is never placed in the URL + or saved by this interface. +

+
+ + setAccessCode(event.target.value)} + required + disabled={pending === 'claim'} + /> + +
+

+ {message} +

+
+ ); + } + + const active = activeEntry(snapshot.entries); + const waiting = waitingEntries(snapshot.entries); + + async function command( + label: string, + action: () => Promise, + success: string, + ) { + setPending(label); + try { + commit(await action()); + setMessage(success); + requestAnimationFrame(() => messageRef.current?.focus()); + } catch (nextError) { + setMessage( + nextError instanceof QueueAdapterError + ? nextError.message + : 'The queue could not be updated.', + ); + } finally { + setPending(''); + } + } + + return ( +
+
+
+ + +
+
+

Now serving

+

+ {active?.displayName ?? + (active ? 'Current customer' : 'No one yet')} +

+
+ +
+ {active ? ( + <> + + + + ) : ( + + )} + {snapshot.queue.status === 'OPEN' && ( + + )} + {snapshot.queue.status === 'PAUSED' && ( + + )} + {snapshot.queue.status !== 'CLOSED' && ( + + )} +
+

+ {pending ? 'Updating the queue…' : message} +

+
+
+
+

Waiting

+ + {waiting.length} {waiting.length === 1 ? 'person' : 'people'} + +
+ {waiting.length ? ( +
    + + {waiting.map((entry) => ( + + {entry.numberLabel} + {entry.displayName ?? 'Guest'} + + + ))} + +
+ ) : ( +

+ No customers waiting. New arrivals will appear here automatically. +

+ )} +
+
+ ); +} diff --git a/src/features/queue/transitions.ts b/src/features/queue/transitions.ts index 104cc06..d3139e0 100644 --- a/src/features/queue/transitions.ts +++ b/src/features/queue/transitions.ts @@ -87,6 +87,7 @@ export function applyQueueCommand( number: nextNumber, ...(displayName ? { displayName } : {}), status: 'WAITING', + revision: queue.revision, joinedAt: now, updatedAt: now, }; diff --git a/src/features/queue/types.ts b/src/features/queue/types.ts index 387b792..3b82942 100644 --- a/src/features/queue/types.ts +++ b/src/features/queue/types.ts @@ -15,6 +15,7 @@ export interface Queue { name: string; prefix: string; status: QueueStatus; + revision: number; createdAt: string; updatedAt: string; } @@ -23,18 +24,24 @@ export interface QueueEntry { id: string; queueId: string; number: number; - displayName?: string; + numberLabel?: string | undefined; + displayName?: string | undefined; status: QueueEntryStatus; + revision: number; joinedAt: string; - calledAt?: string; - completedAt?: string; - skippedAt?: string; + calledAt?: string | undefined; + completedAt?: string | undefined; + skippedAt?: string | undefined; updatedAt: string; } export interface QueueSnapshot { queue: Queue; entries: QueueEntry[]; + role?: 'public' | 'customer' | 'staff'; + ownEntryId?: string; + waitingCount?: number; + serverTime?: string; } export type QueueCommand = @@ -44,4 +51,5 @@ export type QueueCommand = | { type: 'SKIP'; entryId: string } | { type: 'SET_QUEUE_STATUS'; status: QueueStatus }; -export type ConnectionState = 'connected' | 'reconnecting' | 'offline'; +export type ConnectionState = + 'connecting' | 'connected' | 'reconnecting' | 'offline' | 'error'; diff --git a/src/features/queue/use-live-queue.ts b/src/features/queue/use-live-queue.ts new file mode 100644 index 0000000..4fb910a --- /dev/null +++ b/src/features/queue/use-live-queue.ts @@ -0,0 +1,73 @@ +'use client'; + +import { useCallback, useEffect, useMemo, useState } from 'react'; +import type { ConnectionState, QueueSnapshot } from './types'; +import { QueueAdapterError } from '@/lib/realtime/errors'; +import { SupabaseQueueAdapter } from '@/lib/realtime/supabase-adapter'; + +export function useLiveQueue(slug: string) { + const adapterResult = useMemo(() => { + try { + return { adapter: new SupabaseQueueAdapter() } as const; + } catch (error) { + return { + error: + error instanceof Error + ? error + : new Error('Supabase configuration is unavailable.'), + } as const; + } + }, []); + const [snapshot, setSnapshot] = useState(); + const [connection, setConnection] = useState('connecting'); + const [error, setError] = useState( + 'error' in adapterResult ? adapterResult.error : undefined, + ); + + const commit = useCallback((next: QueueSnapshot) => { + setSnapshot((current) => + !current || next.queue.revision >= current.queue.revision + ? next + : current, + ); + }, []); + + useEffect(() => { + if (!('adapter' in adapterResult)) return; + let unsubscribe: (() => Promise) | undefined; + let cancelled = false; + adapterResult.adapter + .subscribe(slug, { + onSnapshot: commit, + onConnectionState: setConnection, + onError: (nextError) => setError(nextError), + }) + .then((cleanup) => { + if (cancelled) void cleanup(); + else unsubscribe = cleanup; + }) + .catch((nextError: unknown) => { + setConnection('error'); + setError( + nextError instanceof Error + ? nextError + : new Error('Unable to connect to this queue.'), + ); + }); + return () => { + cancelled = true; + if (unsubscribe) void unsubscribe(); + }; + }, [adapterResult, commit, slug]); + + return { + adapter: 'adapter' in adapterResult ? adapterResult.adapter : undefined, + snapshot, + connection, + error, + commit, + clearError: () => setError(undefined), + isNotFound: + error instanceof QueueAdapterError && error.code === 'QUEUE_NOT_FOUND', + }; +} diff --git a/src/lib/realtime/adapter.ts b/src/lib/realtime/adapter.ts index a959383..4c2f376 100644 --- a/src/lib/realtime/adapter.ts +++ b/src/lib/realtime/adapter.ts @@ -1,24 +1,31 @@ -import type { - ConnectionState, - QueueCommand, - QueueSnapshot, -} from '@/features/queue/types'; +import type { ConnectionState, QueueSnapshot } from '@/features/queue/types'; + +export interface QueueCreationResult { + snapshot: QueueSnapshot; + accessCode?: string; +} + +export interface QueueSubscriptionCallbacks { + onSnapshot(snapshot: QueueSnapshot): void; + onConnectionState(state: ConnectionState): void; + onError(error: Error): void; +} export interface QueueRealtimeAdapter { - connect(): Promise; - disconnect(): Promise; + getSnapshot(slug: string): Promise; subscribe( - queueId: string, - onSnapshot: (snapshot: QueueSnapshot) => void, - ): () => void; - publish(queueId: string, command: QueueCommand): Promise; - observeConnectionState( - listener: (state: ConnectionState) => void, - ): () => void; + slug: string, + callbacks: QueueSubscriptionCallbacks, + ): Promise<() => Promise>; + createQueue(name: string, prefix: string): Promise; + claimStaffAccess(slug: string, accessCode: string): Promise; + joinQueue(slug: string, displayName?: string): Promise; + callNext(queueId: string): Promise; + completeCurrent(queueId: string): Promise; + skipEntry(queueId: string, entryId: string): Promise; + pauseQueue(queueId: string): Promise; + reopenQueue(queueId: string): Promise; + closeQueue(queueId: string): Promise; } -/** - * Story 1 boundary. A persistent multi-client adapter will implement this - * contract in the next engineering story; no mock is presented as production. - */ -export const REALTIME_IMPLEMENTATION_STATUS = 'visual-prototype-only' as const; +export const REALTIME_IMPLEMENTATION_STATUS = 'supabase-persistent' as const; diff --git a/src/lib/realtime/errors.ts b/src/lib/realtime/errors.ts new file mode 100644 index 0000000..f491f68 --- /dev/null +++ b/src/lib/realtime/errors.ts @@ -0,0 +1,32 @@ +export const queueErrorCodes = [ + 'QUEUE_NOT_FOUND', + 'QUEUE_PAUSED', + 'QUEUE_CLOSED', + 'ALREADY_JOINED', + 'EMPTY_QUEUE', + 'ACTIVE_ENTRY_EXISTS', + 'NOT_STAFF', + 'INVALID_ACCESS_CODE', + 'RATE_LIMITED', + 'CONFLICT', + 'OFFLINE', + 'AUTH_INITIALIZATION_FAILED', + 'SUBSCRIPTION_FAILED', + 'INVALID_QUEUE_NAME', + 'INVALID_QUEUE_PREFIX', + 'INVALID_DISPLAY_NAME', + 'QUEUE_LIMIT_REACHED', + 'UNKNOWN', +] as const; + +export type QueueErrorCode = (typeof queueErrorCodes)[number]; + +export class QueueAdapterError extends Error { + constructor( + readonly code: QueueErrorCode, + message: string, + ) { + super(message); + this.name = 'QueueAdapterError'; + } +} diff --git a/src/lib/realtime/realtime-controller.test.ts b/src/lib/realtime/realtime-controller.test.ts new file mode 100644 index 0000000..052ead4 --- /dev/null +++ b/src/lib/realtime/realtime-controller.test.ts @@ -0,0 +1,239 @@ +import { waitFor } from '@testing-library/react'; +import type { SupabaseClient } from '@supabase/supabase-js'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Database, Json } from '@/lib/supabase/database.types'; +import { resetAnonymousSessionForTests } from '@/lib/supabase/session'; +import { + RevisionGate, + SupabaseQueueAdapter, + withStableRequestId, +} from './supabase-adapter'; + +function snapshot(revision: number): Json { + return { + ok: true, + queue: { + id: '50000000-0000-4000-8000-000000000001', + slug: 'test-queue', + name: 'Test Queue', + prefix: 'T', + status: 'OPEN', + revision, + createdAt: '2026-07-17T00:00:00Z', + updatedAt: '2026-07-17T00:00:00Z', + }, + entries: [], + role: 'public', + ownEntryId: null, + waitingCount: 0, + serverTime: '2026-07-17T00:00:00Z', + }; +} + +class FakeChannel { + changes: Array<() => void> = []; + status: + | (( + status: 'SUBSCRIBED' | 'CHANNEL_ERROR' | 'TIMED_OUT' | 'CLOSED', + ) => void) + | undefined; + + on(_kind: string, _filter: object, callback: () => void) { + this.changes.push(callback); + return this; + } + + subscribe(callback: FakeChannel['status']) { + this.status = callback; + queueMicrotask(() => callback?.('SUBSCRIBED')); + return this; + } + + invalidate() { + this.changes.forEach((callback) => callback()); + } +} + +function fakeClient(revisions: number[]) { + const channel = new FakeChannel(); + const rpc = vi.fn(async () => ({ + data: snapshot(revisions.shift() ?? 0), + error: null, + })); + const client = { + auth: { + getSession: vi.fn(async () => ({ + data: { + session: { user: { id: '51000000-0000-4000-8000-000000000001' } }, + }, + error: null, + })), + signInAnonymously: vi.fn(), + }, + rpc, + channel: vi.fn(() => channel), + removeChannel: vi.fn(async () => 'ok'), + }; + return { + channel, + rpc, + removeChannel: client.removeChannel, + client: client as unknown as SupabaseClient, + }; +} + +beforeEach(() => { + resetAnonymousSessionForTests(); + vi.useRealTimers(); +}); + +describe('Realtime invalidation and convergence', () => { + it('reuses one request ID for an automatic retry', async () => { + const seen: string[] = []; + const result = await withStableRequestId( + async (stableRequestId) => { + seen.push(stableRequestId); + return { retry: seen.length === 1 }; + }, + (value) => value.retry, + ); + expect(result.retry).toBe(false); + expect(seen).toHaveLength(2); + expect(seen[0]).toBe(seen[1]); + }); + + it('uses a new request ID for a new user action', async () => { + const first = await withStableRequestId( + async (id) => id, + () => false, + ); + const second = await withStableRequestId( + async (id) => id, + () => false, + ); + expect(first).not.toBe(second); + }); + + it('accepts only authoritative non-stale revisions', () => { + const gate = new RevisionGate(); + const revisionTwo = { queue: { revision: 2 } } as never; + const revisionOne = { queue: { revision: 1 } } as never; + expect(gate.accept(revisionTwo)).toBe(true); + expect(gate.accept(revisionTwo)).toBe(false); + expect(gate.accept(revisionOne, true)).toBe(false); + expect(gate.current()).toBe(2); + }); + + it('subscribes before publishing the authoritative initial snapshot', async () => { + const fake = fakeClient([1, 2]); + const revisions: number[] = []; + const states: string[] = []; + const cleanup = await new SupabaseQueueAdapter(fake.client).subscribe( + 'test-queue', + { + onSnapshot: (value) => revisions.push(value.queue.revision), + onConnectionState: (state) => states.push(state), + onError: vi.fn(), + }, + ); + await waitFor(() => expect(revisions).toEqual([2])); + expect(states).toContain('connected'); + expect(fake.rpc).toHaveBeenCalledTimes(2); + await cleanup(); + }); + + it('debounces duplicate queue and entry invalidations into one refresh', async () => { + vi.useFakeTimers(); + const fake = fakeClient([1, 2, 3]); + const onSnapshot = vi.fn(); + const cleanup = await new SupabaseQueueAdapter(fake.client).subscribe( + 'test-queue', + { + onSnapshot, + onConnectionState: vi.fn(), + onError: vi.fn(), + }, + ); + await vi.runAllTicks(); + await vi.advanceTimersByTimeAsync(1); + fake.channel.invalidate(); + fake.channel.invalidate(); + await vi.advanceTimersByTimeAsync(75); + expect(fake.rpc).toHaveBeenCalledTimes(3); + expect(onSnapshot.mock.calls.at(-1)?.[0].queue.revision).toBe(3); + await cleanup(); + }); + + it('ignores a stale snapshot returned after a later revision', async () => { + vi.useFakeTimers(); + const fake = fakeClient([4, 5, 3]); + const onSnapshot = vi.fn(); + const cleanup = await new SupabaseQueueAdapter(fake.client).subscribe( + 'test-queue', + { + onSnapshot, + onConnectionState: vi.fn(), + onError: vi.fn(), + }, + ); + await vi.runAllTicks(); + await vi.advanceTimersByTimeAsync(1); + fake.channel.invalidate(); + await vi.advanceTimersByTimeAsync(75); + expect(onSnapshot).toHaveBeenCalledTimes(1); + expect(onSnapshot.mock.calls[0]?.[0].queue.revision).toBe(5); + await cleanup(); + }); + + it('resynchronizes when the browser returns online', async () => { + const fake = fakeClient([1, 2, 4]); + const onSnapshot = vi.fn(); + const states: string[] = []; + const cleanup = await new SupabaseQueueAdapter(fake.client).subscribe( + 'test-queue', + { + onSnapshot, + onConnectionState: (state) => states.push(state), + onError: vi.fn(), + }, + ); + await waitFor(() => expect(onSnapshot).toHaveBeenCalledTimes(1)); + window.dispatchEvent(new Event('online')); + await waitFor(() => + expect(onSnapshot.mock.calls.at(-1)?.[0].queue.revision).toBe(4), + ); + expect(states).toContain('reconnecting'); + await cleanup(); + }); + + it('maps channel errors to a calm reconnecting state', async () => { + const fake = fakeClient([1, 2]); + const states: string[] = []; + const cleanup = await new SupabaseQueueAdapter(fake.client).subscribe( + 'test-queue', + { + onSnapshot: vi.fn(), + onConnectionState: (state) => states.push(state), + onError: vi.fn(), + }, + ); + await waitFor(() => expect(states).toContain('connected')); + fake.channel.status?.('CHANNEL_ERROR'); + expect(states.at(-1)).toBe('reconnecting'); + await cleanup(); + }); + + it('unsubscribes and removes browser listeners during cleanup', async () => { + const fake = fakeClient([1, 2]); + const cleanup = await new SupabaseQueueAdapter(fake.client).subscribe( + 'test-queue', + { + onSnapshot: vi.fn(), + onConnectionState: vi.fn(), + onError: vi.fn(), + }, + ); + await cleanup(); + expect(fake.removeChannel).toHaveBeenCalledWith(fake.channel); + }); +}); diff --git a/src/lib/realtime/schemas.ts b/src/lib/realtime/schemas.ts new file mode 100644 index 0000000..a52db23 --- /dev/null +++ b/src/lib/realtime/schemas.ts @@ -0,0 +1,47 @@ +import { z } from 'zod'; + +const entrySchema = z.object({ + id: z.uuid(), + queueId: z.uuid(), + number: z.number().int().positive(), + numberLabel: z.string(), + status: z.enum(['WAITING', 'SERVING', 'COMPLETED', 'SKIPPED']), + displayName: z.string().optional(), + joinedAt: z.string(), + calledAt: z.string().optional(), + completedAt: z.string().optional(), + skippedAt: z.string().optional(), + updatedAt: z.string(), + revision: z.number().int().nonnegative(), +}); + +export const snapshotSchema = z.object({ + ok: z.literal(true), + queue: z.object({ + id: z.uuid(), + slug: z.string(), + name: z.string(), + prefix: z.string(), + status: z.enum(['OPEN', 'PAUSED', 'CLOSED']), + revision: z.number().int().nonnegative(), + createdAt: z.string(), + updatedAt: z.string(), + }), + entries: z.array(entrySchema), + role: z.enum(['public', 'customer', 'staff']), + ownEntryId: z.uuid().nullable().optional(), + waitingCount: z.number().int().nonnegative(), + serverTime: z.string(), + replayed: z.boolean().optional(), + alreadyJoined: z.boolean().optional(), + accessCode: z.string().nullable().optional(), + accessCodeShownOnce: z.boolean().optional(), +}); + +export const errorSchema = z.object({ + ok: z.literal(false), + error: z.string(), + message: z.string(), +}); + +export type SnapshotResult = z.infer; diff --git a/src/lib/realtime/supabase-adapter.ts b/src/lib/realtime/supabase-adapter.ts new file mode 100644 index 0000000..205af7b --- /dev/null +++ b/src/lib/realtime/supabase-adapter.ts @@ -0,0 +1,331 @@ +import type { RealtimeChannel, SupabaseClient } from '@supabase/supabase-js'; +import type { QueueSnapshot } from '@/features/queue/types'; +import type { Database, Json } from '@/lib/supabase/database.types'; +import { ensureAnonymousSession } from '@/lib/supabase/session'; +import { createSupabaseBrowserClient } from '@/lib/supabase/client'; +import type { + QueueCreationResult, + QueueRealtimeAdapter, + QueueSubscriptionCallbacks, +} from './adapter'; +import { QueueAdapterError, queueErrorCodes } from './errors'; +import { errorSchema, snapshotSchema } from './schemas'; + +type SnapshotWithMetadata = QueueSnapshot & { + accessCode?: string; +}; + +function requestId() { + return crypto.randomUUID(); +} + +type RpcResult = { + data: Json | null; + error: { message: string } | null; +}; + +function isRetryableNetworkFailure(result: RpcResult) { + return Boolean( + result.error && + /failed to fetch|fetch failed|network error|load failed/i.test( + result.error.message, + ), + ); +} + +export async function withStableRequestId( + operation: (requestId: string) => PromiseLike, + shouldRetry: (result: T) => boolean, +) { + const stableRequestId = requestId(); + const first = await operation(stableRequestId); + return shouldRetry(first) ? operation(stableRequestId) : first; +} + +function parseResult( + data: Json | null, + transportError: { message: string } | null, +): SnapshotWithMetadata { + if (transportError) { + throw new QueueAdapterError( + 'UNKNOWN', + 'The queue service could not complete that request.', + ); + } + + const error = errorSchema.safeParse(data); + if (error.success) { + const code = queueErrorCodes.includes(error.data.error as never) + ? (error.data.error as (typeof queueErrorCodes)[number]) + : 'UNKNOWN'; + throw new QueueAdapterError(code, error.data.message); + } + + const parsed = snapshotSchema.safeParse(data); + if (!parsed.success) { + throw new QueueAdapterError( + 'UNKNOWN', + 'The queue service returned an invalid snapshot.', + ); + } + + const value = parsed.data; + return { + queue: value.queue, + entries: value.entries, + role: value.role, + ...(value.ownEntryId ? { ownEntryId: value.ownEntryId } : {}), + waitingCount: value.waitingCount, + serverTime: value.serverTime, + ...(value.accessCode ? { accessCode: value.accessCode } : {}), + }; +} + +export class RevisionGate { + private revision = -1; + + accept(snapshot: QueueSnapshot, force = false) { + if (!force && snapshot.queue.revision <= this.revision) return false; + if (snapshot.queue.revision < this.revision) return false; + this.revision = snapshot.queue.revision; + return true; + } + + current() { + return this.revision; + } +} + +export class SupabaseQueueAdapter implements QueueRealtimeAdapter { + constructor( + private readonly client: SupabaseClient = createSupabaseBrowserClient(), + ) {} + + private async ready() { + await ensureAnonymousSession(this.client); + } + + private snapshot(result: { + data: Json | null; + error: { message: string } | null; + }) { + return parseResult(result.data, result.error); + } + + private async mutate( + operation: (stableRequestId: string) => PromiseLike, + ) { + await this.ready(); + return this.snapshot( + await withStableRequestId(operation, isRetryableNetworkFailure), + ); + } + + async getSnapshot(slug: string) { + await this.ready(); + return this.snapshot( + await this.client.rpc('get_queue_snapshot', { queue_slug: slug }), + ); + } + + async createQueue( + name: string, + prefix: string, + ): Promise { + const result = await this.mutate((stableRequestId) => + this.client.rpc('create_queue', { + queue_name: name, + queue_prefix: prefix, + request_id: stableRequestId, + }), + ); + const { accessCode, ...snapshot } = result; + return { snapshot, ...(accessCode ? { accessCode } : {}) }; + } + + async claimStaffAccess(slug: string, accessCode: string) { + return this.mutate((stableRequestId) => + this.client.rpc('claim_staff_access', { + queue_slug: slug, + access_code: accessCode, + request_id: stableRequestId, + }), + ); + } + + async joinQueue(slug: string, displayName?: string) { + return this.mutate((stableRequestId) => + this.client.rpc('join_queue', { + queue_slug: slug, + display_name: displayName ?? '', + request_id: stableRequestId, + }), + ); + } + + async callNext(queueId: string) { + return this.mutate((stableRequestId) => + this.client.rpc('call_next', { + queue_id: queueId, + request_id: stableRequestId, + }), + ); + } + + async completeCurrent(queueId: string) { + return this.mutate((stableRequestId) => + this.client.rpc('complete_active', { + queue_id: queueId, + request_id: stableRequestId, + }), + ); + } + + async skipEntry(queueId: string, entryId: string) { + return this.mutate((stableRequestId) => + this.client.rpc('skip_entry', { + queue_id: queueId, + entry_id: entryId, + request_id: stableRequestId, + }), + ); + } + + async pauseQueue(queueId: string) { + return this.mutate((stableRequestId) => + this.client.rpc('pause_queue', { + queue_id: queueId, + request_id: stableRequestId, + }), + ); + } + + async reopenQueue(queueId: string) { + return this.mutate((stableRequestId) => + this.client.rpc('reopen_queue', { + queue_id: queueId, + request_id: stableRequestId, + }), + ); + } + + async closeQueue(queueId: string) { + return this.mutate((stableRequestId) => + this.client.rpc('close_queue', { + queue_id: queueId, + request_id: stableRequestId, + }), + ); + } + + async subscribe(slug: string, callbacks: QueueSubscriptionCallbacks) { + callbacks.onConnectionState(navigator.onLine ? 'connecting' : 'offline'); + const preliminary = await this.getSnapshot(slug); + const gate = new RevisionGate(); + let stopped = false; + let timer: ReturnType | undefined; + let refreshInFlight: Promise | undefined; + let refreshQueued = false; + let hiddenAt = 0; + + const refresh = (reason: string, force = false): Promise => { + if (stopped) return Promise.resolve(); + if (refreshInFlight) { + refreshQueued = true; + return refreshInFlight; + } + refreshInFlight = this.getSnapshot(slug) + .then((snapshot) => { + if (gate.accept(snapshot, force)) callbacks.onSnapshot(snapshot); + if (process.env.NODE_ENV === 'development') { + console.debug('[queue-sync]', { + reason, + revision: snapshot.queue.revision, + }); + } + }) + .catch((error: unknown) => + callbacks.onError( + error instanceof Error + ? error + : new Error('Snapshot refresh failed.'), + ), + ) + .finally(() => { + refreshInFlight = undefined; + if (refreshQueued) { + refreshQueued = false; + void refresh('queued-invalidation'); + } + }); + return refreshInFlight; + }; + + const invalidate = () => { + if (timer) clearTimeout(timer); + timer = setTimeout(() => void refresh('postgres-change'), 75); + }; + + const channel: RealtimeChannel = this.client + .channel(`queue:${preliminary.queue.id}:changes`) + .on( + 'postgres_changes', + { + event: '*', + schema: 'public', + table: 'queues', + filter: `id=eq.${preliminary.queue.id}`, + }, + invalidate, + ) + .on( + 'postgres_changes', + { + event: '*', + schema: 'public', + table: 'queue_entries', + filter: `queue_id=eq.${preliminary.queue.id}`, + }, + invalidate, + ) + .subscribe((status) => { + if (process.env.NODE_ENV === 'development') + console.debug('[queue-sync]', { status }); + if (status === 'SUBSCRIBED') { + callbacks.onConnectionState('connected'); + void refresh('subscribed', true); + } else if (status === 'CHANNEL_ERROR' || status === 'TIMED_OUT') { + callbacks.onConnectionState( + navigator.onLine ? 'reconnecting' : 'offline', + ); + } else if (status === 'CLOSED' && !stopped) { + callbacks.onConnectionState( + navigator.onLine ? 'reconnecting' : 'offline', + ); + } + }); + + const online = () => { + callbacks.onConnectionState('reconnecting'); + void refresh('browser-online', true); + }; + const offline = () => callbacks.onConnectionState('offline'); + const visibility = () => { + if (document.hidden) hiddenAt = Date.now(); + else if (hiddenAt && Date.now() - hiddenAt > 5_000) + void refresh('visibility-return', true); + }; + window.addEventListener('online', online); + window.addEventListener('offline', offline); + document.addEventListener('visibilitychange', visibility); + + return async () => { + stopped = true; + if (timer) clearTimeout(timer); + window.removeEventListener('online', online); + window.removeEventListener('offline', offline); + document.removeEventListener('visibilitychange', visibility); + await this.client.removeChannel(channel); + }; + } +} diff --git a/src/lib/supabase/client.ts b/src/lib/supabase/client.ts new file mode 100644 index 0000000..6073ce4 --- /dev/null +++ b/src/lib/supabase/client.ts @@ -0,0 +1,25 @@ +import { createBrowserClient } from '@supabase/ssr'; +import type { SupabaseClient } from '@supabase/supabase-js'; +import type { Database } from './database.types'; + +let browserClient: SupabaseClient | undefined; + +function publicConfiguration() { + const url = process.env.NEXT_PUBLIC_SUPABASE_URL; + const publishableKey = process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY; + + if (!url || !publishableKey) { + throw new Error( + 'Supabase is not configured. Copy .env.example to .env.local and set the public project URL and publishable key.', + ); + } + + return { url, publishableKey }; +} + +export function createSupabaseBrowserClient(): SupabaseClient { + if (browserClient) return browserClient; + const { url, publishableKey } = publicConfiguration(); + browserClient = createBrowserClient(url, publishableKey); + return browserClient; +} diff --git a/src/lib/supabase/database.types.ts b/src/lib/supabase/database.types.ts new file mode 100644 index 0000000..b24a02d --- /dev/null +++ b/src/lib/supabase/database.types.ts @@ -0,0 +1,512 @@ +export type Json = + | string + | number + | boolean + | null + | { [key: string]: Json | undefined } + | Json[]; + +export type Database = { + public: { + Tables: { + queue_commands: { + Row: { + actor_user_id: string; + command_type: string; + created_at: string; + queue_id: string | null; + request_id: string; + }; + Insert: { + actor_user_id: string; + command_type: string; + created_at?: string; + queue_id?: string | null; + request_id: string; + }; + Update: { + actor_user_id?: string; + command_type?: string; + created_at?: string; + queue_id?: string | null; + request_id?: string; + }; + Relationships: [ + { + foreignKeyName: 'queue_commands_queue_id_fkey'; + columns: ['queue_id']; + isOneToOne: false; + referencedRelation: 'queues'; + referencedColumns: ['id']; + }, + ]; + }; + queue_entries: { + Row: { + called_at: string | null; + completed_at: string | null; + id: string; + joined_at: string; + number_label: string; + queue_id: string; + revision: number; + sequence: number; + skipped_at: string | null; + status: Database['public']['Enums']['queue_entry_status']; + updated_at: string; + }; + Insert: { + called_at?: string | null; + completed_at?: string | null; + id?: string; + joined_at?: string; + number_label: string; + queue_id: string; + revision: number; + sequence: number; + skipped_at?: string | null; + status?: Database['public']['Enums']['queue_entry_status']; + updated_at?: string; + }; + Update: { + called_at?: string | null; + completed_at?: string | null; + id?: string; + joined_at?: string; + number_label?: string; + queue_id?: string; + revision?: number; + sequence?: number; + skipped_at?: string | null; + status?: Database['public']['Enums']['queue_entry_status']; + updated_at?: string; + }; + Relationships: [ + { + foreignKeyName: 'queue_entries_queue_id_fkey'; + columns: ['queue_id']; + isOneToOne: false; + referencedRelation: 'queues'; + referencedColumns: ['id']; + }, + ]; + }; + queue_entry_private: { + Row: { + created_at: string; + customer_user_id: string; + display_name: string | null; + entry_id: string; + queue_id: string; + }; + Insert: { + created_at?: string; + customer_user_id: string; + display_name?: string | null; + entry_id: string; + queue_id: string; + }; + Update: { + created_at?: string; + customer_user_id?: string; + display_name?: string | null; + entry_id?: string; + queue_id?: string; + }; + Relationships: [ + { + foreignKeyName: 'queue_entry_private_entry_id_fkey'; + columns: ['entry_id']; + isOneToOne: true; + referencedRelation: 'queue_entries'; + referencedColumns: ['id']; + }, + { + foreignKeyName: 'queue_entry_private_queue_id_fkey'; + columns: ['queue_id']; + isOneToOne: false; + referencedRelation: 'queues'; + referencedColumns: ['id']; + }, + ]; + }; + queue_events: { + Row: { + actor_user_id: string | null; + entry_id: string | null; + event_type: Database['public']['Enums']['queue_event_type']; + id: number; + occurred_at: string; + queue_id: string; + queue_revision: number; + request_id: string; + }; + Insert: { + actor_user_id?: string | null; + entry_id?: string | null; + event_type: Database['public']['Enums']['queue_event_type']; + id?: never; + occurred_at?: string; + queue_id: string; + queue_revision: number; + request_id: string; + }; + Update: { + actor_user_id?: string | null; + entry_id?: string | null; + event_type?: Database['public']['Enums']['queue_event_type']; + id?: never; + occurred_at?: string; + queue_id?: string; + queue_revision?: number; + request_id?: string; + }; + Relationships: [ + { + foreignKeyName: 'queue_events_entry_id_fkey'; + columns: ['entry_id']; + isOneToOne: false; + referencedRelation: 'queue_entries'; + referencedColumns: ['id']; + }, + { + foreignKeyName: 'queue_events_queue_id_fkey'; + columns: ['queue_id']; + isOneToOne: false; + referencedRelation: 'queues'; + referencedColumns: ['id']; + }, + { + foreignKeyName: 'queue_events_request_id_fkey'; + columns: ['request_id']; + isOneToOne: true; + referencedRelation: 'queue_commands'; + referencedColumns: ['request_id']; + }, + ]; + }; + queue_staff_access: { + Row: { + code_hash: string; + queue_id: string; + updated_at: string; + }; + Insert: { + code_hash: string; + queue_id: string; + updated_at?: string; + }; + Update: { + code_hash?: string; + queue_id?: string; + updated_at?: string; + }; + Relationships: [ + { + foreignKeyName: 'queue_staff_access_queue_id_fkey'; + columns: ['queue_id']; + isOneToOne: true; + referencedRelation: 'queues'; + referencedColumns: ['id']; + }, + ]; + }; + queue_staff_access_attempts: { + Row: { + attempt_count: number; + blocked_until: string | null; + queue_id: string; + updated_at: string; + user_id: string; + window_started_at: string; + }; + Insert: { + attempt_count?: number; + blocked_until?: string | null; + queue_id: string; + updated_at?: string; + user_id: string; + window_started_at?: string; + }; + Update: { + attempt_count?: number; + blocked_until?: string | null; + queue_id?: string; + updated_at?: string; + user_id?: string; + window_started_at?: string; + }; + Relationships: [ + { + foreignKeyName: 'queue_staff_access_attempts_queue_id_fkey'; + columns: ['queue_id']; + isOneToOne: false; + referencedRelation: 'queues'; + referencedColumns: ['id']; + }, + ]; + }; + queue_staff_memberships: { + Row: { + created_at: string; + queue_id: string; + user_id: string; + }; + Insert: { + created_at?: string; + queue_id: string; + user_id: string; + }; + Update: { + created_at?: string; + queue_id?: string; + user_id?: string; + }; + Relationships: [ + { + foreignKeyName: 'queue_staff_memberships_queue_id_fkey'; + columns: ['queue_id']; + isOneToOne: false; + referencedRelation: 'queues'; + referencedColumns: ['id']; + }, + ]; + }; + queues: { + Row: { + created_at: string; + created_by: string; + id: string; + name: string; + next_sequence: number; + prefix: string; + revision: number; + slug: string; + status: Database['public']['Enums']['queue_status']; + updated_at: string; + }; + Insert: { + created_at?: string; + created_by: string; + id?: string; + name: string; + next_sequence?: number; + prefix: string; + revision?: number; + slug: string; + status?: Database['public']['Enums']['queue_status']; + updated_at?: string; + }; + Update: { + created_at?: string; + created_by?: string; + id?: string; + name?: string; + next_sequence?: number; + prefix?: string; + revision?: number; + slug?: string; + status?: Database['public']['Enums']['queue_status']; + updated_at?: string; + }; + Relationships: []; + }; + }; + Views: { + [_ in never]: never; + }; + Functions: { + call_next: { + Args: { queue_id: string; request_id: string }; + Returns: Json; + }; + claim_staff_access: { + Args: { access_code: string; queue_slug: string; request_id: string }; + Returns: Json; + }; + close_queue: { + Args: { queue_id: string; request_id: string }; + Returns: Json; + }; + complete_active: { + Args: { queue_id: string; request_id: string }; + Returns: Json; + }; + create_queue: { + Args: { queue_name: string; queue_prefix: string; request_id: string }; + Returns: Json; + }; + get_queue_snapshot: { Args: { queue_slug: string }; Returns: Json }; + join_queue: { + Args: { display_name: string; queue_slug: string; request_id: string }; + Returns: Json; + }; + pause_queue: { + Args: { queue_id: string; request_id: string }; + Returns: Json; + }; + reopen_queue: { + Args: { queue_id: string; request_id: string }; + Returns: Json; + }; + skip_entry: { + Args: { entry_id: string; queue_id: string; request_id: string }; + Returns: Json; + }; + }; + Enums: { + queue_entry_status: 'WAITING' | 'SERVING' | 'COMPLETED' | 'SKIPPED'; + queue_event_type: + | 'QUEUE_CREATED' + | 'STAFF_ACCESS_CLAIMED' + | 'CUSTOMER_JOINED' + | 'CUSTOMER_CALLED' + | 'CUSTOMER_COMPLETED' + | 'CUSTOMER_SKIPPED' + | 'QUEUE_PAUSED' + | 'QUEUE_REOPENED' + | 'QUEUE_CLOSED'; + queue_status: 'OPEN' | 'PAUSED' | 'CLOSED'; + }; + CompositeTypes: { + [_ in never]: never; + }; + }; +}; + +type DatabaseWithoutInternals = Omit; + +type DefaultSchema = DatabaseWithoutInternals[Extract< + keyof Database, + 'public' +>]; + +export type Tables< + DefaultSchemaTableNameOrOptions extends + | keyof (DefaultSchema['Tables'] & DefaultSchema['Views']) + | { schema: keyof DatabaseWithoutInternals }, + TableName extends (DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals; + } + ? keyof (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions['schema']]['Tables'] & + DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions['schema']]['Views']) + : never) = never, +> = DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals; +} + ? (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions['schema']]['Tables'] & + DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions['schema']]['Views'])[TableName] extends { + Row: infer R; + } + ? R + : never + : DefaultSchemaTableNameOrOptions extends keyof (DefaultSchema['Tables'] & + DefaultSchema['Views']) + ? (DefaultSchema['Tables'] & + DefaultSchema['Views'])[DefaultSchemaTableNameOrOptions] extends { + Row: infer R; + } + ? R + : never + : never; + +export type TablesInsert< + DefaultSchemaTableNameOrOptions extends + keyof DefaultSchema['Tables'] | { schema: keyof DatabaseWithoutInternals }, + TableName extends (DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals; + } + ? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions['schema']]['Tables'] + : never) = never, +> = DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals; +} + ? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions['schema']]['Tables'][TableName] extends { + Insert: infer I; + } + ? I + : never + : DefaultSchemaTableNameOrOptions extends keyof DefaultSchema['Tables'] + ? DefaultSchema['Tables'][DefaultSchemaTableNameOrOptions] extends { + Insert: infer I; + } + ? I + : never + : never; + +export type TablesUpdate< + DefaultSchemaTableNameOrOptions extends + keyof DefaultSchema['Tables'] | { schema: keyof DatabaseWithoutInternals }, + TableName extends (DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals; + } + ? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions['schema']]['Tables'] + : never) = never, +> = DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals; +} + ? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions['schema']]['Tables'][TableName] extends { + Update: infer U; + } + ? U + : never + : DefaultSchemaTableNameOrOptions extends keyof DefaultSchema['Tables'] + ? DefaultSchema['Tables'][DefaultSchemaTableNameOrOptions] extends { + Update: infer U; + } + ? U + : never + : never; + +export type Enums< + DefaultSchemaEnumNameOrOptions extends + keyof DefaultSchema['Enums'] | { schema: keyof DatabaseWithoutInternals }, + EnumName extends (DefaultSchemaEnumNameOrOptions extends { + schema: keyof DatabaseWithoutInternals; + } + ? keyof DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions['schema']]['Enums'] + : never) = never, +> = DefaultSchemaEnumNameOrOptions extends { + schema: keyof DatabaseWithoutInternals; +} + ? DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions['schema']]['Enums'][EnumName] + : DefaultSchemaEnumNameOrOptions extends keyof DefaultSchema['Enums'] + ? DefaultSchema['Enums'][DefaultSchemaEnumNameOrOptions] + : never; + +export type CompositeTypes< + PublicCompositeTypeNameOrOptions extends + | keyof DefaultSchema['CompositeTypes'] + | { schema: keyof DatabaseWithoutInternals }, + CompositeTypeName extends (PublicCompositeTypeNameOrOptions extends { + schema: keyof DatabaseWithoutInternals; + } + ? keyof DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions['schema']]['CompositeTypes'] + : never) = never, +> = PublicCompositeTypeNameOrOptions extends { + schema: keyof DatabaseWithoutInternals; +} + ? DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions['schema']]['CompositeTypes'][CompositeTypeName] + : PublicCompositeTypeNameOrOptions extends keyof DefaultSchema['CompositeTypes'] + ? DefaultSchema['CompositeTypes'][PublicCompositeTypeNameOrOptions] + : never; + +export const Constants = { + public: { + Enums: { + queue_entry_status: ['WAITING', 'SERVING', 'COMPLETED', 'SKIPPED'], + queue_event_type: [ + 'QUEUE_CREATED', + 'STAFF_ACCESS_CLAIMED', + 'CUSTOMER_JOINED', + 'CUSTOMER_CALLED', + 'CUSTOMER_COMPLETED', + 'CUSTOMER_SKIPPED', + 'QUEUE_PAUSED', + 'QUEUE_REOPENED', + 'QUEUE_CLOSED', + ], + queue_status: ['OPEN', 'PAUSED', 'CLOSED'], + }, + }, +} as const; diff --git a/src/lib/supabase/proxy.ts b/src/lib/supabase/proxy.ts new file mode 100644 index 0000000..0929e9e --- /dev/null +++ b/src/lib/supabase/proxy.ts @@ -0,0 +1,31 @@ +import { createServerClient } from '@supabase/ssr'; +import { NextResponse, type NextRequest } from 'next/server'; +import type { Database } from './database.types'; + +export async function updateSession(request: NextRequest) { + const url = process.env.NEXT_PUBLIC_SUPABASE_URL; + const publishableKey = process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY; + if (!url || !publishableKey) return NextResponse.next({ request }); + + let response = NextResponse.next({ request }); + const client = createServerClient(url, publishableKey, { + cookies: { + getAll: () => request.cookies.getAll(), + setAll: (cookiesToSet, headers) => { + cookiesToSet.forEach(({ name, value }) => + request.cookies.set(name, value), + ); + response = NextResponse.next({ request }); + cookiesToSet.forEach(({ name, value, options }) => + response.cookies.set(name, value, options), + ); + Object.entries(headers).forEach(([name, value]) => + response.headers.set(name, value), + ); + }, + }, + }); + await client.auth.getClaims(); + response.headers.set('Cache-Control', 'private, no-store'); + return response; +} diff --git a/src/lib/supabase/server.ts b/src/lib/supabase/server.ts new file mode 100644 index 0000000..83e7d82 --- /dev/null +++ b/src/lib/supabase/server.ts @@ -0,0 +1,25 @@ +import { createServerClient } from '@supabase/ssr'; +import { cookies } from 'next/headers'; +import type { Database } from './database.types'; + +export async function createSupabaseServerClient() { + const url = process.env.NEXT_PUBLIC_SUPABASE_URL; + const publishableKey = process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY; + if (!url || !publishableKey) + throw new Error('Supabase public configuration is missing.'); + const store = await cookies(); + return createServerClient(url, publishableKey, { + cookies: { + getAll: () => store.getAll(), + setAll: (cookiesToSet) => { + try { + cookiesToSet.forEach(({ name, value, options }) => + store.set(name, value, options), + ); + } catch { + // Server Components cannot write cookies; proxy.ts performs refreshes. + } + }, + }, + }); +} diff --git a/src/lib/supabase/session.test.ts b/src/lib/supabase/session.test.ts new file mode 100644 index 0000000..eb4a299 --- /dev/null +++ b/src/lib/supabase/session.test.ts @@ -0,0 +1,55 @@ +import type { SupabaseClient } from '@supabase/supabase-js'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Database } from './database.types'; +import { + AnonymousSessionError, + ensureAnonymousSession, + resetAnonymousSessionForTests, +} from './session'; + +beforeEach(resetAnonymousSessionForTests); + +function client(existing = false) { + const user = { id: '60000000-0000-4000-8000-000000000001' }; + const value = { + auth: { + getSession: vi.fn(async () => ({ + data: { session: existing ? { user } : null }, + error: null, + })), + signInAnonymously: vi.fn(async () => ({ data: { user }, error: null })), + }, + }; + return { value, typed: value as unknown as SupabaseClient }; +} + +describe('ensureAnonymousSession', () => { + it('reuses an existing browser session', async () => { + const fake = client(true); + await expect(ensureAnonymousSession(fake.typed)).resolves.toMatchObject({ + id: expect.any(String), + }); + expect(fake.value.auth.signInAnonymously).not.toHaveBeenCalled(); + }); + + it('deduplicates simultaneous anonymous initialization', async () => { + const fake = client(); + const [first, second] = await Promise.all([ + ensureAnonymousSession(fake.typed), + ensureAnonymousSession(fake.typed), + ]); + expect(first.id).toBe(second.id); + expect(fake.value.auth.signInAnonymously).toHaveBeenCalledTimes(1); + }); + + it('surfaces a typed recoverable initialization error', async () => { + const fake = client(); + fake.value.auth.signInAnonymously.mockResolvedValueOnce({ + data: { user: null } as never, + error: { message: 'failed' } as never, + }); + await expect(ensureAnonymousSession(fake.typed)).rejects.toBeInstanceOf( + AnonymousSessionError, + ); + }); +}); diff --git a/src/lib/supabase/session.ts b/src/lib/supabase/session.ts new file mode 100644 index 0000000..417f2a0 --- /dev/null +++ b/src/lib/supabase/session.ts @@ -0,0 +1,41 @@ +import type { SupabaseClient, User } from '@supabase/supabase-js'; +import type { Database } from './database.types'; +import { createSupabaseBrowserClient } from './client'; + +export class AnonymousSessionError extends Error { + readonly code = 'AUTH_INITIALIZATION_FAILED'; + + constructor(message = 'Your private browser session could not be prepared.') { + super(message); + this.name = 'AnonymousSessionError'; + } +} + +let pendingSession: Promise | undefined; + +export function ensureAnonymousSession( + client: SupabaseClient = createSupabaseBrowserClient(), +): Promise { + if (pendingSession) return pendingSession; + + pendingSession = (async () => { + const { data: sessionData, error: sessionError } = + await client.auth.getSession(); + if (sessionError) throw new AnonymousSessionError(); + if (sessionData.session?.user) return sessionData.session.user; + + const { data, error } = await client.auth.signInAnonymously(); + if (error || !data.user) throw new AnonymousSessionError(); + return data.user; + })().catch((error: unknown) => { + pendingSession = undefined; + if (error instanceof AnonymousSessionError) throw error; + throw new AnonymousSessionError(); + }); + + return pendingSession; +} + +export function resetAnonymousSessionForTests() { + pendingSession = undefined; +} diff --git a/src/proxy.ts b/src/proxy.ts new file mode 100644 index 0000000..3ea88a1 --- /dev/null +++ b/src/proxy.ts @@ -0,0 +1,10 @@ +import type { NextRequest } from 'next/server'; +import { updateSession } from '@/lib/supabase/proxy'; + +export async function proxy(request: NextRequest) { + return updateSession(request); +} + +export const config = { + matcher: ['/demo', '/q/:path*'], +}; diff --git a/supabase/.gitignore b/supabase/.gitignore new file mode 100644 index 0000000..ad9264f --- /dev/null +++ b/supabase/.gitignore @@ -0,0 +1,8 @@ +# Supabase +.branches +.temp + +# dotenvx +.env.keys +.env.local +.env.*.local diff --git a/supabase/config.toml b/supabase/config.toml new file mode 100644 index 0000000..eb7ce84 --- /dev/null +++ b/supabase/config.toml @@ -0,0 +1,414 @@ +# For detailed configuration reference documentation, visit: +# https://supabase.com/docs/guides/local-development/cli/config +# A string used to distinguish different Supabase projects on the same host. Defaults to the +# working directory name when running `supabase init`. +project_id = "next-queue" + +[api] +enabled = true +# Port to use for the API URL. +port = 54321 +# Schemas to expose in your API. Tables, views and stored procedures in this schema will get API +# endpoints. `public` and `graphql_public` schemas are included by default. +schemas = ["public", "graphql_public"] +# Extra schemas to add to the search_path of every request. +extra_search_path = ["public", "extensions"] +# The maximum number of rows returns from a view, table, or stored procedure. Limits payload size +# for accidental or malicious requests. +max_rows = 1000 +# Controls whether new tables, views, sequences and functions created in the `public` schema by +# `postgres` are reachable through the Data API roles (`anon`, `authenticated`, `service_role`) +# without explicit GRANTs. When unset, new entities are NOT auto-exposed, matching the new cloud +# default. Set to `true` to keep the legacy behaviour of auto-exposing new entities; this is +# deprecated and the field is removed on 2026-10-30 once the always-revoked behaviour is permanent. +# auto_expose_new_tables = true + +[api.tls] +# Enable HTTPS endpoints locally using a self-signed certificate. +enabled = false +# Paths to self-signed certificate pair. +# cert_path = "../certs/my-cert.pem" +# key_path = "../certs/my-key.pem" + +[db] +# Port to use for the local database URL. +port = 54322 +# Port used by db diff command to initialize the shadow database. +shadow_port = 54320 +# Maximum amount of time to wait for health check when starting the local database. +health_timeout = "2m" +# The database major version to use. This has to be the same as your remote database's. Run `SHOW +# server_version;` on the remote database to check. +major_version = 17 + +[db.pooler] +enabled = false +# Port to use for the local connection pooler. +port = 54329 +# Specifies when a server connection can be reused by other clients. +# Configure one of the supported pooler modes: `transaction`, `session`. +pool_mode = "transaction" +# How many server connections to allow per user/database pair. +default_pool_size = 20 +# Maximum number of client connections allowed. +max_client_conn = 100 + +# [db.vault] +# secret_key = "env(SECRET_VALUE)" + +[db.migrations] +# If disabled, migrations will be skipped during a db push or reset. +enabled = true +# Specifies an ordered list of schema files, directories, or glob patterns that describe your database. +# Supports paths relative to supabase directory: "./schemas/*.sql", "./database". +schema_paths = [] + +[db.seed] +# If enabled, seeds the database after migrations during a db reset. +enabled = true +# Specifies an ordered list of seed files to load during db reset. +# Supports glob patterns relative to supabase directory: "./seeds/*.sql" +sql_paths = ["./seed.sql"] + +[db.network_restrictions] +# Enable management of network restrictions. +enabled = false +# List of IPv4 CIDR blocks allowed to connect to the database. +# Defaults to allow all IPv4 connections. Set empty array to block all IPs. +allowed_cidrs = ["0.0.0.0/0"] +# List of IPv6 CIDR blocks allowed to connect to the database. +# Defaults to allow all IPv6 connections. Set empty array to block all IPs. +allowed_cidrs_v6 = ["::/0"] + +# Uncomment to reject non-secure connections to the database. +# [db.ssl_enforcement] +# enabled = true + +[realtime] +enabled = true +# Bind realtime via either IPv4 or IPv6. (default: IPv4) +# ip_version = "IPv6" +# The maximum length in bytes of HTTP request headers. (default: 4096) +# max_header_length = 4096 + +[studio] +enabled = true +# Port to use for Supabase Studio. +port = 54323 +# External URL of the API server that frontend connects to. +api_url = "http://127.0.0.1" +# OpenAI API Key to use for Supabase AI in the Supabase Studio. +openai_api_key = "env(OPENAI_API_KEY)" + +# Email testing server. Emails sent with the local dev setup are not actually sent - rather, they +# are monitored, and you can view the emails that would have been sent from the web interface. +[local_smtp] +enabled = true +# Port to use for the email testing server web interface. +port = 54324 +# Uncomment to expose additional ports for testing user applications that send emails. +# smtp_port = 54325 +# pop3_port = 54326 +# admin_email = "admin@email.com" +# sender_name = "Admin" + +[storage] +enabled = true +# The maximum file size allowed (e.g. "5MB", "500KB"). +file_size_limit = "50MiB" + +# Uncomment to configure local storage buckets +# [storage.buckets.images] +# public = false +# file_size_limit = "50MiB" +# allowed_mime_types = ["image/png", "image/jpeg"] +# objects_path = "./images" + +# Allow connections via S3 compatible clients +[storage.s3_protocol] +enabled = true + +# Image transformation API is available to Supabase Pro plan. +# [storage.image_transformation] +# enabled = true + +# Store analytical data in S3 for running ETL jobs over Iceberg Catalog +# This feature is only available on the hosted platform. +[storage.analytics] +enabled = false +max_namespaces = 5 +max_tables = 10 +max_catalogs = 2 + +# Analytics Buckets is available to Supabase Pro plan. +# [storage.analytics.buckets.my-warehouse] + +# Store vector embeddings in S3 for large and durable datasets +[storage.vector] +enabled = true +max_buckets = 10 +max_indexes = 5 + +# Vector Buckets is available to Supabase Pro plan. +# [storage.vector.buckets.documents-openai] + +[auth] +enabled = true +# The base URL of your website. Used as an allow-list for redirects and for constructing URLs used +# in emails. +site_url = "http://127.0.0.1:3000" +# The public URL that Auth serves on. Defaults to the API external URL with `/auth/v1` appended. +# external_url = "" +# A list of *exact* URLs that auth providers are permitted to redirect to post authentication. +additional_redirect_urls = ["https://127.0.0.1:3000"] +# How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 (1 week). +jwt_expiry = 3600 +# JWT issuer URL. If not set, defaults to auth.external_url. +# jwt_issuer = "" +# Path to JWT signing key. DO NOT commit your signing keys file to git. +# signing_keys_path = "./signing_keys.json" +# If disabled, the refresh token will never expire. +enable_refresh_token_rotation = true +# Allows refresh tokens to be reused after expiry, up to the specified interval in seconds. +# Requires enable_refresh_token_rotation = true. +refresh_token_reuse_interval = 10 +# Allow/disallow new user signups to your project. +enable_signup = true +# Allow/disallow anonymous sign-ins to your project. +enable_anonymous_sign_ins = true +# Allow/disallow testing manual linking of accounts +enable_manual_linking = false +# Passwords shorter than this value will be rejected as weak. Minimum 6, recommended 8 or more. +minimum_password_length = 6 +# Passwords that do not meet the following requirements will be rejected as weak. Supported values +# are: `letters_digits`, `lower_upper_letters_digits`, `lower_upper_letters_digits_symbols` +password_requirements = "" + +# Configure passkey sign-ins. +# [auth.passkey] +# enabled = false + +# Configure WebAuthn relying party settings (required when passkey is enabled). +# [auth.webauthn] +# rp_display_name = "Supabase" +# rp_id = "localhost" +# rp_origins = ["http://127.0.0.1:3000"] + +[auth.rate_limit] +# Number of emails that can be sent per hour. Requires auth.email.smtp to be enabled. +email_sent = 2 +# Number of SMS messages that can be sent per hour. Requires auth.sms to be enabled. +sms_sent = 30 +# Number of anonymous sign-ins that can be made per hour per IP address. Requires enable_anonymous_sign_ins = true. +anonymous_users = 30 +# Number of sessions that can be refreshed in a 5 minute interval per IP address. +token_refresh = 150 +# Number of sign up and sign-in requests that can be made in a 5 minute interval per IP address (excludes anonymous users). +sign_in_sign_ups = 30 +# Number of OTP / Magic link verifications that can be made in a 5 minute interval per IP address. +token_verifications = 30 +# Number of Web3 logins that can be made in a 5 minute interval per IP address. +web3 = 30 + +# Configure one of the supported captcha providers: `hcaptcha`, `turnstile`. +# [auth.captcha] +# enabled = true +# provider = "hcaptcha" +# secret = "" + +[auth.email] +# Allow/disallow new user signups via email to your project. +enable_signup = true +# If enabled, a user will be required to confirm any email change on both the old, and new email +# addresses. If disabled, only the new email is required to confirm. +double_confirm_changes = true +# If enabled, users need to confirm their email address before signing in. +enable_confirmations = false +# If enabled, users will need to reauthenticate or have logged in recently to change their password. +secure_password_change = false +# Controls the minimum amount of time that must pass before sending another signup confirmation or password reset email. +max_frequency = "1s" +# Number of characters used in the email OTP. +otp_length = 6 +# Number of seconds before the email OTP expires (defaults to 1 hour). +otp_expiry = 3600 + +# Use a production-ready SMTP server +# [auth.email.smtp] +# enabled = true +# host = "smtp.sendgrid.net" +# port = 587 +# user = "apikey" +# pass = "env(SENDGRID_API_KEY)" +# admin_email = "admin@email.com" +# sender_name = "Admin" + +# Uncomment to customize email template +# [auth.email.template.invite] +# subject = "You have been invited" +# content_path = "./supabase/templates/invite.html" + +# Uncomment to customize notification email template +# [auth.email.notification.password_changed] +# enabled = true +# subject = "Your password has been changed" +# content_path = "./templates/password_changed_notification.html" + +[auth.sms] +# Allow/disallow new user signups via SMS to your project. +enable_signup = false +# If enabled, users need to confirm their phone number before signing in. +enable_confirmations = false +# Template for sending OTP to users +template = "Your code is {{ `{{ .Code }}` }}" +# Controls the minimum amount of time that must pass before sending another sms otp. +max_frequency = "5s" + +# Use pre-defined map of phone number to OTP for testing. +# [auth.sms.test_otp] +# 4152127777 = "123456" + +# Configure logged in session timeouts. +# [auth.sessions] +# Force log out after the specified duration. +# timebox = "24h" +# Force log out if the user has been inactive longer than the specified duration. +# inactivity_timeout = "8h" + +# This hook runs before a new user is created and allows developers to reject the request based on the incoming user object. +# [auth.hook.before_user_created] +# enabled = true +# uri = "pg-functions://postgres/auth/before-user-created-hook" + +# This hook runs before a token is issued and allows you to add additional claims based on the authentication method used. +# [auth.hook.custom_access_token] +# enabled = true +# uri = "pg-functions:////" + +# Configure one of the supported SMS providers: `twilio`, `twilio_verify`, `messagebird`, `textlocal`, `vonage`. +[auth.sms.twilio] +enabled = false +account_sid = "" +message_service_sid = "" +# DO NOT commit your Twilio auth token to git. Use environment variable substitution instead: +auth_token = "env(SUPABASE_AUTH_SMS_TWILIO_AUTH_TOKEN)" + +# Multi-factor-authentication is available to Supabase Pro plan. +[auth.mfa] +# Control how many MFA factors can be enrolled at once per user. +max_enrolled_factors = 10 + +# Control MFA via App Authenticator (TOTP) +[auth.mfa.totp] +enroll_enabled = false +verify_enabled = false + +# Configure MFA via Phone Messaging +[auth.mfa.phone] +enroll_enabled = false +verify_enabled = false +otp_length = 6 +template = "Your code is {{ `{{ .Code }}` }}" +max_frequency = "5s" + +# Configure MFA via WebAuthn +# [auth.mfa.web_authn] +# enroll_enabled = true +# verify_enabled = true + +# Use an external OAuth provider. The full list of providers are: `apple`, `azure`, `bitbucket`, +# `discord`, `facebook`, `github`, `gitlab`, `google`, `keycloak`, `linkedin_oidc`, `notion`, `twitch`, +# `twitter`, `x`, `slack`, `spotify`, `workos`, `zoom`. +[auth.external.apple] +enabled = false +client_id = "" +# DO NOT commit your OAuth provider secret to git. Use environment variable substitution instead: +secret = "env(SUPABASE_AUTH_EXTERNAL_APPLE_SECRET)" +# Overrides the default auth callback URL derived from auth.external_url. +redirect_uri = "" +# Overrides the default auth provider URL. Used to support self-hosted gitlab, single-tenant Azure, +# or any other third-party OIDC providers. +url = "" +# If enabled, the nonce check will be skipped. Required for local sign in with Google auth. +skip_nonce_check = false +# If enabled, it will allow the user to successfully authenticate when the provider does not return an email address. +email_optional = false + +# Allow Solana wallet holders to sign in to your project via the Sign in with Solana (SIWS, EIP-4361) standard. +# You can configure "web3" rate limit in the [auth.rate_limit] section and set up [auth.captcha] if self-hosting. +[auth.web3.solana] +enabled = false + +# Use Firebase Auth as a third-party provider alongside Supabase Auth. +[auth.third_party.firebase] +enabled = false +# project_id = "my-firebase-project" + +# Use Auth0 as a third-party provider alongside Supabase Auth. +[auth.third_party.auth0] +enabled = false +# tenant = "my-auth0-tenant" +# tenant_region = "us" + +# Use AWS Cognito (Amplify) as a third-party provider alongside Supabase Auth. +[auth.third_party.aws_cognito] +enabled = false +# user_pool_id = "my-user-pool-id" +# user_pool_region = "us-east-1" + +# Use Clerk as a third-party provider alongside Supabase Auth. +[auth.third_party.clerk] +enabled = false +# Obtain from https://clerk.com/setup/supabase +# domain = "example.clerk.accounts.dev" + +# OAuth server configuration +[auth.oauth_server] +# Enable OAuth server functionality +enabled = false +# Path for OAuth consent flow UI +authorization_url_path = "/oauth/consent" +# Allow dynamic client registration +allow_dynamic_registration = false + +[edge_runtime] +enabled = true +# Supported request policies: `oneshot`, `per_worker`. +# `per_worker` (default) — enables hot reload during local development. +# `oneshot` — fallback mode if hot reload causes issues (e.g. in large repos or with symlinks). +policy = "per_worker" +# Port to attach the Chrome inspector for debugging edge functions. +inspector_port = 8083 +# The Deno major version to use. +deno_version = 2 + +# [edge_runtime.secrets] +# secret_key = "env(SECRET_VALUE)" + +[analytics] +enabled = true +port = 54327 +# Configure one of the supported backends: `postgres`, `bigquery`. +backend = "postgres" + +# Experimental features may be deprecated any time +[experimental] +# Configures Postgres storage engine to use OrioleDB (S3) +orioledb_version = "" +# Configures S3 bucket URL, eg. .s3-.amazonaws.com +s3_host = "env(S3_HOST)" +# Configures S3 bucket region, eg. us-east-1 +s3_region = "env(S3_REGION)" +# Configures AWS_ACCESS_KEY_ID for S3 bucket +s3_access_key = "env(S3_ACCESS_KEY)" +# Configures AWS_SECRET_ACCESS_KEY for S3 bucket +s3_secret_key = "env(S3_SECRET_KEY)" + +# pg-delta is the schema diff engine for db diff / db pull / db remote commit. +# Set enabled = false to fall back to the legacy migra engine. +[experimental.pgdelta] +enabled = true +# Directory under `supabase/` where declarative files are written. +# declarative_schema_path = "./database" +# JSON string passed through to pg-delta SQL formatting. +# format_options = "{\"keywordCase\":\"upper\",\"indent\":2,\"maxWidth\":80,\"commaStyle\":\"trailing\"}" diff --git a/supabase/migrations/20260717190000_queue_schema.sql b/supabase/migrations/20260717190000_queue_schema.sql new file mode 100644 index 0000000..5936c4e --- /dev/null +++ b/supabase/migrations/20260717190000_queue_schema.sql @@ -0,0 +1,300 @@ +create extension if not exists pgcrypto with schema extensions; + +create type public.queue_status as enum ('OPEN', 'PAUSED', 'CLOSED'); +create type public.queue_entry_status as enum ('WAITING', 'SERVING', 'COMPLETED', 'SKIPPED'); +create type public.queue_event_type as enum ( + 'QUEUE_CREATED', + 'STAFF_ACCESS_CLAIMED', + 'CUSTOMER_JOINED', + 'CUSTOMER_CALLED', + 'CUSTOMER_COMPLETED', + 'CUSTOMER_SKIPPED', + 'QUEUE_PAUSED', + 'QUEUE_REOPENED', + 'QUEUE_CLOSED' +); + +create table public.queues ( + id uuid primary key default gen_random_uuid(), + slug text not null unique, + name text not null, + prefix text not null, + status public.queue_status not null default 'OPEN', + next_sequence integer not null default 1, + revision bigint not null default 0, + created_by uuid not null references auth.users(id) on delete restrict, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + constraint queues_slug_format check (slug ~ '^[a-z0-9]+(?:-[a-z0-9]+)*$' and length(slug) between 3 and 80), + constraint queues_name_length check (length(btrim(name)) between 2 and 80 and name = btrim(name)), + constraint queues_prefix_format check (prefix ~ '^[A-Z]{1,3}$'), + constraint queues_next_sequence_positive check (next_sequence >= 1), + constraint queues_revision_nonnegative check (revision >= 0) +); + +create table public.queue_entries ( + id uuid primary key default gen_random_uuid(), + queue_id uuid not null references public.queues(id) on delete restrict, + sequence integer not null, + number_label text not null, + status public.queue_entry_status not null default 'WAITING', + joined_at timestamptz not null default now(), + called_at timestamptz, + completed_at timestamptz, + skipped_at timestamptz, + updated_at timestamptz not null default now(), + revision bigint not null, + constraint queue_entries_sequence_positive check (sequence >= 1), + constraint queue_entries_revision_nonnegative check (revision >= 0), + constraint queue_entries_label_format check (number_label ~ '^[A-Z]{1,3}-[0-9]{3,}$'), + constraint queue_entries_queue_sequence_unique unique (queue_id, sequence), + constraint queue_entries_queue_label_unique unique (queue_id, number_label), + constraint queue_entries_timestamp_state check ( + (status = 'WAITING' and called_at is null and completed_at is null and skipped_at is null) + or (status = 'SERVING' and called_at is not null and completed_at is null and skipped_at is null) + or (status = 'COMPLETED' and called_at is not null and completed_at is not null and skipped_at is null) + or (status = 'SKIPPED' and skipped_at is not null and completed_at is null) + ) +); + +create table public.queue_entry_private ( + entry_id uuid primary key references public.queue_entries(id) on delete cascade, + queue_id uuid not null references public.queues(id) on delete restrict, + customer_user_id uuid not null references auth.users(id) on delete restrict, + display_name text, + created_at timestamptz not null default now(), + constraint queue_entry_private_name check ( + display_name is null or (display_name = btrim(display_name) and length(display_name) between 1 and 30) + ) +); + +create table public.queue_staff_memberships ( + queue_id uuid not null references public.queues(id) on delete restrict, + user_id uuid not null references auth.users(id) on delete restrict, + created_at timestamptz not null default now(), + primary key (queue_id, user_id) +); + +create table public.queue_staff_access ( + queue_id uuid primary key references public.queues(id) on delete restrict, + code_hash text not null, + updated_at timestamptz not null default now(), + constraint queue_staff_access_hash_not_blank check (length(code_hash) >= 20) +); + +create table public.queue_staff_access_attempts ( + queue_id uuid not null references public.queues(id) on delete cascade, + user_id uuid not null references auth.users(id) on delete cascade, + window_started_at timestamptz not null default now(), + attempt_count integer not null default 0, + blocked_until timestamptz, + updated_at timestamptz not null default now(), + primary key (queue_id, user_id), + constraint queue_staff_attempt_count check (attempt_count >= 0) +); + +create table public.queue_commands ( + request_id uuid primary key, + queue_id uuid references public.queues(id) on delete restrict, + actor_user_id uuid not null references auth.users(id) on delete restrict, + command_type text not null, + created_at timestamptz not null default now(), + constraint queue_commands_type check (command_type in ( + 'CREATE_QUEUE', 'CLAIM_STAFF_ACCESS', 'JOIN_QUEUE', 'CALL_NEXT', + 'COMPLETE_ACTIVE', 'SKIP_ENTRY', 'PAUSE_QUEUE', 'REOPEN_QUEUE', 'CLOSE_QUEUE' + )) +); + +create table public.queue_events ( + id bigint generated always as identity primary key, + queue_id uuid not null references public.queues(id) on delete restrict, + entry_id uuid references public.queue_entries(id) on delete restrict, + event_type public.queue_event_type not null, + queue_revision bigint not null, + actor_user_id uuid references auth.users(id) on delete set null, + request_id uuid not null unique references public.queue_commands(request_id) on delete restrict, + occurred_at timestamptz not null default now(), + constraint queue_events_revision_nonnegative check (queue_revision >= 0) +); + +create index queues_slug_idx on public.queues (slug); +create index queue_entries_waiting_idx on public.queue_entries (queue_id, sequence) where status = 'WAITING'; +create index queue_entries_status_idx on public.queue_entries (queue_id, status); +create unique index queue_entries_one_serving_idx on public.queue_entries (queue_id) where status = 'SERVING'; +create index queue_entry_private_customer_idx on public.queue_entry_private (customer_user_id, queue_id); +create index queue_entry_private_queue_idx on public.queue_entry_private (queue_id); +create index queue_staff_memberships_user_idx on public.queue_staff_memberships (user_id, queue_id); +create index queue_staff_attempts_user_idx on public.queue_staff_access_attempts (user_id, queue_id); +create index queue_commands_queue_idx on public.queue_commands (queue_id, created_at desc); +create unique index queue_events_queue_revision_idx on public.queue_events (queue_id, queue_revision); + +alter table public.queues enable row level security; +alter table public.queue_entries enable row level security; +alter table public.queue_entry_private enable row level security; +alter table public.queue_staff_memberships enable row level security; +alter table public.queue_staff_access enable row level security; +alter table public.queue_staff_access_attempts enable row level security; +alter table public.queue_commands enable row level security; +alter table public.queue_events enable row level security; + +revoke all on all tables in schema public from anon, authenticated; +revoke all on all sequences in schema public from anon, authenticated; +grant usage on schema public to anon, authenticated; +grant select on public.queues, public.queue_entries to authenticated; +grant select on public.queue_entry_private, public.queue_staff_memberships, public.queue_events to authenticated; + +create policy queues_authenticated_read on public.queues + for select to authenticated using (true); +create policy queue_entries_authenticated_read on public.queue_entries + for select to authenticated using (true); +create policy queue_private_owner_or_staff_read on public.queue_entry_private + for select to authenticated using ( + customer_user_id = (select auth.uid()) + or exists ( + select 1 from public.queue_staff_memberships membership + where membership.queue_id = queue_entry_private.queue_id + and membership.user_id = (select auth.uid()) + ) + ); +create policy queue_staff_memberships_self_read on public.queue_staff_memberships + for select to authenticated using (user_id = (select auth.uid())); +create policy queue_events_staff_read on public.queue_events + for select to authenticated using ( + exists ( + select 1 from public.queue_staff_memberships membership + where membership.queue_id = queue_events.queue_id + and membership.user_id = (select auth.uid()) + ) + ); + +create schema if not exists private; +revoke all on schema private from public, anon, authenticated; + +create or replace function private.require_actor() +returns uuid +language plpgsql +stable +security invoker +set search_path = '' +as $$ +declare + actor uuid := auth.uid(); +begin + if actor is null then + raise exception 'AUTH_REQUIRED' using errcode = 'P0001'; + end if; + return actor; +end; +$$; + +create or replace function private.is_staff(target_queue_id uuid, actor uuid) +returns boolean +language sql +stable +security definer +set search_path = '' +as $$ + select exists ( + select 1 from public.queue_staff_memberships membership + where membership.queue_id = target_queue_id and membership.user_id = actor + ); +$$; + +create or replace function private.error_result(code text, message text) +returns jsonb +language sql +immutable +set search_path = '' +as $$ + select jsonb_build_object('ok', false, 'error', code, 'message', message); +$$; + +create or replace function private.queue_snapshot(target_queue_id uuid, actor uuid) +returns jsonb +language plpgsql +stable +security definer +set search_path = '' +as $$ +declare + result jsonb; + staff boolean := private.is_staff(target_queue_id, actor); + own_entry_id uuid; +begin + select entry.id into own_entry_id + from public.queue_entries entry + join public.queue_entry_private secret on secret.entry_id = entry.id + where entry.queue_id = target_queue_id + and secret.customer_user_id = actor + and entry.status in ('WAITING', 'SERVING') + order by entry.sequence desc + limit 1; + + select jsonb_build_object( + 'ok', true, + 'queue', jsonb_build_object( + 'id', queue.id, + 'slug', queue.slug, + 'name', queue.name, + 'prefix', queue.prefix, + 'status', queue.status, + 'revision', queue.revision, + 'createdAt', queue.created_at, + 'updatedAt', queue.updated_at + ), + 'entries', coalesce(( + select jsonb_agg(jsonb_strip_nulls(jsonb_build_object( + 'id', entry.id, + 'queueId', entry.queue_id, + 'number', entry.sequence, + 'numberLabel', entry.number_label, + 'status', entry.status, + 'displayName', case when staff or entry.id = own_entry_id then secret.display_name else null end, + 'joinedAt', entry.joined_at, + 'calledAt', entry.called_at, + 'completedAt', entry.completed_at, + 'skippedAt', entry.skipped_at, + 'updatedAt', entry.updated_at, + 'revision', entry.revision + )) order by entry.sequence) + from public.queue_entries entry + left join public.queue_entry_private secret on secret.entry_id = entry.id + where entry.queue_id = target_queue_id + and (entry.status in ('WAITING', 'SERVING') or (staff and entry.updated_at > now() - interval '2 hours')) + ), '[]'::jsonb), + 'role', case when staff then 'staff' when own_entry_id is not null then 'customer' else 'public' end, + 'ownEntryId', own_entry_id, + 'waitingCount', (select count(*) from public.queue_entries entry where entry.queue_id = target_queue_id and entry.status = 'WAITING'), + 'serverTime', now() + ) into result + from public.queues queue + where queue.id = target_queue_id; + + return result; +end; +$$; + +create or replace function public.get_queue_snapshot(queue_slug text) +returns jsonb +language plpgsql +stable +security definer +set search_path = '' +as $$ +declare + actor uuid := private.require_actor(); + target_queue_id uuid; +begin + select id into target_queue_id from public.queues where slug = lower(btrim(queue_slug)); + if target_queue_id is null then + return private.error_result('QUEUE_NOT_FOUND', 'This queue could not be found.'); + end if; + return private.queue_snapshot(target_queue_id, actor); +end; +$$; + +revoke all on function public.get_queue_snapshot(text) from public, anon; +grant execute on function public.get_queue_snapshot(text) to authenticated; + +alter publication supabase_realtime add table public.queues; +alter publication supabase_realtime add table public.queue_entries; diff --git a/supabase/migrations/20260717191000_queue_commands.sql b/supabase/migrations/20260717191000_queue_commands.sql new file mode 100644 index 0000000..ad1ba34 --- /dev/null +++ b/supabase/migrations/20260717191000_queue_commands.sql @@ -0,0 +1,366 @@ +create or replace function private.replayed_queue_id( + p_request_id uuid, + p_actor uuid, + p_command_type text +) +returns uuid +language plpgsql +stable +security definer +set search_path = '' +as $$ +declare + receipt public.queue_commands%rowtype; +begin + select * into receipt from public.queue_commands where request_id = p_request_id; + if not found then + return null; + end if; + if receipt.actor_user_id <> p_actor or receipt.command_type <> p_command_type then + raise exception 'REQUEST_ID_MISMATCH' using errcode = 'P0001'; + end if; + return receipt.queue_id; +end; +$$; + +create or replace function public.create_queue( + queue_name text, + queue_prefix text, + request_id uuid +) +returns jsonb +language plpgsql +security definer +set search_path = '' +as $$ +declare + actor uuid := private.require_actor(); + normalized_name text := regexp_replace(btrim(queue_name), '\s+', ' ', 'g'); + normalized_prefix text := upper(btrim(queue_prefix)); + target_queue_id uuid; + target_slug text; + access_code text; + replay_queue_id uuid; + result jsonb; +begin + replay_queue_id := private.replayed_queue_id(request_id, actor, 'CREATE_QUEUE'); + if replay_queue_id is not null then + return private.queue_snapshot(replay_queue_id, actor) || jsonb_build_object('replayed', true, 'accessCode', null); + end if; + if normalized_name is null or length(normalized_name) not between 2 and 80 then + return private.error_result('INVALID_QUEUE_NAME', 'Use a queue name between 2 and 80 characters.'); + end if; + if normalized_prefix !~ '^[A-Z]{1,3}$' then + return private.error_result('INVALID_QUEUE_PREFIX', 'Use one to three letters for the queue prefix.'); + end if; + if (select count(*) from public.queues where created_by = actor and status <> 'CLOSED') >= 3 then + return private.error_result('QUEUE_LIMIT_REACHED', 'This browser already has three active demonstration queues.'); + end if; + + target_queue_id := gen_random_uuid(); + target_slug := trim(both '-' from regexp_replace(lower(normalized_name), '[^a-z0-9]+', '-', 'g')) + || '-' || substring(replace(target_queue_id::text, '-', '') from 1 for 8); + if length(target_slug) > 80 then + target_slug := substring(target_slug from 1 for 71) || '-' || substring(replace(target_queue_id::text, '-', '') from 1 for 8); + end if; + access_code := upper(encode(extensions.gen_random_bytes(18), 'hex')); + + insert into public.queues (id, slug, name, prefix, status, next_sequence, revision, created_by) + values (target_queue_id, target_slug, normalized_name, normalized_prefix, 'OPEN', 1, 1, actor); + insert into public.queue_staff_memberships (queue_id, user_id) values (target_queue_id, actor); + insert into public.queue_staff_access (queue_id, code_hash) + values (target_queue_id, extensions.crypt(access_code, extensions.gen_salt('bf', 10))); + insert into public.queue_commands (request_id, queue_id, actor_user_id, command_type) + values (request_id, target_queue_id, actor, 'CREATE_QUEUE'); + insert into public.queue_events (queue_id, event_type, queue_revision, actor_user_id, request_id) + values (target_queue_id, 'QUEUE_CREATED', 1, actor, request_id); + + result := private.queue_snapshot(target_queue_id, actor); + return result || jsonb_build_object('accessCode', access_code, 'accessCodeShownOnce', true); +end; +$$; + +create or replace function public.claim_staff_access( + queue_slug text, + access_code text, + request_id uuid +) +returns jsonb +language plpgsql +security definer +set search_path = '' +as $$ +declare + actor uuid := private.require_actor(); + target_queue public.queues%rowtype; + stored_hash text; + attempt public.queue_staff_access_attempts%rowtype; + replay_queue_id uuid; + new_revision bigint; +begin + select * into target_queue from public.queues where slug = lower(btrim(queue_slug)) for update; + if not found then + return private.error_result('INVALID_ACCESS_CODE', 'That access code could not be verified.'); + end if; + replay_queue_id := private.replayed_queue_id(request_id, actor, 'CLAIM_STAFF_ACCESS'); + if replay_queue_id is not null then + return private.queue_snapshot(replay_queue_id, actor) || jsonb_build_object('replayed', true); + end if; + if private.is_staff(target_queue.id, actor) then + insert into public.queue_commands (request_id, queue_id, actor_user_id, command_type) + values (request_id, target_queue.id, actor, 'CLAIM_STAFF_ACCESS'); + return private.queue_snapshot(target_queue.id, actor) || jsonb_build_object('replayed', true); + end if; + + select * into attempt from public.queue_staff_access_attempts + where queue_id = target_queue.id and user_id = actor for update; + if found and attempt.blocked_until is not null and attempt.blocked_until > now() then + return private.error_result('RATE_LIMITED', 'Too many attempts. Wait before trying again.'); + end if; + select code_hash into stored_hash from public.queue_staff_access where queue_id = target_queue.id; + if stored_hash is null or access_code is null or length(access_code) > 128 + or extensions.crypt(access_code, stored_hash) <> stored_hash then + insert into public.queue_staff_access_attempts ( + queue_id, user_id, window_started_at, attempt_count, blocked_until, updated_at + ) values ( + target_queue.id, actor, now(), 1, null, now() + ) on conflict (queue_id, user_id) do update set + window_started_at = case + when public.queue_staff_access_attempts.window_started_at < now() - interval '15 minutes' then now() + else public.queue_staff_access_attempts.window_started_at end, + attempt_count = case + when public.queue_staff_access_attempts.window_started_at < now() - interval '15 minutes' then 1 + else public.queue_staff_access_attempts.attempt_count + 1 end, + blocked_until = case + when (case when public.queue_staff_access_attempts.window_started_at < now() - interval '15 minutes' then 1 else public.queue_staff_access_attempts.attempt_count + 1 end) >= 5 + then now() + interval '15 minutes' else null end, + updated_at = now(); + if (select attempt_count >= 5 from public.queue_staff_access_attempts where queue_id = target_queue.id and user_id = actor) then + return private.error_result('RATE_LIMITED', 'Too many attempts. Wait before trying again.'); + end if; + return private.error_result('INVALID_ACCESS_CODE', 'That access code could not be verified.'); + end if; + + delete from public.queue_staff_access_attempts where queue_id = target_queue.id and user_id = actor; + insert into public.queue_staff_memberships (queue_id, user_id) values (target_queue.id, actor); + update public.queues set revision = revision + 1, updated_at = now() + where id = target_queue.id returning revision into new_revision; + insert into public.queue_commands (request_id, queue_id, actor_user_id, command_type) + values (request_id, target_queue.id, actor, 'CLAIM_STAFF_ACCESS'); + insert into public.queue_events (queue_id, event_type, queue_revision, actor_user_id, request_id) + values (target_queue.id, 'STAFF_ACCESS_CLAIMED', new_revision, actor, request_id); + return private.queue_snapshot(target_queue.id, actor); +end; +$$; + +create or replace function public.join_queue( + queue_slug text, + display_name text, + request_id uuid +) +returns jsonb +language plpgsql +security definer +set search_path = '' +as $$ +declare + actor uuid := private.require_actor(); + target_queue public.queues%rowtype; + normalized_name text := nullif(regexp_replace(btrim(display_name), '\s+', ' ', 'g'), ''); + active_id uuid; + entry_id uuid := gen_random_uuid(); + reserved_sequence integer; + new_revision bigint; + replay_queue_id uuid; +begin + select * into target_queue from public.queues where slug = lower(btrim(queue_slug)) for update; + if not found then return private.error_result('QUEUE_NOT_FOUND', 'This queue could not be found.'); end if; + replay_queue_id := private.replayed_queue_id(request_id, actor, 'JOIN_QUEUE'); + if replay_queue_id is not null then + return private.queue_snapshot(replay_queue_id, actor) || jsonb_build_object('replayed', true); + end if; + if target_queue.status = 'PAUSED' then return private.error_result('QUEUE_PAUSED', 'Check-in is paused.'); end if; + if target_queue.status = 'CLOSED' then return private.error_result('QUEUE_CLOSED', 'This queue is closed.'); end if; + if normalized_name is not null and length(normalized_name) > 30 then + return private.error_result('INVALID_DISPLAY_NAME', 'Use 30 characters or fewer.'); + end if; + select entry.id into active_id + from public.queue_entries entry + join public.queue_entry_private secret on secret.entry_id = entry.id + where entry.queue_id = target_queue.id and secret.customer_user_id = actor + and entry.status in ('WAITING', 'SERVING') + limit 1; + if active_id is not null then + insert into public.queue_commands (request_id, queue_id, actor_user_id, command_type) + values (request_id, target_queue.id, actor, 'JOIN_QUEUE'); + return private.queue_snapshot(target_queue.id, actor) || jsonb_build_object('alreadyJoined', true); + end if; + + update public.queues set + next_sequence = next_sequence + 1, + revision = revision + 1, + updated_at = now() + where id = target_queue.id + returning next_sequence - 1, revision into reserved_sequence, new_revision; + insert into public.queue_entries ( + id, queue_id, sequence, number_label, status, revision + ) values ( + entry_id, target_queue.id, reserved_sequence, + target_queue.prefix || '-' || lpad(reserved_sequence::text, 3, '0'), 'WAITING', new_revision + ); + insert into public.queue_entry_private (entry_id, queue_id, customer_user_id, display_name) + values (entry_id, target_queue.id, actor, normalized_name); + insert into public.queue_commands (request_id, queue_id, actor_user_id, command_type) + values (request_id, target_queue.id, actor, 'JOIN_QUEUE'); + insert into public.queue_events (queue_id, entry_id, event_type, queue_revision, actor_user_id, request_id) + values (target_queue.id, entry_id, 'CUSTOMER_JOINED', new_revision, actor, request_id); + return private.queue_snapshot(target_queue.id, actor); +end; +$$; + +create or replace function public.call_next(queue_id uuid, request_id uuid) +returns jsonb +language plpgsql +security definer +set search_path = '' +as $$ +declare + actor uuid := private.require_actor(); + target_queue public.queues%rowtype; + target_entry_id uuid; + new_revision bigint; + replay_queue_id uuid; +begin + select * into target_queue from public.queues where id = queue_id for update; + if not found then return private.error_result('QUEUE_NOT_FOUND', 'This queue could not be found.'); end if; + if not private.is_staff(queue_id, actor) then return private.error_result('NOT_STAFF', 'Staff access is required.'); end if; + replay_queue_id := private.replayed_queue_id(request_id, actor, 'CALL_NEXT'); + if replay_queue_id is not null then return private.queue_snapshot(replay_queue_id, actor) || jsonb_build_object('replayed', true); end if; + if target_queue.status <> 'OPEN' then return private.error_result(case when target_queue.status = 'PAUSED' then 'QUEUE_PAUSED' else 'QUEUE_CLOSED' end, 'Open the queue before calling the next customer.'); end if; + if exists (select 1 from public.queue_entries entry where entry.queue_id = target_queue.id and entry.status = 'SERVING') then + return private.error_result('ACTIVE_ENTRY_EXISTS', 'Complete or skip the active customer first.'); + end if; + select entry.id into target_entry_id from public.queue_entries entry + where entry.queue_id = target_queue.id and entry.status = 'WAITING' order by entry.sequence limit 1 for update; + if target_entry_id is null then return private.error_result('EMPTY_QUEUE', 'No customers are waiting.'); end if; + update public.queues set revision = revision + 1, updated_at = now() where id = target_queue.id returning revision into new_revision; + update public.queue_entries set status = 'SERVING', called_at = now(), updated_at = now(), revision = new_revision where id = target_entry_id; + insert into public.queue_commands (request_id, queue_id, actor_user_id, command_type) values (request_id, target_queue.id, actor, 'CALL_NEXT'); + insert into public.queue_events (queue_id, entry_id, event_type, queue_revision, actor_user_id, request_id) + values (target_queue.id, target_entry_id, 'CUSTOMER_CALLED', new_revision, actor, request_id); + return private.queue_snapshot(target_queue.id, actor); +end; +$$; + +create or replace function public.complete_active(queue_id uuid, request_id uuid) +returns jsonb +language plpgsql +security definer +set search_path = '' +as $$ +declare + actor uuid := private.require_actor(); target_queue public.queues%rowtype; target_entry_id uuid; new_revision bigint; replay_queue_id uuid; +begin + select * into target_queue from public.queues where id = queue_id for update; + if not found then return private.error_result('QUEUE_NOT_FOUND', 'This queue could not be found.'); end if; + if not private.is_staff(queue_id, actor) then return private.error_result('NOT_STAFF', 'Staff access is required.'); end if; + replay_queue_id := private.replayed_queue_id(request_id, actor, 'COMPLETE_ACTIVE'); + if replay_queue_id is not null then return private.queue_snapshot(replay_queue_id, actor) || jsonb_build_object('replayed', true); end if; + select entry.id into target_entry_id from public.queue_entries entry where entry.queue_id = target_queue.id and entry.status = 'SERVING' for update; + if target_entry_id is null then return private.error_result('CONFLICT', 'No customer is currently being served.'); end if; + update public.queues set revision = revision + 1, updated_at = now() where id = target_queue.id returning revision into new_revision; + update public.queue_entries set status = 'COMPLETED', completed_at = now(), updated_at = now(), revision = new_revision where id = target_entry_id; + insert into public.queue_commands (request_id, queue_id, actor_user_id, command_type) values (request_id, target_queue.id, actor, 'COMPLETE_ACTIVE'); + insert into public.queue_events (queue_id, entry_id, event_type, queue_revision, actor_user_id, request_id) + values (target_queue.id, target_entry_id, 'CUSTOMER_COMPLETED', new_revision, actor, request_id); + return private.queue_snapshot(target_queue.id, actor); +end; +$$; + +create or replace function public.skip_entry(queue_id uuid, entry_id uuid, request_id uuid) +returns jsonb +language plpgsql +security definer +set search_path = '' +as $$ +declare + actor uuid := private.require_actor(); target_queue public.queues%rowtype; target_status public.queue_entry_status; new_revision bigint; replay_queue_id uuid; +begin + select * into target_queue from public.queues where id = queue_id for update; + if not found then return private.error_result('QUEUE_NOT_FOUND', 'This queue could not be found.'); end if; + if not private.is_staff(queue_id, actor) then return private.error_result('NOT_STAFF', 'Staff access is required.'); end if; + replay_queue_id := private.replayed_queue_id(request_id, actor, 'SKIP_ENTRY'); + if replay_queue_id is not null then return private.queue_snapshot(replay_queue_id, actor) || jsonb_build_object('replayed', true); end if; + select entry.status into target_status from public.queue_entries entry where entry.id = entry_id and entry.queue_id = target_queue.id for update; + if target_status is null then return private.error_result('CONFLICT', 'That queue entry no longer exists.'); end if; + if target_status not in ('WAITING', 'SERVING') then return private.error_result('CONFLICT', 'That queue entry can no longer be skipped.'); end if; + update public.queues set revision = revision + 1, updated_at = now() where id = target_queue.id returning revision into new_revision; + update public.queue_entries set status = 'SKIPPED', skipped_at = now(), updated_at = now(), revision = new_revision where id = entry_id; + insert into public.queue_commands (request_id, queue_id, actor_user_id, command_type) values (request_id, target_queue.id, actor, 'SKIP_ENTRY'); + insert into public.queue_events (queue_id, entry_id, event_type, queue_revision, actor_user_id, request_id) + values (target_queue.id, entry_id, 'CUSTOMER_SKIPPED', new_revision, actor, request_id); + return private.queue_snapshot(target_queue.id, actor); +end; +$$; + +create or replace function private.change_queue_status( + p_queue_id uuid, + p_request_id uuid, + p_command_type text, + p_allowed_from public.queue_status[], + p_target public.queue_status, + p_event public.queue_event_type +) +returns jsonb +language plpgsql +security definer +set search_path = '' +as $$ +declare + actor uuid := private.require_actor(); target_queue public.queues%rowtype; new_revision bigint; replay_queue_id uuid; +begin + select * into target_queue from public.queues where id = p_queue_id for update; + if not found then return private.error_result('QUEUE_NOT_FOUND', 'This queue could not be found.'); end if; + if not private.is_staff(p_queue_id, actor) then return private.error_result('NOT_STAFF', 'Staff access is required.'); end if; + replay_queue_id := private.replayed_queue_id(p_request_id, actor, p_command_type); + if replay_queue_id is not null then return private.queue_snapshot(replay_queue_id, actor) || jsonb_build_object('replayed', true); end if; + if not (target_queue.status = any(p_allowed_from)) then return private.error_result('CONFLICT', 'That queue status change is not allowed.'); end if; + update public.queues set status = p_target, revision = revision + 1, updated_at = now() where id = p_queue_id returning revision into new_revision; + insert into public.queue_commands (request_id, queue_id, actor_user_id, command_type) values (p_request_id, p_queue_id, actor, p_command_type); + insert into public.queue_events (queue_id, event_type, queue_revision, actor_user_id, request_id) + values (p_queue_id, p_event, new_revision, actor, p_request_id); + return private.queue_snapshot(p_queue_id, actor); +end; +$$; + +create or replace function public.pause_queue(queue_id uuid, request_id uuid) returns jsonb +language sql security definer set search_path = '' as $$ + select private.change_queue_status(queue_id, request_id, 'PAUSE_QUEUE', array['OPEN'::public.queue_status], 'PAUSED', 'QUEUE_PAUSED'); +$$; +create or replace function public.reopen_queue(queue_id uuid, request_id uuid) returns jsonb +language sql security definer set search_path = '' as $$ + select private.change_queue_status(queue_id, request_id, 'REOPEN_QUEUE', array['PAUSED'::public.queue_status], 'OPEN', 'QUEUE_REOPENED'); +$$; +create or replace function public.close_queue(queue_id uuid, request_id uuid) returns jsonb +language sql security definer set search_path = '' as $$ + select private.change_queue_status(queue_id, request_id, 'CLOSE_QUEUE', array['OPEN'::public.queue_status, 'PAUSED'::public.queue_status], 'CLOSED', 'QUEUE_CLOSED'); +$$; + +revoke all on function public.create_queue(text, text, uuid) from public, anon; +revoke all on function public.claim_staff_access(text, text, uuid) from public, anon; +revoke all on function public.join_queue(text, text, uuid) from public, anon; +revoke all on function public.call_next(uuid, uuid) from public, anon; +revoke all on function public.complete_active(uuid, uuid) from public, anon; +revoke all on function public.skip_entry(uuid, uuid, uuid) from public, anon; +revoke all on function public.pause_queue(uuid, uuid) from public, anon; +revoke all on function public.reopen_queue(uuid, uuid) from public, anon; +revoke all on function public.close_queue(uuid, uuid) from public, anon; +grant execute on function public.create_queue(text, text, uuid) to authenticated; +grant execute on function public.claim_staff_access(text, text, uuid) to authenticated; +grant execute on function public.join_queue(text, text, uuid) to authenticated; +grant execute on function public.call_next(uuid, uuid) to authenticated; +grant execute on function public.complete_active(uuid, uuid) to authenticated; +grant execute on function public.skip_entry(uuid, uuid, uuid) to authenticated; +grant execute on function public.pause_queue(uuid, uuid) to authenticated; +grant execute on function public.reopen_queue(uuid, uuid) to authenticated; +grant execute on function public.close_queue(uuid, uuid) to authenticated; diff --git a/supabase/migrations/20260717230000_harden_request_id_serialization.sql b/supabase/migrations/20260717230000_harden_request_id_serialization.sql new file mode 100644 index 0000000..be81f33 --- /dev/null +++ b/supabase/migrations/20260717230000_harden_request_id_serialization.sql @@ -0,0 +1,34 @@ +create or replace function private.replayed_queue_id( + p_request_id uuid, + p_actor uuid, + p_command_type text +) +returns uuid +language plpgsql +security definer +set search_path = '' +as $$ +declare + receipt public.queue_commands%rowtype; +begin + if p_request_id is null then + raise exception using errcode = '22023', message = 'REQUEST_ID_REQUIRED'; + end if; + + -- Serialize every use of one request UUID, including CREATE_QUEUE where no + -- queue row exists yet to provide the normal transaction lock. + perform pg_catalog.pg_advisory_xact_lock( + pg_catalog.hashtextextended(p_request_id::text, 0) + ); + + select * into receipt + from public.queue_commands + where request_id = p_request_id; + + if not found then return null; end if; + if receipt.actor_user_id <> p_actor or receipt.command_type <> p_command_type then + raise exception using errcode = '22023', message = 'REQUEST_ID_MISMATCH'; + end if; + return receipt.queue_id; +end; +$$; diff --git a/supabase/seed.sql b/supabase/seed.sql new file mode 100644 index 0000000..41c78d9 --- /dev/null +++ b/supabase/seed.sql @@ -0,0 +1,78 @@ +-- Local-only synthetic identity used to own the deterministic visual-test queue. +-- It has no email, password, reusable credential, or recoverable staff code. +insert into auth.users ( + instance_id, + id, + aud, + role, + email, + encrypted_password, + email_confirmed_at, + raw_app_meta_data, + raw_user_meta_data, + created_at, + updated_at, + is_anonymous +) values ( + '00000000-0000-0000-0000-000000000000', + '10000000-0000-4000-8000-000000000001', + 'authenticated', + 'authenticated', + null, + null, + '2026-07-17T14:00:00Z', + '{"provider":"anonymous","providers":["anonymous"]}', + '{}', + '2026-07-17T14:00:00Z', + '2026-07-17T14:00:00Z', + true +); + +insert into public.queues ( + id, slug, name, prefix, status, next_sequence, revision, created_by, created_at, updated_at +) values ( + '20000000-0000-4000-8000-000000000001', + 'north-star-cafe', + 'North Star Café', + 'A', + 'OPEN', + 1, + 1, + '10000000-0000-4000-8000-000000000001', + '2026-07-17T14:00:00Z', + '2026-07-17T14:00:00Z' +); + +insert into public.queue_staff_memberships (queue_id, user_id, created_at) +values ( + '20000000-0000-4000-8000-000000000001', + '10000000-0000-4000-8000-000000000001', + '2026-07-17T14:00:00Z' +); + +insert into public.queue_staff_access (queue_id, code_hash, updated_at) +values ( + '20000000-0000-4000-8000-000000000001', + extensions.crypt(encode(extensions.gen_random_bytes(24), 'hex'), extensions.gen_salt('bf', 10)), + '2026-07-17T14:00:00Z' +); + +insert into public.queue_commands (request_id, queue_id, actor_user_id, command_type, created_at) +values ( + '30000000-0000-4000-8000-000000000001', + '20000000-0000-4000-8000-000000000001', + '10000000-0000-4000-8000-000000000001', + 'CREATE_QUEUE', + '2026-07-17T14:00:00Z' +); + +insert into public.queue_events ( + queue_id, event_type, queue_revision, actor_user_id, request_id, occurred_at +) values ( + '20000000-0000-4000-8000-000000000001', + 'QUEUE_CREATED', + 1, + '10000000-0000-4000-8000-000000000001', + '30000000-0000-4000-8000-000000000001', + '2026-07-17T14:00:00Z' +); diff --git a/supabase/tests/001_schema.test.sql b/supabase/tests/001_schema.test.sql new file mode 100644 index 0000000..e2a09e0 --- /dev/null +++ b/supabase/tests/001_schema.test.sql @@ -0,0 +1,42 @@ +begin; +create extension if not exists pgtap with schema extensions; +select plan(27); + +select has_type('public', 'queue_status', 'queue status enum exists'); +select has_type('public', 'queue_entry_status', 'entry status enum exists'); +select has_type('public', 'queue_event_type', 'event type enum exists'); + +select has_table('public', 'queues', 'queues exists'); +select has_table('public', 'queue_entries', 'queue entries exists'); +select has_table('public', 'queue_entry_private', 'private entries exists'); +select has_table('public', 'queue_staff_memberships', 'staff memberships exists'); +select has_table('public', 'queue_staff_access', 'staff access exists'); +select has_table('public', 'queue_staff_access_attempts', 'staff attempts exists'); +select has_table('public', 'queue_commands', 'command receipts exist'); +select has_table('public', 'queue_events', 'events exist'); + +select has_index('public', 'queues', 'queues_slug_idx', 'slug index exists'); +select has_index('public', 'queue_entries', 'queue_entries_waiting_idx', 'waiting order index exists'); +select has_index('public', 'queue_entries', 'queue_entries_status_idx', 'status index exists'); +select has_index('public', 'queue_entries', 'queue_entries_one_serving_idx', 'one-serving partial index exists'); +select has_index('public', 'queue_entry_private', 'queue_entry_private_customer_idx', 'customer ownership index exists'); +select has_index('public', 'queue_staff_memberships', 'queue_staff_memberships_user_idx', 'staff lookup index exists'); +select has_index('public', 'queue_events', 'queue_events_queue_revision_idx', 'event revision index exists'); + +select ok((select relrowsecurity from pg_class where oid = 'public.queues'::regclass), 'queues RLS enabled'); +select ok((select relrowsecurity from pg_class where oid = 'public.queue_entries'::regclass), 'entries RLS enabled'); +select ok((select relrowsecurity from pg_class where oid = 'public.queue_entry_private'::regclass), 'private entries RLS enabled'); +select ok((select relrowsecurity from pg_class where oid = 'public.queue_staff_memberships'::regclass), 'memberships RLS enabled'); +select ok((select relrowsecurity from pg_class where oid = 'public.queue_staff_access'::regclass), 'access hash RLS enabled'); +select ok((select relrowsecurity from pg_class where oid = 'public.queue_staff_access_attempts'::regclass), 'attempts RLS enabled'); +select ok((select relrowsecurity from pg_class where oid = 'public.queue_commands'::regclass), 'commands RLS enabled'); +select ok((select relrowsecurity from pg_class where oid = 'public.queue_events'::regclass), 'events RLS enabled'); + +select is( + (select array_agg(schemaname || '.' || tablename order by schemaname, tablename) + from pg_publication_tables where pubname = 'supabase_realtime'), + array['public.queue_entries', 'public.queues'], + 'only display-safe tables are published' +); +select * from finish(); +rollback; diff --git a/supabase/tests/002_commands.test.sql b/supabase/tests/002_commands.test.sql new file mode 100644 index 0000000..e224ab3 --- /dev/null +++ b/supabase/tests/002_commands.test.sql @@ -0,0 +1,108 @@ +begin; +create extension if not exists pgtap with schema extensions; +select plan(35); + +insert into auth.users (instance_id, id, aud, role, raw_app_meta_data, raw_user_meta_data, created_at, updated_at, is_anonymous) +values + ('00000000-0000-0000-0000-000000000000', '41000000-0000-4000-8000-000000000001', 'authenticated', 'authenticated', '{"provider":"anonymous"}', '{}', now(), now(), true), + ('00000000-0000-0000-0000-000000000000', '41000000-0000-4000-8000-000000000002', 'authenticated', 'authenticated', '{"provider":"anonymous"}', '{}', now(), now(), true), + ('00000000-0000-0000-0000-000000000000', '41000000-0000-4000-8000-000000000003', 'authenticated', 'authenticated', '{"provider":"anonymous"}', '{}', now(), now(), true), + ('00000000-0000-0000-0000-000000000000', '41000000-0000-4000-8000-000000000004', 'authenticated', 'authenticated', '{"provider":"anonymous"}', '{}', now(), now(), true); + +set local role authenticated; +select set_config('request.jwt.claim.sub', '41000000-0000-4000-8000-000000000001', true); +do $$ +declare result jsonb; +begin + result := public.create_queue('Test Counter', 'T', '42000000-0000-4000-8000-000000000001'); + perform set_config('test.queue_id', result #>> '{queue,id}', true); + perform set_config('test.queue_slug', result #>> '{queue,slug}', true); + perform set_config('test.access_code', result ->> 'accessCode', true); +end; +$$; + +select ok(current_setting('test.queue_id')::uuid is not null, 'authenticated user creates a queue'); +select ok(exists (select 1 from public.queue_staff_memberships where queue_id = current_setting('test.queue_id')::uuid and user_id = auth.uid()), 'creator becomes staff'); +reset role; +select isnt( + (select code_hash from public.queue_staff_access where queue_id = current_setting('test.queue_id')::uuid), + current_setting('test.access_code'), + 'stored staff credential is a hash' +); +set local role authenticated; +select set_config('request.jwt.claim.sub', '41000000-0000-4000-8000-000000000001', true); +select is( + (public.create_queue('Test Counter', 'T', '42000000-0000-4000-8000-000000000001')->>'replayed')::boolean, + true, + 'duplicate create request is idempotent' +); +select is( + public.create_queue('Bad Prefix', 'T1', '42000000-0000-4000-8000-000000000002')->>'error', + 'INVALID_QUEUE_PREFIX', + 'invalid prefix is rejected' +); + +select set_config('request.jwt.claim.sub', '41000000-0000-4000-8000-000000000003', true); +select is( + public.join_queue(current_setting('test.queue_slug'), ' Casey ', '43000000-0000-4000-8000-000000000001')->>'ok', + 'true', + 'customer joins an open queue' +); +select is((select display_name from public.queue_entry_private where customer_user_id = auth.uid()), 'Casey', 'display name is normalized privately'); +select is(public.join_queue(current_setting('test.queue_slug'), 'Casey', '43000000-0000-4000-8000-000000000001')->>'replayed', 'true', 'join retry is idempotent'); +select is((public.get_queue_snapshot(current_setting('test.queue_slug')) #>> '{role}'), 'customer', 'owner snapshot identifies customer role'); +select is((public.get_queue_snapshot(current_setting('test.queue_slug')) #>> '{entries,0,displayName}'), 'Casey', 'owner sees own optional name'); + +select set_config('request.jwt.claim.sub', '41000000-0000-4000-8000-000000000004', true); +select is((public.get_queue_snapshot(current_setting('test.queue_slug')) #>> '{entries,0,displayName}'), null, 'public snapshot excludes another customer name'); +select is(public.call_next(current_setting('test.queue_id')::uuid, '44000000-0000-4000-8000-000000000001')->>'error', 'NOT_STAFF', 'customer cannot call staff RPC'); +select is(public.claim_staff_access(current_setting('test.queue_slug'), 'incorrect', '45000000-0000-4000-8000-000000000001')->>'error', 'INVALID_ACCESS_CODE', 'invalid staff code is generic'); + +select set_config('request.jwt.claim.sub', '41000000-0000-4000-8000-000000000002', true); +select is(public.claim_staff_access(current_setting('test.queue_slug'), current_setting('test.access_code'), '45000000-0000-4000-8000-000000000002')->>'ok', 'true', 'valid staff code grants membership'); +select ok(exists (select 1 from public.queue_staff_memberships where queue_id = current_setting('test.queue_id')::uuid and user_id = auth.uid()), 'claimed user is staff'); +select is((public.get_queue_snapshot(current_setting('test.queue_slug')) #>> '{entries,0,displayName}'), 'Casey', 'staff snapshot includes optional name'); +select is(public.call_next(current_setting('test.queue_id')::uuid, '44000000-0000-4000-8000-000000000002') #>> '{entries,0,status}', 'SERVING', 'call next serves earliest waiting entry'); +select is((select count(*)::integer from public.queue_entries where queue_id = current_setting('test.queue_id')::uuid and status = 'SERVING'), 1, 'only one entry is serving'); +select is(public.call_next(current_setting('test.queue_id')::uuid, '44000000-0000-4000-8000-000000000002')->>'replayed', 'true', 'call retry is idempotent'); +select is((select revision::integer from public.queues where id = current_setting('test.queue_id')::uuid), 4, 'claim, join, and call each increment revision once'); +select is(public.complete_active(current_setting('test.queue_id')::uuid, '46000000-0000-4000-8000-000000000001') #>> '{entries,0,status}', 'COMPLETED', 'active entry completes'); +select is(public.complete_active(current_setting('test.queue_id')::uuid, '46000000-0000-4000-8000-000000000001')->>'replayed', 'true', 'complete retry is idempotent'); + +select set_config('request.jwt.claim.sub', '41000000-0000-4000-8000-000000000003', true); +select is(public.join_queue(current_setting('test.queue_slug'), null, '43000000-0000-4000-8000-000000000002')->>'ok', 'true', 'customer can rejoin after terminal state'); +select set_config('request.jwt.claim.sub', '41000000-0000-4000-8000-000000000002', true); +select is(public.skip_entry(current_setting('test.queue_id')::uuid, (select id from public.queue_entries where queue_id = current_setting('test.queue_id')::uuid and status = 'WAITING'), '47000000-0000-4000-8000-000000000001') #>> '{entries,1,status}', 'SKIPPED', 'waiting entry can be skipped'); +select is(public.skip_entry(current_setting('test.queue_id')::uuid, (select id from public.queue_entries where queue_id = current_setting('test.queue_id')::uuid and status = 'SKIPPED'), '47000000-0000-4000-8000-000000000001')->>'replayed', 'true', 'skip retry is idempotent'); +select is(public.pause_queue(current_setting('test.queue_id')::uuid, '48000000-0000-4000-8000-000000000001') #>> '{queue,status}', 'PAUSED', 'open queue pauses'); +select is(public.pause_queue(current_setting('test.queue_id')::uuid, '48000000-0000-4000-8000-000000000001')->>'replayed', 'true', 'pause retry is idempotent'); +select throws_ok( + $$select public.close_queue(current_setting('test.queue_id')::uuid, '48000000-0000-4000-8000-000000000001')$$, + '22023', + 'REQUEST_ID_MISMATCH', + 'request ID cannot be reused for another command type' +); + +select set_config('request.jwt.claim.sub', '41000000-0000-4000-8000-000000000004', true); +select is(public.join_queue(current_setting('test.queue_slug'), null, '43000000-0000-4000-8000-000000000003')->>'error', 'QUEUE_PAUSED', 'paused queue rejects join'); +select set_config('request.jwt.claim.sub', '41000000-0000-4000-8000-000000000002', true); +select is(public.reopen_queue(current_setting('test.queue_id')::uuid, '48000000-0000-4000-8000-000000000002') #>> '{queue,status}', 'OPEN', 'paused queue reopens'); +select is(public.close_queue(current_setting('test.queue_id')::uuid, '48000000-0000-4000-8000-000000000003') #>> '{queue,status}', 'CLOSED', 'open queue closes'); +select is(public.close_queue(current_setting('test.queue_id')::uuid, '48000000-0000-4000-8000-000000000003')->>'replayed', 'true', 'close retry is idempotent'); +select is(public.reopen_queue(current_setting('test.queue_id')::uuid, '48000000-0000-4000-8000-000000000004')->>'error', 'CONFLICT', 'closed queue cannot reopen'); + +select throws_ok( + $$insert into public.queue_entries (queue_id, sequence, number_label, status, revision) values (current_setting('test.queue_id')::uuid, 99, 'T-099', 'WAITING', 99)$$, + '42501', + 'permission denied for table queue_entries', + 'direct entry insert is denied' +); +select throws_ok( + $$update public.queues set revision = 999 where id = current_setting('test.queue_id')::uuid$$, + '42501', + 'permission denied for table queues', + 'direct queue update is denied' +); + +select * from finish(); +rollback; diff --git a/tests/e2e/app.spec.ts b/tests/e2e/app.spec.ts index a29bccd..6860250 100644 --- a/tests/e2e/app.spec.ts +++ b/tests/e2e/app.spec.ts @@ -18,15 +18,18 @@ test.describe('primary routes', () => { await page.goto(route); await expect(page.locator('h1')).toHaveCount(1); const results = await new AxeBuilder({ page }).analyze(); - const severe = results.violations.filter((violation) => - ['serious', 'critical'].includes(violation.impact ?? ''), - ); - expect(severe).toEqual([]); + expect( + results.violations.filter((violation) => + ['serious', 'critical'].includes(violation.impact ?? ''), + ), + ).toEqual([]); }); } }); -test('landing and demo routes communicate the product', async ({ page }) => { +test('landing and demo routes communicate the persistent product', async ({ + page, +}) => { await page.goto('/'); await expect( page.getByRole('heading', { name: 'A calmer way to wait.' }), @@ -36,26 +39,22 @@ test('landing and demo routes communicate the product', async ({ page }) => { await expect( page.getByRole('heading', { name: /every side/i }), ).toBeVisible(); + await expect( + page.getByRole('heading', { name: /create a queue/i }), + ).toBeVisible(); }); -test('customer prototype joins the queue', async ({ page }) => { - await page.goto('/q/north-star-cafe'); - await page.getByLabel(/first name/i).fill('Ari'); - await page.getByRole('button', { name: 'Join the queue' }).click(); - await expect(page.getByLabel('Queue number A-029')).toBeVisible(); -}); - -test('staff prototype changes local queue state', async ({ page }) => { +test('unauthorized staff sees only the claim form and a generic failure', async ({ + page, +}) => { await page.goto('/q/north-star-cafe/staff'); - await page.getByRole('button', { name: 'Complete' }).click(); - await page.getByRole('button', { name: 'Call next' }).click(); - await expect(page.getByLabel('Queue number A-025')).toBeVisible(); -}); - -test('public display shows active and upcoming numbers', async ({ page }) => { - await page.goto('/q/north-star-cafe/display'); - await expect(page.getByLabel('Queue number A-024')).toBeVisible(); - await expect(page.getByText('A-025')).toBeVisible(); + await expect(page.getByRole('button', { name: 'Call next' })).toHaveCount(0); + await page.getByLabel('Private access code').fill('not-valid'); + await page.getByRole('button', { name: 'Open staff board' }).click(); + await expect(page.locator('#staff-access-message')).toContainText( + /could not be verified/i, + ); + await expect(page).not.toHaveURL(/code=/); }); test('mobile navigation is keyboard operable', async ({ page }) => { @@ -75,10 +74,10 @@ test('theme switching applies the dark theme', async ({ page }) => { await expect(page.locator('html')).toHaveClass(/dark/); }); -test('reduced motion keeps content visible', async ({ page }) => { +test('reduced motion keeps live content visible', async ({ page }) => { await page.emulateMedia({ reducedMotion: 'reduce' }); await page.goto('/q/north-star-cafe/display'); - await expect(page.getByLabel('Queue number A-024')).toBeVisible(); + await expect(page.getByText('Queue is clear')).toBeVisible(); }); for (const width of [320, 375]) { @@ -86,12 +85,15 @@ for (const width of [320, 375]) { await page.setViewportSize({ width, height: 800 }); for (const route of routes) { await page.goto(route); - const overflow = await page.evaluate( - () => - document.documentElement.scrollWidth > - document.documentElement.clientWidth, - ); - expect(overflow, `${route} overflowed at ${width}px`).toBe(false); + await expect + .poll(() => + page.evaluate( + () => + document.documentElement.scrollWidth <= + document.documentElement.clientWidth, + ), + ) + .toBe(true); } }); } diff --git a/tests/e2e/helpers.ts b/tests/e2e/helpers.ts new file mode 100644 index 0000000..67d2d4a --- /dev/null +++ b/tests/e2e/helpers.ts @@ -0,0 +1,45 @@ +import type { Browser } from '@playwright/test'; +import { expect } from '@playwright/test'; + +export async function createTestQueue( + browser: Browser, + name: string, + prefix: string, +) { + const context = await browser.newContext(); + const page = await context.newPage(); + await page.goto('/demo'); + await page.getByLabel('Queue name').fill(name); + await page.getByLabel('Number prefix').fill(prefix); + await page.getByRole('button', { name: 'Create persistent queue' }).click(); + const codeField = page.getByLabel('One-time staff access code'); + await expect(codeField).toBeVisible(); + const code = await codeField.inputValue(); + const slug = (await page.locator('.field-hint code').textContent())?.trim(); + if (!slug) throw new Error('Created queue slug was not shown.'); + return { context, page, code, slug }; +} + +export async function claimStaff(browser: Browser, slug: string, code: string) { + const context = await browser.newContext(); + const page = await context.newPage(); + await page.goto(`/q/${slug}/staff`); + await page.getByLabel('Private access code').fill(code); + await page.getByRole('button', { name: 'Open staff board' }).click(); + await expect(page.getByRole('heading', { name: 'Waiting' })).toBeVisible(); + return { context, page }; +} + +export async function joinCustomer( + browser: Browser, + slug: string, + name: string, +) { + const context = await browser.newContext(); + const page = await context.newPage(); + await page.goto(`/q/${slug}`); + await page.getByLabel(/first name/i).fill(name); + await page.getByRole('button', { name: 'Join the queue' }).click(); + await expect(page.getByText('Your place is saved')).toBeVisible(); + return { context, page }; +} diff --git a/tests/e2e/realtime.spec.ts b/tests/e2e/realtime.spec.ts new file mode 100644 index 0000000..0258f37 --- /dev/null +++ b/tests/e2e/realtime.spec.ts @@ -0,0 +1,90 @@ +import { expect, test } from '@playwright/test'; +import { claimStaff, createTestQueue, joinCustomer } from './helpers'; + +test('customer, staff, and display converge without refresh and resync after offline', async ({ + browser, +}) => { + const created = await createTestQueue( + browser, + `Realtime ${crypto.randomUUID().slice(0, 8)}`, + 'R', + ); + await created.page.goto(`/q/${created.slug}/staff`); + await expect( + created.page.getByRole('heading', { name: 'Waiting' }), + ).toBeVisible(); + + const displayContext = await browser.newContext(); + const display = await displayContext.newPage(); + await display.goto(`/q/${created.slug}/display`); + await expect(display.getByText('Queue is clear')).toBeVisible(); + + const first = await joinCustomer(browser, created.slug, 'River'); + const second = await joinCustomer(browser, created.slug, 'Sky'); + await expect(created.page.getByText('River')).toBeVisible(); + await expect(created.page.getByText('Sky')).toBeVisible(); + await expect(display.getByText('R-001')).toBeVisible(); + await expect(second.page.getByText('2 of 2')).toBeVisible(); + + await created.page.getByRole('button', { name: 'Call next' }).click(); + await expect(first.page.getByText('It’s your turn.')).toBeVisible(); + await expect(display.getByLabel('Queue number R-001')).toBeVisible(); + await expect(second.page.getByText('1 of 1')).toBeVisible(); + + await created.page.getByRole('button', { name: 'Complete' }).click(); + await expect( + display.getByText('No one is currently being served'), + ).toBeAttached(); + await expect( + created.page.getByRole('button', { name: 'Call next' }), + ).toBeEnabled(); + + await second.context.setOffline(true); + await expect(second.page.getByText('Offline')).toBeVisible(); + await created.page.getByRole('button', { name: 'Call next' }).click(); + await second.context.setOffline(false); + await expect(second.page.getByText('It’s your turn.')).toBeVisible(); + await expect(second.page.getByText('Connected')).toBeVisible(); + await expect(second.page.getByLabel('Queue number R-002')).toHaveCount(1); + + await Promise.all([ + created.context.close(), + displayContext.close(), + first.context.close(), + second.context.close(), + ]); +}); + +test('two authorized staff clients cannot create two serving entries', async ({ + browser, +}) => { + const created = await createTestQueue( + browser, + `Concurrency ${crypto.randomUUID().slice(0, 8)}`, + 'C', + ); + await created.page.goto(`/q/${created.slug}/staff`); + const secondStaff = await claimStaff(browser, created.slug, created.code); + const firstCustomer = await joinCustomer(browser, created.slug, 'One'); + const secondCustomer = await joinCustomer(browser, created.slug, 'Two'); + + await expect(created.page.getByText('One', { exact: true })).toBeVisible(); + await expect( + secondStaff.page.getByText('Two', { exact: true }), + ).toBeVisible(); + await Promise.all([ + created.page.getByRole('button', { name: 'Call next' }).click(), + secondStaff.page.getByRole('button', { name: 'Call next' }).click(), + ]); + await expect(created.page.getByLabel('Queue number C-001')).toBeVisible(); + await expect(secondStaff.page.getByLabel('Queue number C-001')).toBeVisible(); + await expect(created.page.getByText('1 person')).toBeVisible(); + await expect(secondStaff.page.getByText('1 person')).toBeVisible(); + + await Promise.all([ + created.context.close(), + secondStaff.context.close(), + firstCustomer.context.close(), + secondCustomer.context.close(), + ]); +}); diff --git a/tests/integration/queue-engine.test.ts b/tests/integration/queue-engine.test.ts new file mode 100644 index 0000000..70fda2d --- /dev/null +++ b/tests/integration/queue-engine.test.ts @@ -0,0 +1,262 @@ +import { createClient } from '@supabase/supabase-js'; +import { beforeAll, describe, expect, it } from 'vitest'; +import type { Database, Json } from '@/lib/supabase/database.types'; + +function requiredEnvironment(name: string) { + const value = process.env[name]; + if (!value) + throw new Error(`Local Supabase test configuration ${name} is missing.`); + return value; +} + +const url = requiredEnvironment('NEXT_PUBLIC_SUPABASE_URL'); +const key = requiredEnvironment('NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY'); + +function isolatedClient() { + return createClient(url, key, { + auth: { + persistSession: false, + autoRefreshToken: false, + detectSessionInUrl: false, + }, + }); +} + +function record(value: Json | null) { + if (!value || Array.isArray(value) || typeof value !== 'object') + throw new Error('Expected an RPC object result.'); + return value; +} + +async function anonymousClient() { + const client = isolatedClient(); + const { error } = await client.auth.signInAnonymously(); + if (error) throw error; + return client; +} + +describe('persistent queue engine', () => { + let creator: ReturnType; + let staff: ReturnType; + let customerOne: ReturnType; + let customerTwo: ReturnType; + let slug: string; + let queueId: string; + let accessCode: string; + + beforeAll(async () => { + [creator, staff, customerOne, customerTwo] = await Promise.all([ + anonymousClient(), + anonymousClient(), + anonymousClient(), + anonymousClient(), + ]); + const { data, error } = await creator.rpc('create_queue', { + queue_name: 'Integration Counter', + queue_prefix: 'I', + request_id: crypto.randomUUID(), + }); + if (error) throw error; + const created = record(data); + slug = String((created.queue as Record).slug); + queueId = String((created.queue as Record).id); + accessCode = String(created.accessCode); + }); + + it('initializes four isolated anonymous identities', async () => { + const ids = await Promise.all( + [creator, staff, customerOne, customerTwo].map( + async (client) => (await client.auth.getUser()).data.user?.id, + ), + ); + expect(new Set(ids).size).toBe(4); + }); + + it('creates a persistent queue and creator staff membership', async () => { + const snapshot = record( + (await creator.rpc('get_queue_snapshot', { queue_slug: slug })).data, + ); + expect(snapshot.role).toBe('staff'); + expect((snapshot.queue as Record).revision).toBe(1); + expect(accessCode).toMatch(/^[A-F0-9]{36}$/); + }); + + it('serializes simultaneous queue creation with one request ID', async () => { + const sharedRequestId = crypto.randomUUID(); + const [first, second] = await Promise.all([ + creator.rpc('create_queue', { + queue_name: 'Retry-safe Counter', + queue_prefix: 'R', + request_id: sharedRequestId, + }), + creator.rpc('create_queue', { + queue_name: 'Retry-safe Counter', + queue_prefix: 'R', + request_id: sharedRequestId, + }), + ]); + if (first.error) throw first.error; + if (second.error) throw second.error; + const results = [record(first.data), record(second.data)]; + expect( + new Set( + results.map((result) => + String((result.queue as Record).id), + ), + ).size, + ).toBe(1); + expect(results.filter((result) => result.replayed === true)).toHaveLength( + 1, + ); + }); + + it('claims staff access in a second identity', async () => { + const result = record( + ( + await staff.rpc('claim_staff_access', { + queue_slug: slug, + access_code: accessCode, + request_id: crypto.randomUUID(), + }) + ).data, + ); + expect(result.ok).toBe(true); + expect(result.role).toBe('staff'); + }); + + it('joins customers with unique monotonic labels', async () => { + const first = record( + ( + await customerOne.rpc('join_queue', { + queue_slug: slug, + display_name: 'River', + request_id: crypto.randomUUID(), + }) + ).data, + ); + const second = record( + ( + await customerTwo.rpc('join_queue', { + queue_slug: slug, + display_name: 'Sky', + request_id: crypto.randomUUID(), + }) + ).data, + ); + expect((first.entries as Record[])[0]?.numberLabel).toBe( + 'I-001', + ); + expect((second.entries as Record[])[1]?.numberLabel).toBe( + 'I-002', + ); + }); + + it('separates public and staff snapshot names', async () => { + const publicClient = await anonymousClient(); + const publicSnapshot = record( + (await publicClient.rpc('get_queue_snapshot', { queue_slug: slug })).data, + ); + const staffSnapshot = record( + (await staff.rpc('get_queue_snapshot', { queue_slug: slug })).data, + ); + expect( + (publicSnapshot.entries as Record[]).every( + (entry) => !('displayName' in entry), + ), + ).toBe(true); + expect( + (staffSnapshot.entries as Record[]).map( + (entry) => entry.displayName, + ), + ).toEqual(['River', 'Sky']); + }); + + it('denies direct writes and non-staff commands', async () => { + const direct = await customerOne.from('queue_entries').insert({ + queue_id: queueId, + sequence: 99, + number_label: 'I-099', + revision: 99, + }); + const command = record( + ( + await customerOne.rpc('call_next', { + queue_id: queueId, + request_id: crypto.randomUUID(), + }) + ).data, + ); + expect(direct.error?.code).toBe('42501'); + expect(command.error).toBe('NOT_STAFF'); + }); + + it('serializes simultaneous call-next commands', async () => { + const [first, second] = await Promise.all([ + creator.rpc('call_next', { + queue_id: queueId, + request_id: crypto.randomUUID(), + }), + staff.rpc('call_next', { + queue_id: queueId, + request_id: crypto.randomUUID(), + }), + ]); + const results = [record(first.data), record(second.data)]; + expect(results.filter((result) => result.ok === true)).toHaveLength(1); + expect( + results.filter((result) => result.error === 'ACTIVE_ENTRY_EXISTS'), + ).toHaveLength(1); + const snapshot = record( + (await staff.rpc('get_queue_snapshot', { queue_slug: slug })).data, + ); + expect( + (snapshot.entries as Record[]).filter( + (entry) => entry.status === 'SERVING', + ), + ).toHaveLength(1); + }); + + it('reuses command request IDs without repeating side effects', async () => { + const requestId = crypto.randomUUID(); + const before = record( + (await staff.rpc('get_queue_snapshot', { queue_slug: slug })).data, + ); + await staff.rpc('complete_active', { + queue_id: queueId, + request_id: requestId, + }); + const repeated = record( + ( + await staff.rpc('complete_active', { + queue_id: queueId, + request_id: requestId, + }) + ).data, + ); + expect(repeated.replayed).toBe(true); + expect(Number((repeated.queue as Record).revision)).toBe( + Number((before.queue as Record).revision) + 1, + ); + }); + + it('enforces request ID actor binding', async () => { + const shared = crypto.randomUUID(); + await creator.rpc('pause_queue', { queue_id: queueId, request_id: shared }); + const mismatch = await staff.rpc('pause_queue', { + queue_id: queueId, + request_id: shared, + }); + expect(mismatch.error?.message).toContain('REQUEST_ID_MISMATCH'); + }); + + it('persists the final authoritative revision across a fresh client', async () => { + const fresh = await anonymousClient(); + const snapshot = record( + (await fresh.rpc('get_queue_snapshot', { queue_slug: slug })).data, + ); + expect((snapshot.queue as Record).status).toBe('PAUSED'); + expect( + Number((snapshot.queue as Record).revision), + ).toBeGreaterThan(1); + }); +}); diff --git a/vitest.integration.config.ts b/vitest.integration.config.ts new file mode 100644 index 0000000..bab8eef --- /dev/null +++ b/vitest.integration.config.ts @@ -0,0 +1,17 @@ +import { loadEnv } from 'vite'; +import { defineConfig } from 'vitest/config'; +import path from 'node:path'; + +const environment = loadEnv('test', process.cwd(), ''); +Object.assign(process.env, environment); + +export default defineConfig({ + resolve: { alias: { '@': path.resolve(__dirname, './src') } }, + test: { + environment: 'node', + include: ['tests/integration/**/*.test.ts'], + testTimeout: 20_000, + hookTimeout: 20_000, + fileParallelism: false, + }, +});