chore(quotesdb): commit tickets, TODO, and infra README update
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>main
parent
7d0df10d75
commit
89a235bfa3
@ -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<Arc<dyn QuoteRepository>>,
|
||||
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<Arc<dyn QuoteRepository>>,
|
||||
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
|
||||
```
|
||||
@ -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<bool>` | `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
|
||||
<div class="submissions-closed-banner">
|
||||
<p>Submissions are currently closed.</p>
|
||||
</div>
|
||||
```
|
||||
|
||||
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
|
||||
```
|
||||
@ -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<StatusResponse, ApiError> { ... }
|
||||
|
||||
/// 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<String, ApiError> { ... }
|
||||
|
||||
/// 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<bool, ApiError> { ... }
|
||||
|
||||
/// 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<bool, ApiError> { ... }
|
||||
```
|
||||
|
||||
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
|
||||
```
|
||||
@ -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<bool, DbError>;
|
||||
|
||||
/// 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<Arc<dyn QuoteRepository>>) -> 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
|
||||
```
|
||||
@ -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<dyn QuoteRepository + Send + Sync>`
|
||||
5. Builds Axum router via existing `handlers::router(repo)`
|
||||
6. Converts `worker::Request` → `http::Request<axum::body::Body>` (method, uri, headers, body bytes)
|
||||
7. Calls router via `tower_service::Service::call()`
|
||||
8. Converts `http::Response<axum::body::Body>` → `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<dyn ... + Send + Sync> 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
|
||||
```
|
||||
@ -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<String, DbError>;
|
||||
```
|
||||
|
||||
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<String>,
|
||||
}
|
||||
|
||||
#[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<Arc<dyn QuoteRepository>>,
|
||||
headers: HeaderMap,
|
||||
Json(payload): Json<ResetAuthCodeRequest>,
|
||||
) -> 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
|
||||
```
|
||||
@ -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<String, DbError>;
|
||||
|
||||
/// Return whether submissions are currently locked.
|
||||
async fn get_submissions_locked(&self) -> Result<bool, DbError>;
|
||||
|
||||
/// 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`
|
||||
@ -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<Option<String>, 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<DeleteResult, DbError> {
|
||||
// ... 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<Option<String>, 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`
|
||||
@ -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 `<ErrorDisplay>`, 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 `<ErrorDisplay>`.
|
||||
|
||||
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`
|
||||
@ -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
|
||||
```
|
||||
@ -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<bool, Error>` function that POSTs to `https://challenges.cloudflare.com/turnstile/v0/siteverify`.
|
||||
|
||||
**`quotesdb::CreateQuoteInput` in `src/lib.rs`:**
|
||||
|
||||
Add a `cf_turnstile_token: Option<String>` 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 `<head>`:
|
||||
```html
|
||||
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
|
||||
```
|
||||
|
||||
### `src/bin/ui/pages/submit.rs`
|
||||
|
||||
1. Add a `turnstile_token: UseStateHandle<Option<String>>` state handle.
|
||||
2. Add the Turnstile widget div in the form, before the submit button:
|
||||
```html
|
||||
<div class="cf-turnstile"
|
||||
data-sitekey="TURNSTILE_SITE_KEY_HERE"
|
||||
data-callback="turnstile_callback">
|
||||
</div>
|
||||
```
|
||||
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`
|
||||
@ -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`
|
||||
@ -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<dyn QuoteRepository + Send + Sync>` bound used in the Axum state.
|
||||
|
||||
**2. Helper row structs** (serde::Deserialize, field names = SQL column names):
|
||||
- `QuoteRow { id, text, author, source: Option<String>, date: Option<String>, created_at, updated_at }`
|
||||
- `AuthRow { auth_code: String }`
|
||||
- `TagRow { tag: String }`
|
||||
- `CountRow { count: u32 }`
|
||||
|
||||
**3. Helper method** `fetch_tags(&self, id: &str) -> Result<Vec<String>, DbError>`:
|
||||
`SELECT tag FROM quote_tags WHERE quote_id = ?1 ORDER BY tag`, bind with `JsValue::from_str(id)`, deserialise as `Vec<TagRow>`.
|
||||
|
||||
**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::<QuoteRow>(None)`. Fetch tags. Return `Ok(None)` if missing.
|
||||
|
||||
- **get_random_quote**: `SELECT ... FROM quotes ORDER BY RANDOM() LIMIT 1`. Use `.first::<QuoteRow>(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
|
||||
```
|
||||
@ -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<String>` | Newly returned auth code after reset |
|
||||
| `reset_error` | `Option<String>` | Error message for the reset section |
|
||||
| `submissions_locked` | `Option<bool>` | Current lock state, fetched on mount |
|
||||
| `lock_error` | `Option<String>` | 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
|
||||
```
|
||||
@ -1,12 +1,3 @@
|
||||
# TODO
|
||||
|
||||
- Ability to 'report' quotes for moderation
|
||||
- Should be backed by an API `/report` endpoint
|
||||
- Reports should include a reason with a finite set of options, and 'other'
|
||||
- Should also include a form for additional context limited to 256 characters
|
||||
- Admin UI should aggregate all reported quotes with the ability to dismiss
|
||||
|
||||
---
|
||||
|
||||
- Can we add rate limiting to the API?
|
||||
- Ideally this is done within Cloudflare and not manged in-app.
|
||||
* add an about page.
|
||||
|
||||
Loading…
Reference in New Issue