Skip to main content

max / makenotwork

4.9 KB · 165 lines History Blame Raw
1 //! Chapter marker handlers for audio items.
2
3 use axum::{
4 Json,
5 extract::{Path, State},
6 http::{StatusCode, header::HeaderMap},
7 response::{IntoResponse, Response},
8 };
9 use serde::{Deserialize, Serialize};
10
11 use crate::{
12 auth::AuthUser,
13 db::{self, ChapterId, ItemId},
14 error::{AppError, Result},
15 helpers::{htmx_toast_response, is_htmx_request},
16 types::ListResponse,
17 validation,
18 };
19 use sqlx::PgPool;
20
21 use super::super::verify_item_ownership;
22
23 /// JSON input for creating a chapter marker on an audio item.
24 #[derive(Debug, Deserialize)]
25 pub(crate) struct CreateChapterRequest {
26 pub title: String,
27 pub start_seconds: f32,
28 #[serde(default)]
29 pub sort_order: i32,
30 }
31
32 /// JSON input for updating an existing chapter marker.
33 #[derive(Debug, Deserialize)]
34 pub(crate) struct UpdateChapterRequest {
35 pub title: String,
36 pub start_seconds: f32,
37 pub sort_order: i32,
38 }
39
40 /// JSON response representing a chapter marker.
41 #[derive(Debug, Serialize)]
42 struct ChapterResponse {
43 id: ChapterId,
44 item_id: ItemId,
45 title: String,
46 start_seconds: f32,
47 sort_order: i32,
48 }
49
50 /// Create a new chapter marker on an owned audio item.
51 #[tracing::instrument(skip_all, name = "items::create_chapter")]
52 pub(in crate::routes::api) async fn create_chapter(
53 State(db): State<PgPool>,
54 AuthUser(user): AuthUser,
55 Path(item_id): Path<ItemId>,
56 Json(req): Json<CreateChapterRequest>,
57 ) -> Result<impl IntoResponse> {
58 user.check_not_suspended()?;
59 validation::validate_chapter_title(&req.title)?;
60 let (item, _) = verify_item_ownership(&db, item_id, user.id).await?;
61
62 let chapter =
63 db::chapters::create_chapter(&db, item_id, &req.title, req.start_seconds, req.sort_order)
64 .await?;
65
66 db::projects::bump_cache_generation(&db, item.project_id).await?;
67
68 Ok(Json(ChapterResponse {
69 id: chapter.id,
70 item_id: chapter.item_id,
71 title: chapter.title,
72 start_seconds: chapter.start_seconds,
73 sort_order: chapter.sort_order,
74 }))
75 }
76
77 /// List all chapters for a given item (public items only; drafts return 404).
78 #[tracing::instrument(skip_all, name = "items::list_chapters")]
79 pub(in crate::routes::api) async fn list_chapters(
80 State(db): State<PgPool>,
81 Path(item_id): Path<ItemId>,
82 ) -> Result<impl IntoResponse> {
83 let item = db::items::get_item_by_id(&db, item_id)
84 .await?
85 .ok_or(AppError::NotFound)?;
86 if !item.is_public {
87 return Err(AppError::NotFound);
88 }
89 let chapters = db::chapters::get_chapters_by_item(&db, item_id).await?;
90 let data: Vec<ChapterResponse> = chapters
91 .into_iter()
92 .map(|c| ChapterResponse {
93 id: c.id,
94 item_id: c.item_id,
95 title: c.title,
96 start_seconds: c.start_seconds,
97 sort_order: c.sort_order,
98 })
99 .collect();
100 Ok(Json(ListResponse { data }))
101 }
102
103 /// Update an existing chapter marker on an owned item.
104 #[tracing::instrument(skip_all, name = "items::update_chapter")]
105 pub(in crate::routes::api) async fn update_chapter(
106 State(db): State<PgPool>,
107 AuthUser(user): AuthUser,
108 Path(chapter_id): Path<ChapterId>,
109 Json(req): Json<UpdateChapterRequest>,
110 ) -> Result<impl IntoResponse> {
111 user.check_not_suspended()?;
112 validation::validate_chapter_title(&req.title)?;
113 // Get chapter to find item_id for ownership check
114 let chapter = db::chapters::get_chapter_by_id(&db, chapter_id)
115 .await?
116 .ok_or(AppError::NotFound)?;
117
118 let (item, _) = verify_item_ownership(&db, chapter.item_id, user.id).await?;
119
120 let updated = db::chapters::update_chapter(
121 &db,
122 chapter_id,
123 &req.title,
124 req.start_seconds,
125 req.sort_order,
126 )
127 .await?;
128
129 db::projects::bump_cache_generation(&db, item.project_id).await?;
130
131 Ok(Json(ChapterResponse {
132 id: updated.id,
133 item_id: updated.item_id,
134 title: updated.title,
135 start_seconds: updated.start_seconds,
136 sort_order: updated.sort_order,
137 }))
138 }
139
140 /// Delete a chapter marker from an owned item.
141 #[tracing::instrument(skip_all, name = "items::delete_chapter")]
142 pub(in crate::routes::api) async fn delete_chapter(
143 State(db): State<PgPool>,
144 headers: HeaderMap,
145 AuthUser(user): AuthUser,
146 Path(chapter_id): Path<ChapterId>,
147 ) -> Result<Response> {
148 user.check_not_suspended()?;
149 // Get chapter to find item_id for ownership check
150 let chapter = db::chapters::get_chapter_by_id(&db, chapter_id)
151 .await?
152 .ok_or(AppError::NotFound)?;
153
154 let (item, _) = verify_item_ownership(&db, chapter.item_id, user.id).await?;
155
156 db::chapters::delete_chapter(&db, chapter_id).await?;
157 db::projects::bump_cache_generation(&db, item.project_id).await?;
158
159 if is_htmx_request(&headers) {
160 return Ok(htmx_toast_response("Chapter deleted", "success").into_response());
161 }
162
163 Ok(StatusCode::NO_CONTENT.into_response())
164 }
165