From 00d195c86f2b2c37f68ba8dc3c40d7dc4e745148 Mon Sep 17 00:00:00 2001 From: Elijah Voigt Date: Sat, 7 Mar 2026 21:19:07 -0800 Subject: [PATCH] chore(quotesdb): commit tickets, TODO, and infra README update Co-Authored-By: Claude Sonnet 4.6 --- quotesdb/.nbd/tickets/03fa32.md | 162 ++++++++++++++++++++++++++++++ quotesdb/.nbd/tickets/0c73cd.md | 89 +++++++++++++++++ quotesdb/.nbd/tickets/14570c.md | 100 +++++++++++++++++++ quotesdb/.nbd/tickets/161f32.md | 122 +++++++++++++++++++++++ quotesdb/.nbd/tickets/35685a.md | 100 +++++++++++++++++++ quotesdb/.nbd/tickets/5b3475.md | 83 ++++++++++++++++ quotesdb/.nbd/tickets/65e220.md | 45 +++++++++ quotesdb/.nbd/tickets/68fd11.md | 121 +++++++++++++++++++++++ quotesdb/.nbd/tickets/69a2c5.md | 64 ++++++++++++ quotesdb/.nbd/tickets/6a4c61.md | 75 ++++++++++++++ quotesdb/.nbd/tickets/809cba.md | 168 ++++++++++++++++++++++++++++++++ quotesdb/.nbd/tickets/9d756a.md | 46 +++++++++ quotesdb/.nbd/tickets/a57b95.md | 68 +++++++++++++ quotesdb/.nbd/tickets/a57e7e.md | 144 +++++++++++++++++++++++++++ quotesdb/.nbd/tickets/b01bad.md | 62 ++++++++++++ quotesdb/.nbd/tickets/bacb16.md | 68 +++++++++++++ quotesdb/.nbd/tickets/c3c8c6.md | 103 ++++++++++++++++++++ quotesdb/.nbd/tickets/dfd185.md | 49 ++++++++++ quotesdb/TODO.md | 2 + quotesdb/infra/README.md | 14 +-- 20 files changed, 1678 insertions(+), 7 deletions(-) create mode 100644 quotesdb/.nbd/tickets/03fa32.md create mode 100644 quotesdb/.nbd/tickets/0c73cd.md create mode 100644 quotesdb/.nbd/tickets/14570c.md create mode 100644 quotesdb/.nbd/tickets/161f32.md create mode 100644 quotesdb/.nbd/tickets/35685a.md create mode 100644 quotesdb/.nbd/tickets/5b3475.md create mode 100644 quotesdb/.nbd/tickets/65e220.md create mode 100644 quotesdb/.nbd/tickets/68fd11.md create mode 100644 quotesdb/.nbd/tickets/69a2c5.md create mode 100644 quotesdb/.nbd/tickets/6a4c61.md create mode 100644 quotesdb/.nbd/tickets/809cba.md create mode 100644 quotesdb/.nbd/tickets/9d756a.md create mode 100644 quotesdb/.nbd/tickets/a57b95.md create mode 100644 quotesdb/.nbd/tickets/a57e7e.md create mode 100644 quotesdb/.nbd/tickets/b01bad.md create mode 100644 quotesdb/.nbd/tickets/bacb16.md create mode 100644 quotesdb/.nbd/tickets/c3c8c6.md create mode 100644 quotesdb/.nbd/tickets/dfd185.md create mode 100644 quotesdb/TODO.md diff --git a/quotesdb/.nbd/tickets/03fa32.md b/quotesdb/.nbd/tickets/03fa32.md new file mode 100644 index 0000000..29eb97b --- /dev/null +++ b/quotesdb/.nbd/tickets/03fa32.md @@ -0,0 +1,162 @@ ++++ +title = "Filter quotes by date range (before/after with year/month/day granularity)" +priority = 5 +status = "done" +ticket_type = "feature" +dependencies = [] ++++ +## Feature + +Extend `GET /api/quotes` with date range filtering. Users can specify a "before" and/or "after" bound, with year/month/day granularity for each. Quotes without a date (`date IS NULL`) are excluded when a date filter is active. + +--- + +## Query parameter design + +Six optional query params are added: + +| Param | Type | Description | +|---|---|---| +| `date_after_year` | u16 | Only include quotes dated on or after this year | +| `date_after_month` | u8 (1–12) | Narrows after-bound to this month | +| `date_after_day` | u8 (1–31) | Narrows after-bound to this day | +| `date_before_year` | u16 | Only include quotes dated on or before this year | +| `date_before_month` | u8 (1–12) | Narrows before-bound to this month | +| `date_before_day` | u8 (1–31) | Narrows before-bound to this day | + +The handler constructs two partial ISO strings from these fields before calling `list_quotes`: + +- **after bound** (`>=`): `YYYY`, `YYYY-MM`, or `YYYY-MM-DD` — string comparison anchors to the start of the specified period (e.g., `2020` means ≥ `2020-01-01` in text comparison, but actually `>= '2020'` which works correctly since any 2020 date starts with `2020`). +- **before bound** (`<=`): `YYYY-MM-DD` where missing month defaults to `12` and missing day defaults to `31` — so `date_before_year=2020` means `<= '2020-12-31'`. + +**Validation rules:** +- Month must be 1–12 if provided (without its year, it is an error). +- Day must be 1–31 if provided (without its year+month, it is an error). +- Return `400 Bad Request` for invalid combinations. + +--- + +## Part 1 — API: `handlers/mod.rs` + +**Extend `ListParams`:** +```rust +#[derive(Debug, Deserialize)] +struct ListParams { + #[serde(default = "default_page")] + page: u32, + author: Option, + tag: Option, + // Date range filter + date_after_year: Option, + date_after_month: Option, + date_after_day: Option, + date_before_year: Option, + date_before_month: Option, + date_before_day: Option, +} +``` + +**In `list_handler`**, construct the `date_after` and `date_before` strings before calling `repo.list_quotes`: +```rust +fn build_date_bound(year: Option, month: Option, day: Option) -> Option { + match (year, month, day) { + (None, _, _) => None, + (Some(y), None, _) => Some(format!("{y:04}")), + (Some(y), Some(m), None) => Some(format!("{y:04}-{m:02}")), + (Some(y), Some(m), Some(d)) => Some(format!("{y:04}-{m:02}-{d:02}")), + } +} +``` + +Return `400` if month is present without year, or day is present without year+month. + +--- + +## Part 2 — Repository trait: `db/mod.rs` + +Extend `list_quotes` signature: +```rust +async fn list_quotes( + &self, + page: u32, + author: Option<&str>, + tag: Option<&str>, + date_after: Option<&str>, // new + date_before: Option<&str>, // new +) -> Result; +``` + +--- + +## Part 3 — Native implementation: `db/native.rs` + +In `NativeRepository::list_quotes`, build the WHERE clause dynamically. Current query filters on `author` and `tag` via subquery. Extend it: + +```sql +-- Additional clauses appended when filters are present: +AND q.date IS NOT NULL +AND q.date >= ? -- when date_after is Some +AND q.date <= ? -- when date_before is Some +``` + +Use string comparison — ISO YYYY-MM-DD format sorts lexicographically, so `>=`/`<=` on the `date` TEXT column is correct. Prefix matching (e.g. `date >= '2020'` with `date = '2020-06-15'`) works because `'2020-06-15' >= '2020'` is true in SQLite string comparison. + +--- + +## Part 4 — D1 implementation: `db/d1.rs` + +Apply the same WHERE clause extensions to the D1 query builder. + +--- + +## Part 5 — UI: Browse page + +File: `src/bin/ui/pages/browse.rs` + +Add date filter controls to the filter panel alongside author and tag filters: + +- "After:" year input (``), optional month select (1–12), optional day input. +- "Before:" same. + +On filter apply, include the populated fields as query params. When fields are empty, omit them. + +--- + +## Part 6 — Mock repo in tests: `handlers/mod.rs` + +Update `MockRepo::list_quotes` to accept the two new `date_after` / `date_before` params (ignore them in the mock — just extend the signature). + +--- + +## Part 7 — OpenAPI spec: `api/openapi.yaml` + +Add the six new query parameters to the `GET /api/quotes` operation with descriptions and `schema: {type: integer}`. + +--- + +## Files touched + +- `src/bin/api/handlers/mod.rs` — `ListParams` + `list_handler` + `MockRepo` +- `src/bin/api/db/mod.rs` — `QuoteRepository::list_quotes` signature +- `src/bin/api/db/native.rs` — SQL query extension +- `src/bin/api/db/d1.rs` — SQL query extension +- `src/bin/ui/pages/browse.rs` — date filter UI +- `api/openapi.yaml` — new query params + +## Validation + +```sh +# From quotesdb/ root +cargo fmt && cargo check && cargo clippy && cargo test +trunk build +redocly lint api/openapi.yaml +``` + +Test manually: +- `GET /api/quotes?date_after_year=1900&date_before_year=2000` — should return only quotes with dates in the 20th century. +- `GET /api/quotes?date_after_year=2020&date_after_month=6` — on or after June 2020. +- `GET /api/quotes?date_after_month=3` — should return 400 (month without year). + +## Commit scope + +`feat(quotesdb): date range filter for quotes list` \ No newline at end of file diff --git a/quotesdb/.nbd/tickets/0c73cd.md b/quotesdb/.nbd/tickets/0c73cd.md new file mode 100644 index 0000000..fe18bb7 --- /dev/null +++ b/quotesdb/.nbd/tickets/0c73cd.md @@ -0,0 +1,89 @@ ++++ +title = "quotesdb/api: POST /api/admin/lock and /api/admin/unlock endpoints" +priority = 6 +status = "done" +ticket_type = "feature" +dependencies = ["69a2c5"] ++++ +## POST /api/admin/lock and /api/admin/unlock endpoints + +Add the two admin-protected endpoints that toggle the global submissions lock. Both require `X-Admin-Code` and return the current lock state after the operation. + +Note: This ticket depends on ticket 35685a (GET /api/status) because that ticket adds `get_submissions_locked` and `set_submissions_locked` to the `QuoteRepository` trait and seeds the `submissions_locked` row in the database. Complete 35685a first. + +--- + +## Files to modify + +- `src/bin/api/handlers/mod.rs` (or `src/bin/api/handlers/admin.rs`) — add `lock_submissions` and `unlock_submissions` handlers +- `src/bin/api/main.rs` — register the two new routes + +No new DB trait methods are needed; both handlers reuse `set_submissions_locked(bool)` introduced in 35685a. + +--- + +## Handlers + +```rust +/// POST /api/admin/lock +/// Requires X-Admin-Code header. Sets submissions_locked = true. +/// Response: 200 { "submissions_locked": true } or 403 on bad code. +pub async fn lock_submissions( + State(repo): State>, + headers: HeaderMap, +) -> impl IntoResponse { + let admin_code = extract_admin_code(&headers); + if !verify_admin_code(&repo, admin_code).await { ... } + match repo.set_submissions_locked(true).await { + Ok(()) => Json(json!({ "submissions_locked": true })).into_response(), + Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(), + } +} + +/// POST /api/admin/unlock +/// Requires X-Admin-Code header. Sets submissions_locked = false. +/// Response: 200 { "submissions_locked": false } or 403 on bad code. +pub async fn unlock_submissions( + State(repo): State>, + headers: HeaderMap, +) -> impl IntoResponse { + // same pattern, locked = false +} +``` + +Implement a shared helper `verify_admin_code(repo, code) -> bool` (or extract inline) that fetches the stored admin code from `admin_config` and compares it. Use constant-time comparison if possible. + +--- + +## Route registration (src/bin/api/main.rs) + +```rust +.route("/api/admin/lock", post(handlers::lock_submissions)) +.route("/api/admin/unlock", post(handlers::unlock_submissions)) +``` + +--- + +## Tests + +- `POST /api/admin/lock` with correct `X-Admin-Code` → `200 { "submissions_locked": true }` +- `POST /api/admin/unlock` with correct `X-Admin-Code` → `200 { "submissions_locked": false }` +- `POST /api/admin/lock` with wrong code → `403` +- `POST /api/admin/unlock` with missing header → `403` +- Lock/unlock idempotent: locking when already locked still returns `200 { "submissions_locked": true }` + +--- + +## Validation + +```sh +cargo fmt && cargo check && cargo clippy && cargo test +``` + +--- + +## Commit + +``` +feat(quotesdb): POST /api/admin/lock and /api/admin/unlock endpoints +``` \ No newline at end of file diff --git a/quotesdb/.nbd/tickets/14570c.md b/quotesdb/.nbd/tickets/14570c.md new file mode 100644 index 0000000..6b379a3 --- /dev/null +++ b/quotesdb/.nbd/tickets/14570c.md @@ -0,0 +1,100 @@ ++++ +title = "quotesdb/ui: /submit page locked-state banner" +priority = 5 +status = "done" +ticket_type = "feature" +dependencies = ["161f32"] ++++ +## /submit page locked-state banner (UI) + +Modify the existing /submit page to check the submission lock on mount. When the lock is active, hide the submission form entirely and show a closed-submissions banner in its place. + +Depends on ticket 161f32 (admin API client functions). Complete that ticket first. + +--- + +## Files to modify + +- `src/bin/ui/pages/submit.rs` — add status check on mount and conditional rendering + +--- + +## Changes to SubmitPage component + +### New state fields + +Add to the existing component state: + +| Field | Type | Purpose | +|---|---|---| +| `submissions_locked` | `Option` | `None` while loading, `Some(true/false)` after status check | +| `status_error` | `bool` | Set if `get_status()` itself fails (show form as fallback) | + +### On mount + +Spawn an async task that calls `api::get_status()`: + +```rust +// In use_effect_with or similar hook, fired once on mount: +let submissions_locked = submissions_locked.clone(); +wasm_bindgen_futures::spawn_local(async move { + match api::get_status().await { + Ok(status) => submissions_locked.set(Some(status.submissions_locked)), + Err(_) => { + // On error, default to showing the form (fail open). + submissions_locked.set(Some(false)); + } + } +}); +``` + +### Render logic + +``` +if submissions_locked == None: + show a loading indicator (or nothing / skeleton) +elif submissions_locked == Some(true): + show closed banner, hide form +else: + show form as normal +``` + +### Closed banner markup (approximate) + +```html +
+

Submissions are currently closed.

+
+``` + +Style the banner to be visually distinct — use a muted/warning colour. Add the `.submissions-closed-banner` CSS class to `src/bin/ui/style.css`. + +### Fail-open behaviour + +If `api::get_status()` returns an error, treat it as unlocked (`Some(false)`) and display the form. Do not block a user from submitting due to a network error on the status check. + +--- + +## Tests + +No runtime unit tests (wasm-only). Verify the build: + +```sh +cargo check --target wasm32-unknown-unknown --bin ui +``` + +--- + +## Validation + +```sh +cargo fmt && cargo check --target wasm32-unknown-unknown --bin ui +``` + +--- + +## Commit + +``` +feat(quotesdb): show locked banner on /submit when submissions are closed +``` \ No newline at end of file diff --git a/quotesdb/.nbd/tickets/161f32.md b/quotesdb/.nbd/tickets/161f32.md new file mode 100644 index 0000000..337c3ec --- /dev/null +++ b/quotesdb/.nbd/tickets/161f32.md @@ -0,0 +1,122 @@ ++++ +title = "quotesdb/ui: admin API client functions" +priority = 5 +status = "done" +ticket_type = "feature" +dependencies = [] ++++ +## Admin API client functions (UI) + +Add four async functions to the UI API client module that cover every admin and status endpoint introduced by the API tickets. These functions are consumed by the /admin page and the /submit page. + +--- + +## Files to modify + +- `src/bin/ui/api.rs` — add four new public async functions + +--- + +## New types + +Add to `src/bin/ui/api.rs` (or to a shared types module imported by api.rs): + +```rust +/// Response from GET /api/status. +#[derive(Deserialize, Clone, PartialEq)] +pub struct StatusResponse { + pub submissions_locked: bool, +} + +/// Response from POST /api/admin/reset-auth-code. +#[derive(Deserialize)] +struct ResetAuthCodeResponse { + pub auth_code: String, +} + +/// Response from POST /api/admin/lock or /api/admin/unlock. +#[derive(Deserialize)] +struct LockResponse { + pub submissions_locked: bool, +} +``` + +--- + +## New functions + +```rust +/// Fetch the current submission lock state from GET /api/status. +/// Returns Ok(StatusResponse) on success or ApiError on failure. +pub async fn get_status() -> Result { ... } + +/// Call POST /api/admin/reset-auth-code. +/// Sends X-Admin-Code: admin_code in the request header. +/// Body: { "new_code": new_code } (omit field if new_code is None). +/// Returns the new auth code string on success, or ApiError on failure. +pub async fn admin_reset_auth_code( + current: &str, + new_code: Option<&str>, + admin_code: &str, +) -> Result { ... } + +/// Call POST /api/admin/lock. +/// Sends X-Admin-Code: admin_code in the request header. +/// Returns Ok(true) on success, or ApiError (including ApiError::Forbidden on 403). +pub async fn admin_lock(admin_code: &str) -> Result { ... } + +/// Call POST /api/admin/unlock. +/// Sends X-Admin-Code: admin_code in the request header. +/// Returns Ok(false) on success, or ApiError (including ApiError::Forbidden on 403). +pub async fn admin_unlock(admin_code: &str) -> Result { ... } +``` + +Implementation notes: +- Use the same `gloo_net::http::Request` pattern already used in `api.rs` for other endpoints. +- Add an `ApiError::Forbidden` variant (or reuse an existing error variant) to communicate `403` responses back to the UI so pages can show "Wrong auth code." without a generic error. +- `admin_reset_auth_code`: serialize the body as `{ "new_code": "..." }` when `new_code` is `Some`, or as `{}` when `None`. +- `admin_lock` and `admin_unlock` send no request body (empty POST). + +--- + +## ApiError extension + +If `ApiError` does not already have a `Forbidden` variant, add one: + +```rust +pub enum ApiError { + // ... existing variants ... + /// The server returned 403 Forbidden (wrong admin code). + Forbidden, +} +``` + +Map HTTP 403 → `ApiError::Forbidden` in each new function before returning. + +--- + +## Tests + +This module compiles only for `wasm32-unknown-unknown` so no `cargo test` unit tests are practical here. Instead, verify the build compiles cleanly: + +```sh +cargo check --target wasm32-unknown-unknown --bin ui +``` + +Write a brief doc-comment on each function describing its endpoint, required header, and error conditions. + +--- + +## Validation + +```sh +cargo fmt && cargo check --target wasm32-unknown-unknown --bin ui +``` + +--- + +## Commit + +``` +feat(quotesdb): admin API client functions in UI +``` \ No newline at end of file diff --git a/quotesdb/.nbd/tickets/35685a.md b/quotesdb/.nbd/tickets/35685a.md new file mode 100644 index 0000000..a62da2c --- /dev/null +++ b/quotesdb/.nbd/tickets/35685a.md @@ -0,0 +1,100 @@ ++++ +title = "quotesdb/api: GET /api/status public endpoint" +priority = 6 +status = "done" +ticket_type = "feature" +dependencies = ["69a2c5"] ++++ +## GET /api/status public endpoint + +Add a public status endpoint that exposes whether submissions are currently locked. The UI calls this on mount for both the /submit and /admin pages. + +--- + +## Files to modify + +- `src/bin/api/db/mod.rs` — add `get_submissions_locked` and `set_submissions_locked` to the `QuoteRepository` trait +- `src/bin/api/db/d1.rs` — implement the two new trait methods for the D1 backend; seed `submissions_locked = "0"` alongside `admin_auth_code` in the startup migration if not already present +- `src/bin/api/db/native.rs` — implement the two new trait methods for the native/SQLite backend +- `src/bin/api/handlers/mod.rs` (or a new `src/bin/api/handlers/status.rs`) — add the `get_status` handler +- `src/bin/api/main.rs` — register the new route + +--- + +## Database + +The `admin_config` key/value table gains a second row. Seed it on startup (alongside `admin_auth_code`) if it does not already exist: + +```sql +INSERT OR IGNORE INTO admin_config (key, value) VALUES ('submissions_locked', '0'); +``` + +--- + +## New trait methods (src/bin/api/db/mod.rs) + +Add to the `QuoteRepository` trait: + +```rust +/// Return whether submissions are currently locked. +async fn get_submissions_locked(&self) -> Result; + +/// Set the submissions lock state. +async fn set_submissions_locked(&self, locked: bool) -> Result<(), DbError>; +``` + +Both implementations read/write the `submissions_locked` key in `admin_config`, treating `"1"` as `true` and anything else as `false`. + +--- + +## Handler + +Add to the handlers module: + +```rust +/// GET /api/status — returns current submission lock state; requires no auth. +pub async fn get_status(State(repo): State>) -> impl IntoResponse { + match repo.get_submissions_locked().await { + Ok(locked) => Json(json!({ "submissions_locked": locked })).into_response(), + Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(), + } +} +``` + +--- + +## Route registration (src/bin/api/main.rs) + +Register before the quotes router: + +```rust +.route("/api/status", get(handlers::get_status)) +``` + +--- + +## Tests + +In `src/bin/api/handlers/` (or the relevant test module), add unit tests covering: + +- `GET /api/status` returns `200` with `{ "submissions_locked": false }` when the DB value is `"0"` +- `GET /api/status` returns `200` with `{ "submissions_locked": true }` when the DB value is `"1"` +- `get_submissions_locked` returns `false` for missing key (graceful default) + +Use a mock or in-memory SQLite repo for all handler tests. + +--- + +## Validation + +```sh +cargo fmt && cargo check && cargo clippy && cargo test +``` + +--- + +## Commit + +``` +feat(quotesdb): GET /api/status public endpoint +``` \ No newline at end of file diff --git a/quotesdb/.nbd/tickets/5b3475.md b/quotesdb/.nbd/tickets/5b3475.md new file mode 100644 index 0000000..d242ca8 --- /dev/null +++ b/quotesdb/.nbd/tickets/5b3475.md @@ -0,0 +1,83 @@ ++++ +title = "Submit form: author optional (default Anonymous), clarify auth code auto-generation" +priority = 5 +status = "done" +ticket_type = "bug" +dependencies = [] ++++ +## Bug + +Two related UX issues on the submit form (`src/bin/ui/pages/submit.rs`): + +### 1. Author field should be optional + +Currently, `author` is required — the validation at line 53 returns early with `"Text and author are required."` if it is empty, and the field has `required=true` at line 177. Not all quotes have a known author, so it should be optional. When left blank, the API should receive `"Anonymous"` as the author. + +### 2. Auth code label should clarify auto-generation + +The label currently reads `"Custom auth code (optional)"`. The label itself does not explain what happens if left blank. The placeholder does say `"word-word-word-word (auto-generated if empty)"`, but users may not notice placeholders. The label should make it explicit. + +## Expected behaviour + +- Author field: leaving it blank submits the quote with author = `"Anonymous"`. The field should not block form submission. +- Auth code label: reads something like `"Custom auth code (optional — one will be generated if left blank)"`. + +## How to fix + +In `src/bin/ui/pages/submit.rs`: + +**Author optional:** + +1. Change the validation at line 53: + ```rust + // Before + if text_val.is_empty() || author_val.is_empty() { + error.set(Some("Text and author are required.".to_string())); + return; + } + // After + if text_val.is_empty() { + error.set(Some("Quote text is required.".to_string())); + return; + } + ``` +2. When building `CreateQuoteInput`, default the author to `"Anonymous"` if empty: + ```rust + author: if author_val.is_empty() { + "Anonymous".to_string() + } else { + author_val + }, + ``` +3. Remove `required=true` from the author `` (~line 177). +4. Update the label from `"Author *"` to `"Author (optional, defaults to Anonymous)"` or similar. +5. Update the placeholder from `"e.g. Mark Twain"` to `"e.g. Mark Twain (leave blank for Anonymous)"`. + +**Auth code label:** + +6. Change the label at line 236 from: + ``` + "Custom auth code (optional)" + ``` + to: + ``` + "Auth code (optional — one will be generated if left blank)" + ``` + +## File + +- `src/bin/ui/pages/submit.rs` (lines 53–56, 64–66, 163–178, 235–237) + +## Validation + +```sh +# From quotesdb/ root +cargo fmt && cargo check && cargo clippy && cargo test +trunk build +``` + +Manually test: submit a quote with author left blank — it should succeed and display `Anonymous` as the author. + +## Commit scope + +`fix(quotesdb): submit form author optional and auth code label` \ No newline at end of file diff --git a/quotesdb/.nbd/tickets/65e220.md b/quotesdb/.nbd/tickets/65e220.md new file mode 100644 index 0000000..bb34246 --- /dev/null +++ b/quotesdb/.nbd/tickets/65e220.md @@ -0,0 +1,45 @@ ++++ +title = "Add workers-rs WASM entry point to api binary" +priority = 7 +status = "done" +ticket_type = "feature" +dependencies = [] ++++ +## Goal + +Enable `cargo build --release --bin api --target wasm32-unknown-unknown` so the api binary deploys as a Cloudflare Worker (see `infra/worker.tf`). + +## Changes Required + +### 1. `Cargo.toml` +Add axum to `[target.'cfg(target_arch = "wasm32")'.dependencies]` with tokio disabled: +```toml +axum = { version = "0.8", default-features = false, features = ["json"] } +``` +All axum types used in handlers (Router, Path, Query, State, Json, etc.) are available without the tokio feature. + +### 2. `src/bin/api/main.rs` +- Wrap existing native code (`mod handlers;`, `#[tokio::main] async fn main()`) in `#[cfg(not(target_arch = "wasm32"))]` +- Add `#[cfg(target_arch = "wasm32")] mod handlers;` (no change to handlers themselves) +- Add `#[event(fetch)]` workers-rs entry point that: + 1. Gets D1 binding from env: `env.d1("DB")` + 2. Creates `D1Repository::new(db)` — see companion ticket for D1 implementation + 3. Calls `repo.run_migrations()` + 4. Wraps repo in `Arc` + 5. Builds Axum router via existing `handlers::router(repo)` + 6. Converts `worker::Request` → `http::Request` (method, uri, headers, body bytes) + 7. Calls router via `tower_service::Service::call()` + 8. Converts `http::Response` → `worker::Response` (status, headers, body bytes) + +`tower_service` is already a transitive dep of axum. + +## Dependencies +- Companion ticket for D1Repository implementation must be done first (or in parallel). + D1Repository must have `unsafe impl Send` and `unsafe impl Sync` for the Arc wrapper to work. + +## Validation +```sh +cargo build --release --bin api --target wasm32-unknown-unknown +cargo build --release --bin api # native must still work +cargo fmt && cargo check && cargo clippy && cargo test +``` \ No newline at end of file diff --git a/quotesdb/.nbd/tickets/68fd11.md b/quotesdb/.nbd/tickets/68fd11.md new file mode 100644 index 0000000..e95b1ac --- /dev/null +++ b/quotesdb/.nbd/tickets/68fd11.md @@ -0,0 +1,121 @@ ++++ +title = "quotesdb/api: POST /api/admin/reset-auth-code endpoint" +priority = 6 +status = "done" +ticket_type = "feature" +dependencies = ["69a2c5"] ++++ +## POST /api/admin/reset-auth-code endpoint + +Add the admin-protected endpoint that replaces the stored admin auth code. The caller must supply the current code via `X-Admin-Code`. A new code may be provided in the request body; if omitted, the server generates a fresh 4-word passphrase. + +--- + +## Files to modify + +- `src/bin/api/db/mod.rs` — add `update_admin_auth_code` to the `QuoteRepository` trait +- `src/bin/api/db/d1.rs` — implement `update_admin_auth_code` for D1 +- `src/bin/api/db/native.rs` — implement `update_admin_auth_code` for native SQLite +- `src/bin/api/handlers/mod.rs` (or a new `src/bin/api/handlers/admin.rs`) — add the `reset_auth_code` handler +- `src/bin/api/main.rs` — register the new route + +--- + +## New trait method (src/bin/api/db/mod.rs) + +Add to the `QuoteRepository` trait: + +```rust +/// Replace the admin auth code if `current` matches the stored value. +/// If `new_code` is `None`, generates a fresh 4-word passphrase. +/// Returns the new auth code string on success, or `DbError::Unauthorized` +/// if `current` does not match. +async fn update_admin_auth_code( + &self, + current: &str, + new_code: Option<&str>, +) -> Result; +``` + +Implementation steps: +1. Fetch the stored `admin_auth_code` from `admin_config`. +2. If it does not match `current`, return `DbError::Unauthorized` (or a dedicated variant). +3. Determine the new code: use `new_code` if provided, otherwise call the existing passphrase-generation utility. +4. Write the new value to `admin_config` with `UPDATE`. +5. Return the new code string. + +--- + +## Request / response types + +```rust +#[derive(Deserialize)] +struct ResetAuthCodeRequest { + new_code: Option, +} + +#[derive(Serialize)] +struct ResetAuthCodeResponse { + auth_code: String, +} +``` + +--- + +## Handler + +```rust +/// POST /api/admin/reset-auth-code +/// Requires X-Admin-Code header matching the stored admin passphrase. +/// Body: { "new_code": "optional-string" } +/// Response: 200 { "auth_code": "new-code" } or 403 on mismatch. +pub async fn reset_auth_code( + State(repo): State>, + headers: HeaderMap, + Json(payload): Json, +) -> impl IntoResponse { + let admin_code = match headers.get("x-admin-code").and_then(|v| v.to_str().ok()) { + Some(c) => c.to_owned(), + None => return StatusCode::FORBIDDEN.into_response(), + }; + match repo.update_admin_auth_code(&admin_code, payload.new_code.as_deref()).await { + Ok(new_code) => Json(ResetAuthCodeResponse { auth_code: new_code }).into_response(), + Err(DbError::Unauthorized) => StatusCode::FORBIDDEN.into_response(), + Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(), + } +} +``` + +--- + +## Route registration (src/bin/api/main.rs) + +```rust +.route("/api/admin/reset-auth-code", post(handlers::reset_auth_code)) +``` + +--- + +## Tests + +- `POST /api/admin/reset-auth-code` with correct `X-Admin-Code` and no body `new_code` → `200`, response contains a non-empty `auth_code` +- `POST /api/admin/reset-auth-code` with correct `X-Admin-Code` and explicit `new_code` → `200`, `auth_code` equals the supplied value +- `POST /api/admin/reset-auth-code` with wrong `X-Admin-Code` → `403` +- `POST /api/admin/reset-auth-code` with missing `X-Admin-Code` header → `403` +- After a successful reset, subsequent calls with the old code return `403` and with the new code return `200` + +--- + +## Validation + +```sh +cargo fmt && cargo check && cargo clippy && cargo test +``` + +--- + +## Commit + +``` +feat(quotesdb): POST /api/admin/reset-auth-code endpoint +``` \ No newline at end of file diff --git a/quotesdb/.nbd/tickets/69a2c5.md b/quotesdb/.nbd/tickets/69a2c5.md new file mode 100644 index 0000000..ac1ce2a --- /dev/null +++ b/quotesdb/.nbd/tickets/69a2c5.md @@ -0,0 +1,64 @@ ++++ +title = "quotesdb/api: DB layer — add submissions_locked + update_admin_auth_code" +priority = 7 +status = "done" +ticket_type = "feature" +dependencies = [] ++++ +## Goal +Extend the DB abstraction layer with three new trait methods and seed on startup. + +## New trait methods (add to `src/bin/api/db/mod.rs`) + +```rust +/// Replace the admin auth code if `current` matches. +/// If `new_code` is None, generates a fresh 4-word passphrase. +/// Returns the new auth code on success. +/// Returns Err(DbError::Forbidden) if `current` does not match. +async fn update_admin_auth_code( + &self, + current: &str, + new_code: Option<&str>, +) -> Result; + +/// Return whether submissions are currently locked. +async fn get_submissions_locked(&self) -> Result; + +/// Persist the submissions lock state. +async fn set_submissions_locked(&self, locked: bool) -> Result<(), DbError>; +``` + +## Implementations + +Implement in both: +- `src/bin/api/db/native.rs` (NativeRepository — rusqlite) +- `src/bin/api/db/d1.rs` (D1Repository — Cloudflare Workers WASM) + +## Seeding (startup) + +In `src/bin/api/main.rs` (both native and wasm32 paths), after seeding +`admin_auth_code`, also seed `submissions_locked = '0'` using +`INSERT OR IGNORE` (use `set_submissions_locked` only when the key is absent, +or add a dedicated `seed_submissions_locked` helper). + +## Testing + +Add unit/integration tests in `src/bin/api/handlers/mod.rs` test module +or `tests/` covering: +- get_submissions_locked returns false by default +- set_submissions_locked(true) then get_submissions_locked returns true +- update_admin_auth_code with correct current succeeds +- update_admin_auth_code with wrong current returns Forbidden + +## Validation + +Run from `quotesdb/`: +``` +cargo fmt && cargo check && cargo clippy && cargo test +``` + +## Commit scope +`feat(quotesdb): ...` + +## Design reference +`docs/plans/2026-03-04-admin-features-design.md` \ No newline at end of file diff --git a/quotesdb/.nbd/tickets/6a4c61.md b/quotesdb/.nbd/tickets/6a4c61.md new file mode 100644 index 0000000..f74d367 --- /dev/null +++ b/quotesdb/.nbd/tickets/6a4c61.md @@ -0,0 +1,75 @@ ++++ +title = "Submit form: remove 'Submit another' link from success screen" +priority = 5 +status = "done" +ticket_type = "bug" +dependencies = [] ++++ +## Bug + +After successfully submitting a quote, the success screen (`src/bin/ui/pages/submit.rs`, lines 111–133) offers two actions: + +1. "View your quote" (primary button → `/quotes/:id`) +2. "Submit another" (secondary button → `/submit`) + +The "Submit another" link pushes users toward submitting more quotes immediately. This is undesirable — the success screen should celebrate the submission and direct the user to view what they just created or browse the collection, not encourage repeat submissions. + +## Expected behaviour + +Remove the "Submit another" link from the success screen. Replace it with a "Browse all quotes" link (`/browse`) so the user has a natural next step that doesn't push them back to the submit form. + +Final success screen actions: +1. "View your quote" (primary, `/quotes/:id`) +2. "Browse all quotes" (secondary, `/browse`) + +## How to fix + +In `src/bin/ui/pages/submit.rs`, in the success render block (lines 120–130): + +```rust +// Before +
+ + to={Route::QuoteDetail { id: quote_id.clone() }} + classes="btn btn--primary" + > + { "View your quote" } + > + to={Route::Submit} classes="btn"> + { "Submit another" } + > +
+ +// After +
+ + to={Route::QuoteDetail { id: quote_id.clone() }} + classes="btn btn--primary" + > + { "View your quote" } + > + to={Route::Browse} classes="btn"> + { "Browse all quotes" } + > +
+``` + +No new imports needed — `Route::Browse` is already defined. + +## File + +- `src/bin/ui/pages/submit.rs` (lines 120–130) + +## Validation + +```sh +# From quotesdb/ root +cargo fmt && cargo check && cargo clippy && cargo test +trunk build +``` + +Manually verify: after submitting a quote, the success screen shows "View your quote" and "Browse all quotes" (not "Submit another"). + +## Commit scope + +`fix(quotesdb): remove submit-another link from success screen` \ No newline at end of file diff --git a/quotesdb/.nbd/tickets/809cba.md b/quotesdb/.nbd/tickets/809cba.md new file mode 100644 index 0000000..0565132 --- /dev/null +++ b/quotesdb/.nbd/tickets/809cba.md @@ -0,0 +1,168 @@ ++++ +title = "Admin super auth code: delete any quote regardless of per-quote auth" +priority = 7 +status = "done" +ticket_type = "feature" +dependencies = [] ++++ +## Feature + +Add an **admin super auth code** — a single global passphrase that can delete (and update) any quote, bypassing the per-quote `auth_code` check. This allows the operator to moderate content without needing the original submitter's code. + +The admin code is: +- Generated once on first startup using the same 4-word passphrase generator (`generate_auth_code` in `src/lib.rs`). +- Stored in the database in a new `admin_config` table. +- Printed prominently to stderr on every startup so the operator can note it. +- Never exposed via the API. + +--- + +## Part 1 — Database: new migration + +Add a new migration constant in `src/bin/api/db/migrations.rs`: + +```rust +/// Creates the admin_config key/value table for storing global configuration. +/// +/// Stores a single row for the admin auth code under the key `admin_auth_code`. +pub const CREATE_ADMIN_CONFIG: &str = "\ +CREATE TABLE IF NOT EXISTS admin_config ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +)"; +``` + +Run this migration in `QuoteRepository::run_migrations` after the existing migrations. The implementation then seeds the admin auth code if absent (see Part 2). + +Update `infra/schema.sql` to include: +```sql +CREATE TABLE IF NOT EXISTS admin_config ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +); +``` + +--- + +## Part 2 — Repository trait: `db/mod.rs` + +Add two new methods to `QuoteRepository`: + +```rust +/// Retrieve the admin super auth code from `admin_config`. +/// +/// Returns `Ok(None)` if the table is empty (should not happen after migrations). +async fn get_admin_auth_code(&self) -> Result, DbError>; + +/// Insert the admin auth code into `admin_config` if it is not already set. +/// +/// Called once during startup, after `run_migrations`. +async fn seed_admin_auth_code(&self, code: &str) -> Result<(), DbError>; +``` + +The startup sequence (in `main.rs`) becomes: +```rust +repo.run_migrations().await?; +// Seed admin code on first run +if repo.get_admin_auth_code().await?.is_none() { + let code = quotesdb::generate_auth_code(); + repo.seed_admin_auth_code(&code).await?; +} +// Always print the admin code at startup +let admin_code = repo.get_admin_auth_code().await?.unwrap(); +eprintln!("╔══════════════════════════════════════════════╗"); +eprintln!("║ ADMIN AUTH CODE: {admin_code:<28}║"); +eprintln!("╚══════════════════════════════════════════════╝"); +``` + +--- + +## Part 3 — Native implementation: `db/native.rs` + +Implement `get_admin_auth_code` and `seed_admin_auth_code` using rusqlite. + +**Extend `delete_quote`** to accept the admin code as a fallback: + +```rust +async fn delete_quote(&self, id: &str, auth_code: &str) -> Result { + // ... existing logic ... + // Before returning Forbidden, check admin auth code + let admin_code = self.get_admin_auth_code().await?; + if Some(auth_code) == admin_code.as_deref() { + // Admin code matches — delete unconditionally + // ... execute DELETE without checking quotes.auth_code ... + return Ok(DeleteResult::Deleted); + } + Ok(DeleteResult::Forbidden) +} +``` + +Similarly extend `update_quote` to allow admin override. + +The cleanest approach is to refactor `delete_quote` and `update_quote` to first attempt the per-quote auth check, and if it fails, check against the admin code. + +--- + +## Part 4 — D1 implementation: `db/d1.rs` + +Apply the same changes as Part 3 for the WASM/Cloudflare D1 path. + +--- + +## Part 5 — API startup: `src/bin/api/main.rs` + +Update the startup sequence as shown in Part 2. The admin code print must be clearly visible in logs. + +--- + +## Part 6 — Mock repo in tests: `handlers/mod.rs` + +Add stub implementations of `get_admin_auth_code` and `seed_admin_auth_code` to `MockRepo`: +```rust +async fn get_admin_auth_code(&self) -> Result, DbError> { + Ok(None) // no admin code in tests by default +} +async fn seed_admin_auth_code(&self, _code: &str) -> Result<(), DbError> { + Ok(()) +} +``` + +--- + +## Design notes + +- The admin code is **never returned by any API endpoint** — there is no way to discover it via HTTP. +- The admin code is stored plaintext in `admin_config`, consistent with per-quote auth codes. This is acceptable given the stated security model (simple passphrase, no user accounts). +- Only `delete_quote` and `update_quote` check the admin code. Read operations are unaffected. +- The admin code is **not rotatable** via the API — an operator who needs to rotate it must manually update the database row. + +--- + +## Files touched + +- `src/bin/api/db/migrations.rs` — `CREATE_ADMIN_CONFIG` constant +- `src/bin/api/db/mod.rs` — two new trait methods + updated docstrings for `delete_quote`/`update_quote` +- `src/bin/api/db/native.rs` — implementations + admin fallback logic +- `src/bin/api/db/d1.rs` — same for D1 +- `src/bin/api/handlers/mod.rs` — `MockRepo` stubs +- `src/bin/api/main.rs` — seed + print admin code on startup +- `infra/schema.sql` — `admin_config` table + +## Validation + +```sh +# From quotesdb/ root +cargo fmt && cargo check && cargo clippy && cargo test +``` + +Manual test: +1. Start the server: `cargo run` +2. Observe admin code printed to stderr. +3. Create a quote: `curl -X PUT http://localhost:3000/api/quotes -H 'Content-Type: application/json' -d '{"text":"Test","author":"A","tags":[]}'` +4. Try deleting with wrong code: should return 403. +5. Try deleting with admin code: should return 204. +6. Restart the server: same admin code should be printed (not regenerated). + +## Commit scope + +`feat(quotesdb): admin super auth code for quote moderation` \ No newline at end of file diff --git a/quotesdb/.nbd/tickets/9d756a.md b/quotesdb/.nbd/tickets/9d756a.md new file mode 100644 index 0000000..47ebcfe --- /dev/null +++ b/quotesdb/.nbd/tickets/9d756a.md @@ -0,0 +1,46 @@ ++++ +title = "Home page: show friendly empty state when no quotes in database" +priority = 7 +status = "done" +ticket_type = "bug" +dependencies = [] ++++ +## Bug + +When the database is empty, `GET /api/quotes/random` returns a 404 response. The home page (`src/bin/ui/pages/home.rs`) currently treats all errors (including 404) the same way — it sets the `error` state and displays it via ``, which results in something like a raw JSON error message being shown to the user. + +## Expected behaviour + +When the API returns a 404 on `/api/quotes/random`, the home page should display a friendly empty-state message instead of a generic error: + +> "Nothing here yet. Submit a quote!" + +The message should include a link to `/submit`. + +## How to fix + +In `src/bin/ui/pages/home.rs`, in the `use_effect_with` block, inspect the `Err` value. The API client returns `ApiError::Server { status, .. }` for HTTP error codes. When `status == 404`, set a dedicated "empty" state (or detect it from the error) and render the friendly message instead of ``. + +Relevant code: `src/bin/ui/pages/home.rs` — the `use_effect_with` error branch and the HTML render block. + +## File + +- `src/bin/ui/pages/home.rs` + +## Validation + +```sh +# From quotesdb/ root +cargo fmt && cargo check && cargo clippy && cargo test +``` + +Also manually test with an empty database: +```sh +rm -f quotesdb.sqlite +cargo run & +# navigate to http://localhost:3000 — should show the friendly message, not a raw error +``` + +## Commit scope + +`fix(quotesdb): home page empty state` \ No newline at end of file diff --git a/quotesdb/.nbd/tickets/a57b95.md b/quotesdb/.nbd/tickets/a57b95.md new file mode 100644 index 0000000..3d81357 --- /dev/null +++ b/quotesdb/.nbd/tickets/a57b95.md @@ -0,0 +1,68 @@ ++++ +title = "quotesdb/api: enforce submission lock on PUT /api/quotes" +priority = 6 +status = "done" +ticket_type = "feature" +dependencies = ["69a2c5"] ++++ +## Enforce submission lock on PUT /api/quotes + +Modify the quote-creation handler to check the submissions lock before accepting a new quote. If locked, return `423 Locked` with a JSON error body. + +Note: This ticket depends on ticket 35685a (GET /api/status) because that ticket adds `get_submissions_locked` to the `QuoteRepository` trait. Complete 35685a first. + +--- + +## Files to modify + +- `src/bin/api/handlers/mod.rs` — modify the `create_quote` handler (the handler for `PUT /api/quotes`) to add a lock pre-flight check + +No new DB methods, routes, or types are needed. + +--- + +## Change to create_quote handler + +At the top of the handler body, before any other logic, add: + +```rust +// Pre-flight: reject new submissions when locked. +match repo.get_submissions_locked().await { + Ok(true) => { + return ( + StatusCode::LOCKED, + Json(json!({ "error": "submissions are closed" })), + ).into_response(); + } + Ok(false) => {} + Err(_) => return StatusCode::INTERNAL_SERVER_ERROR.into_response(), +} +``` + +HTTP 423 is `StatusCode::LOCKED` in axum/hyper. + +--- + +## Tests + +- `PUT /api/quotes` while `submissions_locked = false` → `201` (existing behaviour unchanged) +- `PUT /api/quotes` while `submissions_locked = true` → `423` with body `{ "error": "submissions are closed" }` +- After unlocking (`submissions_locked = false`), `PUT /api/quotes` succeeds again → `201` + +Use the in-memory/mock repo already used by other handler tests; expose a method to toggle the lock state on the test double. + +--- + +## Validation + +```sh +cargo fmt && cargo check && cargo clippy && cargo test +``` + +--- + +## Commit + +``` +feat(quotesdb): enforce submission lock on PUT /api/quotes +``` \ No newline at end of file diff --git a/quotesdb/.nbd/tickets/a57e7e.md b/quotesdb/.nbd/tickets/a57e7e.md new file mode 100644 index 0000000..37ecee0 --- /dev/null +++ b/quotesdb/.nbd/tickets/a57e7e.md @@ -0,0 +1,144 @@ ++++ +title = "Cloudflare Turnstile CAPTCHA on quote submission" +priority = 7 +status = "done" +ticket_type = "feature" +dependencies = [] ++++ +## Feature + +Add Cloudflare Turnstile CAPTCHA to protect the `PUT /api/quotes` endpoint (and the submit form in the UI) from bots and spam. This is a three-part change: infra, API, and UI. + +--- + +## Part 1 — Infra: Turnstile widget resource + +Create `infra/turnstile.tf` with a `cloudflare_turnstile_widget` resource. + +```hcl +# Turnstile CAPTCHA widget protecting the quote submission form. +# Provides a site_key (public, embedded in the UI) and secret_key +# (private, used by the API to verify tokens server-side). +resource "cloudflare_turnstile_widget" "submit" { + account_id = var.cloudflare_account_id + name = "quotesdb-submit" + # "managed" mode: Turnstile decides whether to show a visible challenge. + mode = "managed" + # Restrict the widget to the production domain. + domains = [var.domain] +} + +output "turnstile_site_key" { + description = "Turnstile site key — safe to embed in the UI." + value = cloudflare_turnstile_widget.submit.id +} + +output "turnstile_secret_key" { + description = "Turnstile secret key — inject into Workers via wrangler secret." + value = cloudflare_turnstile_widget.submit.secret + sensitive = true +} +``` + +The `var.domain` variable should already exist or be added alongside `var.cloudflare_account_id` in `providers.tf` / `variables.tf`. + +After `tofu apply`, inject the secret into the Worker: +```sh +wrangler secret put TURNSTILE_SECRET_KEY +# paste the value from `tofu output -raw turnstile_secret_key` +``` + +**Validate:** +```sh +# From infra/ directory +tofu validate +tofu plan +``` + +--- + +## Part 2 — API: Verify Turnstile token in create handler + +The API must verify the Turnstile token before creating a quote. + +### Changes + +**`src/lib.rs` (or a new `turnstile` module in `src/bin/api/`):** + +Add a `verify_turnstile(token: &str, secret: &str, remote_ip: Option<&str>) -> Result` function that POSTs to `https://challenges.cloudflare.com/turnstile/v0/siteverify`. + +**`quotesdb::CreateQuoteInput` in `src/lib.rs`:** + +Add a `cf_turnstile_token: Option` field. It is optional so that local/test environments can skip verification when no secret is configured. + +**`src/bin/api/handlers/mod.rs` — `create_handler`:** + +Before calling `repo.create_quote(input)`, check: +1. Read `TURNSTILE_SECRET_KEY` from the environment. +2. If the env var is set: + - Extract `cf_turnstile_token` from the request body. + - If the token is absent, return `400 Bad Request`. + - Call `verify_turnstile(token, secret, remote_ip)`. + - If verification fails, return `403 Forbidden` with `{"error": "CAPTCHA verification failed"}`. +3. If the env var is absent (local dev), skip verification. + +**HTTP client:** Add `reqwest` (with `default-features = false, features = ["json"]`) as a non-wasm32 dependency for the Turnstile API call. On wasm32 the create handler does not exist, so no conflict. + +**Important:** Strip `cf_turnstile_token` from the `CreateQuoteInput` before passing it to the repository — the DB doesn't store it. + +**Validation:** +```sh +# From quotesdb/ root +cargo fmt && cargo check && cargo clippy && cargo test +``` + +--- + +## Part 3 — UI: Embed Turnstile widget in submit form + +### `index.html` + +Add the Turnstile JS script tag to the ``: +```html + +``` + +### `src/bin/ui/pages/submit.rs` + +1. Add a `turnstile_token: UseStateHandle>` state handle. +2. Add the Turnstile widget div in the form, before the submit button: + ```html +
+
+ ``` + The `data-callback` JS function name must be registered in `window`. Use `web_sys::window()` and `js_sys::Function` to expose a Rust closure that sets `turnstile_token` state. +3. Include the token in the `CreateQuoteInput` sent to the API. + +**Site key:** The Turnstile site key is public and safe to hardcode in the UI source. Retrieve it from `tofu output -raw turnstile_site_key` after applying infra. Add a note in `docs/LOCAL_DEV.md` that local dev skips CAPTCHA (no env var set). + +**Validation:** +```sh +cargo fmt && cargo check && cargo clippy && cargo test +trunk build +``` + +Manually verify: the submit form shows the Turnstile widget and submission is blocked if the challenge is not completed. + +--- + +## Files touched + +- `infra/turnstile.tf` (new) +- `src/lib.rs` — `CreateQuoteInput` + `verify_turnstile` +- `src/bin/api/handlers/mod.rs` — `create_handler` +- `src/bin/ui/pages/submit.rs` — widget embed + token state +- `index.html` — Turnstile JS script +- `Cargo.toml` — `reqwest` dependency (non-wasm32) +- `api/openapi.yaml` — document `cf_turnstile_token` field +- `docs/LOCAL_DEV.md` — note on local dev CAPTCHA bypass + +## Commit scope + +`feat(quotesdb): Cloudflare Turnstile CAPTCHA on submit` \ No newline at end of file diff --git a/quotesdb/.nbd/tickets/b01bad.md b/quotesdb/.nbd/tickets/b01bad.md new file mode 100644 index 0000000..0668e36 --- /dev/null +++ b/quotesdb/.nbd/tickets/b01bad.md @@ -0,0 +1,62 @@ ++++ +title = "Fix compiler warnings in api and ui binaries" +priority = 7 +status = "done" +ticket_type = "bug" +dependencies = [] ++++ +## Bug + +Running `cargo build --target wasm32-unknown-unknown` (and `trunk build`) produces compiler warnings in both the `api` and `ui` binaries. All warnings should be resolved so the build is clean. + +## Warnings + +Reproduce with: +```sh +# From quotesdb/ root +cargo build --target wasm32-unknown-unknown 2>&1 | grep -E 'warning\[|warning: (fields|unused|duplicated)' +``` + +### UI binary (1 warning) + +**`src/bin/ui/api.rs:21:9`** — `fields page and total_count are never read` + +The `QuotesResponse` struct (or equivalent) has `page` and `total_count` fields that are deserialized from the API but never read by any UI code. Either: +- Remove the fields if they are genuinely unused, or +- Add `#[allow(dead_code)]` with a comment explaining they are reserved for future pagination UI, or +- Actually use them (e.g. pass `total_count` to the browse page for "X quotes total" display) + +Preferred fix: use them or remove them. Avoid bare `#[allow(dead_code)]` without justification. + +### API binary (2 warnings) + +**`src/bin/api/db/d1.rs:9:8`** — `duplicated attribute` + +A `#[cfg(...)]` or other attribute is duplicated on the same item. Remove the duplicate. + +**`src/bin/api/db/mod.rs:27:9`** — `unused import: d1::D1Repository` + +`D1Repository` is imported but never used in this module. Remove the import. + +## Files + +- `src/bin/ui/api.rs` (line 21) +- `src/bin/api/db/d1.rs` (line 9) +- `src/bin/api/db/mod.rs` (line 27) + +## Validation + +```sh +# From quotesdb/ root +cargo fmt && cargo check && cargo clippy && cargo test + +# Confirm zero warnings for ui binary +cargo build --target wasm32-unknown-unknown 2>&1 | grep 'warning:.*generated' +# Expected: no output (or "0 warnings") +``` + +Also run `trunk build` and confirm no warnings are emitted for the `quotesdb` crate (dependency warnings from third-party crates are acceptable). + +## Commit scope + +`fix(quotesdb): resolve compiler warnings in api and ui` \ No newline at end of file diff --git a/quotesdb/.nbd/tickets/bacb16.md b/quotesdb/.nbd/tickets/bacb16.md new file mode 100644 index 0000000..8d87853 --- /dev/null +++ b/quotesdb/.nbd/tickets/bacb16.md @@ -0,0 +1,68 @@ ++++ +title = "Implement D1Repository for Cloudflare Workers" +priority = 7 +status = "done" +ticket_type = "feature" +dependencies = [] ++++ +## Goal + +Implement all 7 stub methods in `src/bin/api/db/d1.rs` so the API works against Cloudflare D1 in production. + +## Changes Required + +### `src/bin/api/db/d1.rs` + +**1. Add unsafe Send/Sync** (wasm32 is single-threaded; safe in practice): +```rust +// SAFETY: wasm32-unknown-unknown is single-threaded. +unsafe impl Send for D1Repository {} +unsafe impl Sync for D1Repository {} +``` +These are required so `D1Repository` satisfies the `Arc` bound used in the Axum state. + +**2. Helper row structs** (serde::Deserialize, field names = SQL column names): +- `QuoteRow { id, text, author, source: Option, date: Option, created_at, updated_at }` +- `AuthRow { auth_code: String }` +- `TagRow { tag: String }` +- `CountRow { count: u32 }` + +**3. Helper method** `fetch_tags(&self, id: &str) -> Result, DbError>`: +`SELECT tag FROM quote_tags WHERE quote_id = ?1 ORDER BY tag`, bind with `JsValue::from_str(id)`, deserialise as `Vec`. + +**4. Implement each QuoteRepository method:** + +- **run_migrations**: Call `self.db.exec()` for each migration constant from `migrations::` in sequence (CREATE_QUOTES, CREATE_QUOTE_TAGS, CREATE_TAG_INDEX, CREATE_AUTHOR_INDEX). + +- **list_quotes(page, author, tag)**: Dynamic SQL with positional params (mirror native.rs logic). Run COUNT query for total_count, then data query with LIMIT 10 / OFFSET. Fetch tags per quote via helper. Page size = 10. + +- **get_quote(id)**: `SELECT ... FROM quotes WHERE id = ?1`. Use `.first::(None)`. Fetch tags. Return `Ok(None)` if missing. + +- **get_random_quote**: `SELECT ... FROM quotes ORDER BY RANDOM() LIMIT 1`. Use `.first::(None)`. + +- **create_quote(input)**: + 1. `generate_id()`, `auth_code = input.auth_code.unwrap_or_else(generate_auth_code)` + 2. INSERT quotes row (bind NULL for optional source/date with `JsValue::NULL`) + 3. Batch INSERT tags via `db.batch()` + 4. SELECT back the row to get timestamps + 5. Return `(quote, auth_code)` + +- **update_quote(id, input, auth_code)**: + 1. SELECT auth_code — return Forbidden on mismatch + 2. Build dynamic SET clause for non-None fields + `updated_at = datetime('now')` + 3. Execute UPDATE + 4. If tags provided: DELETE existing, batch INSERT new + 5. SELECT updated row; return it + +- **delete_quote(id, auth_code)**: + 1. SELECT auth_code — return NotFound if absent, Forbidden on mismatch + 2. DELETE FROM quotes (tags cascade) + 3. Return `DeleteResult::Deleted` + +**JsValue bindings**: `JsValue::from_str(s)` for strings, `JsValue::from_f64(n as f64)` for integers, `JsValue::NULL` for SQL NULL. + +## Validation +```sh +cargo build --release --bin api --target wasm32-unknown-unknown +cargo fmt && cargo check && cargo clippy && cargo test +``` \ No newline at end of file diff --git a/quotesdb/.nbd/tickets/c3c8c6.md b/quotesdb/.nbd/tickets/c3c8c6.md new file mode 100644 index 0000000..8a0ce38 --- /dev/null +++ b/quotesdb/.nbd/tickets/c3c8c6.md @@ -0,0 +1,103 @@ ++++ +title = "quotesdb/ui: /admin page component" +priority = 5 +status = "done" +ticket_type = "feature" +dependencies = ["161f32"] ++++ +## /admin page component (UI) + +Create the /admin route and page component. The page provides a persistent admin auth code input, an auth code reset section, and a submissions lock/unlock section. + +Depends on ticket 161f32 (admin API client functions). Complete that ticket first. + +--- + +## Files to create / modify + +- `src/bin/ui/pages/admin.rs` — **create** the new page component +- `src/bin/ui/pages/mod.rs` — add `pub mod admin;` +- `src/bin/ui/main.rs` — add the `/admin` route to the Yew Router switch and add an "Admin" link to the top nav (or wherever the nav links are defined) + +--- + +## Component: AdminPage + +The component holds the following local state: + +| State field | Type | Purpose | +|---|---|---| +| `admin_code` | `String` | The persistent admin auth code input | +| `new_passphrase` | `String` | Optional input for reset section | +| `reset_result` | `Option` | Newly returned auth code after reset | +| `reset_error` | `Option` | Error message for the reset section | +| `submissions_locked` | `Option` | Current lock state, fetched on mount | +| `lock_error` | `Option` | Error message for lock/unlock section | +| `loading` | `bool` | Disables buttons during in-flight requests | + +### On mount + +Call `api::get_status()` and set `submissions_locked` from the response. + +### Layout + +``` +[ Admin ] (page heading) + +Admin auth code: [__________________] (persistent text input) + +--- Reset auth code --- +New passphrase (optional): [__________] +[ Reset ] +> (success) New code: ocean-table-purple-storm +> (error) Wrong auth code. + +--- Submissions --- +Status: Open | Closed +[ Lock submissions ] / [ Unlock submissions ] +> (error) Wrong auth code. +``` + +### Behaviour + +- **Reset button:** calls `api::admin_reset_auth_code(&admin_code, new_passphrase_opt, &admin_code)`. On success, shows the new code in `reset_result` and clears `reset_error`. On `ApiError::Forbidden`, sets `reset_error = "Wrong auth code."`. On other errors, sets a generic message. +- **Lock button** (shown when `submissions_locked == Some(false)`): calls `api::admin_lock(&admin_code)`. On success, sets `submissions_locked = Some(true)`. On `ApiError::Forbidden`, sets `lock_error`. +- **Unlock button** (shown when `submissions_locked == Some(true)`): calls `api::admin_unlock(&admin_code)`. On success, sets `submissions_locked = Some(false)`. On `ApiError::Forbidden`, sets `lock_error`. +- While `loading = true`, disable all buttons. + +--- + +## Route registration (src/bin/ui/main.rs) + +Add to the router switch: + +```rust +Route { path: "/admin".to_string(), render: ... } +// or whichever router pattern is already in use +``` + +--- + +## Tests + +No runtime unit tests (wasm-only). Verify the build: + +```sh +cargo check --target wasm32-unknown-unknown --bin ui +``` + +--- + +## Validation + +```sh +cargo fmt && cargo check --target wasm32-unknown-unknown --bin ui +``` + +--- + +## Commit + +``` +feat(quotesdb): /admin page component +``` \ No newline at end of file diff --git a/quotesdb/.nbd/tickets/dfd185.md b/quotesdb/.nbd/tickets/dfd185.md new file mode 100644 index 0000000..64672bb --- /dev/null +++ b/quotesdb/.nbd/tickets/dfd185.md @@ -0,0 +1,49 @@ ++++ +title = "Submit form: date field should use type=date for calendar picker" +priority = 5 +status = "done" +ticket_type = "bug" +dependencies = [] ++++ +## Bug + +The date field on the submit form (`src/bin/ui/pages/submit.rs`, around line 200–215) uses `type="text"` with a placeholder hinting at YYYY-MM-DD format. This means: +- No calendar UI — users must type the date manually +- No browser-native date validation +- No consistent format enforcement + +## Expected behaviour + +The date field should use `type="date"`, which: +- Provides a browser-native calendar picker on supported browsers +- Automatically enforces valid date format +- Returns the value as a YYYY-MM-DD string, which matches the API's `date` field format — no conversion needed + +## How to fix + +In `src/bin/ui/pages/submit.rs`: + +1. Change the `type` attribute on the date `` from `"text"` to `"date"` (~line 204). +2. Remove or update the label text — it currently says `"Date (optional, YYYY-MM-DD)"`. With `type="date"`, the format hint is no longer needed. Change to `"Date (optional)"`. +3. Remove the `placeholder` attribute — `type="date"` inputs do not display placeholder text in most browsers; it is redundant. +4. The `oninput` handler reads `.value()` from the `HtmlInputElement`, which is already correct — `type="date"` returns the value in YYYY-MM-DD format, so no changes needed to data handling. + +**Note:** The existing `HtmlInputElement` import is already correct. No new imports needed. + +## File + +- `src/bin/ui/pages/submit.rs` (~lines 199–215) + +## Validation + +```sh +# From quotesdb/ root +cargo fmt && cargo check && cargo clippy && cargo test +trunk build +``` + +Manually verify the date field shows a calendar picker in the browser. + +## Commit scope + +`fix(quotesdb): submit form date input type` \ No newline at end of file diff --git a/quotesdb/TODO.md b/quotesdb/TODO.md new file mode 100644 index 0000000..6dbe1f3 --- /dev/null +++ b/quotesdb/TODO.md @@ -0,0 +1,2 @@ +* add an about page. +* add a footer to all pages that says something like "Contact quotes@elijah.run for any inquiries" diff --git a/quotesdb/infra/README.md b/quotesdb/infra/README.md index 0ceaccd..daa2780 100644 --- a/quotesdb/infra/README.md +++ b/quotesdb/infra/README.md @@ -17,16 +17,16 @@ OpenTofu configuration for deploying quotesdb to Cloudflare. | Variable | Description | |---|---| -| `TF_VAR_cloudflare_api_token` | Cloudflare API token (Workers, D1, Pages, DNS edit) | -| `TF_VAR_cloudflare_account_id` | Cloudflare account ID | -| `TF_VAR_cloudflare_zone_id` | Zone ID for `elijah.run` | +| `cloudflare_api_token` | Cloudflare API token (Workers, D1, Pages, DNS edit) | +| `cloudflare_account_id` | Cloudflare account ID | +| `cloudflare_zone_id` | Zone ID for `elijah.run` | -Export these before running `tofu`: +These are set in `terraform.tfvars` (gitignored) ```sh -export TF_VAR_cloudflare_api_token="..." -export TF_VAR_cloudflare_account_id="..." -export TF_VAR_cloudflare_zone_id="..." +cloudflare_api_token="..." +cloudflare_account_id="..." +cloudflare_zone_id="..." ``` ## First-time setup (chicken-and-egg)