Skip to main content

max / multithreaded

Internal API improvements, move todo files to docs/mt/ Add get_max_category_order and thread_exists queries, create-post endpoint, category ordering on create, thread existence validation. Move todo.md and todo_done.md to docs/mt/ (per project docs standard). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-03-25 21:49 UTC
Commit: 5e472eb68517cb902f5dbc2aafc2c07946b473ae
Parent: 9d042fe
5 files changed, +278 insertions, -469 deletions
@@ -1454,6 +1454,36 @@ pub async fn get_image(
1454 1454 .await
1455 1455 }
1456 1456
1457 + /// Get the maximum sort_order among categories in a community. Returns -1 if no categories exist.
1458 + #[tracing::instrument(skip_all)]
1459 + pub async fn get_max_category_order(
1460 + pool: &PgPool,
1461 + community_id: Uuid,
1462 + ) -> Result<i32, sqlx::Error> {
1463 + let row: (Option<i32>,) = sqlx::query_as(
1464 + "SELECT MAX(sort_order) FROM categories WHERE community_id = $1",
1465 + )
1466 + .bind(community_id)
1467 + .fetch_one(pool)
1468 + .await?;
1469 + Ok(row.0.unwrap_or(-1))
1470 + }
1471 +
1472 + /// Check if a thread exists by ID.
1473 + #[tracing::instrument(skip_all)]
1474 + pub async fn thread_exists(
1475 + pool: &PgPool,
1476 + thread_id: Uuid,
1477 + ) -> Result<bool, sqlx::Error> {
1478 + let count: i64 = sqlx::query_scalar(
1479 + "SELECT COUNT(*) FROM threads WHERE id = $1",
1480 + )
1481 + .bind(thread_id)
1482 + .fetch_one(pool)
1483 + .await?;
1484 + Ok(count > 0)
1485 + }
1486 +
1457 1487 /// Count images uploaded by a user in the last N seconds (rate limiting).
1458 1488 #[tracing::instrument(skip_all)]
1459 1489 pub async fn count_recent_uploads_by_user(
@@ -53,6 +53,19 @@ pub struct CreateThreadResponse {
53 53 pub created: bool,
54 54 }
55 55
56 + #[derive(Deserialize)]
57 + pub struct CreatePostRequest {
58 + pub body_markdown: String,
59 + pub author_mnw_id: Uuid,
60 + pub author_username: String,
61 + pub author_display_name: Option<String>,
62 + }
63 +
64 + #[derive(Serialize)]
65 + pub struct CreatePostResponse {
66 + pub post_id: Uuid,
67 + }
68 +
56 69 #[derive(Serialize)]
57 70 pub struct ThreadStatsResponse {
58 71 pub post_count: i64,
@@ -168,17 +181,43 @@ async fn create_thread(
168 181 (StatusCode::NOT_FOUND, "Community not found").into_response()
169 182 })?;
170 183
171 - // Look up category
172 - let category = mt_db::queries::get_category_by_community_and_slug(
184 + // Look up category — auto-create if it doesn't exist (supports on-demand "patches" etc.)
185 + let category = match mt_db::queries::get_category_by_community_and_slug(
173 186 &state.db,
174 187 community.id,
175 188 &req.category_slug,
176 189 )
177 190 .await
178 191 .map_err(db_error)?
179 - .ok_or_else(|| {
180 - (StatusCode::NOT_FOUND, "Category not found").into_response()
181 - })?;
192 + {
193 + Some(cat) => cat,
194 + None => {
195 + let next_order = mt_db::queries::get_max_category_order(&state.db, community.id)
196 + .await
197 + .map_err(db_error)? + 1;
198 + let cat_name = capitalize(&req.category_slug);
199 + let cat_id = mt_db::mutations::create_category(
200 + &state.db,
201 + community.id,
202 + &cat_name,
203 + &req.category_slug,
204 + None,
205 + next_order,
206 + )
207 + .await
208 + .map_err(db_error)?;
209 + tracing::info!(
210 + category_slug = %req.category_slug,
211 + community_slug = %req.community_slug,
212 + "internal: auto-created category"
213 + );
214 + mt_db::queries::CategoryIdRow {
215 + id: cat_id,
216 + name: cat_name,
217 + slug: req.category_slug.clone(),
218 + }
219 + }
220 + };
182 221
183 222 // Upsert author
184 223 mt_db::mutations::upsert_user(
@@ -254,11 +293,77 @@ async fn thread_stats(
254 293 }))
255 294 }
256 295
296 + /// `POST /internal/threads/:id/posts` — Add a reply to an existing thread.
297 + #[tracing::instrument(skip_all, name = "internal::create_post")]
298 + async fn create_post(
299 + State(state): State<AppState>,
300 + Path(id): Path<String>,
301 + InternalAuth(body): InternalAuth,
302 + ) -> Result<Json<CreatePostResponse>, Response> {
303 + let thread_id = Uuid::parse_str(&id).map_err(|_| {
304 + StatusCode::NOT_FOUND.into_response()
305 + })?;
306 +
307 + let req: CreatePostRequest = serde_json::from_slice(&body).map_err(|e| {
308 + tracing::warn!(error = %e, "invalid create_post request body");
309 + (StatusCode::BAD_REQUEST, "Invalid request body").into_response()
310 + })?;
311 +
312 + // Verify thread exists
313 + if !mt_db::queries::thread_exists(&state.db, thread_id)
314 + .await
315 + .map_err(db_error)?
316 + {
317 + return Err((StatusCode::NOT_FOUND, "Thread not found").into_response());
318 + }
319 +
320 + // Upsert author
321 + mt_db::mutations::upsert_user(
322 + &state.db,
323 + req.author_mnw_id,
324 + &req.author_username,
325 + req.author_display_name.as_deref(),
326 + )
327 + .await
328 + .map_err(db_error)?;
329 +
330 + // Look up thread's community and ensure membership
331 + let thread_info = mt_db::queries::get_thread_with_breadcrumb(&state.db, thread_id)
332 + .await
333 + .map_err(db_error)?
334 + .ok_or_else(|| (StatusCode::NOT_FOUND, "Thread not found").into_response())?;
335 +
336 + mt_db::mutations::ensure_membership(&state.db, req.author_mnw_id, thread_info.community_id)
337 + .await
338 + .map_err(db_error)?;
339 +
340 + // Render markdown and create post
341 + let body_html = super::render_markdown(&req.body_markdown);
342 + let post_id = mt_db::mutations::create_post(
343 + &state.db,
344 + thread_id,
345 + req.author_mnw_id,
346 + &req.body_markdown,
347 + &body_html,
348 + )
349 + .await
350 + .map_err(db_error)?;
351 +
352 + tracing::info!(
353 + thread_id = %thread_id,
354 + post_id = %post_id,
355 + "internal: post created"
356 + );
357 +
358 + Ok(Json(CreatePostResponse { post_id }))
359 + }
360 +
257 361 /// Build the internal API router. Registered outside CSRF/session middleware.
258 362 pub fn internal_routes(state: AppState) -> Router {
259 363 Router::new()
260 364 .route("/internal/communities", post(create_community))
261 365 .route("/internal/threads", post(create_thread))
366 + .route("/internal/threads/{id}/posts", post(create_post))
262 367 .route("/internal/threads/{id}/stats", get(thread_stats))
263 368 .with_state(state)
264 369 }
@@ -271,3 +376,12 @@ fn db_error(e: sqlx::Error) -> Response {
271 376 tracing::error!(error = %e, "internal API database error");
272 377 StatusCode::INTERNAL_SERVER_ERROR.into_response()
273 378 }
379 +
380 + /// Capitalize the first letter of a string (for auto-created category names).
381 + fn capitalize(s: &str) -> String {
382 + let mut chars = s.chars();
383 + match chars.next() {
384 + None => String::new(),
385 + Some(c) => c.to_uppercase().collect::<String>() + chars.as_str(),
386 + }
387 + }
@@ -402,3 +402,132 @@ async fn thread_stats_invalid_uuid() {
402 402 let (status, _) = h.get("/internal/threads/not-a-uuid/stats").await;
403 403 assert_eq!(status, StatusCode::NOT_FOUND);
404 404 }
405 +
406 + // ============================================================================
407 + // Create post tests
408 + // ============================================================================
409 +
410 + #[tokio::test]
411 + async fn create_post_happy_path() {
412 + let h = InternalTestHarness::new().await;
413 + let owner_id = Uuid::new_v4();
414 +
415 + // Create community + thread
416 + let comm_body = serde_json::json!({
417 + "name": "Post Project",
418 + "slug": "post-project",
419 + "owner_mnw_id": owner_id,
420 + "owner_username": "postuser",
421 + });
422 + h.signed_post("/internal/communities", &comm_body.to_string()).await;
423 +
424 + let thread_body = serde_json::json!({
425 + "community_slug": "post-project",
426 + "category_slug": "items",
427 + "title": "Thread for Reply",
428 + "body_markdown": "Opening post",
429 + "author_mnw_id": owner_id,
430 + "author_username": "postuser",
431 + "external_ref": "mnw:item:post-test-1"
432 + });
433 + let (_, text) = h.signed_post("/internal/threads", &thread_body.to_string()).await;
434 + let resp: serde_json::Value = serde_json::from_str(&text).unwrap();
435 + let thread_id = resp["thread_id"].as_str().unwrap();
436 +
437 + // Create a reply
438 + let reply_body = serde_json::json!({
439 + "body_markdown": "This is a reply via internal API",
440 + "author_mnw_id": owner_id,
441 + "author_username": "postuser",
442 + "author_display_name": "Post User"
443 + });
444 + let (status, text) = h
445 + .signed_post(
446 + &format!("/internal/threads/{}/posts", thread_id),
447 + &reply_body.to_string(),
448 + )
449 + .await;
450 + assert_eq!(status, StatusCode::OK, "body: {}", text);
451 +
452 + let post_resp: serde_json::Value = serde_json::from_str(&text).unwrap();
453 + assert!(post_resp["post_id"].as_str().is_some());
454 +
455 + // Verify thread now has 2 posts
456 + let (_, stats_text) = h.get(&format!("/internal/threads/{}/stats", thread_id)).await;
457 + let stats: serde_json::Value = serde_json::from_str(&stats_text).unwrap();
458 + assert_eq!(stats["post_count"].as_i64().unwrap(), 2);
459 + }
460 +
461 + #[tokio::test]
462 + async fn create_post_nonexistent_thread() {
463 + let h = InternalTestHarness::new().await;
464 + let fake_id = Uuid::new_v4();
465 +
466 + let body = serde_json::json!({
467 + "body_markdown": "Reply to nothing",
468 + "author_mnw_id": Uuid::new_v4(),
469 + "author_username": "nobody",
470 + });
471 + let (status, _) = h
472 + .signed_post(
473 + &format!("/internal/threads/{}/posts", fake_id),
474 + &body.to_string(),
475 + )
476 + .await;
477 + assert_eq!(status, StatusCode::NOT_FOUND);
478 + }
479 +
480 + // ============================================================================
481 + // Auto-create category tests
482 + // ============================================================================
483 +
484 + #[tokio::test]
485 + async fn create_thread_auto_creates_category() {
486 + let h = InternalTestHarness::new().await;
487 + let owner_id = Uuid::new_v4();
488 +
489 + // Create community (has 4 default categories)
490 + let comm_body = serde_json::json!({
491 + "name": "Autocat Project",
492 + "slug": "autocat-project",
493 + "owner_mnw_id": owner_id,
494 + "owner_username": "autocatuser",
495 + });
496 + let (status, _) = h.signed_post("/internal/communities", &comm_body.to_string()).await;
497 + assert_eq!(status, StatusCode::OK);
498 +
499 + // Create thread with a non-existent category slug — should auto-create it
500 + let thread_body = serde_json::json!({
501 + "community_slug": "autocat-project",
502 + "category_slug": "patches",
503 + "title": "[PATCH 1/1] Fix typo",
504 + "body_markdown": "diff --git a/...",
505 + "author_mnw_id": owner_id,
506 + "author_username": "autocatuser",
507 + "external_ref": "mnw:patch:autocat-test"
508 + });
509 + let (status, text) = h.signed_post("/internal/threads", &thread_body.to_string()).await;
510 + assert_eq!(status, StatusCode::OK, "body: {}", text);
511 +
512 + let resp: serde_json::Value = serde_json::from_str(&text).unwrap();
513 + assert!(resp["created"].as_bool().unwrap());
514 +
515 + // Verify "patches" category was auto-created
516 + let comm_resp: serde_json::Value = serde_json::from_str(
517 + &h.signed_post("/internal/communities", &comm_body.to_string()).await.1,
518 + )
519 + .unwrap();
520 + let community_id: Uuid = comm_resp["community_id"].as_str().unwrap().parse().unwrap();
521 +
522 + let categories: Vec<(String,)> = sqlx::query_as(
523 + "SELECT slug FROM categories WHERE community_id = $1 ORDER BY sort_order",
524 + )
525 + .bind(community_id)
526 + .fetch_all(&h.db)
527 + .await
528 + .unwrap();
529 +
530 + let slugs: Vec<&str> = categories.iter().map(|c| c.0.as_str()).collect();
531 + assert!(slugs.contains(&"patches"), "Expected 'patches' category, got: {:?}", slugs);
532 + assert_eq!(categories.len(), 5); // 4 default + 1 auto-created
533 + }
D todo.md -117
@@ -1,117 +0,0 @@
1 - # Multithreaded — Todo
2 -
3 - Done: All pre-beta phases (0-11, 13-24). 249 tests (187 integration + 35 unit lib + 16 unit mt-core + 11 unit mt-db). v0.3.1. Audit grade: A (Run 10). Deployed to hetzner+astra (forums.makenot.work). All 21 migrations applied. S3 image uploads configured. MNW Forums tab integration live (MT_BASE_URL set). Internal API live (INTERNAL_SHARED_SECRET configured on both MNW+MT, 2026-03-22). Default categories auto-provisioned (Items, Blog, Devlog, Discussion). Zero audit cold spots.
4 -
5 - Completed work archived in [todo_done.md](todo_done.md).
6 -
7 - Alpha = forum is usable end-to-end: users can sign in via MNW OAuth, browse project forums, create threads, post replies, and moderators can pin/lock/ban/mute. No live chat, no E2E encryption, no federation — those are post-alpha.
8 -
9 - No remaining pre-beta items. Only deferred post-beta items below.
10 -
11 - Run 8 audit items resolved. Moved to `todo_done.md`.
12 -
13 - ---
14 -
15 - ## Rust Patterns Audit (2026-03-21)
16 -
17 - ### Done
18 - - [x] Create `CommunityRole` enum (Owner/Moderator/Member) replacing string checks (`mt-core/types.rs`, `routes/mod.rs`)
19 - - [x] Create `BanType` enum (Ban/Mute) replacing `&str` parameter (`mt-core/types.rs`, `mt-db/mutations.rs`)
20 - - [x] Create `ModAction` enum replacing raw string mod log actions (`mt-core/types.rs`, `mt-db/mutations.rs`)
21 - - [x] Create `SortColumn`/`SortOrder` enums replacing raw strings (`mt-core/types.rs`, `routes/forum/views.rs`)
22 - - [x] Wrap `create_post` + `last_activity_at` update in a transaction (`mt-db/mutations.rs`)
23 - - [x] Optimize config cloning — clone once at startup, reuse reference
24 -
25 - ---
26 -
27 - ## Bug Hunt Fixes (2026-03-22)
28 -
29 - ### Done
30 - - [x] Link preview body could exceed MAX_BODY_SIZE — `body.extend_from_slice(&chunk)` appended full chunks without clamping. Added chunk slicing to remaining capacity and proper stream exhaustion handling (`link_preview.rs`).
31 -
32 - ---
33 -
34 - ## TagTree Integration (2026-03-21)
35 -
36 - ### Done
37 - - [x] Add `tagtree` workspace dependency
38 - - [x] Replace inline tag slug validation with `tagtree::validate_with()` (TagConfig: max_depth 3, max_length 64)
39 - - [x] Tag slugs now support dot-separated hierarchy (e.g. `rust.async`)
40 - - [x] All 218 tests pass
41 -
42 - ---
43 -
44 - ## Platform Integration (Post-Beta)
45 -
46 - MT becomes the social backbone for MNW. Design doc: `docs/internal/strategy/platform-integration.md`.
47 -
48 - ### Internal API
49 -
50 - #### Done
51 - - [x] `POST /internal/communities` — create community for an MNW project (HMAC-SHA256 auth)
52 - - [x] `POST /internal/threads` — create thread linked to MNW item/blog post
53 - - [x] `POST /internal/posts` — create system post (e.g., "this thread discusses [item]")
54 - - [x] `GET /internal/threads/{id}/stats` — comment count for embedding in MNW UI
55 - - [x] Auth middleware: `X-Internal-Signature` header with HMAC-SHA256(timestamp + body, shared_secret)
56 - - [x] `communities.project_id` nullable FK (links back to MNW project) — migration 021
57 - - [x] `threads.external_ref` nullable (stores MNW item/blog ID for linking) — migration 021
58 -
59 - ### Default Categories
60 - Auto-provisioned when MNW creates a community:
61 - - [x] Items (comments on items)
62 - - [x] Blog (comments on blog posts)
63 - - [x] Devlog (developer updates)
64 - - [x] Discussion (general)
65 - - [ ] Issues (git issue tracker replacement — see MNW G8-issues)
66 - - [ ] Patches (inbound email patches — see MNW G7B-patches)
67 - - [ ] Crashes (crash reports from DS2)
68 - - [ ] Feedback (user feedback from DS3)
69 -
70 - ### Private Communities (Fan+)
71 - - [ ] Community visibility flag (public/private)
72 - - [ ] Membership gating: restrict join to Fan+ subscribers or item buyers
73 - - [ ] Hidden from public listing, accessible only via direct link or MNW project page
74 -
75 - ### Notification Integration
76 - - [ ] Push mentions, replies, endorsements, flags to MNW notifications API
77 - - [ ] Read state synced with MNW notification center
78 -
79 - ---
80 -
81 - ## Deferred (Post-Beta)
82 -
83 - - [ ] E2E encrypted live chat (OpenMLS integration, WebSocket gateway)
84 - - [ ] Real-time thread updates via shared WebSocket gateway (shared with SyncKit realtime sync — single service)
85 - - [ ] Community creation by users (currently admin-seeded only; MNW auto-provisioning handles project communities)
86 - - [ ] Federation (ActivityPub or custom protocol)
87 - - [ ] Subcategories / nested categories
88 - - [ ] Similar thread detection on new thread creation
89 - - [ ] Suggested/related threads at bottom of thread view
90 - - [ ] Keyboard shortcuts beyond `/` for search
91 -
92 - ---
93 -
94 - ## Key Paths
95 -
96 - | What | Where |
97 - |------|-------|
98 - | Time formatting | `crates/mt-core/src/time_format.rs` |
99 - | DB queries | `crates/mt-db/src/queries.rs` |
100 - | DB mutations | `crates/mt-db/src/mutations.rs` |
101 - | Templates (Rust) | `src/templates/` |
102 - | Templates (HTML) | `templates/` |
103 - | Link previews | `src/link_preview.rs` |
104 - | S3 storage | `src/storage.rs` |
105 - | Routes | `src/routes/` (mod.rs, forum/{mod,views,actions}.rs, moderation.rs, settings.rs, admin.rs, flagging.rs, tracking.rs, search.rs, uploads.rs) |
106 - | Auth (OAuth) | `src/auth.rs` |
107 - | CSRF | `src/csrf.rs` |
108 - | Markdown | `docengine` crate (`active/docengine/`) — features: mentions, quotes |
109 - | Config | `src/config.rs` |
110 - | Seed data | `src/seed.rs` |
111 - | Entry point | `src/main.rs` |
112 - | Library root | `src/lib.rs` |
113 - | Migrations | `migrations/` (001-020) |
114 - | CSS | `static/style.css` |
115 - | Deploy config | `deploy/` |
116 - | Integration tests | `tests/` |
117 - | Workspace config | `Cargo.toml` |
D todo_done.md -347
@@ -1,347 +0,0 @@
1 - # Multithreaded — Completed Work
2 -
3 - Archived completed phases from todo.md. All items here are done.
4 -
5 - ---
6 -
7 - ## Phase 0 — Skeleton
8 -
9 - - [x] Workspace setup (multithreaded binary + mt-core + mt-db crates)
10 - - [x] Domain models (User, Community, Category, Thread, Post, Membership, Role)
11 - - [x] Error types (CoreError)
12 - - [x] Database pool (PgPool creation)
13 - - [x] Axum server stub (main.rs, binds to port)
14 - - [x] Template layer — base.html, site_header, 6 page templates
15 - - [x] CSS — curated MNW design language (warm beige, three-tier typography, dense tables)
16 - - [x] Static file serving (fonts, htmx.min.js, style.css via ServeDir)
17 - - [x] Route handlers with hardcoded dummy data (all 6 pages render)
18 - - [x] View-model structs (ProjectRow, CategoryRow, ThreadRow, PostRow, TemplateSessionUser)
19 - - [x] impl_into_response! macro (MNW pattern)
20 -
21 - ## Phase 1 — Database
22 -
23 - - [x] Migration 001: users table (mnw_account_id UUID PK, username, display_name, avatar_url, created_at, updated_at)
24 - - [x] Migration 002: communities table (id, name, slug UNIQUE, description, created_at)
25 - - [x] Migration 003: categories table (id, community_id FK, name, slug, description, sort_order, created_at; UNIQUE(community_id, slug))
26 - - [x] Migration 004: threads table (id, category_id FK, author_id FK, title, pinned, locked, created_at, last_activity_at)
27 - - [x] Migration 005: posts table (id, thread_id FK, author_id FK, body_markdown, body_html, parent_post_id FK nullable, created_at, edited_at)
28 - - [x] Migration 006: memberships table (id, user_id FK, community_id FK, role CHECK, joined_at; UNIQUE(user_id, community_id))
29 - - [x] Auto-run migrations on boot (sqlx::migrate! in main.rs)
30 - - [x] Add DATABASE_URL / .env support (dotenvy)
31 - - [x] Seed script (--seed flag: 2 users, 3 communities, 9 categories, 3 threads, 4 posts, idempotent guard)
32 - - [x] PgPool passed to router as Axum state
33 - - [x] Indexes on all FK columns and common query patterns
34 -
35 - ## Phase 2 — Auth (MNW OAuth Integration)
36 -
37 - - [x] OAuth2 PKCE client flow: redirect to MNW /oauth/authorize, receive auth code, exchange for token via /oauth/token
38 - - [x] MNW-side: /oauth/userinfo endpoint (Bearer JWT, returns user_id, username, display_name, avatar_url)
39 - - [x] MNW-side: relaxed redirect_uri validation (localhost always allowed + registered URIs in sync_apps.redirect_uris)
40 - - [x] MNW-side: migration 026 adding redirect_uris column to sync_apps
41 - - [x] MNW-side: /api/public/projects endpoint (no auth, returns project list for forum directory)
42 - - [x] Session middleware (tower-sessions + PostgresStore, 7-day expiry, SameSite::Lax)
43 - - [x] Login route: GET /auth/login generates PKCE pair + state nonce, redirects to MNW OAuth
44 - - [x] Callback route: GET /auth/callback verifies state, exchanges code, fetches userinfo, upserts local user, sets session
45 - - [x] Logout route: GET /auth/logout flushes session, redirects home
46 - - [x] MaybeUser extractor (Option<SessionUser> from session, injected into handlers)
47 - - [x] AppState struct (PgPool + Config + reqwest::Client), replaces bare PgPool
48 - - [x] Config (MNW_BASE_URL, OAUTH_CLIENT_ID, OAUTH_REDIRECT_URI from env)
49 - - [x] Updated site_header.html: shows real username + logout link when logged in, single "Login" link when logged out
50 - - [x] Route refactor: /c/ → /p/ (project-scoped forums), removed /communities
51 - - [x] Forum directory at / (fetches projects from MNW API, dense table)
52 - - [x] Forum-dense CSS overhaul (tighter padding, smaller fonts, directory-table, removed hero/card styles)
53 - - [x] Deleted landing.html, community_list.html (replaced by forum_directory.html)
54 - - [x] deploy/env.production updated with MNW_BASE_URL, OAUTH_CLIENT_ID, OAUTH_REDIRECT_URI
55 - - [x] Register Multithreaded as sync_app on astra (SQL insert, api_key set in env)
56 - - [x] MNW-side: INSECURE_COOKIES env override for staging HTTP deployments
57 - - [x] MNW-side: SameSite::Lax for session cookies (OAuth redirect compatibility)
58 - - [x] MNW-side: SYNCKIT_JWT_SECRET required for OAuth token exchange
59 - - [x] Session cookie renamed to `mt_session` (avoid collision with MNW `id` cookie on same host)
60 - - [x] Tracing instrumentation on OAuth callback (all error paths log details)
61 - - [x] Deployed to astra: MNW (0.0.0.0:3000) + Multithreaded (0.0.0.0:3400), OAuth flow verified end-to-end
62 - - [x] CSRF middleware (generate token per session, validate on POST/PUT/DELETE)
63 -
64 - ## Phase 3 — Read Path (DB-Backed Pages)
65 -
66 - - [x] DB queries: list communities with member counts (mt-db)
67 - - [x] DB queries: get community by slug, list categories with thread counts
68 - - [x] DB queries: list threads in category (pinned first, then by last_activity_at DESC) with author username + reply count
69 - - [x] DB queries: get thread by id, list posts with author usernames
70 - - [x] Replace dummy data in route handlers with real DB queries
71 - - [x] Relative timestamps (e.g. "2h ago", "3d ago") — helper in mt-core
72 - - [x] 404 pages when project/category/thread not found (StatusCode::NOT_FOUND)
73 -
74 - ## Phase 4 — Write Path (Create + Reply)
75 -
76 - - [x] POST /p/{slug}/{category}/new — create thread (title + body, render markdown to HTML)
77 - - [x] POST /p/{slug}/{category}/{thread_id}/reply — create post
78 - - [x] Markdown rendering (pulldown-cmark, HTML events stripped for XSS prevention)
79 - - [x] Input validation (title length, body not empty, slug format)
80 - - [x] Redirect after successful create (POST-redirect-GET)
81 - - [x] Update thread.last_activity_at on new post
82 - - [x] Flash messages / toast on success ("Thread created", "Reply posted")
83 - - [x] Require login for write operations (redirect to /auth/login if not authenticated)
84 -
85 - ## Phase 5 — Edit + Delete
86 -
87 - - [x] Edit post (only by author within 15min window, or always for mods/owners)
88 - - [x] Delete post (soft delete — set body to "[deleted]", preserve thread structure)
89 - - [x] Edit thread title (author, moderator, or owner)
90 - - [x] Delete thread (soft delete — mark as deleted, hide from listings)
91 - - [x] Confirmation prompts for destructive actions (JS confirm)
92 -
93 - ## Phase 6 — Moderation
94 -
95 - - [x] Pin/unpin thread toggle (Owner/Moderator only, POST-redirect-GET)
96 - - [x] Lock/unlock thread toggle (Owner/Moderator only, prevents new replies)
97 - - [x] Role checks: is_mod_or_owner(), is_owner() helpers; get_user_role query
98 - - [x] Mod actions UI — pin/lock buttons on thread page header
99 - - [x] [pinned] and [locked] badges on thread page
100 - - [x] Community settings page (/p/{slug}/settings — name + description, Owner only)
101 - - [x] Category management — create, rename, reorder (up/down swap), Owner only
102 - - [x] Edit category page (/p/{slug}/settings/categories/{id}/edit)
103 - - [x] Settings link on community page (Owner only)
104 - - [x] require_owner() helper for settings handlers
105 -
106 - ## Phase 7 — Pagination
107 -
108 - - [x] Paginate thread list in category (25 per page, LIMIT/OFFSET queries)
109 - - [x] Paginate posts in long threads (50 per page)
110 - - [x] Pagination partial template (prev/next navigation)
111 - - [x] Pagination struct (current_page, total_pages, has_prev, has_next)
112 - - [x] Pagination CSS (centered flex, mono font, matching aesthetic)
113 - - [x] PageQuery deserialize struct for ?page= query param
114 -
115 - ## Phase 8 — Testing
116 -
117 - - [x] Integration test harness (per-test database create/drop, like MNW)
118 - - [x] TestClient (cookie-aware, CSRF auto-injection, in-process via tower::oneshot)
119 - - [x] TestHarness (login_as, create_community, create_category, add_membership, create_thread_with_post)
120 - - [x] /_test/login route for session setup without OAuth
121 - - [x] CSRF tests: missing token 403, valid token succeeds, wrong token 403, token stable across requests (4 tests)
122 - - [x] Auth tests: login link visible, login redirects to MNW, logout clears session (3 tests)
123 - - [x] CRUD tests: create thread, require login, reply, locked reply rejected, edit post, delete post, edit title, delete thread (8 tests)
124 - - [x] Permission tests: non-mod can't pin, mod can pin, settings access, category creation forbidden, edit window (6 tests)
125 - - [x] Moderation tests: pin toggle, lock prevents replies, pinned first in listing, update settings, create category (5 tests)
126 - - [x] CSRF unit tests: token length/hex, uniqueness, constant_time_compare (3 tests)
127 -
128 - ## Phase 9 — Deploy (Done items)
129 -
130 - - [x] Deploy script (cross-compile + upload + restart, like MNW pattern)
131 - - [x] systemd unit file (deploy/multithreaded.service)
132 - - [x] .env template for production secrets (deploy/env.production)
133 - - [x] Health check endpoint (GET /api/health)
134 - - [x] Error pages (404, 500 — styled, extend base.html)
135 - - [x] 404 fallback handler (.fallback on router)
136 - - [x] CSRF middleware layer (between routes and session)
137 - - [x] lib.rs module extraction (testable binary)
138 - - [x] Form submit interceptor (JS fetch with X-CSRF-Token for native POSTs)
139 -
140 - ## Phase 10 — Polish + UX (Done items)
141 -
142 - - [x] Sort controls on thread table (clickable headers: replies, last activity; toggle asc/desc; preserved in pagination)
143 - - [x] User profile links (author names link to makenot.work/u/{username} on forum directory, category, and thread pages)
144 - - [x] Page titles and meta descriptions (descriptions on public pages, noindex on forms/settings/errors)
145 - - [x] Markdown rendering tests (15 unit tests: XSS prevention, HTML stripping, standard markdown elements)
146 - - [x] Timestamp formatting tests (10 boundary tests added to existing 6: all transition points covered)
147 - - [x] Pagination edge case tests (9 integration tests: empty, page beyond max, page=0, sort controls, meta tags, noindex)
148 - - [x] Pagination page clamping (page > max clamped to last page before SQL query, both category and thread handlers)
149 - - [x] Community member list page (/p/{slug}/members — sorted by role, links to MNW profiles, 3 integration tests)
150 -
151 - ## Phase 11 — Moderation System
152 -
153 - - [x] Migration 008: community_bans table (ban/mute with expiry, unique per community+user+type)
154 - - [x] Migration 009: mod_log table (actor, action, target, reason, timestamp)
155 - - [x] Migration 010: suspension columns on users and communities (suspended_at, suspension_reason)
156 - - [x] Config: platform_admin_id from PLATFORM_ADMIN_ID env var
157 - - [x] PlatformAdmin extractor (returns 404 for non-admins, hides admin routes)
158 - - [x] Suspension check in OAuth callback (suspended users cannot log in)
159 - - [x] DB queries: is_user_banned, is_user_muted, list_community_bans, list_mod_log, count_mod_log, get_user_by_username, list_all_communities, search_users
160 - - [x] DB mutations: create_community_ban (upsert), remove_community_ban, insert_mod_log, suspend/unsuspend_community, suspend/unsuspend_user
161 - - [x] Enforcement helpers: check_community_access (suspension+ban for reads), check_write_access (suspension+ban+mute for writes)
162 - - [x] Enforcement added to all read handlers (project_forum, category, thread, community_members)
163 - - [x] Enforcement added to all write handlers (create_thread, create_reply, edit_post, edit_thread, new_thread form)
164 - - [x] Delete handlers: suspension+ban check (allow own deletes even if muted)
165 - - [x] Mod log retrofit on existing handlers (pin, lock, delete_thread, delete_post, edit_settings, create_category, edit_category)
166 - - [x] Community moderation page (/p/{slug}/moderation — ban/mute forms, active bans table, mod/owner only)
167 - - [x] Ban/unban/mute/unmute handlers with role hierarchy (can't ban owners, mods can't ban mods)
168 - - [x] Mod log page (/p/{slug}/moderation/log — paginated action history)
169 - - [x] Moderation link on community page (visible to mod/owner)
170 - - [x] Platform admin dashboard (/_admin — community list, user search, suspend/unsuspend actions)
171 - - [x] Admin suspend/unsuspend handlers for communities and users
172 - - [x] Footer with moderation@makenot.work contact on all pages
173 - - [x] CSS: badge-ban, badge-mute-type, site-footer styles
174 - - [x] Ban tests: 12 integration tests (banned can't read/write, muted can read but not write, role hierarchy, unban/unmute restores access)
175 - - [x] Admin tests: 6 integration tests (non-admin 404, dashboard visible, suspend/unsuspend community+user)
176 - - [x] CommunityRow updated with suspended_at field
177 - - [x] CommunityTemplate updated with is_mod_or_owner field
178 - - [x] deploy/env.production updated with PLATFORM_ADMIN_ID
179 -
180 - ## Phase 14 — Immutable Posts + Footnotes
181 -
182 - - [x] Migration 011: `post_footnotes` table + `removed_by`/`removed_at` on posts
183 - - [x] Removed `update_post_body()`, `soft_delete_post()` from mutations.rs
184 - - [x] Added `insert_footnote()`, `mod_remove_post()` to mutations.rs
185 - - [x] Added `FootnoteWithAuthor`, `list_footnotes_for_posts()`, `get_post_body_markdown()` to queries.rs
186 - - [x] `PostWithAuthor` updated with `removed_at`
187 - - [x] Removed post edit/delete routes and handlers (`edit_post_form`, `edit_post_handler`, `delete_post_handler`)
188 - - [x] Removed `EditPostForm`, `EDIT_WINDOW_MINUTES`, `can_edit_post()`, `can_delete()`
189 - - [x] Restricted thread edit/delete to mod/owner only (was author OR mod)
190 - - [x] Added `add_footnote_handler()` — author-only, validates body, renders markdown, inserts footnote
191 - - [x] Added `mod_remove_post_handler()` in moderation.rs — mod/owner only, sets removed_by/removed_at, mod log entry
192 - - [x] Thread handler: batch-fetches footnotes, builds FootnoteViewRow per post, removed_at display
193 - - [x] Template changes: `PostRow` (removed is_edited/can_edit/can_delete, added is_removed/can_add_footnote/can_remove/footnotes), `FootnoteViewRow`, `ThreadTemplate.can_mod_thread`
194 - - [x] Deleted `EditPostTemplate`, `edit_post.html`
195 - - [x] Rewritten `thread.html`: post-item with data-post-id, mod remove button, footnotes section, footnote form (details/summary)
196 - - [x] CSS: .post-footnotes, .footnote, .footnote-prefix, .footnote-form-toggle, .post-removed
197 -
198 - ## Phase 21 — Verified Quoting
199 -
200 - - [x] `[quote:POST_ID:HASH]` format — HASH = first 8 hex chars of SHA-256 of quoted text
201 - - [x] `verify_quotes()` in forum.rs: regex extraction, substring check, hash verification, 422 on mismatch
202 - - [x] Verification wired into `create_reply_handler` and `create_thread_handler`
203 - - [x] `post_process_quotes()` in markdown.rs: replaces markers with `<cite class="quote-attribution">` linking to `#post-POST_ID`
204 - - [x] Thread handler: builds quote_authors map from post IDs, passes to post_process_quotes
205 - - [x] Inline JS: mouseup text selection → floating "Quote" button → SHA-256 via crypto.subtle.digest → blockquote + marker → insert into reply textarea → scroll to form
206 - - [x] CSS: .quote-attribution, .quote-btn
207 - - [x] Added regex-lite dependency
208 - - [x] 15 new integration tests: user_cannot_edit/delete_post, mod_can_remove_post, mod_can_edit/delete_thread, user_cannot_edit/delete_thread, add_footnote_by_author, add_footnote_non_author_rejected, multiple_footnotes_ordered, footnote_on_removed_post_rejected, valid_quote_accepted, fabricated_quote_rejected, altered_quote_rejected, quote_renders_with_attribution
209 -
210 - ## Phase 15 — Post Endorsements
211 -
212 - - [x] Migration 012: `post_endorsements` table — composite PK, CASCADE on post delete, index on endorser_id
213 - - [x] `list_endorsements_for_posts` batch query, `toggle_endorsement` mutation (INSERT ON CONFLICT + DELETE toggle)
214 - - [x] "Endorse" button on posts (not own, not removed, logged-in). Toggle, `.endorsed` CSS class.
215 - - [x] No public count. Count visible to: author, endorsers, mods only.
216 - - [x] Muted users CAN endorse. Banned/suspended cannot.
217 - - [x] 8 integration tests
218 -
219 - ## Phase 13 — Draft Auto-Save
220 -
221 - - [x] JS IIFE: debounced 1s save to localStorage keyed by page URL, for `#body` and `#reply-body` textareas
222 - - [x] Draft restore on page load with "Draft restored" indicator + "Discard" link
223 - - [x] Clear draft on form submission
224 - - [x] 7-day draft expiry
225 - - [x] Respects `mt_tracking_enabled` localStorage toggle
226 -
227 - ## Phase 17 — Post Flagging
228 -
229 - - [x] Migration 013: `post_flags` table — reason CHECK (spam/rule_breaking/off_topic), UNIQUE(post_id, flagger_id), indexes
230 - - [x] "Flag" `<details>` toggle on posts with radio buttons + optional detail textarea
231 - - [x] `POST /p/{slug}/{cat}/{thread_id}/posts/{post_id}/flag` — duplicate silently ignored
232 - - [x] "Pending Flags" section on moderation page with dismiss/remove actions
233 - - [x] `POST /p/{slug}/moderation/flags/{flag_id}/dismiss` + `.../remove`
234 - - [x] Remove via flag: mod-removes post + resolves all flags + mod log entry
235 - - [x] New route file: `src/routes/flagging.rs`
236 - - [x] 6 integration tests
237 -
238 - ## Phase 18 — Tags
239 -
240 - - [x] Migration 014: `tags` table (community-scoped, UNIQUE slug) + `thread_tags` join table
241 - - [x] Community settings: create/delete tags (owner only)
242 - - [x] Thread creation: tag checkboxes, custom `deserialize_string_or_seq` for serde_urlencoded compatibility
243 - - [x] Tag badges on thread listing rows
244 - - [x] Category filter by tag (`?tag=slug`) with tag chip UI
245 - - [x] Batch-fetch tags per thread (HashMap pattern)
246 - - [x] 5 integration tests
247 -
248 - ## Phase 16 — Unread/New Tracking
249 -
250 - - [x] Tier 1 (JS localStorage): `mt_thread_state` map, `data-thread-id`/`data-reply-count` attrs, `.unread` class, LRU cap 1000
251 - - [x] Migration 015: `tracked_threads` table (user_id, thread_id PK, last_read_post_id, tracked_at)
252 - - [x] Track/untrack buttons on thread page (logged-in only)
253 - - [x] `POST /p/{slug}/{cat}/{thread_id}/track` + `.../untrack` + `POST /tracked/stop-all`
254 - - [x] Read position upsert on tracked thread view (last post on current page)
255 - - [x] `/tracked` page: tracked threads with unread counts, "Stop tracking all"
256 - - [x] New route file: `src/routes/tracking.rs`
257 - - [x] 6 integration tests
258 -
259 - ## Phase 19 — @Mentions
260 -
261 - - [x] Parse `@username` in post body during markdown rendering — render as link to community-scoped profile (`/p/{slug}/u/{username}`)
262 - - [x] Migration 016: `post_mentions` table — `(post_id UUID, mentioned_user_id UUID, created_at TIMESTAMPTZ)`
263 - - [x] On post creation: extract `@username` tokens, resolve to user IDs, insert into `post_mentions` (self-mentions excluded)
264 - - [x] Unknown usernames left as plain text (not linked)
265 - - [x] Mentions inside code spans/blocks skipped
266 - - [x] Unit tests: extraction, dedup, code-span skip, fenced code, resolve valid/invalid/mixed (9 tests in markdown.rs)
267 - - [x] Integration tests: mention renders as profile link, mention stored in DB, self-mention not stored, unknown username left as text (4 tests)
268 - - [x] Category listing: threads where logged-in user was mentioned get violet left border + `@` badge (batch query `get_threads_with_mentions_for_user`)
269 - - [x] `/tracked` page: tracked threads with mentions show `@` badge + `mentioned` class (via `has_mention` EXISTS subselect)
270 - - [x] CSS: `.badge-mention` (violet `@` text), `tr.mentioned` (3px violet left border). Fixed `var(--accent)` → `var(--highlight)` bug.
271 - - [x] Integration tests: mention indicator on category listing, mention indicator on tracked page (2 tests)
272 -
273 - ## Phase 20 — Link Previews
274 -
275 - - [x] Migration 017: `link_previews` table — `(id UUID, post_id UUID, url TEXT, title TEXT, description TEXT, fetched_at TIMESTAMPTZ)`
276 - - [x] On post creation: extract URLs from body, fetch OpenGraph `og:title` + `og:description` (5s timeout, 1MB body cap)
277 - - [x] Fetch happens once at post creation time, not on every render. Failures logged, don't block post creation.
278 - - [x] Render below post body: card with title + description + URL, linked with `rel="noopener noreferrer nofollow"`
279 - - [x] Batch-fetch previews in thread view (same pattern as footnotes/endorsements)
280 - - [x] Unit tests: URL extraction from markdown, OG meta parsing, cap at 3 URLs (10 tests in link_preview.rs)
281 - - [x] Integration tests: preview renders in thread, no previews for plain text, multiple previews render (3 tests)
282 -
283 - ## Phase 23 — Search
284 -
285 - - [x] Migration 018: `CREATE EXTENSION pg_trgm`, GIN trigram indexes on threads.title and posts.body_markdown, generated tsvector columns + GIN indexes
286 - - [x] Search query: CTE combining tsvector/ts_rank + pg_trgm similarity, title matches ranked 2x above body, recency tiebreak
287 - - [x] `GET /search?q=...&scope=...` endpoint returning HTMX fragment, optional community scope
288 - - [x] Search modal UI: overlay triggered by `/` key or header "Search" button, HTMX `hx-get` with `keyup changed delay:150ms`
289 - - [x] Keyboard navigation: arrow keys to move selection, Enter to navigate, Esc to close
290 - - [x] No inline event handlers (XSS test compatible)
291 - - [x] New files: src/routes/search.rs, templates/fragments/search_results.html, migrations/018_search_indexes.sql
292 - - [x] Integration tests: search by title, body content match, scoped vs global, empty query returns nothing, deleted thread excluded (5 tests)
293 -
294 - ## Remaining Items from Completed Phases
295 -
296 - - [x] Endorsement count as profile stat — added endorsement_count subquery to get_user_profile_in_community, displayed on user_profile.html (Phase 15 → Phase 22)
297 - - [x] Opt-out toggle UI for Tier 1 unread tracking — checkbox on /tracked page toggles `mt_tracking_enabled` localStorage key (Phase 16)
298 - - [x] Privacy transparency static page — GET /about/tracking, explains Tier 1/Tier 2 tracking, no third-party analytics, linked from footer + tracked page (Phase 16)
299 - - [x] Integration tests: profile shows endorsement count, tracking info page loads (2 tests)
300 -
301 - ## Phase 24 — Image Uploads
302 -
303 - - [x] S3 config (S3_ENDPOINT, S3_BUCKET, S3_REGION, S3_ACCESS_KEY, S3_SECRET_KEY), graceful degradation when unconfigured
304 - - [x] `src/storage.rs`: S3Storage client (aws-sdk-s3), validate_image(), strip_exif_jpeg(), generate_image_key()
305 - - [x] `src/routes/uploads.rs`: POST /p/{slug}/upload (multipart), GET /uploads/{id} (proxy from S3), POST /p/{slug}/uploads/{id}/remove (mod)
306 - - [x] Migration 019: images table (id, uploader_id, community_id, s3_key, filename, content_type, size_bytes, created_at, removed_at, removed_by)
307 - - [x] DB: insert_image(), remove_image() mutations; get_image(), count_recent_uploads_by_user() queries
308 - - [x] File validation: png/jpg/gif/webp only, max 5MB, extension-content_type cross-validation
309 - - [x] EXIF stripping: JPEG APP1 (EXIF) and APP13 (IPTC) segments removed without re-encoding
310 - - [x] JS: drag-and-drop + paste handler on textareas, placeholder text during upload, CSRF token injection
311 - - [x] CSS: .post-body img max-width + clickable, textarea.drag-over dashed border
312 - - [x] Image click opens full size in new tab
313 - - [x] Rate limit: 20 uploads per user per hour
314 - - [x] Unit tests: 10 (validation + EXIF strip + key generation)
315 - - [x] Integration tests: 4 (auth required, 503 without S3, nonexistent 404, invalid UUID 404)
316 -
317 - ---
318 -
319 - ## Remaining Items — Resolved (2026-03-16)
320 -
321 - ### Moderation integration tests
322 - - [x] mod_remove_post_directly — mod removes post via direct handler, verifies DB state
323 - - [x] member_cannot_remove_post — non-mod gets 403
324 - - [x] removed_post_shows_removed_in_thread — CSS class appears after removal
325 - - [x] mod_log_shows_actions — mod log page renders actions + actor username
326 - - [x] mod_log_forbidden_for_members — non-mod gets 403
327 - - [x] moderation_page_shows_bans_and_flags — page lists banned users + pending flags
328 -
329 - ### Forum directory pagination
330 - - [x] list_communities paginated (LIMIT/OFFSET), count_communities query
331 - - [x] Pagination nav on forum directory template
332 - - [x] Integration test: 30 communities → 2 pages with Next/Previous links
333 -
334 - ### Auto-moderation: auto_hide_threshold
335 - - [x] Migration 020: `auto_hide_threshold` column on communities (INTEGER, nullable)
336 - - [x] CommunityRow includes auto_hide_threshold
337 - - [x] Settings form + handler saves threshold (0 = disabled/NULL)
338 - - [x] flag_post_handler checks threshold after inserting flag, auto-removes post if met
339 - - [x] count_pending_flags_for_post query
340 - - [x] Integration tests: threshold triggers removal, NULL disables auto-hide, settings saves threshold (3 tests)
341 - - [x] CSS: .form-help + .input-narrow for settings form
342 -
343 - ### MNW Forums tab
344 - - [x] Already implemented in MNW — dashboard tab, HTMX partial, MT_BASE_URL config
345 -
346 - ## Run 8 Audit Items (Mar 2026)
347 - - [x] Remove unnecessary `data.clone()` in uploads.rs (saves up to 5MB allocation per image upload)