diff --git a/.env.example b/.env.example index e72db10..61af78f 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,7 @@ -# Copy to .env on the deploy host (never commit real tokens). +# Copy to .env for local run / deploy host (never commit real tokens). +# Optional: bootstraps a "Family" board that legacy ?invite= links can resolve. STORK_INVITE_TOKEN=change-me-long-random STORK_ADMIN_TOKEN=change-me-admin-random STORK_COOKIE_SECURE=false STORK_DATA=./data +# STORK_PUBLIC_URL=https://stork.levkin.ca diff --git a/AGENTS.md b/AGENTS.md index cd632de..1e22869 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,13 +4,13 @@ Short orientation for Cursor agents. ## Defaults -- Family baby-name board (votes + en/ru/he notes). Run/install: `README.md`. +- Multi-board baby-name app (`/b/` share links). Run/install: `README.md`. - Lint/tests are CI hard gates: `make lint` / `make test` — never `|| true`. -- Secrets: Infisical `/apps/stork` or gitignored `.env`. Never commit tokens. +- Secrets: gitignored `.env` or deploy host `.env`. Never commit tokens. - Deploy/DNS/Caddy/Kuma live in `~/Documents/code/ansible` (`make deploy-stork`). ## Close-out 1. `make test` and `make lint` before claiming done. -2. Do not put LAN IPs or real invites in this repo. -3. Merge only when the user asks. +2. Do not put LAN IPs or real invites/board ids in this repo. +3. Merge only when the user asks (or when they explicitly say merge when green). diff --git a/Makefile b/Makefile index c44ebf8..c9c0806 100644 --- a/Makefile +++ b/Makefile @@ -19,10 +19,18 @@ run: ## Local uvicorn on :8094 (needs .env) @test -f .env || (echo "Copy .env.example to .env first" && exit 1) set -a && . ./.env && set +a && uvicorn stork.app:app --reload --host 127.0.0.1 --port 8094 -seed: ## Seed example first names (usage: make seed STORK_INVITE=... [STORK_URL=...]) - @test -n "$(STORK_INVITE)" || (echo "Set STORK_INVITE=..." && exit 1) - STORK_URL="$(or $(STORK_URL),http://127.0.0.1:8094)" STORK_INVITE="$(STORK_INVITE)" \ +seed: ## Seed example names (usage: make seed STORK_BOARD_ID=b_… OR STORK_INVITE=…) + @if [ -z "$(STORK_BOARD_ID)" ] && [ -z "$(STORK_INVITE)" ]; then \ + echo "Set STORK_BOARD_ID=b_… or STORK_INVITE=…"; exit 1; \ + fi + STORK_URL="$(or $(STORK_URL),http://127.0.0.1:8094)" \ + STORK_BOARD_ID="$(STORK_BOARD_ID)" STORK_INVITE="$(STORK_INVITE)" \ .venv/bin/python scripts/seed_names.py +create-board: ## Create empty board + print share URL (usage: make create-board [STORK_BOARD_TITLE='…']) + STORK_URL="$(or $(STORK_URL),http://127.0.0.1:8094)" \ + STORK_BOARD_TITLE="$(or $(STORK_BOARD_TITLE),Name board)" \ + .venv/bin/python scripts/create_board.py + docker-build: ## Build local image docker build -t homelab-stork:latest . diff --git a/README.md b/README.md index 8ebf27c..6c50dab 100644 --- a/README.md +++ b/README.md @@ -1,42 +1,71 @@ # Stork -Private family board for baby **first** and **middle** name ideas: suggest -names, vote, and keep origin / meaning / pronunciation in English, Russian, -and Hebrew. +Private family baby-name boards: suggest names, vote, keep origin / meaning / +pronunciation in English, Russian, and Hebrew, and optionally record how a +name sounds. -Homelab deploy lives in the ansible repo (`make deploy-stork`). This repo is -the app only — no LAN IPs or production secrets here. +Each board has a unique share link (`/b/`). Start a board with no password, +add names, share the link. Homelab deploy lives in the ansible repo +(`make deploy-stork`). This repo is the app only — no LAN IPs or production +secrets here. ## Quick start (local) ```bash -cp .env.example .env # set STORK_INVITE_TOKEN (and optional STORK_ADMIN_TOKEN) +cp .env.example .env # optional STORK_INVITE_TOKEN bootstraps a "Family" board make install make test make run ``` -Open http://127.0.0.1:8094 — enter the invite code and your display name. -Share `http://127.0.0.1:8094/?invite=YOUR_TOKEN` with family for one-tap entry. +Open http://127.0.0.1:8094 → **Start new board** → share `/b/…` with family. +They open the link and enter a display name. + +## Features + +- Renamable columns (First is fixed; `+` adds up to 4; ✕ removes extras) +- Votes / ranking per board +- Locale notes (en / ru / he) + voice recordings (re-record overwrites) +- Paper UI at `/` (classic layout at `/v1`) ## API sketch | Method | Path | Notes | |--------|------|-------| | GET | `/api/health` | Liveness | -| POST | `/api/session` | `{invite, display_name}` → cookies | -| GET | `/api/names?kind=first\|middle` | Ranked list | -| POST | `/api/names` | Add name + optional locales | -| PATCH | `/api/names/{id}` | Update locale notes | +| POST | `/api/boards` | Start board `{title}` → `{id, url}` | +| GET | `/api/boards/{id}` | Public board title + url | +| GET | `/api/resolve?invite=` | Legacy invite → board url | +| POST | `/api/session` | `{board_id, display_name}` → cookies | +| GET | `/api/columns` | Columns for session board | +| POST/PATCH/DELETE | `/api/columns…` | Add / rename / remove (not first) | +| GET/POST | `/api/names` | List / add | +| PATCH | `/api/names/{id}` | Locale notes | | POST | `/api/names/{id}/vote` | `{value: 1\|-1\|0}` | +| POST/GET/DELETE | `/api/names/{id}/recording/{lang}` | Voice | | DELETE | `/api/names/{id}` | Admin header `X-Stork-Admin` | +## Scripts + +```bash +# Seed Shai/Roze/Odet/Rivka onto a board +make seed STORK_BOARD_ID=b_… # or STORK_INVITE=… for legacy + +# Create an empty board and print share URL +.venv/bin/python scripts/create_board.py +``` + ## Secrets - Local: gitignored `.env` -- Prod: Infisical `/apps/stork` (or host `.env` written by deploy) — never git +- Prod: host `/opt/stork/.env` from deploy (never git) + +## CI gates + +Gitea Actions (`.gitea/workflows/ci.yml`): **ruff** and **pytest** are hard gates +(no `|| true`). Gitleaks on PRs. Bandit is advisory. ## Production shape -Same host pattern as Compare: Docker on automationlab LXC **225**, public -HTTPS via Caddy (`stork.levkin.ca`). See ansible `docs/guides/stork-deploy.md`. +Docker on automationlab; public HTTPS via Caddy (`stork.levkin.ca`). See ansible +`docs/guides/stork-deploy.md`. diff --git a/data/seed-names.example.json b/data/seed-names.example.json index 641e524..c4e67fd 100644 --- a/data/seed-names.example.json +++ b/data/seed-names.example.json @@ -1,87 +1,39 @@ { "names": [ { - "kind": "first", + "kind": "c1", "spelling": "Shai", "locales": { - "en": { - "pronunciation": "SHY (rhymes with sky)", - "origin": "Hebrew (שי)", - "meaning": "Gift / present; sometimes a short form of Isaiah (Yeshayahu). Variants: Shay, Shai." - }, - "ru": { - "pronunciation": "Шай", - "origin": "иврит", - "meaning": "«дар», «подарок»; иногда уменьшительное от Исайи. Варианты: Shay, Shai." - }, - "he": { - "pronunciation": "שַׁי — shai", - "origin": "עברית", - "meaning": "שי — מתנה / מנחה. לפעמים קיצור של ישעיהו. כתיבים: שי, Shay." - } + "en": {"pronunciation": "Shai", "origin": "Hebrew", "meaning": "Gift. Variants: Shay."}, + "ru": {"pronunciation": "Шай", "origin": "иврит", "meaning": "Дар, подарок."}, + "he": {"pronunciation": "שי", "origin": "עברית", "meaning": "מתנה."} } }, { - "kind": "first", + "kind": "c1", "spelling": "Roze", "locales": { - "en": { - "pronunciation": "ROHZ", - "origin": "Latin rosa / flower name (also Germanic Rose lineage)", - "meaning": "Rose (the flower); love and beauty. Spelling variant of Rose. Related: Rose, Rosa, Roza (RU/PL), Rosie, Rožė (LT), Roze (LV)." - }, - "ru": { - "pronunciation": "Ро́уз / Ро́за", - "origin": "латинское rosa; славянская форма Роза", - "meaning": "роза (цветок). Варианты: Rose, Rosa, Roza, Rosie." - }, - "he": { - "pronunciation": "רוֹז", - "origin": "לטינית / שם פרח", - "meaning": "ורד / רוזה. כתיבים קרובים: Rose, Rosa, Roza; בעברית גם ורד (Vered)." - } + "en": {"pronunciation": "Roze", "origin": "Latin rosa", "meaning": "Rose. Related: Rose, Rosa, Roza."}, + "ru": {"pronunciation": "Роуз", "origin": "латинское rosa", "meaning": "Роза. Также: Rose, Rosa, Roza."}, + "he": {"pronunciation": "שושנה", "origin": "עברית", "meaning": "מקבילה ל־Roze/Rose. גם ורד."} } }, { - "kind": "first", + "kind": "c1", "spelling": "Odet", "locales": { - "en": { - "pronunciation": "oh-DET", - "origin": "French diminutive (Odette) from Germanic od-/ot- “wealth”", - "meaning": "Wealth / prosperity. Short form of Odette (Swan Lake). Variants: Odette, Odetta, Ode, Oda." - }, - "ru": { - "pronunciation": "Оде́т", - "origin": "французское Odette ← германское «богатство»", - "meaning": "богатство / достаток. Варианты: Odette, Одетта, Odetta." - }, - "he": { - "pronunciation": "אוֹדֶט", - "origin": "צרפתית (Odette) משורש גרמאני", - "meaning": "עושר / שפע. צורות: Odette, Odetta, Odet." - } + "en": {"pronunciation": "Odet", "origin": "French (Odette)", "meaning": "Wealth / prosperity."}, + "ru": {"pronunciation": "Одет", "origin": "французское Odette", "meaning": "Богатство."}, + "he": {"pronunciation": "אודט", "origin": "צרפתית", "meaning": "אין מקבילה עברית נפוצה."} } }, { - "kind": "first", + "kind": "c1", "spelling": "Rivka", "locales": { - "en": { - "pronunciation": "riv-KAH / REEV-kah (short form Riva: REE-vah)", - "origin": "Hebrew biblical (רִבְקָה) — wife of Isaac", - "meaning": "From root “to bind / tie / join”; often glossed captivating. One entry for Rivka and Riva (short). English: Rebecca, Rebekah. Also Rifka, Becky." - }, - "ru": { - "pronunciation": "Ри́вка (кратко: Ри́ва)", - "origin": "библейское еврейское имя (Ревекка)", - "meaning": "«связывать», «соединять»; библейская Ревекка. Одна запись для Ривка и Рива. Варианты: Rebecca, Rebekah, Rifka." - }, - "he": { - "pronunciation": "רִבְקָה — rivká (קיצור: רִיבָה / Riva)", - "origin": "תנ״ך — אשת יצחק", - "meaning": "שורש ר־ב־ק (לקשור / לחבר). רבקה ו־Riva הן אותה שם (קיצור). באנגלית Rebecca." - } + "en": {"pronunciation": "Rivka", "origin": "Hebrew", "meaning": "To bind / join. Short form: Riva. English: Rebecca."}, + "ru": {"pronunciation": "Ривка", "origin": "иврит", "meaning": "Связывать. Кратко: Рива. Ревекка."}, + "he": {"pronunciation": "רבקה", "origin": "תנ״ך", "meaning": "קיצור: ריבה. באנגלית Rebecca."} } } ] diff --git a/requirements.txt b/requirements.txt index 8e4382f..0f742e2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ fastapi==0.115.12 uvicorn[standard]==0.34.2 pydantic==2.11.3 +python-multipart==0.0.20 diff --git a/scripts/create_board.py b/scripts/create_board.py new file mode 100644 index 0000000..38ccd81 --- /dev/null +++ b/scripts/create_board.py @@ -0,0 +1,29 @@ +"""Create an extra Stork board (or use the public Start button). + +Usage: + STORK_URL=https://stork.levkin.ca \\ + STORK_BOARD_TITLE='Dan & Mira' \\ + .venv/bin/python scripts/create_board.py +""" + +from __future__ import annotations + +import os + +import httpx + + +def main() -> int: + base = os.environ.get("STORK_URL", "http://127.0.0.1:8094").rstrip("/") + title = os.environ.get("STORK_BOARD_TITLE", "").strip() or "Name board" + with httpx.Client(base_url=base, timeout=30.0) as client: + res = client.post("/api/boards", json={"title": title}) + res.raise_for_status() + data = res.json() + print(f"Board: {data['title']} ({data['id']})") + print(f"Share URL: {data['url']}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/seed_names.py b/scripts/seed_names.py index ba2e38e..2c2df82 100644 --- a/scripts/seed_names.py +++ b/scripts/seed_names.py @@ -1,8 +1,11 @@ -"""Seed starter first names into a local or remote Stork instance. +"""Seed or refresh starter names (add missing, patch locales on existing). Usage: - STORK_URL=http://10.0.10.45:8094 STORK_INVITE=... \\ + STORK_URL=https://stork.levkin.ca STORK_BOARD_ID=b_xxx \\ .venv/bin/python scripts/seed_names.py + + # or resolve legacy invite: + STORK_URL=... STORK_INVITE=... .venv/bin/python scripts/seed_names.py """ from __future__ import annotations @@ -20,24 +23,49 @@ SEED = ROOT / "data" / "seed-names.example.json" def main() -> int: base = os.environ.get("STORK_URL", "http://127.0.0.1:8094").rstrip("/") + board_id = os.environ.get("STORK_BOARD_ID", "").strip() invite = os.environ.get("STORK_INVITE", "").strip() display = os.environ.get("STORK_DISPLAY_NAME", "Ilia").strip() or "Ilia" - if not invite: - print("Set STORK_INVITE", file=sys.stderr) + if not board_id and not invite: + print("Set STORK_BOARD_ID or STORK_INVITE", file=sys.stderr) return 1 names = json.loads(SEED.read_text())["names"] - headers = { - "X-Stork-Invite": invite, - "X-Stork-Display-Name": display, - } - with httpx.Client(base_url=base, timeout=30.0, headers=headers) as client: - for item in names: - res = client.post("/api/names", json=item) - if res.status_code == 400 and "already exists" in res.text: - print(f"skip {item['spelling']} (exists)") + with httpx.Client(base_url=base, timeout=30.0) as client: + if not board_id: + resolved = client.get("/api/resolve", params={"invite": invite}) + resolved.raise_for_status() + board_id = resolved.json()["id"] + headers = { + "X-Stork-Board": board_id, + "X-Stork-Display-Name": display, + } + existing: dict[tuple[str, str], int] = {} + for kind in {n.get("kind", "c1") for n in names} | {"c1"}: + listed = client.get("/api/names", params={"kind": kind}, headers=headers) + if listed.status_code != 200: continue + for row in listed.json().get("items") or []: + existing[(row["kind"], row["spelling"].casefold())] = row["id"] + + for item in names: + kind = item["kind"] + spelling = item["spelling"] + key = (kind, spelling.casefold()) + locales = item.get("locales") or {} + if key in existing: + name_id = existing[key] + res = client.patch( + f"/api/names/{name_id}", + headers=headers, + json={"locales": locales}, + ) + res.raise_for_status() + print(f"updated locales {kind}: {spelling}") + continue + res = client.post("/api/names", headers=headers, json=item) res.raise_for_status() - print(f"added {item['kind']}: {item['spelling']}") + print(f"added {kind}: {spelling}") + print(f"board: {base}/b/{board_id}") return 0 diff --git a/static/favicon.png b/static/favicon.png new file mode 100644 index 0000000..cace1fe Binary files /dev/null and b/static/favicon.png differ diff --git a/static/index.html b/static/index.html index 2d458d3..ac29aca 100644 --- a/static/index.html +++ b/static/index.html @@ -3,87 +3,96 @@ + + + Stork — family name board + + -
+ +
+
+ Theme + + + +
+
- -

Stork names

-

Family board for first and middle names — vote, hear how they sound, and keep notes in English, Russian, and Hebrew.

+ Stork +
+

Stork

+

Classic layout · Paper board · Logo ideas

+
- + - -
- + +
+
diff --git a/static/logo-hero.png b/static/logo-hero.png new file mode 100644 index 0000000..4796265 Binary files /dev/null and b/static/logo-hero.png differ diff --git a/static/logo-mark.png b/static/logo-mark.png new file mode 100644 index 0000000..77fdaf8 Binary files /dev/null and b/static/logo-mark.png differ diff --git a/static/logos.html b/static/logos.html new file mode 100644 index 0000000..f4c6f90 --- /dev/null +++ b/static/logos.html @@ -0,0 +1,215 @@ + + + + + + Stork — pick a logo + + + + + + +
+ +

Pick a Stork mark

+

All five logo concepts. Site currently uses concept 1 (paper stork silhouette) as the header mark and favicon. Name board →

+ +
+ + +
+ + + diff --git a/static/logos/01-silhouette.png b/static/logos/01-silhouette.png new file mode 100644 index 0000000..eb3823e Binary files /dev/null and b/static/logos/01-silhouette.png differ diff --git a/static/logos/02-nest-monogram.png b/static/logos/02-nest-monogram.png new file mode 100644 index 0000000..0176f30 Binary files /dev/null and b/static/logos/02-nest-monogram.png differ diff --git a/static/logos/03-hebrew-stamp.png b/static/logos/03-hebrew-stamp.png new file mode 100644 index 0000000..8223c9a Binary files /dev/null and b/static/logos/03-hebrew-stamp.png differ diff --git a/static/logos/04-ribbon-stork.png b/static/logos/04-ribbon-stork.png new file mode 100644 index 0000000..0104f25 Binary files /dev/null and b/static/logos/04-ribbon-stork.png differ diff --git a/static/logos/05-trilingual-arcs.png b/static/logos/05-trilingual-arcs.png new file mode 100644 index 0000000..0a56c87 Binary files /dev/null and b/static/logos/05-trilingual-arcs.png differ diff --git a/static/stork-common.js b/static/stork-common.js new file mode 100644 index 0000000..005d008 --- /dev/null +++ b/static/stork-common.js @@ -0,0 +1,96 @@ +/** Shared helpers for Stork v1 + v2. */ +(function (global) { + function formatError(detail) { + if (detail == null || detail === "") return "Something went wrong"; + if (typeof detail === "string") { + try { + const parsed = JSON.parse(detail); + return formatError(parsed); + } catch { + return detail; + } + } + if (Array.isArray(detail)) { + return detail + .map((item) => { + if (!item || typeof item !== "object") return String(item); + const loc = Array.isArray(item.loc) ? item.loc.filter((x) => x !== "body").join(".") : ""; + const msg = item.msg || "Invalid value"; + if (loc === "spelling" || (item.loc && item.loc.includes("spelling"))) { + if (String(msg).toLowerCase().includes("at least 1")) return "Enter a name spelling"; + return `Spelling: ${msg}`; + } + return loc ? `${loc}: ${msg}` : msg; + }) + .join(". "); + } + if (typeof detail === "object" && detail.msg) return String(detail.msg); + return String(detail); + } + + function showError(el, detail) { + if (!el) return; + el.textContent = formatError(detail); + el.classList.remove("hidden"); + } + + function clearError(el) { + if (!el) return; + el.textContent = ""; + el.classList.add("hidden"); + } + + const THEME_KEY = "stork_theme"; + + function systemTheme() { + return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; + } + + function currentTheme() { + return localStorage.getItem(THEME_KEY) || "system"; + } + + function resolvedTheme() { + const t = currentTheme(); + return t === "system" ? systemTheme() : t; + } + + function applyTheme() { + const resolved = resolvedTheme(); + document.documentElement.setAttribute("data-theme", resolved); + document.documentElement.style.colorScheme = resolved; + document.querySelectorAll("[data-theme-toggle]").forEach((btn) => { + const mode = btn.getAttribute("data-theme-toggle"); + const active = currentTheme() === mode; + btn.setAttribute("aria-pressed", active ? "true" : "false"); + btn.classList.toggle("active", active); + }); + } + + function setTheme(mode) { + if (mode === "system") localStorage.removeItem(THEME_KEY); + else localStorage.setItem(THEME_KEY, mode); + applyTheme(); + } + + function initThemeControls() { + applyTheme(); + document.querySelectorAll("[data-theme-toggle]").forEach((btn) => { + btn.addEventListener("click", () => setTheme(btn.getAttribute("data-theme-toggle"))); + }); + window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", () => { + if (currentTheme() === "system") applyTheme(); + }); + } + + global.StorkUI = { + formatError, + showError, + clearError, + applyTheme, + setTheme, + initThemeControls, + currentTheme, + resolvedTheme, + }; +})(window); diff --git a/static/v2.html b/static/v2.html new file mode 100644 index 0000000..8a04caf --- /dev/null +++ b/static/v2.html @@ -0,0 +1,990 @@ + + + + + + + + + Stork — family name board + + + + + + + + + +
+
+ Theme + + + +
+ + + +
+ +
+

Stork

+

Family name board — vote, edit how a name is said, and record your own voice when TTS misses.

+
+
+ +
+

Start a private board, add names, then share the link. No password — the link is the key.

+ + + + + +
+ +
+
+ + + + + +

Paper board · classic layout · mark is logo concept 1

+
+ + + diff --git a/stork/app.py b/stork/app.py index 4db267d..b0b58ee 100644 --- a/stork/app.py +++ b/stork/app.py @@ -1,4 +1,4 @@ -"""Stork HTTP API + invite-gated family UI.""" +"""Stork HTTP API + invite-gated multi-board family UI.""" from __future__ import annotations @@ -8,21 +8,37 @@ import secrets from pathlib import Path from typing import Any -from fastapi import Cookie, Depends, FastAPI, Header, HTTPException, Response -from fastapi.responses import FileResponse +from fastapi import Cookie, Depends, FastAPI, File, Header, HTTPException, Response, UploadFile +from fastapi.responses import FileResponse, RedirectResponse from fastapi.staticfiles import StaticFiles from pydantic import BaseModel, Field -from stork.db import KINDS, LANGS, Store +from stork.db import LANGS, MAX_COLUMNS, Store DATA_DIR = Path(os.environ.get("STORK_DATA", "./data")) INVITE_TOKEN = os.environ.get("STORK_INVITE_TOKEN", "").strip() ADMIN_TOKEN = os.environ.get("STORK_ADMIN_TOKEN", "").strip() COOKIE_SECURE = os.environ.get("STORK_COOKIE_SECURE", "false").lower() in {"1", "true", "yes"} +PUBLIC_BASE = os.environ.get("STORK_PUBLIC_URL", "https://stork.levkin.ca").rstrip("/") STATIC = Path(__file__).resolve().parent.parent / "static" +ALLOWED_AUDIO = { + "audio/webm", + "audio/ogg", + "audio/mp4", + "audio/mpeg", + "audio/wav", + "audio/x-m4a", + "audio/aac", + "video/webm", +} + app = FastAPI(title="Stork", docs_url=None, redoc_url=None) -store = Store(DATA_DIR / "stork.sqlite3") +store = Store( + DATA_DIR / "stork.sqlite3", + bootstrap_invite=INVITE_TOKEN, + bootstrap_title="Family", +) if STATIC.is_dir(): app.mount("/static", StaticFiles(directory=STATIC), name="static") @@ -35,11 +51,15 @@ class LocaleIn(BaseModel): class NameIn(BaseModel): - kind: str = Field(pattern="^(first|middle)$") + kind: str = Field(pattern=r"^c[1-4]$") spelling: str = Field(min_length=1, max_length=80) locales: dict[str, LocaleIn] = Field(default_factory=dict) +class ColumnPatch(BaseModel): + label: str = Field(default="", max_length=40) + + class LocalesPatch(BaseModel): locales: dict[str, LocaleIn] @@ -49,40 +69,22 @@ class VoteIn(BaseModel): class SessionIn(BaseModel): - invite: str = Field(min_length=1, max_length=200) display_name: str = Field(min_length=1, max_length=80) + board_id: str | None = Field(default=None, max_length=64) + invite: str | None = Field(default=None, max_length=200) -def _require_invite_configured() -> None: - if not INVITE_TOKEN: - raise HTTPException(503, "STORK_INVITE_TOKEN not configured") +class BoardCreatePublic(BaseModel): + title: str = Field(default="Name board", min_length=1, max_length=80) -def _invite_ok(invite: str | None) -> bool: - _require_invite_configured() - return bool(invite) and secrets.compare_digest(invite, INVITE_TOKEN) +class BoardCreate(BaseModel): + title: str = Field(min_length=1, max_length=80) + invite: str | None = Field(default=None, max_length=200) -def _session( - stork_invite: str | None = Cookie(default=None), - stork_voter: str | None = Cookie(default=None), - stork_name: str | None = Cookie(default=None), - x_stork_invite: str | None = Header(default=None), - x_stork_display_name: str | None = Header(default=None), - x_stork_voter: str | None = Header(default=None), -) -> dict[str, str]: - invite = x_stork_invite or stork_invite - if not _invite_ok(invite): - raise HTTPException(401, "Invite required") - voter = (x_stork_voter or stork_voter or "").strip() - name = (x_stork_display_name or stork_name or "").strip() - if name and not voter: - # API/script clients without cookies (e.g. seed over HTTP while Secure cookies are on). - digest = hashlib.sha256(name.lower().encode()).hexdigest()[:24] - voter = f"hdr-{digest}" - if not voter or not name: - raise HTTPException(401, "Session required — open the invite link and enter your name") - return {"voter_key": voter, "display_name": name} +class BoardPatch(BaseModel): + title: str = Field(min_length=1, max_length=80) def _admin( @@ -94,6 +96,66 @@ def _admin( raise HTTPException(403, "Admin token required") +def _resolve_board( + board_id: str | None = None, + invite: str | None = None, +) -> dict[str, Any]: + board_id = (board_id or "").strip() + invite = (invite or "").strip() + if board_id: + board = store.find_board(board_id) + if board: + return board + if invite: + board = store.find_board_by_invite(invite) + if board: + return board + raise HTTPException(404, "Board not found") + + +def _session( + stork_board: str | None = Cookie(default=None), + stork_invite: str | None = Cookie(default=None), + stork_voter: str | None = Cookie(default=None), + stork_name: str | None = Cookie(default=None), + x_stork_board: str | None = Header(default=None), + x_stork_invite: str | None = Header(default=None), + x_stork_display_name: str | None = Header(default=None), + x_stork_voter: str | None = Header(default=None), +) -> dict[str, str]: + board = _resolve_board( + board_id=x_stork_board or stork_board, + invite=x_stork_invite or stork_invite, + ) + voter = (x_stork_voter or stork_voter or "").strip() + name = (x_stork_display_name or stork_name or "").strip() + if name and not voter: + digest = hashlib.sha256(name.lower().encode()).hexdigest()[:24] + voter = f"hdr-{digest}" + if not voter or not name: + raise HTTPException(401, "Session required — enter your name on this board") + return { + "voter_key": voter, + "display_name": name, + "board_id": board["id"], + "board_title": board["title"], + } + + +def _board_url(board_id: str) -> str: + return f"{PUBLIC_BASE}/b/{board_id}" + + +def _cookie_kwargs() -> dict[str, Any]: + return { + "httponly": True, + "samesite": "lax", + "secure": COOKIE_SECURE, + "max_age": 60 * 60 * 24 * 400, + "path": "/", + } + + @app.get("/api/health") def health() -> dict[str, Any]: return {"ok": True, "service": "stork"} @@ -103,51 +165,133 @@ def health() -> dict[str, Any]: def meta() -> dict[str, Any]: return { "langs": list(LANGS), - "kinds": list(KINDS), - "invite_required": True, + "max_columns": MAX_COLUMNS, + "multi_board": True, } +@app.post("/api/boards") +def create_board_public(body: BoardCreatePublic) -> dict[str, Any]: + """Start a new board — no password. Share the returned URL with family.""" + board = store.create_board(body.title.strip() or "Name board") + return { + "id": board["id"], + "title": board["title"], + "url": _board_url(board["id"]), + } + + +@app.get("/api/boards/{board_id}") +def get_board_public(board_id: str) -> dict[str, Any]: + board = store.find_board(board_id) + if not board: + raise HTTPException(404, "Board not found") + return {"id": board["id"], "title": board["title"], "url": _board_url(board["id"])} + + +@app.get("/api/resolve") +def resolve_invite(invite: str = "") -> dict[str, Any]: + """Map a legacy invite token to a board URL.""" + board = store.find_board_by_invite(invite) + if not board: + raise HTTPException(404, "Board not found") + return {"id": board["id"], "title": board["title"], "url": _board_url(board["id"])} + + @app.post("/api/session") def create_session(body: SessionIn, response: Response) -> dict[str, Any]: - if not _invite_ok(body.invite.strip()): - raise HTTPException(403, "Invalid invite") + board = _resolve_board(board_id=body.board_id, invite=body.invite) voter = secrets.token_urlsafe(16) display = body.display_name.strip() - cookie_kwargs: dict[str, Any] = { - "httponly": True, - "samesite": "lax", - "secure": COOKIE_SECURE, - "max_age": 60 * 60 * 24 * 400, - "path": "/", + kwargs = _cookie_kwargs() + response.set_cookie("stork_board", board["id"], **kwargs) + response.set_cookie("stork_voter", voter, **kwargs) + response.set_cookie("stork_name", display, **{**kwargs, "httponly": False}) + response.delete_cookie("stork_invite", path="/") + return { + "ok": True, + "display_name": display, + "board": {"id": board["id"], "title": board["title"], "url": _board_url(board["id"])}, } - response.set_cookie("stork_invite", INVITE_TOKEN, **cookie_kwargs) - response.set_cookie("stork_voter", voter, **cookie_kwargs) - response.set_cookie("stork_name", display, **{**cookie_kwargs, "httponly": False}) - return {"ok": True, "display_name": display} @app.get("/api/session") def get_session( + stork_board: str | None = Cookie(default=None), stork_invite: str | None = Cookie(default=None), stork_voter: str | None = Cookie(default=None), stork_name: str | None = Cookie(default=None), + x_stork_board: str | None = Header(default=None), x_stork_invite: str | None = Header(default=None), ) -> dict[str, Any]: - invite = x_stork_invite or stork_invite - authed = _invite_ok(invite) if INVITE_TOKEN else False + board = None + try: + board = _resolve_board( + board_id=x_stork_board or stork_board, + invite=x_stork_invite or stork_invite, + ) + except HTTPException: + board = None + authed = bool(board) and bool(stork_voter) and bool(stork_name) return { - "authenticated": authed and bool(stork_voter) and bool(stork_name), + "authenticated": authed, "display_name": stork_name or "", - "has_invite": authed, + "board": ( + {"id": board["id"], "title": board["title"], "url": _board_url(board["id"])} + if board + else None + ), } +@app.get("/api/columns") +def list_columns(session: dict[str, str] = Depends(_session)) -> dict[str, Any]: + return {"items": store.list_columns(session["board_id"]), "max": MAX_COLUMNS} + + +@app.post("/api/columns") +def add_column(session: dict[str, str] = Depends(_session)) -> dict[str, Any]: + try: + return store.add_column(session["board_id"], "") + except ValueError as exc: + raise HTTPException(400, str(exc)) from exc + + +@app.patch("/api/columns/{column_id}") +def rename_column( + column_id: str, + body: ColumnPatch, + session: dict[str, str] = Depends(_session), +) -> dict[str, Any]: + updated = store.rename_column(session["board_id"], column_id, body.label) + if not updated: + raise HTTPException(404, "Column not found") + return updated + + +@app.delete("/api/columns/{column_id}") +def delete_column( + column_id: str, + session: dict[str, str] = Depends(_session), +) -> dict[str, Any]: + try: + if not store.delete_column(session["board_id"], column_id): + raise HTTPException(404, "Column not found") + except ValueError as exc: + raise HTTPException(400, str(exc)) from exc + return {"ok": True, "items": store.list_columns(session["board_id"])} + + @app.get("/api/names") -def list_names(kind: str = "first", session: dict[str, str] = Depends(_session)) -> dict[str, Any]: - if kind not in KINDS: - raise HTTPException(400, "kind must be first or middle") - return {"items": store.list_names(kind, voter_key=session["voter_key"])} +def list_names(kind: str = "c1", session: dict[str, str] = Depends(_session)) -> dict[str, Any]: + try: + return { + "items": store.list_names( + session["board_id"], kind, voter_key=session["voter_key"] + ) + } + except ValueError as exc: + raise HTTPException(400, str(exc)) from exc @app.post("/api/names") @@ -155,6 +299,7 @@ def add_name(body: NameIn, session: dict[str, str] = Depends(_session)) -> dict[ locales = {k: v.model_dump() for k, v in body.locales.items()} try: return store.add_name( + board_id=session["board_id"], kind=body.kind, spelling=body.spelling, created_by=session["display_name"], @@ -170,9 +315,8 @@ def patch_name( body: LocalesPatch, session: dict[str, str] = Depends(_session), ) -> dict[str, Any]: - _ = session locales = {k: v.model_dump() for k, v in body.locales.items()} - updated = store.update_locales(name_id, locales) + updated = store.update_locales(name_id, locales, board_id=session["board_id"]) if not updated: raise HTTPException(404, "Name not found") return updated @@ -190,6 +334,7 @@ def vote_name( voter_key=session["voter_key"], voter_name=session["display_name"], value=body.value, + board_id=session["board_id"], ) except ValueError as exc: raise HTTPException(400, str(exc)) from exc @@ -198,6 +343,75 @@ def vote_name( return updated +@app.post("/api/names/{name_id}/recording/{lang}") +async def upload_recording( + name_id: int, + lang: str, + session: dict[str, str] = Depends(_session), + file: UploadFile = File(...), +) -> dict[str, Any]: + if lang not in LANGS: + raise HTTPException(400, "lang must be en, ru, or he") + raw = await file.read() + content_type = (file.content_type or "audio/webm").split(";")[0].strip().lower() + if content_type not in ALLOWED_AUDIO: + raise HTTPException(400, f"unsupported audio type: {content_type}") + ext = "webm" + if "ogg" in content_type: + ext = "ogg" + elif "mp4" in content_type or "m4a" in content_type or "aac" in content_type: + ext = "m4a" + elif "mpeg" in content_type or content_type == "audio/mp3": + ext = "mp3" + elif "wav" in content_type: + ext = "wav" + try: + updated = store.save_recording( + name_id=name_id, + lang=lang, + data=raw, + content_type=content_type if content_type != "video/webm" else "audio/webm", + recorded_by=session["display_name"], + ext=ext, + board_id=session["board_id"], + ) + except ValueError as exc: + raise HTTPException(400, str(exc)) from exc + if not updated: + raise HTTPException(404, "Name not found") + return updated + + +@app.get("/api/names/{name_id}/recording/{lang}") +def get_recording( + name_id: int, + lang: str, + session: dict[str, str] = Depends(_session), +) -> FileResponse: + if lang not in LANGS: + raise HTTPException(400, "lang must be en, ru, or he") + found = store.get_recording(name_id, lang, board_id=session["board_id"]) + if not found: + raise HTTPException(404, "Recording not found") + path, content_type = found + return FileResponse(path, media_type=content_type, filename=path.name) + + +@app.delete("/api/names/{name_id}/recording/{lang}") +def delete_recording( + name_id: int, + lang: str, + session: dict[str, str] = Depends(_session), +) -> dict[str, Any]: + try: + updated = store.delete_recording(name_id, lang, board_id=session["board_id"]) + except ValueError as exc: + raise HTTPException(400, str(exc)) from exc + if not updated: + raise HTTPException(404, "Name not found") + return updated + + @app.delete("/api/names/{name_id}") def delete_name(name_id: int, _: None = Depends(_admin)) -> dict[str, Any]: if not store.delete_name(name_id): @@ -205,9 +419,100 @@ def delete_name(name_id: int, _: None = Depends(_admin)) -> dict[str, Any]: return {"ok": True} +@app.get("/api/admin/boards") +def admin_list_boards(_: None = Depends(_admin)) -> dict[str, Any]: + items = [] + for b in store.list_boards(include_invite=True): + items.append( + { + "id": b["id"], + "title": b["title"], + "created_at": b["created_at"], + "url": _board_url(b["id"]), + } + ) + return {"items": items} + + +@app.post("/api/admin/boards") +def admin_create_board(body: BoardCreate, _: None = Depends(_admin)) -> dict[str, Any]: + try: + board = store.create_board(body.title, body.invite) + except ValueError as exc: + raise HTTPException(400, str(exc)) from exc + return { + "id": board["id"], + "title": board["title"], + "created_at": board["created_at"], + "url": _board_url(board["id"]), + } + + +@app.patch("/api/admin/boards/{board_id}") +def admin_rename_board( + board_id: str, + body: BoardPatch, + _: None = Depends(_admin), +) -> dict[str, Any]: + try: + updated = store.rename_board(board_id, body.title) + except ValueError as exc: + raise HTTPException(400, str(exc)) from exc + if not updated: + raise HTTPException(404, "Board not found") + return { + "id": updated["id"], + "title": updated["title"], + "created_at": updated["created_at"], + "url": _board_url(updated["id"]), + } + + +@app.delete("/api/admin/boards/{board_id}") +def admin_delete_board(board_id: str, _: None = Depends(_admin)) -> dict[str, Any]: + try: + if not store.delete_board(board_id): + raise HTTPException(404, "Board not found") + except ValueError as exc: + raise HTTPException(400, str(exc)) from exc + return {"ok": True} + + +def _ui_page() -> FileResponse: + page = STATIC / "v2.html" + if not page.is_file(): + raise HTTPException(404, "UI missing") + return FileResponse(page) + + @app.get("/") def home() -> FileResponse: + return _ui_page() + + +@app.get("/b/{board_id}") +def board_page(board_id: str) -> FileResponse: + if not store.find_board(board_id): + raise HTTPException(404, "Board not found") + return _ui_page() + + +@app.get("/v1") +def v1() -> FileResponse: index = STATIC / "index.html" if not index.is_file(): - raise HTTPException(404, "UI missing") + raise HTTPException(404, "Classic UI missing") return FileResponse(index) + + +@app.get("/v2") +def v2() -> RedirectResponse: + return RedirectResponse(url="/", status_code=302) + + +@app.get("/logos") +def logos() -> FileResponse: + page = STATIC / "logos.html" + if not page.is_file(): + raise HTTPException(404, "Logo gallery missing") + return FileResponse(page) diff --git a/stork/db.py b/stork/db.py index b598b76..7c3c76c 100644 --- a/stork/db.py +++ b/stork/db.py @@ -1,8 +1,9 @@ -"""SQLite persistence for names, locales, and votes.""" +"""SQLite persistence: boards, columns, names, locales, votes, recordings.""" from __future__ import annotations import json +import secrets import sqlite3 import threading import time @@ -10,16 +11,41 @@ from pathlib import Path from typing import Any LANGS = ("en", "ru", "he") -KINDS = ("first", "middle") +MAX_COLUMNS = 4 +LEGACY_KIND_ORDER = ("first", "middle", "hebrew", "russian") +LEGACY_LABELS = { + "first": "First", + "middle": "Middle", + "hebrew": "Hebrew", + "russian": "Russian", +} _SCHEMA = """ +CREATE TABLE IF NOT EXISTS boards ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + invite_token TEXT NOT NULL UNIQUE, + created_at REAL NOT NULL +); + +CREATE TABLE IF NOT EXISTS board_columns ( + board_id TEXT NOT NULL REFERENCES boards(id) ON DELETE CASCADE, + id TEXT NOT NULL, + label TEXT NOT NULL DEFAULT '', + position INTEGER NOT NULL, + created_at REAL NOT NULL, + PRIMARY KEY (board_id, id), + UNIQUE (board_id, position) +); + CREATE TABLE IF NOT EXISTS names ( id INTEGER PRIMARY KEY AUTOINCREMENT, - kind TEXT NOT NULL CHECK (kind IN ('first', 'middle')), + board_id TEXT NOT NULL REFERENCES boards(id) ON DELETE CASCADE, + kind TEXT NOT NULL, spelling TEXT NOT NULL, created_by TEXT NOT NULL DEFAULT '', created_at REAL NOT NULL, - UNIQUE (kind, spelling COLLATE NOCASE) + UNIQUE (board_id, kind, spelling COLLATE NOCASE) ); CREATE TABLE IF NOT EXISTS locales ( @@ -40,20 +66,45 @@ CREATE TABLE IF NOT EXISTS votes ( PRIMARY KEY (name_id, voter_key) ); -CREATE INDEX IF NOT EXISTS idx_names_kind ON names(kind); +CREATE TABLE IF NOT EXISTS recordings ( + name_id INTEGER NOT NULL REFERENCES names(id) ON DELETE CASCADE, + lang TEXT NOT NULL CHECK (lang IN ('en', 'ru', 'he')), + path TEXT NOT NULL, + content_type TEXT NOT NULL DEFAULT 'audio/webm', + recorded_by TEXT NOT NULL DEFAULT '', + created_at REAL NOT NULL, + PRIMARY KEY (name_id, lang) +); + +CREATE INDEX IF NOT EXISTS idx_names_board_kind ON names(board_id, kind); CREATE INDEX IF NOT EXISTS idx_votes_name ON votes(name_id); """ class Store: - def __init__(self, path: Path) -> None: + def __init__(self, path: Path, bootstrap_invite: str = "", bootstrap_title: str = "Family") -> None: self.path = path self.path.parent.mkdir(parents=True, exist_ok=True) + self.audio_dir = self.path.parent / "recordings" + self.audio_dir.mkdir(parents=True, exist_ok=True) self._lock = threading.Lock() self._conn = sqlite3.connect(str(self.path), check_same_thread=False) self._conn.row_factory = sqlite3.Row self._conn.execute("PRAGMA foreign_keys = ON") with self._lock: + # Boards table first so invite bootstrap / attach can run before indexes. + self._conn.execute( + """ + CREATE TABLE IF NOT EXISTS boards ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + invite_token TEXT NOT NULL UNIQUE, + created_at REAL NOT NULL + ) + """ + ) + self._migrate_legacy_pre_boards() + self._migrate_attach_boards(bootstrap_invite.strip(), bootstrap_title.strip() or "Family") self._conn.executescript(_SCHEMA) self._conn.commit() @@ -61,9 +112,427 @@ class Store: with self._lock: self._conn.close() + def _table_sql(self, name: str) -> str: + row = self._conn.execute( + "SELECT sql FROM sqlite_master WHERE type='table' AND name=?", + (name,), + ).fetchone() + return (row["sql"] or "") if row else "" + + def _table_exists(self, name: str) -> bool: + row = self._conn.execute( + "SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", + (name,), + ).fetchone() + return row is not None + + def _migrate_legacy_pre_boards(self) -> None: + """Normalize pre-board schemas so the boards migration can attach cleanly.""" + if not self._table_exists("names"): + return + sql = self._table_sql("names") + if "board_id" in sql: + return + # Drop kind CHECK if present + if "CHECK" in sql.upper(): + self._conn.executescript( + """ + CREATE TABLE names_legacy ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + kind TEXT NOT NULL, + spelling TEXT NOT NULL, + created_by TEXT NOT NULL DEFAULT '', + created_at REAL NOT NULL, + UNIQUE (kind, spelling COLLATE NOCASE) + ); + INSERT INTO names_legacy (id, kind, spelling, created_by, created_at) + SELECT id, kind, spelling, created_by, created_at FROM names; + DROP TABLE names; + ALTER TABLE names_legacy RENAME TO names; + """ + ) + # Ensure board_columns exists in old shape for later rewrite + if not self._table_exists("board_columns"): + self._conn.execute( + """ + CREATE TABLE board_columns ( + id TEXT PRIMARY KEY, + label TEXT NOT NULL DEFAULT '', + position INTEGER NOT NULL UNIQUE, + created_at REAL NOT NULL + ) + """ + ) + used = [ + r["kind"] + for r in self._conn.execute("SELECT DISTINCT kind FROM names").fetchall() + ] + ordered: list[str] = [] + for legacy in LEGACY_KIND_ORDER: + if legacy in used: + ordered.append(legacy) + for kind in used: + if kind not in ordered: + ordered.append(kind) + if not ordered: + ordered = ["c1"] + for i, old in enumerate(ordered[:MAX_COLUMNS]): + new_id = old if old.startswith("c") and old[1:].isdigit() else f"c{i + 1}" + label = LEGACY_LABELS.get(old, "First" if i == 0 else "") + self._conn.execute( + "INSERT INTO board_columns (id, label, position, created_at) VALUES (?, ?, ?, ?)", + (new_id, label, i, time.time()), + ) + if old != new_id: + self._conn.execute("UPDATE names SET kind = ? WHERE kind = ?", (new_id, old)) + + def _migrate_attach_boards(self, bootstrap_invite: str, bootstrap_title: str) -> None: + names_sql = self._table_sql("names") + cols_sql = self._table_sql("board_columns") + needs_names = self._table_exists("names") and "board_id" not in names_sql + needs_cols = self._table_exists("board_columns") and "board_id" not in cols_sql + + if not needs_names and not needs_cols: + # Ensure board_columns exists before seeding a first column. + self._conn.execute( + """ + CREATE TABLE IF NOT EXISTS board_columns ( + board_id TEXT NOT NULL, + id TEXT NOT NULL, + label TEXT NOT NULL DEFAULT '', + position INTEGER NOT NULL, + created_at REAL NOT NULL, + PRIMARY KEY (board_id, id), + UNIQUE (board_id, position) + ) + """ + ) + # Ensure at least one board when invite configured + if bootstrap_invite and not self._conn.execute("SELECT 1 FROM boards LIMIT 1").fetchone(): + self._create_board_unlocked(bootstrap_title, bootstrap_invite) + board_id = self._conn.execute( + "SELECT id FROM boards WHERE invite_token = ?", (bootstrap_invite,) + ).fetchone()["id"] + self._conn.execute( + "INSERT INTO board_columns (board_id, id, label, position, created_at) VALUES (?, 'c1', 'First', 0, ?)", + (board_id, time.time()), + ) + elif bootstrap_invite: + # Keep env invite mapped to a board (create if missing) + found = self._conn.execute( + "SELECT id FROM boards WHERE invite_token = ?", (bootstrap_invite,) + ).fetchone() + if not found: + # Prefer updating the oldest board's invite only if unique + oldest = self._conn.execute( + "SELECT id FROM boards ORDER BY created_at ASC LIMIT 1" + ).fetchone() + if oldest and self._conn.execute("SELECT COUNT(*) AS n FROM boards").fetchone()["n"] == 1: + self._conn.execute( + "UPDATE boards SET invite_token = ? WHERE id = ?", + (bootstrap_invite, oldest["id"]), + ) + else: + self._create_board_unlocked(bootstrap_title, bootstrap_invite) + board_id = self._conn.execute( + "SELECT id FROM boards WHERE invite_token = ?", (bootstrap_invite,) + ).fetchone()["id"] + if not self._conn.execute( + "SELECT 1 FROM board_columns WHERE board_id = ?", (board_id,) + ).fetchone(): + self._conn.execute( + "INSERT INTO board_columns (board_id, id, label, position, created_at) VALUES (?, 'c1', 'First', 0, ?)", + (board_id, time.time()), + ) + return + + invite = bootstrap_invite or secrets.token_urlsafe(18) + board_id = "b_" + secrets.token_urlsafe(8) + self._conn.execute( + "INSERT OR IGNORE INTO boards (id, title, invite_token, created_at) VALUES (?, ?, ?, ?)", + (board_id, bootstrap_title, invite, time.time()), + ) + # If ignore hit (token clash), fetch existing + row = self._conn.execute( + "SELECT id FROM boards WHERE invite_token = ?", (invite,) + ).fetchone() + board_id = row["id"] + + if needs_cols: + self._conn.executescript( + f""" + CREATE TABLE board_columns_new ( + board_id TEXT NOT NULL, + id TEXT NOT NULL, + label TEXT NOT NULL DEFAULT '', + position INTEGER NOT NULL, + created_at REAL NOT NULL, + PRIMARY KEY (board_id, id), + UNIQUE (board_id, position) + ); + INSERT INTO board_columns_new (board_id, id, label, position, created_at) + SELECT '{board_id}', id, label, position, created_at FROM board_columns; + DROP TABLE board_columns; + ALTER TABLE board_columns_new RENAME TO board_columns; + """ + ) + if needs_names: + self._conn.executescript( + f""" + CREATE TABLE names_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + board_id TEXT NOT NULL, + kind TEXT NOT NULL, + spelling TEXT NOT NULL, + created_by TEXT NOT NULL DEFAULT '', + created_at REAL NOT NULL, + UNIQUE (board_id, kind, spelling COLLATE NOCASE) + ); + INSERT INTO names_new (id, board_id, kind, spelling, created_by, created_at) + SELECT id, '{board_id}', kind, spelling, created_by, created_at FROM names; + DROP TABLE names; + ALTER TABLE names_new RENAME TO names; + CREATE INDEX IF NOT EXISTS idx_names_board_kind ON names(board_id, kind); + """ + ) + if not self._conn.execute( + "SELECT 1 FROM board_columns WHERE board_id = ?", (board_id,) + ).fetchone(): + self._conn.execute( + "INSERT INTO board_columns (board_id, id, label, position, created_at) VALUES (?, 'c1', 'First', 0, ?)", + (board_id, time.time()), + ) + + def _create_board_unlocked(self, title: str, invite_token: str) -> dict[str, Any]: + board_id = "b_" + secrets.token_urlsafe(8) + now = time.time() + self._conn.execute( + "INSERT INTO boards (id, title, invite_token, created_at) VALUES (?, ?, ?, ?)", + (board_id, title[:80], invite_token, now), + ) + return {"id": board_id, "title": title[:80], "invite_token": invite_token, "created_at": now} + + def _board_row(self, row: sqlite3.Row, *, include_invite: bool = False) -> dict[str, Any]: + out = { + "id": row["id"], + "title": row["title"], + "created_at": row["created_at"], + } + if include_invite: + out["invite_token"] = row["invite_token"] + return out + + def find_board_by_invite(self, invite: str) -> dict[str, Any] | None: + invite = (invite or "").strip() + if not invite: + return None + with self._lock: + row = self._conn.execute( + "SELECT * FROM boards WHERE invite_token = ?", (invite,) + ).fetchone() + return self._board_row(row) if row else None + + def find_board(self, board_id: str) -> dict[str, Any] | None: + board_id = (board_id or "").strip() + if not board_id: + return None + with self._lock: + row = self._conn.execute("SELECT * FROM boards WHERE id = ?", (board_id,)).fetchone() + return self._board_row(row) if row else None + + def get_board(self, board_id: str) -> dict[str, Any] | None: + return self.find_board(board_id) + + def list_boards(self, *, include_invite: bool = False) -> list[dict[str, Any]]: + with self._lock: + rows = self._conn.execute("SELECT * FROM boards ORDER BY created_at").fetchall() + return [self._board_row(r, include_invite=include_invite) for r in rows] + + def create_board(self, title: str, invite_token: str | None = None) -> dict[str, Any]: + title = (title or "").strip()[:80] or "Family" + token = (invite_token or "").strip() or secrets.token_urlsafe(18) + with self._lock: + if self._conn.execute( + "SELECT 1 FROM boards WHERE invite_token = ?", (token,) + ).fetchone(): + raise ValueError("invite token already in use") + board = self._create_board_unlocked(title, token) + self._conn.execute( + "INSERT INTO board_columns (board_id, id, label, position, created_at) VALUES (?, 'c1', 'First', 0, ?)", + (board["id"], time.time()), + ) + self._conn.commit() + return {**board, "invite_token": token} + + def rename_board(self, board_id: str, title: str) -> dict[str, Any] | None: + title = (title or "").strip()[:80] + if not title: + raise ValueError("title required") + with self._lock: + cur = self._conn.execute( + "UPDATE boards SET title = ? WHERE id = ?", (title, board_id) + ) + if cur.rowcount == 0: + return None + self._conn.commit() + row = self._conn.execute("SELECT * FROM boards WHERE id = ?", (board_id,)).fetchone() + assert row is not None + return self._board_row(row, include_invite=True) + + def delete_board(self, board_id: str) -> bool: + with self._lock: + count = self._conn.execute("SELECT COUNT(*) AS n FROM boards").fetchone()["n"] + if count <= 1: + raise ValueError("keep at least one board") + name_ids = [ + r["id"] + for r in self._conn.execute( + "SELECT id FROM names WHERE board_id = ?", (board_id,) + ).fetchall() + ] + for name_id in name_ids: + recs = self._conn.execute( + "SELECT path FROM recordings WHERE name_id = ?", (name_id,) + ).fetchall() + for rec in recs: + path = self.audio_dir / rec["path"] + if path.is_file(): + path.unlink() + cur = self._conn.execute("DELETE FROM boards WHERE id = ?", (board_id,)) + self._conn.commit() + return cur.rowcount > 0 + + def _column_ids(self, board_id: str) -> set[str]: + rows = self._conn.execute( + "SELECT id FROM board_columns WHERE board_id = ?", (board_id,) + ).fetchall() + return {r["id"] for r in rows} + + def _column_row(self, board_id: str, row: sqlite3.Row) -> dict[str, Any]: + count = self._conn.execute( + "SELECT COUNT(*) AS n FROM names WHERE board_id = ? AND kind = ?", + (board_id, row["id"]), + ).fetchone()["n"] + return { + "id": row["id"], + "label": row["label"], + "position": row["position"], + "created_at": row["created_at"], + "name_count": int(count), + } + + def list_columns(self, board_id: str) -> list[dict[str, Any]]: + with self._lock: + rows = self._conn.execute( + "SELECT * FROM board_columns WHERE board_id = ? ORDER BY position", + (board_id,), + ).fetchall() + return [self._column_row(board_id, r) for r in rows] + + def add_column(self, board_id: str, label: str = "") -> dict[str, Any]: + label = (label or "").strip()[:40] + with self._lock: + if not self._conn.execute("SELECT 1 FROM boards WHERE id = ?", (board_id,)).fetchone(): + raise ValueError("unknown board") + rows = self._conn.execute( + "SELECT id, position FROM board_columns WHERE board_id = ? ORDER BY position", + (board_id,), + ).fetchall() + if len(rows) >= MAX_COLUMNS: + raise ValueError(f"at most {MAX_COLUMNS} columns") + used = {r["id"] for r in rows} + new_id = next(f"c{i}" for i in range(1, MAX_COLUMNS + 1) if f"c{i}" not in used) + position = max((r["position"] for r in rows), default=-1) + 1 + self._conn.execute( + "INSERT INTO board_columns (board_id, id, label, position, created_at) VALUES (?, ?, ?, ?, ?)", + (board_id, new_id, label, position, time.time()), + ) + self._conn.commit() + row = self._conn.execute( + "SELECT * FROM board_columns WHERE board_id = ? AND id = ?", + (board_id, new_id), + ).fetchone() + assert row is not None + return self._column_row(board_id, row) + + def rename_column(self, board_id: str, column_id: str, label: str) -> dict[str, Any] | None: + label = (label or "").strip()[:40] + with self._lock: + cur = self._conn.execute( + "UPDATE board_columns SET label = ? WHERE board_id = ? AND id = ?", + (label, board_id, column_id), + ) + if cur.rowcount == 0: + return None + self._conn.commit() + row = self._conn.execute( + "SELECT * FROM board_columns WHERE board_id = ? AND id = ?", + (board_id, column_id), + ).fetchone() + assert row is not None + return self._column_row(board_id, row) + + def delete_column(self, board_id: str, column_id: str) -> bool: + with self._lock: + count = self._conn.execute( + "SELECT COUNT(*) AS n FROM board_columns WHERE board_id = ?", + (board_id,), + ).fetchone()["n"] + if count <= 1: + raise ValueError("keep at least one column") + first = self._conn.execute( + "SELECT id FROM board_columns WHERE board_id = ? ORDER BY position ASC LIMIT 1", + (board_id,), + ).fetchone() + if first and first["id"] == column_id: + raise ValueError("cannot remove the first column") + row = self._conn.execute( + "SELECT id FROM board_columns WHERE board_id = ? AND id = ?", + (board_id, column_id), + ).fetchone() + if not row: + return False + name_ids = [ + r["id"] + for r in self._conn.execute( + "SELECT id FROM names WHERE board_id = ? AND kind = ?", + (board_id, column_id), + ).fetchall() + ] + for name_id in name_ids: + recs = self._conn.execute( + "SELECT path FROM recordings WHERE name_id = ?", (name_id,) + ).fetchall() + for rec in recs: + path = self.audio_dir / rec["path"] + if path.is_file(): + path.unlink() + self._conn.execute( + "DELETE FROM names WHERE board_id = ? AND kind = ?", + (board_id, column_id), + ) + self._conn.execute( + "DELETE FROM board_columns WHERE board_id = ? AND id = ?", + (board_id, column_id), + ) + remaining = self._conn.execute( + "SELECT id FROM board_columns WHERE board_id = ? ORDER BY position", + (board_id,), + ).fetchall() + for i, rem in enumerate(remaining): + self._conn.execute( + "UPDATE board_columns SET position = ? WHERE board_id = ? AND id = ?", + (i, board_id, rem["id"]), + ) + self._conn.commit() + return True + def _empty_locales(self) -> dict[str, dict[str, str]]: return {lang: {"pronunciation": "", "origin": "", "meaning": ""} for lang in LANGS} + def _empty_recordings(self) -> dict[str, bool]: + return {lang: False for lang in LANGS} + def _normalize_locales(self, locales: dict[str, Any] | None) -> dict[str, dict[str, str]]: out = self._empty_locales() if not locales: @@ -79,13 +548,20 @@ class Store: } return out + def _recordings_flags(self, name_id: int) -> dict[str, bool]: + flags = self._empty_recordings() + for row in self._conn.execute( + "SELECT lang FROM recordings WHERE name_id = ?", (name_id,) + ).fetchall(): + flags[row["lang"]] = True + return flags + def _row_to_name(self, row: sqlite3.Row, score: int, vote_count: int, my_vote: int | None) -> dict[str, Any]: locales = self._empty_locales() - cur = self._conn.execute( + for loc in self._conn.execute( "SELECT lang, pronunciation, origin, meaning FROM locales WHERE name_id = ?", (row["id"],), - ) - for loc in cur.fetchall(): + ).fetchall(): locales[loc["lang"]] = { "pronunciation": loc["pronunciation"], "origin": loc["origin"], @@ -93,23 +569,25 @@ class Store: } return { "id": row["id"], + "board_id": row["board_id"], "kind": row["kind"], "spelling": row["spelling"], "created_by": row["created_by"], "created_at": row["created_at"], "locales": locales, + "recordings": self._recordings_flags(row["id"]), "score": score, "vote_count": vote_count, "my_vote": my_vote, } - def list_names(self, kind: str, voter_key: str | None = None) -> list[dict[str, Any]]: - if kind not in KINDS: - raise ValueError("invalid kind") + def list_names(self, board_id: str, kind: str, voter_key: str | None = None) -> list[dict[str, Any]]: with self._lock: + if kind not in self._column_ids(board_id): + raise ValueError("unknown column") rows = self._conn.execute( - "SELECT * FROM names WHERE kind = ? ORDER BY spelling COLLATE NOCASE", - (kind,), + "SELECT * FROM names WHERE board_id = ? AND kind = ? ORDER BY spelling COLLATE NOCASE", + (board_id, kind), ).fetchall() out: list[dict[str, Any]] = [] for row in rows: @@ -136,11 +614,18 @@ class Store: out.sort(key=lambda n: (-n["score"], n["spelling"].casefold())) return out - def get_name(self, name_id: int, voter_key: str | None = None) -> dict[str, Any] | None: + def get_name( + self, + name_id: int, + voter_key: str | None = None, + board_id: str | None = None, + ) -> dict[str, Any] | None: with self._lock: row = self._conn.execute("SELECT * FROM names WHERE id = ?", (name_id,)).fetchone() if not row: return None + if board_id is not None and row["board_id"] != board_id: + return None score_row = self._conn.execute( "SELECT COALESCE(SUM(value), 0) AS score, COUNT(*) AS vote_count FROM votes WHERE name_id = ?", (name_id,), @@ -162,13 +647,12 @@ class Store: def add_name( self, + board_id: str, kind: str, spelling: str, created_by: str, locales: dict[str, Any] | None = None, ) -> dict[str, Any]: - if kind not in KINDS: - raise ValueError("invalid kind") spelling = spelling.strip() if not spelling or len(spelling) > 80: raise ValueError("spelling required (1–80 chars)") @@ -176,13 +660,15 @@ class Store: locs = self._normalize_locales(locales) now = time.time() with self._lock: + if kind not in self._column_ids(board_id): + raise ValueError("unknown column") try: cur = self._conn.execute( - "INSERT INTO names (kind, spelling, created_by, created_at) VALUES (?, ?, ?, ?)", - (kind, spelling, created_by, now), + "INSERT INTO names (board_id, kind, spelling, created_by, created_at) VALUES (?, ?, ?, ?, ?)", + (board_id, kind, spelling, created_by, now), ) except sqlite3.IntegrityError as exc: - raise ValueError("name already exists for this kind") from exc + raise ValueError("name already exists for this column") from exc name_id = int(cur.lastrowid) for lang, payload in locs.items(): self._conn.execute( @@ -190,17 +676,34 @@ class Store: (name_id, lang, payload["pronunciation"], payload["origin"], payload["meaning"]), ) self._conn.commit() - result = self.get_name(name_id) + result = self.get_name(name_id, board_id=board_id) assert result is not None return result - def update_locales(self, name_id: int, locales: dict[str, Any]) -> dict[str, Any] | None: - locs = self._normalize_locales(locales) + def update_locales( + self, + name_id: int, + locales: dict[str, Any], + board_id: str | None = None, + ) -> dict[str, Any] | None: + if not locales: + return self.get_name(name_id, board_id=board_id) + patch: dict[str, dict[str, str]] = {} + for lang, raw in locales.items(): + if lang not in LANGS or not isinstance(raw, dict): + continue + patch[lang] = { + "pronunciation": str(raw.get("pronunciation") or "").strip()[:200], + "origin": str(raw.get("origin") or "").strip()[:200], + "meaning": str(raw.get("meaning") or "").strip()[:500], + } + if not patch: + return self.get_name(name_id, board_id=board_id) with self._lock: - row = self._conn.execute("SELECT id FROM names WHERE id = ?", (name_id,)).fetchone() - if not row: + row = self._conn.execute("SELECT id, board_id FROM names WHERE id = ?", (name_id,)).fetchone() + if not row or (board_id is not None and row["board_id"] != board_id): return None - for lang, payload in locs.items(): + for lang, payload in patch.items(): self._conn.execute( """ INSERT INTO locales (name_id, lang, pronunciation, origin, meaning) @@ -213,7 +716,7 @@ class Store: (name_id, lang, payload["pronunciation"], payload["origin"], payload["meaning"]), ) self._conn.commit() - return self.get_name(name_id) + return self.get_name(name_id, board_id=board_id) def vote( self, @@ -221,6 +724,7 @@ class Store: voter_key: str, voter_name: str, value: int, + board_id: str | None = None, ) -> dict[str, Any] | None: if value not in (-1, 0, 1): raise ValueError("vote must be -1, 0, or 1") @@ -229,8 +733,8 @@ class Store: raise ValueError("invalid voter") voter_name = (voter_name or "").strip()[:80] with self._lock: - row = self._conn.execute("SELECT id FROM names WHERE id = ?", (name_id,)).fetchone() - if not row: + row = self._conn.execute("SELECT id, board_id FROM names WHERE id = ?", (name_id,)).fetchone() + if not row or (board_id is not None and row["board_id"] != board_id): return None if value == 0: self._conn.execute( @@ -250,16 +754,120 @@ class Store: (name_id, voter_key, voter_name, value, time.time()), ) self._conn.commit() - return self.get_name(name_id, voter_key=voter_key) + return self.get_name(name_id, voter_key=voter_key, board_id=board_id) + + def save_recording( + self, + name_id: int, + lang: str, + data: bytes, + content_type: str, + recorded_by: str, + ext: str = "webm", + board_id: str | None = None, + ) -> dict[str, Any] | None: + if lang not in LANGS: + raise ValueError("invalid lang") + if not data: + raise ValueError("empty recording") + if len(data) > 2_500_000: + raise ValueError("recording too large (max ~2.5MB)") + recorded_by = (recorded_by or "").strip()[:80] + content_type = (content_type or "audio/webm").split(";")[0].strip()[:80] + with self._lock: + row = self._conn.execute("SELECT id, board_id FROM names WHERE id = ?", (name_id,)).fetchone() + if not row or (board_id is not None and row["board_id"] != board_id): + return None + old = self._conn.execute( + "SELECT path FROM recordings WHERE name_id = ? AND lang = ?", + (name_id, lang), + ).fetchone() + rel = f"{name_id}-{lang}.{ext}" + dest = self.audio_dir / rel + dest.write_bytes(data) + if old and old["path"] != rel: + prev = self.audio_dir / old["path"] + if prev.is_file(): + prev.unlink() + self._conn.execute( + """ + INSERT INTO recordings (name_id, lang, path, content_type, recorded_by, created_at) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(name_id, lang) DO UPDATE SET + path = excluded.path, + content_type = excluded.content_type, + recorded_by = excluded.recorded_by, + created_at = excluded.created_at + """, + (name_id, lang, rel, content_type, recorded_by, time.time()), + ) + self._conn.commit() + return self.get_name(name_id, board_id=board_id) + + def get_recording( + self, + name_id: int, + lang: str, + board_id: str | None = None, + ) -> tuple[Path, str] | None: + if lang not in LANGS: + return None + with self._lock: + row = self._conn.execute("SELECT id, board_id FROM names WHERE id = ?", (name_id,)).fetchone() + if not row or (board_id is not None and row["board_id"] != board_id): + return None + rec = self._conn.execute( + "SELECT path, content_type FROM recordings WHERE name_id = ? AND lang = ?", + (name_id, lang), + ).fetchone() + if not rec: + return None + path = self.audio_dir / rec["path"] + if not path.is_file(): + return None + return path, rec["content_type"] + + def delete_recording( + self, + name_id: int, + lang: str, + board_id: str | None = None, + ) -> dict[str, Any] | None: + if lang not in LANGS: + raise ValueError("invalid lang") + with self._lock: + row = self._conn.execute("SELECT id, board_id FROM names WHERE id = ?", (name_id,)).fetchone() + if not row or (board_id is not None and row["board_id"] != board_id): + return None + rec = self._conn.execute( + "SELECT path FROM recordings WHERE name_id = ? AND lang = ?", + (name_id, lang), + ).fetchone() + if rec: + path = self.audio_dir / rec["path"] + if path.is_file(): + path.unlink() + self._conn.execute( + "DELETE FROM recordings WHERE name_id = ? AND lang = ?", + (name_id, lang), + ) + self._conn.commit() + return self.get_name(name_id, board_id=board_id) def delete_name(self, name_id: int) -> bool: with self._lock: + recs = self._conn.execute( + "SELECT path FROM recordings WHERE name_id = ?", (name_id,) + ).fetchall() + for rec in recs: + path = self.audio_dir / rec["path"] + if path.is_file(): + path.unlink() cur = self._conn.execute("DELETE FROM names WHERE id = ?", (name_id,)) self._conn.commit() return cur.rowcount > 0 def export_snapshot(self) -> str: - """Debug helper for tests.""" with self._lock: names = [dict(r) for r in self._conn.execute("SELECT * FROM names").fetchall()] return json.dumps(names) diff --git a/tests/conftest.py b/tests/conftest.py index 2d06366..34dfae1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -25,10 +25,18 @@ def client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> TestClient: @pytest.fixture() -def authed(client: TestClient) -> TestClient: +def family_board(client: TestClient) -> dict: + """Bootstrap Family board id (from STORK_INVITE_TOKEN).""" + res = client.get("/api/resolve", params={"invite": "test-invite-token"}) + assert res.status_code == 200 + return res.json() + + +@pytest.fixture() +def authed(client: TestClient, family_board: dict) -> TestClient: res = client.post( "/api/session", - json={"invite": "test-invite-token", "display_name": "Aunt Mira"}, + json={"board_id": family_board["id"], "display_name": "Aunt Mira"}, ) assert res.status_code == 200 return client diff --git a/tests/test_api.py b/tests/test_api.py index 32e44a0..5021437 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -11,111 +11,191 @@ def test_health(client: TestClient) -> None: assert res.json()["ok"] is True -def test_session_rejects_bad_invite(client: TestClient) -> None: - res = client.post("/api/session", json={"invite": "nope", "display_name": "Ilia"}) - assert res.status_code == 403 +def test_logos_gallery(client: TestClient) -> None: + res = client.get("/logos") + assert res.status_code == 200 + assert b"Pick a Stork mark" in res.content + + +def test_home_is_paper_board(client: TestClient) -> None: + res = client.get("/") + assert res.status_code == 200 + assert b"Start a private board" in res.content + + +def test_v2_redirects_home(client: TestClient) -> None: + res = client.get("/v2", follow_redirects=False) + assert res.status_code == 302 + assert res.headers["location"] == "/" + + +def test_public_create_board_and_share_url(client: TestClient) -> None: + created = client.post("/api/boards", json={"title": "Baby Levkin"}) + assert created.status_code == 200 + board_id = created.json()["id"] + assert board_id.startswith("b_") + assert created.json()["url"].endswith(f"/b/{board_id}") + page = client.get(f"/b/{board_id}") + assert page.status_code == 200 + assert b"Start a private board" in page.content or b"Your name" in page.content + + +def test_join_board_with_display_name(client: TestClient) -> None: + created = client.post("/api/boards", json={"title": "Friends"}).json() + session = client.post( + "/api/session", + json={"board_id": created["id"], "display_name": "Dan"}, + ) + assert session.status_code == 200 + assert session.json()["board"]["id"] == created["id"] + assert client.post("/api/names", json={"kind": "c1", "spelling": "Noa"}).status_code == 200 + + +def test_boards_are_isolated(client: TestClient) -> None: + a = client.post("/api/boards", json={"title": "A"}).json() + b = client.post("/api/boards", json={"title": "B"}).json() + client.post("/api/session", json={"board_id": a["id"], "display_name": "Ilia"}) + assert client.post("/api/names", json={"kind": "c1", "spelling": "Roze"}).status_code == 200 + client.post("/api/session", json={"board_id": b["id"], "display_name": "Dan"}) + assert client.post("/api/names", json={"kind": "c1", "spelling": "Roze"}).status_code == 200 + a_names = client.get( + "/api/names?kind=c1", + headers={"X-Stork-Board": a["id"], "X-Stork-Display-Name": "Ilia"}, + ).json()["items"] + b_names = client.get( + "/api/names?kind=c1", + headers={"X-Stork-Board": b["id"], "X-Stork-Display-Name": "Dan"}, + ).json()["items"] + assert len(a_names) == 1 and len(b_names) == 1 + assert a_names[0]["id"] != b_names[0]["id"] + + +def test_default_one_column(authed: TestClient) -> None: + cols = authed.get("/api/columns").json()["items"] + assert len(cols) == 1 + assert cols[0]["id"] == "c1" + assert cols[0]["label"] == "First" + + +def test_add_rename_columns(authed: TestClient) -> None: + c2 = authed.post("/api/columns") + assert c2.status_code == 200 + assert c2.json()["id"] == "c2" + renamed = authed.patch("/api/columns/c2", json={"label": "Hebrew"}) + assert renamed.status_code == 200 + assert renamed.json()["label"] == "Hebrew" + assert authed.post("/api/names", json={"kind": "c2", "spelling": "שי"}).status_code == 200 + + +def test_column_delete_by_member(authed: TestClient) -> None: + assert authed.post("/api/columns").status_code == 200 + assert authed.delete("/api/columns/c2").status_code == 200 + assert len(authed.get("/api/columns").json()["items"]) == 1 + + +def test_cannot_delete_first_column(authed: TestClient) -> None: + assert authed.post("/api/columns").status_code == 200 + assert authed.delete("/api/columns/c1").status_code == 400 + assert len(authed.get("/api/columns").json()["items"]) == 2 + + +def test_max_four_columns(authed: TestClient) -> None: + for _ in range(3): + assert authed.post("/api/columns").status_code == 200 + assert authed.post("/api/columns").status_code == 400 + + +def test_cannot_delete_last_column(authed: TestClient) -> None: + assert authed.delete("/api/columns/c1").status_code == 400 + + +def test_recording_upload_and_play(authed: TestClient) -> None: + created = authed.post("/api/names", json={"kind": "c1", "spelling": "Roze"}).json() + name_id = created["id"] + files = {"file": ("voice.webm", b"fake-webm-bytes", "audio/webm")} + up = authed.post(f"/api/names/{name_id}/recording/en", files=files) + assert up.status_code == 200 + assert up.json()["recordings"]["en"] is True + audio = authed.get(f"/api/names/{name_id}/recording/en") + assert audio.status_code == 200 + assert audio.content == b"fake-webm-bytes" + + +def test_session_rejects_missing_board(client: TestClient) -> None: + res = client.post("/api/session", json={"display_name": "Ilia", "board_id": "b_missing"}) + assert res.status_code == 404 def test_names_require_session(client: TestClient) -> None: res = client.get("/api/names") - assert res.status_code == 401 + assert res.status_code in (401, 404) def test_add_vote_and_rank(authed: TestClient) -> None: a = authed.post( "/api/names", json={ - "kind": "first", + "kind": "c1", "spelling": "Noa", "locales": { - "he": {"pronunciation": "No-ah", "origin": "Hebrew", "meaning": "motion / movement"}, + "he": {"pronunciation": "No-ah", "origin": "Hebrew", "meaning": "motion"}, "en": {"pronunciation": "NO-uh", "origin": "Hebrew", "meaning": "movement"}, }, }, ) assert a.status_code == 200 noa_id = a.json()["id"] - - b = authed.post("/api/names", json={"kind": "first", "spelling": "Levi"}) - assert b.status_code == 200 + b = authed.post("/api/names", json={"kind": "c1", "spelling": "Levi"}) levi_id = b.json()["id"] - assert authed.post(f"/api/names/{noa_id}/vote", json={"value": 1}).status_code == 200 assert authed.post(f"/api/names/{levi_id}/vote", json={"value": -1}).status_code == 200 - - listed = authed.get("/api/names?kind=first").json()["items"] + listed = authed.get("/api/names?kind=c1").json()["items"] assert [n["spelling"] for n in listed] == ["Noa", "Levi"] - assert listed[0]["score"] == 1 - assert listed[0]["my_vote"] == 1 - assert listed[1]["score"] == -1 - - # clear vote - cleared = authed.post(f"/api/names/{noa_id}/vote", json={"value": 0}) - assert cleared.status_code == 200 - assert cleared.json()["my_vote"] is None - assert cleared.json()["score"] == 0 def test_duplicate_name_rejected(authed: TestClient) -> None: - assert authed.post("/api/names", json={"kind": "first", "spelling": "Maya"}).status_code == 200 - dup = authed.post("/api/names", json={"kind": "first", "spelling": "maya"}) - assert dup.status_code == 400 - - -def test_middle_names_separate(authed: TestClient) -> None: - assert authed.post("/api/names", json={"kind": "first", "spelling": "Ari"}).status_code == 200 - assert authed.post("/api/names", json={"kind": "middle", "spelling": "Ari"}).status_code == 200 - first = authed.get("/api/names?kind=first").json()["items"] - middle = authed.get("/api/names?kind=middle").json()["items"] - assert len(first) == 1 - assert len(middle) == 1 + assert authed.post("/api/names", json={"kind": "c1", "spelling": "Maya"}).status_code == 200 + assert authed.post("/api/names", json={"kind": "c1", "spelling": "maya"}).status_code == 400 def test_patch_locales(authed: TestClient) -> None: - created = authed.post("/api/names", json={"kind": "first", "spelling": "Eden"}).json() + created = authed.post("/api/names", json={"kind": "c1", "spelling": "Eden"}).json() patched = authed.patch( f"/api/names/{created['id']}", json={"locales": {"ru": {"pronunciation": "Э-ден", "origin": "иврит", "meaning": "рай"}}}, ) assert patched.status_code == 200 assert patched.json()["locales"]["ru"]["meaning"] == "рай" + assert patched.json()["locales"]["en"]["pronunciation"] == "" -def test_admin_delete(authed: TestClient) -> None: - created = authed.post("/api/names", json={"kind": "first", "spelling": "Temp"}).json() - denied = authed.delete(f"/api/names/{created['id']}") - assert denied.status_code == 403 - ok = authed.delete(f"/api/names/{created['id']}", headers={"X-Stork-Admin": "test-admin-token"}) - assert ok.status_code == 200 - assert authed.get("/api/names?kind=first").json()["items"] == [] - - -def test_second_voter_ranking(client: TestClient, authed: TestClient) -> None: - name = authed.post("/api/names", json={"kind": "first", "spelling": "Shai"}).json() - authed.post(f"/api/names/{name['id']}/vote", json={"value": 1}) - - other = client.post( - "/api/session", - json={"invite": "test-invite-token", "display_name": "Uncle Dan"}, +def test_admin_delete_name(authed: TestClient) -> None: + created = authed.post("/api/names", json={"kind": "c1", "spelling": "Temp"}).json() + assert authed.delete(f"/api/names/{created['id']}").status_code == 403 + assert ( + authed.delete( + f"/api/names/{created['id']}", headers={"X-Stork-Admin": "test-admin-token"} + ).status_code + == 200 ) - assert other.status_code == 200 - client.post(f"/api/names/{name['id']}/vote", json={"value": 1}) - listed = client.get("/api/names?kind=first").json()["items"] - assert listed[0]["score"] == 2 - assert listed[0]["vote_count"] == 2 -def test_header_auth_without_cookies(client: TestClient) -> None: +def test_header_auth_with_board(client: TestClient, family_board: dict) -> None: headers = { - "X-Stork-Invite": "test-invite-token", + "X-Stork-Board": family_board["id"], "X-Stork-Display-Name": "Seed Bot", } res = client.post( "/api/names", headers=headers, - json={"kind": "first", "spelling": "HeaderOnly"}, + json={"kind": "c1", "spelling": "HeaderOnly"}, ) assert res.status_code == 200 - listed = client.get("/api/names?kind=first", headers=headers) - assert listed.status_code == 200 + listed = client.get("/api/names?kind=c1", headers=headers) assert any(n["spelling"] == "HeaderOnly" for n in listed.json()["items"]) + + +def test_legacy_invite_resolve(client: TestClient, family_board: dict) -> None: + res = client.get("/api/resolve", params={"invite": "test-invite-token"}) + assert res.status_code == 200 + assert res.json()["id"] == family_board["id"] diff --git a/tests/test_store.py b/tests/test_store.py index d189fc4..58c0aa7 100644 --- a/tests/test_store.py +++ b/tests/test_store.py @@ -8,13 +8,14 @@ from stork.db import Store def test_store_ranks_by_score_then_alpha(tmp_path: Path) -> None: - store = Store(tmp_path / "t.sqlite3") - a = store.add_name("first", "Zed", "x") - store.add_name("first", "Ann", "x") - c = store.add_name("first", "Bo", "x") - store.vote(a["id"], "v1", "A", 1) - store.vote(c["id"], "v1", "A", 1) - store.vote(c["id"], "v2", "B", 1) - ranked = store.list_names("first") + store = Store(tmp_path / "t.sqlite3", bootstrap_invite="unit-invite") + board_id = store.list_boards()[0]["id"] + a = store.add_name(board_id, "c1", "Zed", "x") + store.add_name(board_id, "c1", "Ann", "x") + c = store.add_name(board_id, "c1", "Bo", "x") + store.vote(a["id"], "v1", "A", 1, board_id=board_id) + store.vote(c["id"], "v1", "A", 1, board_id=board_id) + store.vote(c["id"], "v2", "B", 1, board_id=board_id) + ranked = store.list_names(board_id, "c1") assert [n["spelling"] for n in ranked] == ["Bo", "Zed", "Ann"] store.close()