Skip to main content

max / multithreaded

11.5 KB · 388 lines History Blame Raw
1 //! Internal API routes — called by MNW with HMAC-SHA256 authentication.
2 //! Registered outside the CSRF/session middleware stack.
3
4 use axum::{
5 extract::{Path, State},
6 http::StatusCode,
7 response::{IntoResponse, Response},
8 routing::{get, post},
9 Json, Router,
10 };
11 use serde::{Deserialize, Serialize};
12 use uuid::Uuid;
13
14 use crate::internal_auth::InternalAuth;
15 use crate::AppState;
16
17 // ============================================================================
18 // Request/response types
19 // ============================================================================
20
21 #[derive(Deserialize)]
22 pub struct CreateCommunityRequest {
23 pub name: String,
24 pub slug: String,
25 pub description: Option<String>,
26 pub owner_mnw_id: Uuid,
27 pub owner_username: String,
28 pub owner_display_name: Option<String>,
29 }
30
31 #[derive(Serialize)]
32 pub struct CreateCommunityResponse {
33 pub community_id: Uuid,
34 pub created: bool,
35 }
36
37 #[derive(Deserialize)]
38 pub struct CreateThreadRequest {
39 pub community_slug: String,
40 pub category_slug: String,
41 pub title: String,
42 pub body_markdown: String,
43 pub author_mnw_id: Uuid,
44 pub author_username: String,
45 pub author_display_name: Option<String>,
46 pub external_ref: String,
47 }
48
49 #[derive(Serialize)]
50 pub struct CreateThreadResponse {
51 pub thread_id: Uuid,
52 pub post_id: Uuid,
53 pub created: bool,
54 }
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
69 #[derive(Serialize)]
70 pub struct ThreadStatsResponse {
71 pub post_count: i64,
72 pub last_activity_at: Option<chrono::DateTime<chrono::Utc>>,
73 }
74
75 // ============================================================================
76 // Handlers
77 // ============================================================================
78
79 /// `POST /internal/communities` — Create or return an existing community.
80 #[tracing::instrument(skip_all, name = "internal::create_community")]
81 async fn create_community(
82 State(state): State<AppState>,
83 InternalAuth(body): InternalAuth,
84 ) -> Result<Json<CreateCommunityResponse>, Response> {
85 let req: CreateCommunityRequest = serde_json::from_slice(&body).map_err(|e| {
86 tracing::warn!(error = %e, "invalid create_community request body");
87 (StatusCode::BAD_REQUEST, "Invalid request body").into_response()
88 })?;
89
90 // Check if community already exists (idempotent)
91 if let Some(existing) = mt_db::queries::get_community_by_slug(&state.db, &req.slug)
92 .await
93 .map_err(db_error)?
94 {
95 return Ok(Json(CreateCommunityResponse {
96 community_id: existing.id,
97 created: false,
98 }));
99 }
100
101 // Upsert the owner user (may not have logged into MT yet)
102 mt_db::mutations::upsert_user(
103 &state.db,
104 req.owner_mnw_id,
105 &req.owner_username,
106 req.owner_display_name.as_deref(),
107 )
108 .await
109 .map_err(db_error)?;
110
111 // Create the community
112 let community_id =
113 mt_db::mutations::create_community(&state.db, &req.name, &req.slug, req.description.as_deref())
114 .await
115 .map_err(db_error)?;
116
117 // Create default categories
118 let categories = [
119 ("Items", "items", 0),
120 ("Blog", "blog", 1),
121 ("Devlog", "devlog", 2),
122 ("Discussion", "discussion", 3),
123 ];
124 for (name, slug, order) in categories {
125 mt_db::mutations::create_category(&state.db, community_id, name, slug, None, order)
126 .await
127 .map_err(db_error)?;
128 }
129
130 // Create owner membership
131 mt_db::mutations::ensure_membership_with_role(
132 &state.db,
133 req.owner_mnw_id,
134 community_id,
135 "owner",
136 )
137 .await
138 .map_err(db_error)?;
139
140 tracing::info!(community_id = %community_id, slug = %req.slug, "internal: community created");
141
142 Ok(Json(CreateCommunityResponse {
143 community_id,
144 created: true,
145 }))
146 }
147
148 /// `POST /internal/threads` — Create a thread with external reference (idempotent).
149 #[tracing::instrument(skip_all, name = "internal::create_thread")]
150 async fn create_thread(
151 State(state): State<AppState>,
152 InternalAuth(body): InternalAuth,
153 ) -> Result<Json<CreateThreadResponse>, Response> {
154 let req: CreateThreadRequest = serde_json::from_slice(&body).map_err(|e| {
155 tracing::warn!(error = %e, "invalid create_thread request body");
156 (StatusCode::BAD_REQUEST, "Invalid request body").into_response()
157 })?;
158
159 // Idempotent: return existing thread if external_ref matches
160 if let Some((thread_id,)) = mt_db::queries::get_thread_by_external_ref(&state.db, &req.external_ref)
161 .await
162 .map_err(db_error)?
163 {
164 // Get the opening post ID
165 let posts = mt_db::queries::list_posts_in_thread_paginated(&state.db, thread_id, 1, 0)
166 .await
167 .map_err(db_error)?;
168 let post_id = posts.first().map(|p| p.id).unwrap_or(thread_id);
169 return Ok(Json(CreateThreadResponse {
170 thread_id,
171 post_id,
172 created: false,
173 }));
174 }
175
176 // Look up community
177 let community = mt_db::queries::get_community_by_slug(&state.db, &req.community_slug)
178 .await
179 .map_err(db_error)?
180 .ok_or_else(|| {
181 (StatusCode::NOT_FOUND, "Community not found").into_response()
182 })?;
183
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(
186 &state.db,
187 community.id,
188 &req.category_slug,
189 )
190 .await
191 .map_err(db_error)?
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 };
221
222 // Upsert author
223 mt_db::mutations::upsert_user(
224 &state.db,
225 req.author_mnw_id,
226 &req.author_username,
227 req.author_display_name.as_deref(),
228 )
229 .await
230 .map_err(db_error)?;
231
232 // Ensure membership
233 mt_db::mutations::ensure_membership(&state.db, req.author_mnw_id, community.id)
234 .await
235 .map_err(db_error)?;
236
237 // Create thread with external_ref
238 let thread_id = mt_db::mutations::create_thread_with_external_ref(
239 &state.db,
240 category.id,
241 req.author_mnw_id,
242 &req.title,
243 &req.external_ref,
244 )
245 .await
246 .map_err(db_error)?;
247
248 // Create opening post
249 let body_html = super::render_markdown(&req.body_markdown);
250 let post_id = mt_db::mutations::create_post(
251 &state.db,
252 thread_id,
253 req.author_mnw_id,
254 &req.body_markdown,
255 &body_html,
256 )
257 .await
258 .map_err(db_error)?;
259
260 tracing::info!(
261 thread_id = %thread_id,
262 external_ref = %req.external_ref,
263 "internal: thread created"
264 );
265
266 Ok(Json(CreateThreadResponse {
267 thread_id,
268 post_id,
269 created: true,
270 }))
271 }
272
273 /// `GET /internal/threads/:id/stats` — Thread post count and last activity.
274 #[tracing::instrument(skip_all, name = "internal::thread_stats")]
275 async fn thread_stats(
276 State(state): State<AppState>,
277 Path(id): Path<String>,
278 ) -> Result<Json<ThreadStatsResponse>, Response> {
279 // Note: stats endpoint doesn't require HMAC auth (read-only, no sensitive data).
280 // But it's only accessible via the internal route prefix which is not public.
281 let thread_id = Uuid::parse_str(&id).map_err(|_| {
282 StatusCode::NOT_FOUND.into_response()
283 })?;
284
285 let (post_count, last_activity_at) = mt_db::queries::get_thread_stats(&state.db, thread_id)
286 .await
287 .map_err(db_error)?
288 .unwrap_or((0, None));
289
290 Ok(Json(ThreadStatsResponse {
291 post_count,
292 last_activity_at,
293 }))
294 }
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
361 /// Build the internal API router. Registered outside CSRF/session middleware.
362 pub fn internal_routes(state: AppState) -> Router {
363 Router::new()
364 .route("/internal/communities", post(create_community))
365 .route("/internal/threads", post(create_thread))
366 .route("/internal/threads/{id}/posts", post(create_post))
367 .route("/internal/threads/{id}/stats", get(thread_stats))
368 .with_state(state)
369 }
370
371 // ============================================================================
372 // Helpers
373 // ============================================================================
374
375 fn db_error(e: sqlx::Error) -> Response {
376 tracing::error!(error = %e, "internal API database error");
377 StatusCode::INTERNAL_SERVER_ERROR.into_response()
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 }
388