Skip to main content

headless_lms_server/controllers/main_frontend/
course_designer.rs

1/*!
2Handlers for HTTP requests to `/api/v0/main-frontend/course-plans`.
3*/
4
5use actix_web::HttpResponse;
6use chrono::NaiveDate;
7use models::course_designer_analysis_workspace::CourseDesignerStageWorkspace;
8use models::course_designer_plans::{
9    CourseDesignerCourseSize, CourseDesignerPlan, CourseDesignerPlanDetails,
10    CourseDesignerPlanStageTask, CourseDesignerPlanSummary, CourseDesignerScheduleStageInput,
11    CourseDesignerStage, PlanMemberWithDetails,
12};
13use utoipa::{OpenApi, ToSchema};
14
15use crate::prelude::*;
16
17#[derive(OpenApi)]
18#[openapi(paths(
19    post_new_plan,
20    get_plans,
21    get_plan,
22    post_schedule_suggestion,
23    put_schedule,
24    post_finalize_schedule,
25    post_stage_task,
26    patch_task,
27    delete_task,
28    post_extend_stage,
29    post_advance_stage,
30    patch_stage_workspace,
31    get_plan_members,
32    post_plan_member,
33    delete_plan_member
34))]
35pub(crate) struct MainFrontendCourseDesignerApiDoc;
36
37#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, ToSchema)]
38pub struct CreateCourseDesignerPlanRequest {
39    pub name: Option<String>,
40}
41
42#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, ToSchema)]
43pub struct CourseDesignerScheduleSuggestionRequest {
44    pub course_size: CourseDesignerCourseSize,
45    pub starts_on: NaiveDate,
46}
47
48#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, ToSchema)]
49pub struct CourseDesignerScheduleSuggestionResponse {
50    pub stages: Vec<CourseDesignerScheduleStageInput>,
51}
52
53#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, ToSchema)]
54pub struct SaveCourseDesignerScheduleRequest {
55    pub name: Option<String>,
56    pub stages: Vec<CourseDesignerScheduleStageInput>,
57}
58
59#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, ToSchema)]
60pub struct CreateCourseDesignerStageTaskRequest {
61    pub title: String,
62    pub description: Option<String>,
63}
64
65#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, ToSchema)]
66pub struct UpdateCourseDesignerStageTaskRequest {
67    pub title: Option<String>,
68    pub description: Option<String>,
69    pub is_completed: Option<bool>,
70}
71
72#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, ToSchema)]
73pub struct ExtendStageRequest {
74    pub months: u32,
75}
76
77fn sanitize_optional_name(name: Option<String>) -> Option<String> {
78    name.and_then(|n| {
79        let trimmed = n.trim();
80        if trimmed.is_empty() {
81            None
82        } else {
83            Some(trimmed.to_string())
84        }
85    })
86}
87
88#[instrument(skip(pool))]
89#[utoipa::path(
90    post,
91    path = "",
92    operation_id = "createCourseDesignerPlan",
93    tag = "course-plans",
94    request_body = CreateCourseDesignerPlanRequest,
95    responses((status = 200, description = "Created plan", body = CourseDesignerPlan))
96)]
97async fn post_new_plan(
98    payload: web::Json<CreateCourseDesignerPlanRequest>,
99    pool: web::Data<PgPool>,
100    user: AuthUser,
101) -> ControllerResult<web::Json<CourseDesignerPlan>> {
102    let mut conn = pool.acquire().await?;
103    let token = authorize(
104        &mut conn,
105        Act::CreateCoursesOrExams,
106        Some(user.id),
107        Res::AnyCourse,
108    )
109    .await?;
110    let plan = models::course_designer_plans::create_plan(
111        &mut conn,
112        user.id,
113        sanitize_optional_name(payload.name.clone()),
114    )
115    .await?;
116    token.authorized_ok(web::Json(plan))
117}
118
119#[instrument(skip(pool))]
120#[utoipa::path(
121    get,
122    path = "",
123    operation_id = "getCourseDesignerPlans",
124    tag = "course-plans",
125    responses((status = 200, description = "Plans", body = [CourseDesignerPlanSummary]))
126)]
127async fn get_plans(
128    pool: web::Data<PgPool>,
129    user: AuthUser,
130) -> ControllerResult<web::Json<Vec<CourseDesignerPlanSummary>>> {
131    let mut conn = pool.acquire().await?;
132    let plans =
133        models::course_designer_plan_members::list_plans_for_user(&mut conn, user.id).await?;
134    let token = skip_authorize();
135    token.authorized_ok(web::Json(plans))
136}
137
138#[instrument(skip(pool))]
139#[utoipa::path(
140    get,
141    path = "/{plan_id}",
142    operation_id = "getCourseDesignerPlan",
143    tag = "course-plans",
144    params(("plan_id" = Uuid, Path, description = "Plan id")),
145    responses((status = 200, description = "Plan details", body = CourseDesignerPlanDetails))
146)]
147async fn get_plan(
148    plan_id: web::Path<Uuid>,
149    pool: web::Data<PgPool>,
150    user: AuthUser,
151) -> ControllerResult<web::Json<CourseDesignerPlanDetails>> {
152    let mut conn = pool.acquire().await?;
153    let plan =
154        models::course_designer_plans::get_plan_details_for_user(&mut conn, *plan_id, user.id)
155            .await?;
156    let token = skip_authorize();
157    token.authorized_ok(web::Json(plan))
158}
159
160#[instrument(skip(pool))]
161#[utoipa::path(
162    post,
163    path = "/{plan_id}/schedule/suggestions",
164    operation_id = "createCourseDesignerScheduleSuggestion",
165    tag = "course-plans",
166    params(("plan_id" = Uuid, Path, description = "Plan id")),
167    request_body = CourseDesignerScheduleSuggestionRequest,
168    responses((status = 200, description = "Suggested schedule", body = CourseDesignerScheduleSuggestionResponse))
169)]
170async fn post_schedule_suggestion(
171    plan_id: web::Path<Uuid>,
172    payload: web::Json<CourseDesignerScheduleSuggestionRequest>,
173    pool: web::Data<PgPool>,
174    user: AuthUser,
175) -> ControllerResult<web::Json<CourseDesignerScheduleSuggestionResponse>> {
176    let mut conn = pool.acquire().await?;
177    // Membership check by fetching the plan; suggestions are only available to plan members.
178    models::course_designer_plan_members::get_plan_for_user(&mut conn, *plan_id, user.id).await?;
179    let stages = models::course_designer_plans::build_schedule_suggestion(
180        payload.course_size,
181        payload.starts_on,
182    )?;
183    let token = skip_authorize();
184    token.authorized_ok(web::Json(CourseDesignerScheduleSuggestionResponse {
185        stages,
186    }))
187}
188
189#[instrument(skip(pool))]
190#[utoipa::path(
191    put,
192    path = "/{plan_id}/schedule",
193    operation_id = "saveCourseDesignerSchedule",
194    tag = "course-plans",
195    params(("plan_id" = Uuid, Path, description = "Plan id")),
196    request_body = SaveCourseDesignerScheduleRequest,
197    responses((status = 200, description = "Updated plan details", body = CourseDesignerPlanDetails))
198)]
199async fn put_schedule(
200    plan_id: web::Path<Uuid>,
201    payload: web::Json<SaveCourseDesignerScheduleRequest>,
202    pool: web::Data<PgPool>,
203    user: AuthUser,
204) -> ControllerResult<web::Json<CourseDesignerPlanDetails>> {
205    models::course_designer_plans::validate_schedule_input(&payload.stages)?;
206    let mut conn = pool.acquire().await?;
207    let details = models::course_designer_plan_members::replace_schedule_for_user(
208        &mut conn,
209        *plan_id,
210        user.id,
211        sanitize_optional_name(payload.name.clone()),
212        &payload.stages,
213    )
214    .await?;
215    let token = skip_authorize();
216    token.authorized_ok(web::Json(details))
217}
218
219#[instrument(skip(pool))]
220#[utoipa::path(
221    post,
222    path = "/{plan_id}/schedule/finalize",
223    operation_id = "finalizeCourseDesignerSchedule",
224    tag = "course-plans",
225    params(("plan_id" = Uuid, Path, description = "Plan id")),
226    responses((status = 200, description = "Finalized plan", body = CourseDesignerPlan))
227)]
228async fn post_finalize_schedule(
229    plan_id: web::Path<Uuid>,
230    pool: web::Data<PgPool>,
231    user: AuthUser,
232) -> ControllerResult<web::Json<CourseDesignerPlan>> {
233    let mut conn = pool.acquire().await?;
234    let plan = models::course_designer_plan_members::finalize_schedule_for_user(
235        &mut conn, *plan_id, user.id,
236    )
237    .await?;
238    let token = skip_authorize();
239    token.authorized_ok(web::Json(plan))
240}
241
242#[instrument(skip(pool))]
243#[utoipa::path(
244    post,
245    path = "/{plan_id}/stages/{stage_id}/tasks",
246    operation_id = "createCourseDesignerStageTask",
247    tag = "course-plans",
248    params(
249        ("plan_id" = Uuid, Path, description = "Plan id"),
250        ("stage_id" = Uuid, Path, description = "Stage id")
251    ),
252    request_body = CreateCourseDesignerStageTaskRequest,
253    responses((status = 200, description = "Created task", body = CourseDesignerPlanStageTask))
254)]
255async fn post_stage_task(
256    path: web::Path<(Uuid, Uuid)>,
257    payload: web::Json<CreateCourseDesignerStageTaskRequest>,
258    pool: web::Data<PgPool>,
259    user: AuthUser,
260) -> ControllerResult<web::Json<CourseDesignerPlanStageTask>> {
261    let (plan_id, stage_id) = path.into_inner();
262    let mut conn = pool.acquire().await?;
263    let task = models::course_designer_plan_members::create_stage_task_for_user(
264        &mut conn,
265        plan_id,
266        stage_id,
267        user.id,
268        payload.title.clone(),
269        payload.description.clone(),
270    )
271    .await?;
272    let token = skip_authorize();
273    token.authorized_ok(web::Json(task))
274}
275
276#[instrument(skip(pool))]
277#[utoipa::path(
278    patch,
279    path = "/{plan_id}/tasks/{task_id}",
280    operation_id = "updateCourseDesignerStageTask",
281    tag = "course-plans",
282    params(
283        ("plan_id" = Uuid, Path, description = "Plan id"),
284        ("task_id" = Uuid, Path, description = "Task id")
285    ),
286    request_body = UpdateCourseDesignerStageTaskRequest,
287    responses((status = 200, description = "Updated task", body = CourseDesignerPlanStageTask))
288)]
289async fn patch_task(
290    path: web::Path<(Uuid, Uuid)>,
291    payload: web::Json<UpdateCourseDesignerStageTaskRequest>,
292    pool: web::Data<PgPool>,
293    user: AuthUser,
294) -> ControllerResult<web::Json<CourseDesignerPlanStageTask>> {
295    let (plan_id, task_id) = path.into_inner();
296    let mut conn = pool.acquire().await?;
297    let task = models::course_designer_plan_members::update_stage_task_for_user(
298        &mut conn,
299        plan_id,
300        task_id,
301        user.id,
302        payload.title.clone(),
303        payload.description.clone(),
304        payload.is_completed,
305    )
306    .await?;
307    let token = skip_authorize();
308    token.authorized_ok(web::Json(task))
309}
310
311#[instrument(skip(pool))]
312#[utoipa::path(
313    delete,
314    path = "/{plan_id}/tasks/{task_id}",
315    operation_id = "deleteCourseDesignerStageTask",
316    tag = "course-plans",
317    params(
318        ("plan_id" = Uuid, Path, description = "Plan id"),
319        ("task_id" = Uuid, Path, description = "Task id")
320    ),
321    responses((status = 204, description = "Task deleted"))
322)]
323async fn delete_task(
324    path: web::Path<(Uuid, Uuid)>,
325    pool: web::Data<PgPool>,
326    user: AuthUser,
327) -> ControllerResult<HttpResponse> {
328    let (plan_id, task_id) = path.into_inner();
329    let mut conn = pool.acquire().await?;
330    models::course_designer_plan_members::delete_stage_task_for_user(
331        &mut conn, plan_id, task_id, user.id,
332    )
333    .await?;
334    let token = skip_authorize();
335    token.authorized_ok(HttpResponse::NoContent().finish())
336}
337
338fn parse_stage(path_stage: &str) -> Option<CourseDesignerStage> {
339    match path_stage.to_lowercase().as_str() {
340        "analysis" => Some(CourseDesignerStage::Analysis),
341        "design" => Some(CourseDesignerStage::Design),
342        "development" => Some(CourseDesignerStage::Development),
343        "implementation" => Some(CourseDesignerStage::Implementation),
344        "evaluation" => Some(CourseDesignerStage::Evaluation),
345        _ => None,
346    }
347}
348
349#[instrument(skip(pool))]
350#[utoipa::path(
351    post,
352    path = "/{plan_id}/stages/{stage}/extend",
353    operation_id = "extendCourseDesignerStage",
354    tag = "course-plans",
355    params(
356        ("plan_id" = Uuid, Path, description = "Plan id"),
357        ("stage" = String, Path, description = "Stage name")
358    ),
359    request_body = ExtendStageRequest,
360    responses((status = 200, description = "Updated plan details", body = CourseDesignerPlanDetails))
361)]
362async fn post_extend_stage(
363    path: web::Path<(Uuid, String)>,
364    payload: web::Json<ExtendStageRequest>,
365    pool: web::Data<PgPool>,
366    user: AuthUser,
367) -> ControllerResult<web::Json<CourseDesignerPlanDetails>> {
368    let (plan_id, stage_str) = path.into_inner();
369    let stage = parse_stage(&stage_str).ok_or_else(|| {
370        ControllerError::new(
371            ControllerErrorType::BadRequest,
372            "Invalid stage name.".to_string(),
373            None,
374        )
375    })?;
376    let mut conn = pool.acquire().await?;
377    let details = models::course_designer_plan_members::extend_stage_for_user(
378        &mut conn,
379        plan_id,
380        stage,
381        payload.months,
382        user.id,
383    )
384    .await?;
385    let token = skip_authorize();
386    token.authorized_ok(web::Json(details))
387}
388
389#[instrument(skip(pool))]
390#[utoipa::path(
391    post,
392    path = "/{plan_id}/stages/advance",
393    operation_id = "advanceCourseDesignerStage",
394    tag = "course-plans",
395    params(("plan_id" = Uuid, Path, description = "Plan id")),
396    responses((status = 200, description = "Updated plan details", body = CourseDesignerPlanDetails))
397)]
398async fn post_advance_stage(
399    plan_id: web::Path<Uuid>,
400    pool: web::Data<PgPool>,
401    user: AuthUser,
402) -> ControllerResult<web::Json<CourseDesignerPlanDetails>> {
403    let mut conn = pool.acquire().await?;
404    let details = models::course_designer_plan_members::advance_to_next_stage_for_user(
405        &mut conn, *plan_id, user.id,
406    )
407    .await?;
408    let token = skip_authorize();
409    token.authorized_ok(web::Json(details))
410}
411
412#[instrument(skip(pool))]
413#[utoipa::path(
414    patch,
415    path = "/{plan_id}/stages/{stage}/workspace",
416    operation_id = "updateCourseDesignerStageWorkspace",
417    tag = "course-plans",
418    params(
419        ("plan_id" = Uuid, Path, description = "Plan id"),
420        ("stage" = String, Path, description = "Stage name")
421    ),
422    request_body = CourseDesignerStageWorkspace,
423    responses((status = 200, description = "Updated plan details", body = CourseDesignerPlanDetails))
424)]
425async fn patch_stage_workspace(
426    path: web::Path<(Uuid, String)>,
427    payload: web::Json<CourseDesignerStageWorkspace>,
428    pool: web::Data<PgPool>,
429    user: AuthUser,
430) -> ControllerResult<web::Json<CourseDesignerPlanDetails>> {
431    let (plan_id, stage_str) = path.into_inner();
432    let stage = parse_stage(&stage_str).ok_or_else(|| {
433        ControllerError::new(
434            ControllerErrorType::BadRequest,
435            "Invalid stage name.".to_string(),
436            None,
437        )
438    })?;
439    let mut conn = pool.acquire().await?;
440    let details = models::course_designer_plans::update_stage_workspace_for_user(
441        &mut conn,
442        plan_id,
443        user.id,
444        stage,
445        payload.into_inner(),
446    )
447    .await?;
448    let token = skip_authorize();
449    token.authorized_ok(web::Json(details))
450}
451
452#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, ToSchema)]
453pub struct AddPlanMemberRequest {
454    #[schema(value_type = String, format = Email)]
455    pub email: String,
456}
457
458#[instrument(skip(pool))]
459#[utoipa::path(
460    get,
461    path = "/{plan_id}/members",
462    operation_id = "getCoursePlanMembers",
463    tag = "course-plans",
464    params(("plan_id" = Uuid, Path, description = "Plan id")),
465    responses((status = 200, description = "Plan members", body = [PlanMemberWithDetails]))
466)]
467async fn get_plan_members(
468    plan_id: web::Path<Uuid>,
469    pool: web::Data<PgPool>,
470    user: AuthUser,
471) -> ControllerResult<web::Json<Vec<PlanMemberWithDetails>>> {
472    let mut conn = pool.acquire().await?;
473    let members = models::course_designer_plan_members::get_plan_members_with_details(
474        &mut conn, *plan_id, user.id,
475    )
476    .await?;
477    let token = skip_authorize();
478    token.authorized_ok(web::Json(members))
479}
480
481#[instrument(skip(pool))]
482#[utoipa::path(
483    post,
484    path = "/{plan_id}/members",
485    operation_id = "addCoursePlanMember",
486    tag = "course-plans",
487    params(("plan_id" = Uuid, Path, description = "Plan id")),
488    request_body = AddPlanMemberRequest,
489    responses((status = 200, description = "Added member", body = PlanMemberWithDetails))
490)]
491async fn post_plan_member(
492    plan_id: web::Path<Uuid>,
493    payload: web::Json<AddPlanMemberRequest>,
494    pool: web::Data<PgPool>,
495    user: AuthUser,
496) -> ControllerResult<web::Json<PlanMemberWithDetails>> {
497    let mut conn = pool.acquire().await?;
498    let member = models::course_designer_plan_members::add_plan_member_by_email(
499        &mut conn,
500        *plan_id,
501        user.id,
502        &payload.email,
503    )
504    .await?;
505    let token = skip_authorize();
506    token.authorized_ok(web::Json(member))
507}
508
509#[instrument(skip(pool))]
510#[utoipa::path(
511    delete,
512    path = "/{plan_id}/members/{user_id}",
513    operation_id = "removeCoursePlanMember",
514    tag = "course-plans",
515    params(
516        ("plan_id" = Uuid, Path, description = "Plan id"),
517        ("user_id" = Uuid, Path, description = "User id to remove")
518    ),
519    responses((status = 204, description = "Member removed"))
520)]
521async fn delete_plan_member(
522    path: web::Path<(Uuid, Uuid)>,
523    pool: web::Data<PgPool>,
524    user: AuthUser,
525) -> ControllerResult<HttpResponse> {
526    let (plan_id, target_user_id) = path.into_inner();
527    let mut conn = pool.acquire().await?;
528    models::course_designer_plan_members::remove_plan_member(
529        &mut conn,
530        plan_id,
531        user.id,
532        target_user_id,
533    )
534    .await?;
535    let token = skip_authorize();
536    token.authorized_ok(HttpResponse::NoContent().finish())
537}
538
539pub fn _add_routes(cfg: &mut ServiceConfig) {
540    cfg.route("", web::post().to(post_new_plan))
541        .route("", web::get().to(get_plans))
542        .route("/{plan_id}", web::get().to(get_plan))
543        .route(
544            "/{plan_id}/schedule/suggestions",
545            web::post().to(post_schedule_suggestion),
546        )
547        .route("/{plan_id}/schedule", web::put().to(put_schedule))
548        .route(
549            "/{plan_id}/schedule/finalize",
550            web::post().to(post_finalize_schedule),
551        )
552        .route(
553            "/{plan_id}/stages/advance",
554            web::post().to(post_advance_stage),
555        )
556        .route(
557            "/{plan_id}/stages/{stage}/extend",
558            web::post().to(post_extend_stage),
559        )
560        .route(
561            "/{plan_id}/stages/{stage}/workspace",
562            web::patch().to(patch_stage_workspace),
563        )
564        .route(
565            "/{plan_id}/stages/{stage_id}/tasks",
566            web::post().to(post_stage_task),
567        )
568        .route("/{plan_id}/tasks/{task_id}", web::patch().to(patch_task))
569        .route("/{plan_id}/tasks/{task_id}", web::delete().to(delete_task))
570        .route("/{plan_id}/members", web::get().to(get_plan_members))
571        .route("/{plan_id}/members", web::post().to(post_plan_member))
572        .route(
573            "/{plan_id}/members/{user_id}",
574            web::delete().to(delete_plan_member),
575        );
576}