1use 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 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}