Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
NEXT_PUBLIC_SUPABASE_URL=http://127.0.0.1:54321
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=replace-with-local-publishable-key
42 changes: 41 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -23,17 +27,53 @@ 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
with:
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
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
98 changes: 55 additions & 43 deletions README.md
Original file line number Diff line number Diff line change
@@ -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)
41 changes: 41 additions & 0 deletions docs/architecture/adr-002-supabase-realtime.md
Original file line number Diff line number Diff line change
@@ -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.
36 changes: 36 additions & 0 deletions docs/architecture/data-model.md
Original file line number Diff line number Diff line change
@@ -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.
Loading
Loading