Team Lunch is a collaborative lunch coordination app for teams. It helps a group pick a place to eat, collect everyone's order, and track delivery status in real time.
The app has three main phases:
- Start a timed poll to choose a menu or restaurant.
- Run a timed ordering round for the winning menu.
- Track order placement and delivery until lunch arrives.
Everything stays synced across connected browsers through Server-Sent Events (SSE).
- See USER_DOCUMENTATION.md for the user workflow and phase-by-phase CTAs.
- Real-time poll voting
- Tie handling with extension or random winner selection
- Timed food ordering rounds
- Delivery tracking with ETA updates
- Menu management and JSON import
- Learned meal recommendations with safe, explore, and pre-vote guidance
- Anticipated like/dislike marks and onboarding seeds for cold start
- Shopping list support
- Multi-office support
- Optional local auth
- Optional Microsoft Entra SSO
- Optional Microsoft Graph email notifications
- React 18 + Vite + TypeScript
- Fastify + TypeScript
- Prisma 7 ORM (driver adapters, engine-free)
- PostgreSQL
- Vitest, Testing Library, Supertest, Playwright
- pnpm as the package manager (npm/yarn are blocked via
only-allow)
- Node.js (v24 LTS)
- pnpm (v11+) — this repo enforces pnpm; npm/yarn are blocked by
only-allow. The pinned version is managed by Corepack: runcorepack enable. - Python (v3.10+) — used only for the semgrep security scanner
- PowerShell (v7+) —
pwshis used for setup and validation scripts (install guide) - Docker — for PostgreSQL
Run pwsh -File ./setup.ps1 to install all project dependencies automatically (pnpm packages, Prisma client, Python venv with semgrep).
- Clone the repo and run the setup script:
git clone https://github.com/TKlerx/team-lunch.git
cd team-lunch
pwsh -File ./setup.ps1This installs pnpm packages, generates the Prisma client, and creates a Python venv with semgrep.
- Create your local env file:
cp .env.example .env- Start PostgreSQL:
docker compose up db -d- Apply migrations:
pnpm prisma migrate dev- Start the app:
pnpm dev- Open the app:
- Frontend:
http://localhost:5173 - Backend:
http://localhost:3000
git clone https://github.com/TKlerx/team-lunch.git
cd team-lunch
cp .env.example .env
docker compose up --buildThen open http://localhost:3000.
For a production-style deploy, configure .env and run:
pnpm deployThis runs scripts/deploy.sh, which exports and prints build metadata, lists
Compose data volumes, builds the app and migrate images, starts the database,
verifies the target does not look like the wrong/empty database, creates a
PostgreSQL backup, runs Prisma pre-deploy verification, applies migrations,
restarts the app, and checks the database again.
Backups are written to backups/postgres/ by default and pruned by count
(BACKUP_KEEP_COUNT, default 5) and age (KEEP_DAYS, default 90). For an
intentional fresh bootstrap, set ALLOW_EMPTY_DATABASE_DEPLOY=true.
Existing deployments that were initialized before the app was renamed may still
use the old paiqo database and PostgreSQL 16 data directory. Keep those values
in .env until you do a dump/restore major-version upgrade:
POSTGRES_IMAGE="postgres:16-alpine"
POSTGRES_USER="paiqo"
POSTGRES_PASSWORD="paiqo"
POSTGRES_DB="paiqo"
POSTGRES_PGDATA="/var/lib/postgresql"
COMPOSE_DATABASE_URL="postgresql://paiqo:paiqo@db:5432/paiqo?schema=public"For local development, these settings are enough in most cases:
DATABASE_URL="postgresql://teamlunch:teamlunch@localhost:5433/teamlunch?schema=public"
AUTH_SESSION_SECRET="replace-with-a-long-random-secret-of-at-least-32-chars"Generate a real AUTH_SESSION_SECRET before using the app outside throwaway local testing:
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"Or with OpenSSL:
openssl rand -hex 32If you want approval-gated local auth with a bootstrap admin:
AUTH_ADMIN_EMAIL="admin@example.com"
AUTH_ADMIN_PASSWORD="change-me-please"Then seed the admin:
pnpm auth:seedThe repo ships a documented .env.example. The most important variables are:
DATABASE_URL: primary database connection stringAUTH_SESSION_SECRET: required for auth cookies and local auth flows
PORT: backend port used by the Fastify server in dev and productionVITE_PORT: frontend dev-server port; mainly relevant in local development when Vite and the backend run as separate processesDEFAULT_FOOD_SELECTION_DURATION_MINUTES: optional global fallback for auto-started food selection; if unset or invalid, the app falls back to30AUTH_ADMIN_EMAILandAUTH_ADMIN_PASSWORD: bootstrap local admin account
The app exposes GET /api/version and shows the same metadata on the Settings
page for support diagnostics.
APP_VERSION: displayed app version, for example a release tag in production or20260611.3in stagingGIT_SHA: deployed commit SHAGIT_BRANCH: deployed branch nameGIT_DIRTY:truewhen the deployed worktree had local changesBUILD_TIME: UTC build/deploy timestamp
pnpm deploy sets these automatically from Git unless you override them in the
environment. In local dev, /api/version falls back to reading Git directly.
Use these only if you deploy the app under a subpath such as /team-lunch.
BASE_PATH: backend/app path prefixVITE_BASE_PATH: frontend build-time counterpart ofBASE_PATH
You usually want:
- root deployment:
BASE_PATH=""VITE_BASE_PATH=""- app URL:
https://example.com/ - API URL:
https://example.com/api/health
- subpath deployment:
BASE_PATH="/team-lunch"VITE_BASE_PATH="/team-lunch"- app URL:
https://example.com/team-lunch/ - API URL:
https://example.com/team-lunch/api/health
Why both exist:
BASE_PATHtells the backend/router where the app livesVITE_BASE_PATHtells the frontend build where its JS/CSS/assets and client routes live- They represent the same deployment path concept, but they are consumed by different runtimes:
- Fastify reads
BASE_PATHat runtime - Vite reads
VITE_BASE_PATHwhen building the frontend
- Fastify reads
If you deploy at the site root, leave both empty.
ENTRA_CLIENT_ID,ENTRA_CLIENT_SECRET,ENTRA_TENANT_ID: enable Entra loginGRAPH_MAIL_SENDER: enable Graph-based mail delivery
AI_RECOMMENDATION_ENDPOINT,AI_RECOMMENDATION_API_KEY,AI_RECOMMENDATION_MODEL,AI_RECOMMENDATION_PROVIDER: enable AI-assisted explanations for "Recommend a meal". All four must be set or AI assistance stays disabled.- The AI never picks or ranks items - ranking is always deterministic. AI only supplies short reason text for the items the deterministic ranker already selected.
- The same
AI_RECOMMENDATION_*environment boundary is reused for menu-import feature gap filling; if AI tagging is configured, untagged imports can gain ingredient/style features automatically, otherwise imports still complete. - Ranking is per person and content-based: items are tagged with ingredient and style features (e.g. chicken, thai, spicy), a taste profile is learned from each user's own history, and unrated menu items are scored by how well their features match flavors that user prefers - not just by exact dishes they ordered before. The profile uses both explicit star ratings and implicit signal (simply ordering an item is a mild positive vote for its features), so it still works when people rarely rate.
- Deterministic meal recommendations (
POST /api/food-selections/:id/recommendations) always work without these variables. When AI is requested but not configured, unavailable, or times out (2s), the response falls back tosource: "deterministic_fallback"with a warning, never an error. - AI payloads sent to the provider only include item/menu names, ranks, scores, signal categories, and ingredient preferences. Names, emails, actor keys, order notes, and feedback remarks are never sent.
Set AI_RECOMMENDATION_PROVIDER="azure-openai" to use an Azure OpenAI chat
deployment directly:
AI_RECOMMENDATION_ENDPOINT: full Azure OpenAI chat-completions URL, e.g.https://<resource>.openai.azure.com/openai/deployments/<deployment>/chat/completions?api-version=2024-08-01-previewAI_RECOMMENDATION_API_KEY: Azure OpenAI key, sent as theapi-keyheaderAI_RECOMMENDATION_MODEL: informational (the deployment in the endpoint URL determines the actual model)
In this mode the app sends a chat-completion request with
response_format: { type: "json_object" } and a system prompt instructing the
model to return {"explanations":[{"itemName":"...","reason":"..."}]} based
only on the sanitized item/preference data above. Any other
AI_RECOMMENDATION_PROVIDER value uses the generic custom-endpoint contract
(Authorization: Bearer header, {model, items, preferences} body, expects
{explanations:[...]} directly).
pnpm dev # server + client
pnpm dev:server # backend only
pnpm dev:client # frontend only
pnpm build # production build (tsc + vite build -> dist/)
pnpm start # run built app (node dist/server/index.js)pnpm typecheck
pnpm lint
pnpm test
pwsh -File ./validate.ps1validate.ps1 is the aggregate gate. With no argument it runs the all phase
(typecheck + lint + architecture + complexity + function-size + duplication +
semgrep + production audit + tests with coverage). Useful phases:
pwsh -File ./validate.ps1 quick # typecheck only
pwsh -File ./validate.ps1 precommit # typecheck + architecture + duplication
pwsh -File ./validate.ps1 prepush # lint + complexity + function-size
pwsh -File ./validate.ps1 test # tests only, no coverage
pwsh -File ./validate.ps1 e2e # Playwright E2E tests only
pwsh -File ./validate.ps1 quality # lint + architecture + complexity + size + duplication + semgrep
pwsh -File ./validate.ps1 full # all + Trivy image scan + Playwright E2EThe Git hooks keep local commits quick: pre-commit runs precommit (typecheck,
architecture, duplication), and pre-push runs prepush (lint, complexity,
function-size). Set RUN_FULL_VALIDATION_ON_PUSH=1 when you want pre-push to run
pwsh -File ./validate.ps1 full locally. CI and pre-merge-commit run the full
merge gate.
pwsh -File ./validate.ps1 full builds a local team-lunch:trivy-scan image and scans it
with the official Trivy container image pinned by digest. Override the scanner
image with TRIVY_IMAGE only when intentionally updating the pinned scanner, and
override the scan target with TRIVY_SCAN_IMAGE if needed.
The simplest mode. Users identify themselves with a nickname stored in local storage.
Enable local sign-in by setting:
AUTH_SESSION_SECRET="replace-with-a-long-random-secret"
AUTH_ADMIN_EMAIL="admin@example.com"
AUTH_ADMIN_PASSWORD="change-me-please"Then run:
pnpm auth:seedEnable Microsoft Entra backend auth with:
APP_PUBLIC_URL="https://lunch.example.com"
ENTRA_CLIENT_ID="your-app-client-id"
ENTRA_CLIENT_SECRET="your-app-client-secret"
ENTRA_TENANT_ID="your-tenant-id"Optional:
ENTRA_REDIRECT_URI="https://lunch.example.com/team-lunch/api/auth/entra/callback"
GRAPH_MAIL_SENDER="sender@example.com"If Entra is not configured, the app can still run without Microsoft sign-in. The login UI disables the Microsoft button and continues to support other available auth modes. If authentication config cannot be loaded at all, the app shows an authentication error instead of falling back to open access.
For subpath deployments, set both values to the same path prefix:
BASE_PATH="/team-lunch"
VITE_BASE_PATH="/team-lunch"
APP_PUBLIC_URL="https://lunch.example.com"This produces URLs like:
- app:
https://lunch.example.com/team-lunch/ - menus page:
https://lunch.example.com/team-lunch/menus - API health check:
https://lunch.example.com/team-lunch/api/health
An example Nginx config is included at deploy/nginx/team-lunch.conf.
src/
client/ React frontend
server/ Fastify backend
lib/ shared types
prisma/ Prisma schema and migrations
tests/ client and server tests
specs/ planning docs and optional continuity snapshots
- Server tests use a separate schema by default via
TEST_DATABASE_SCHEMA. - Browser sessions and local storage keys were renamed during open-source cleanup, so older local sessions may need a fresh login.