Skip to main content

max / makenotwork

11.9 KB · 397 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. Issues + Patches are seeded empty so they
118 // surface in the directory before the first email lands — the internal
119 // API also auto-creates any missing category on demand (see thread
120 // handler below), so this is for discoverability, not correctness.
121 let categories = [
122 ("Items", "items", 0),
123 ("Blog", "blog", 1),
124 ("Devlog", "devlog", 2),
125 ("Discussion", "discussion", 3),
126 ("Issues", "issues", 4),
127 ("Patches", "patches", 5),
128 ];
129 for (name, slug, order) in categories {
130 mt_db::mutations::create_category(&state.db, community_id, name, slug, None, order)
131 .await
132 .map_err(db_error)?;
133 }
134
135 // Create owner membership
136 mt_db::mutations::ensure_membership_with_role(
137 &state.db,
138 req.owner_mnw_id,
139 community_id,
140 "owner",
141 )
142 .await
143 .map_err(db_error)?;
144
145 tracing::info!(community_id = %community_id, slug = %req.slug, "internal: community created");
146
147 Ok(Json(CreateCommunityResponse {
148 community_id,
149 created: true,
150 }))
151 }
152
153 /// `POST /internal/threads` — Create a thread with external reference (idempotent).
154 #[tracing::instrument(skip_all, name = "internal::create_thread")]
155 async fn create_thread(
156 State(state): State<AppState>,
157 InternalAuth(body): InternalAuth,
158 ) -> Result<Json<CreateThreadResponse>, Response> {
159 let req: CreateThreadRequest = serde_json::from_slice(&body).map_err(|e| {
160 tracing::warn!(error = %e, "invalid create_thread request body");
161 (StatusCode::BAD_REQUEST, "Invalid request body").into_response()
162 })?;
163
164 // Idempotent: return existing thread if external_ref matches
165 if let Some((thread_id,)) = mt_db::queries::get_thread_by_external_ref(&state.db, &req.external_ref)
166 .await
167 .map_err(db_error)?
168 {
169 // Get the opening post ID
170 let posts = mt_db::queries::list_posts_in_thread_paginated(&state.db, thread_id, 1, 0)
171 .await
172 .map_err(db_error)?;
173 let post_id = posts.first().map(|p| p.id).unwrap_or(thread_id);
174 return Ok(Json(CreateThreadResponse {
175 thread_id,
176 post_id,
177 created: false,
178 }));
179 }
180
181 // Look up community
182 let community = mt_db::queries::get_community_by_slug(&state.db, &req.community_slug)
183 .await
184 .map_err(db_error)?
185 .ok_or_else(|| {
186 (StatusCode::NOT_FOUND, "Community not found").into_response()
187 })?;
188
189 // Look up category — auto-create if it doesn't exist (supports on-demand "patches" etc.)
190 let category = match mt_db::queries::get_category_by_community_and_slug(
191 &state.db,
192 community.id,
193 &req.category_slug,
194 )
195 .await
196 .map_err(db_error)?
197 {
198 Some(cat) => cat,
199 None => {
200 let next_order = mt_db::queries::get_max_category_order(&state.db, community.id)
201 .await
202 .map_err(db_error)? + 1;
203 let cat_name = capitalize(&req.category_slug);
204 let cat_id = mt_db::mutations::create_category(
205 &state.db,
206 community.id,
207 &cat_name,
208 &req.category_slug,
209 None,
210 next_order,
211 )
212 .await
213 .map_err(db_error)?;
214 tracing::info!(
215 category_slug = %req.category_slug,
216 community_slug = %req.community_slug,
217 "internal: auto-created category"
218 );
219 mt_db::queries::CategoryIdRow {
220 id: cat_id,
221 name: cat_name,
222 slug: req.category_slug.clone(),
223 }
224 }
225 };
226
227 // Upsert author
228 mt_db::mutations::upsert_user(
229 &state.db,
230 req.author_mnw_id,
231 &req.author_username,
232 req.author_display_name.as_deref(),
233 )
234 .await
235 .map_err(db_error)?;
236
237 // Ensure membership
238 mt_db::mutations::ensure_membership(&state.db, req.author_mnw_id, community.id)
239 .await
240 .map_err(db_error)?;
241
242 // Create thread with external_ref
243 let thread_id = mt_db::mutations::create_thread_with_external_ref(
244 &state.db,
245 category.id,
246 req.author_mnw_id,
247 &req.title,
248 &req.external_ref,
249 )
250 .await
251 .map_err(db_error)?;
252
253 // Create opening post
254 let body_html = super::render_markdown(&req.body_markdown);
255 let post_id = mt_db::mutations::create_post(
256 &state.db,
257 thread_id,
258 req.author_mnw_id,
259 &req.body_markdown,
260 &body_html,
261 false,
262 )
263 .await
264 .map_err(db_error)?;
265
266 tracing::info!(
267 thread_id = %thread_id,
268 external_ref = %req.external_ref,
269 "internal: thread created"
270 );
271
272 Ok(Json(CreateThreadResponse {
273 thread_id,
274 post_id,
275 created: true,
276 }))
277 }
278
279 /// `GET /internal/threads/:id/stats` — Thread post count and last activity.
280 #[tracing::instrument(skip_all, name = "internal::thread_stats")]
281 async fn thread_stats(
282 State(state): State<AppState>,
283 headers: axum::http::HeaderMap,
284 Path(id): Path<String>,
285 ) -> Result<Json<ThreadStatsResponse>, Response> {
286 crate::internal_auth::verify_hmac_headers(&state, &headers, b"")
287 .map_err(|e| e.into_response())?;
288
289 let thread_id = Uuid::parse_str(&id).map_err(|_| {
290 StatusCode::NOT_FOUND.into_response()
291 })?;
292
293 let (post_count, last_activity_at) = mt_db::queries::get_thread_stats(&state.db, thread_id)
294 .await
295 .map_err(db_error)?
296 .unwrap_or((0, None));
297
298 Ok(Json(ThreadStatsResponse {
299 post_count,
300 last_activity_at,
301 }))
302 }
303
304 /// `POST /internal/threads/:id/posts` — Add a reply to an existing thread.
305 #[tracing::instrument(skip_all, name = "internal::create_post")]
306 async fn create_post(
307 State(state): State<AppState>,
308 Path(id): Path<String>,
309 InternalAuth(body): InternalAuth,
310 ) -> Result<Json<CreatePostResponse>, Response> {
311 let thread_id = Uuid::parse_str(&id).map_err(|_| {
312 StatusCode::NOT_FOUND.into_response()
313 })?;
314
315 let req: CreatePostRequest = serde_json::from_slice(&body).map_err(|e| {
316 tracing::warn!(error = %e, "invalid create_post request body");
317 (StatusCode::BAD_REQUEST, "Invalid request body").into_response()
318 })?;
319
320 // Verify thread exists
321 if !mt_db::queries::thread_exists(&state.db, thread_id)
322 .await
323 .map_err(db_error)?
324 {
325 return Err((StatusCode::NOT_FOUND, "Thread not found").into_response());
326 }
327
328 // Upsert author
329 mt_db::mutations::upsert_user(
330 &state.db,
331 req.author_mnw_id,
332 &req.author_username,
333 req.author_display_name.as_deref(),
334 )
335 .await
336 .map_err(db_error)?;
337
338 // Look up thread's community and ensure membership
339 let thread_info = mt_db::queries::get_thread_with_breadcrumb(&state.db, thread_id)
340 .await
341 .map_err(db_error)?
342 .ok_or_else(|| (StatusCode::NOT_FOUND, "Thread not found").into_response())?;
343
344 mt_db::mutations::ensure_membership(&state.db, req.author_mnw_id, thread_info.community_id)
345 .await
346 .map_err(db_error)?;
347
348 // Render markdown and create post
349 let body_html = super::render_markdown(&req.body_markdown);
350 let post_id = mt_db::mutations::create_post(
351 &state.db,
352 thread_id,
353 req.author_mnw_id,
354 &req.body_markdown,
355 &body_html,
356 true,
357 )
358 .await
359 .map_err(db_error)?;
360
361 tracing::info!(
362 thread_id = %thread_id,
363 post_id = %post_id,
364 "internal: post created"
365 );
366
367 Ok(Json(CreatePostResponse { post_id }))
368 }
369
370 /// Build the internal API router. Registered outside CSRF/session middleware.
371 pub fn internal_routes(state: AppState) -> Router {
372 Router::new()
373 .route("/internal/communities", post(create_community))
374 .route("/internal/threads", post(create_thread))
375 .route("/internal/threads/{id}/posts", post(create_post))
376 .route("/internal/threads/{id}/stats", get(thread_stats))
377 .with_state(state)
378 }
379
380 // ============================================================================
381 // Helpers
382 // ============================================================================
383
384 fn db_error(e: sqlx::Error) -> Response {
385 tracing::error!(error = %e, "internal API database error");
386 StatusCode::INTERNAL_SERVER_ERROR.into_response()
387 }
388
389 /// Capitalize the first letter of a string (for auto-created category names).
390 fn capitalize(s: &str) -> String {
391 let mut chars = s.chars();
392 match chars.next() {
393 None => String::new(),
394 Some(c) => c.to_uppercase().collect::<String>() + chars.as_str(),
395 }
396 }
397