headless_lms_server/controllers/main_frontend/
chapters.rs

1//! Controllers for requests starting with `/api/v0/main-frontend/chapters`.
2
3use std::{path::PathBuf, str::FromStr, sync::Arc};
4
5use headless_lms_models::chapters::DatabaseChapter;
6use models::chapters::{Chapter, ChapterUpdate, NewChapter};
7use utoipa::{OpenApi, ToSchema};
8
9use crate::{
10    domain::{
11        models_requests::{self, JwtKey},
12        request_id::RequestId,
13    },
14    prelude::*,
15};
16
17#[derive(OpenApi)]
18#[openapi(paths(
19    post_new_chapter,
20    delete_chapter,
21    update_chapter,
22    set_chapter_image,
23    remove_chapter_image,
24    get_all_chapters_by_course_id
25))]
26pub(crate) struct MainFrontendChaptersApiDoc;
27
28#[allow(dead_code)]
29#[derive(Debug, ToSchema)]
30struct ChapterImageUploadPayload {
31    #[schema(content_media_type = "application/octet-stream")]
32    file: Vec<u8>,
33}
34
35/**
36POST `/api/v0/main-frontend/chapters` - Create a new course part.
37# Example
38
39Request:
40```http
41POST /api/v0/main-frontend/chapters HTTP/1.1
42Content-Type: application/json
43
44{
45    "name": "The Basics",
46    "course_id": "d86cf910-4d26-40e9-8c9c-1cc35294fdbb",
47    "chapter_number": 1,
48    "front_page_id": null
49}
50```
51*/
52
53#[instrument(skip(pool, file_store, app_conf))]
54#[utoipa::path(
55    post,
56    path = "",
57    operation_id = "createChapter",
58    tag = "chapters",
59    request_body = NewChapter,
60    responses(
61        (status = 200, description = "Created chapter", body = Chapter)
62    )
63)]
64async fn post_new_chapter(
65    request_id: RequestId,
66    pool: web::Data<PgPool>,
67    payload: web::Json<NewChapter>,
68    user: AuthUser,
69    file_store: web::Data<dyn FileStore>,
70    app_conf: web::Data<ApplicationConfiguration>,
71    jwt_key: web::Data<JwtKey>,
72) -> ControllerResult<web::Json<Chapter>> {
73    let mut conn = pool.acquire().await?;
74    let token = authorize(
75        &mut conn,
76        Act::Edit,
77        Some(user.id),
78        Res::Course(payload.course_id),
79    )
80    .await?;
81    let new_chapter = payload.0;
82    let (database_chapter, ..) = models::library::content_management::create_new_chapter(
83        &mut conn,
84        PKeyPolicy::Generate,
85        &new_chapter,
86        user.id,
87        models_requests::make_spec_fetcher(
88            app_conf.base_url.clone(),
89            request_id.0,
90            Arc::clone(&jwt_key),
91        ),
92        models_requests::fetch_service_info,
93    )
94    .await?;
95    return token.authorized_ok(web::Json(Chapter::from_database_chapter(
96        &database_chapter,
97        file_store.as_ref(),
98        app_conf.as_ref(),
99    )));
100}
101
102/**
103DELETE `/api/v0/main-frontend/chapters/:chapter_id` - Delete a chapter.
104*/
105
106#[instrument(skip(pool, file_store, app_conf))]
107#[utoipa::path(
108    delete,
109    path = "/{chapter_id}",
110    operation_id = "deleteChapter",
111    tag = "chapters",
112    params(
113        ("chapter_id" = Uuid, Path, description = "Chapter id")
114    ),
115    responses(
116        (status = 200, description = "Deleted chapter", body = Chapter)
117    )
118)]
119async fn delete_chapter(
120    chapter_id: web::Path<String>,
121    pool: web::Data<PgPool>,
122    user: AuthUser,
123    file_store: web::Data<dyn FileStore>,
124    app_conf: web::Data<ApplicationConfiguration>,
125) -> ControllerResult<web::Json<Chapter>> {
126    let mut conn = pool.acquire().await?;
127    let chapter_id = Uuid::from_str(&chapter_id)?;
128    let token = authorize(
129        &mut conn,
130        Act::Edit,
131        Some(user.id),
132        Res::Chapter(chapter_id),
133    )
134    .await?;
135    let deleted_chapter = models::chapters::delete_chapter(&mut conn, chapter_id).await?;
136    return token.authorized_ok(web::Json(Chapter::from_database_chapter(
137        &deleted_chapter,
138        file_store.as_ref(),
139        app_conf.as_ref(),
140    )));
141}
142
143/**
144PUT `/api/v0/main-frontend/chapters/:chapter_id` - Update chapter.
145# Example
146
147Request:
148```http
149PUT /api/v0/main-frontend/chapters/d332f3d9-39a5-4a18-80f4-251727693c37  HTTP/1.1
150Content-Type: application/json
151
152{
153    "name": "The Basics",
154    "chapter_image_url": null,
155    "chapter_number": 2,
156    "front_page_id": "0ebba931-b027-4154-8274-2afb00d79306"
157}
158
159```
160*/
161
162#[instrument(skip(payload, pool, file_store, app_conf))]
163#[utoipa::path(
164    put,
165    path = "/{chapter_id}",
166    operation_id = "updateChapter",
167    tag = "chapters",
168    params(
169        ("chapter_id" = Uuid, Path, description = "Chapter id")
170    ),
171    request_body = ChapterUpdate,
172    responses(
173        (status = 200, description = "Updated chapter", body = Chapter)
174    )
175)]
176async fn update_chapter(
177    payload: web::Json<ChapterUpdate>,
178    chapter_id: web::Path<String>,
179    pool: web::Data<PgPool>,
180    user: AuthUser,
181    file_store: web::Data<dyn FileStore>,
182    app_conf: web::Data<ApplicationConfiguration>,
183) -> ControllerResult<web::Json<Chapter>> {
184    let mut conn = pool.acquire().await?;
185    let chapter_id = Uuid::from_str(&chapter_id)?;
186    let course_id = models::chapters::get_course_id(&mut conn, chapter_id).await?;
187    let token = authorize(&mut conn, Act::Edit, Some(user.id), Res::Course(course_id)).await?;
188    let course_update = payload.0;
189    let chapter = models::chapters::update_chapter(&mut conn, chapter_id, course_update).await?;
190
191    let response = Chapter::from_database_chapter(&chapter, file_store.as_ref(), app_conf.as_ref());
192
193    token.authorized_ok(web::Json(response))
194}
195
196/**
197PUT `/api/v0/main-frontend/chapters/:chapter_id/image` - Sets or updates the chapter image.
198
199# Example
200
201Request:
202```http
203PUT /api/v0/main-frontend/chapters/d332f3d9-39a5-4a18-80f4-251727693c37/image HTTP/1.1
204Content-Type: multipart/form-data
205
206BINARY_DATA
207```
208*/
209
210#[instrument(skip(request, payload, pool, file_store, app_conf))]
211#[utoipa::path(
212    put,
213    path = "/{chapter_id}/image",
214    operation_id = "updateChapterImage",
215    tag = "chapters",
216    params(
217        ("chapter_id" = Uuid, Path, description = "Chapter id")
218    ),
219    request_body(content = inline(ChapterImageUploadPayload), content_type = "multipart/form-data"),
220    responses(
221        (status = 200, description = "Updated chapter image", body = Chapter)
222    )
223)]
224async fn set_chapter_image(
225    request: HttpRequest,
226    payload: Multipart,
227    chapter_id: web::Path<Uuid>,
228    pool: web::Data<PgPool>,
229    user: AuthUser,
230    file_store: web::Data<dyn FileStore>,
231    app_conf: web::Data<ApplicationConfiguration>,
232) -> ControllerResult<web::Json<Chapter>> {
233    let mut conn = pool.acquire().await?;
234    let chapter = models::chapters::get_chapter(&mut conn, *chapter_id).await?;
235    let token = authorize(
236        &mut conn,
237        Act::Edit,
238        Some(user.id),
239        Res::Course(chapter.course_id),
240    )
241    .await?;
242
243    let course = models::courses::get_course(&mut conn, chapter.course_id).await?;
244    let chapter_image = upload_file_from_cms(
245        request.headers(),
246        payload,
247        StoreKind::Course(course.id),
248        file_store.as_ref(),
249        &mut conn,
250        user,
251    )
252    .await?
253    .to_string_lossy()
254    .to_string();
255    let updated_chapter =
256        models::chapters::update_chapter_image_path(&mut conn, chapter.id, Some(chapter_image))
257            .await?;
258
259    // Remove old image if one exists.
260    if let Some(old_image_path) = chapter.chapter_image_path {
261        let file = PathBuf::from_str(&old_image_path).map_err(|original_error| {
262            ControllerError::new(
263                ControllerErrorType::InternalServerError,
264                original_error.to_string(),
265                Some(original_error.into()),
266            )
267        })?;
268        file_store.delete(&file).await.map_err(|original_error| {
269            ControllerError::new(
270                ControllerErrorType::InternalServerError,
271                original_error.to_string(),
272                Some(original_error.into()),
273            )
274        })?;
275    }
276
277    let response =
278        Chapter::from_database_chapter(&updated_chapter, file_store.as_ref(), app_conf.as_ref());
279
280    token.authorized_ok(web::Json(response))
281}
282
283/**
284DELETE `/api/v0/main-frontend/chapters/:chapter_id/image` - Removes the chapter image.
285
286# Example
287
288Request:
289```http
290DELETE /api/v0/main-frontend/chapters/d332f3d9-39a5-4a18-80f4-251727693c37/image HTTP/1.1
291```
292*/
293
294#[instrument(skip(pool, file_store))]
295#[utoipa::path(
296    delete,
297    path = "/{chapter_id}/image",
298    operation_id = "deleteChapterImage",
299    tag = "chapters",
300    params(
301        ("chapter_id" = Uuid, Path, description = "Chapter id")
302    ),
303    responses(
304        (status = 200, description = "Deleted chapter image")
305    )
306)]
307async fn remove_chapter_image(
308    chapter_id: web::Path<Uuid>,
309    pool: web::Data<PgPool>,
310    user: AuthUser,
311    file_store: web::Data<dyn FileStore>,
312) -> ControllerResult<web::Json<()>> {
313    let mut conn = pool.acquire().await?;
314    let chapter = models::chapters::get_chapter(&mut conn, *chapter_id).await?;
315    let token = authorize(
316        &mut conn,
317        Act::Edit,
318        Some(user.id),
319        Res::Course(chapter.course_id),
320    )
321    .await?;
322    if let Some(chapter_image_path) = chapter.chapter_image_path {
323        let file = PathBuf::from_str(&chapter_image_path).map_err(|original_error| {
324            ControllerError::new(
325                ControllerErrorType::InternalServerError,
326                original_error.to_string(),
327                Some(original_error.into()),
328            )
329        })?;
330        let _res = models::chapters::update_chapter_image_path(&mut conn, chapter.id, None).await?;
331        file_store.delete(&file).await.map_err(|original_error| {
332            ControllerError::new(
333                ControllerErrorType::InternalServerError,
334                original_error.to_string(),
335                Some(original_error.into()),
336            )
337        })?;
338    }
339    token.authorized_ok(web::Json(()))
340}
341
342/**
343GET `/api/v0/main-frontend/chapters/{course_id}/all-chapters-for-course - Gets all chapters with a course_id
344*/
345#[utoipa::path(
346    get,
347    path = "/{course_id}/all-chapters-for-course",
348    operation_id = "getCourseChapters",
349    tag = "chapters",
350    params(
351        ("course_id" = Uuid, Path, description = "Course id")
352    ),
353    responses(
354        (status = 200, description = "Course chapters", body = Vec<DatabaseChapter>)
355    )
356)]
357async fn get_all_chapters_by_course_id(
358    course_id: web::Path<Uuid>,
359    pool: web::Data<PgPool>,
360    user: AuthUser,
361) -> ControllerResult<web::Json<Vec<DatabaseChapter>>> {
362    let mut conn = pool.acquire().await?;
363    let token = authorize(&mut conn, Act::View, Some(user.id), Res::Course(*course_id)).await?;
364
365    let mut chapters = models::chapters::course_chapters(&mut conn, *course_id).await?;
366
367    chapters.sort_by(|a, b| a.chapter_number.cmp(&b.chapter_number));
368
369    token.authorized_ok(web::Json(chapters))
370}
371
372/**
373Add a route for each controller in this module.
374
375The name starts with an underline in order to appear before other functions in the module documentation.
376
377We add the routes by calling the route method instead of using the route annotations because this method preserves the function signatures for documentation.
378*/
379pub fn _add_routes(cfg: &mut ServiceConfig) {
380    cfg.route("", web::post().to(post_new_chapter))
381        .route("/{chapter_id}", web::delete().to(delete_chapter))
382        .route("/{chapter_id}", web::put().to(update_chapter))
383        .route("/{chapter_id}/image", web::put().to(set_chapter_image))
384        .route(
385            "/{chapter_id}/image",
386            web::delete().to(remove_chapter_image),
387        )
388        .route(
389            "/{course_id}/all-chapters-for-course",
390            web::get().to(get_all_chapters_by_course_id),
391        );
392}