1use crate::course_designer_plans::{
2 CourseDesignerPlan, CourseDesignerPlanDetails, CourseDesignerPlanMember,
3 CourseDesignerPlanStage, CourseDesignerPlanStageStatus, CourseDesignerPlanStageTask,
4 CourseDesignerPlanStatus, CourseDesignerPlanSummary, CourseDesignerScheduleStageInput,
5 CourseDesignerStage, PlanMemberWithDetails,
6};
7use crate::prelude::*;
8use chrono::Duration;
9use serde_json::json;
10
11pub async fn list_plans_for_user(
13 conn: &mut PgConnection,
14 user_id: Uuid,
15) -> ModelResult<Vec<CourseDesignerPlanSummary>> {
16 let plans = sqlx::query_as!(
17 CourseDesignerPlanSummary,
18 r#"
19SELECT
20 p.id,
21 p.created_at,
22 p.updated_at,
23 p.created_by_user_id,
24 p.name,
25 p.status,
26 p.active_stage,
27 p.last_weekly_stage_email_sent_at,
28 COUNT(DISTINCT members.user_id)::BIGINT AS "member_count!",
29 COUNT(DISTINCT stages.stage)::BIGINT AS "stage_count!"
30FROM course_designer_plans p
31JOIN course_designer_plan_members self_member
32 ON self_member.course_designer_plan_id = p.id
33 AND self_member.user_id = $1
34 AND self_member.deleted_at IS NULL
35LEFT JOIN course_designer_plan_members members
36 ON members.course_designer_plan_id = p.id
37 AND members.deleted_at IS NULL
38LEFT JOIN course_designer_plan_stages stages
39 ON stages.course_designer_plan_id = p.id
40 AND stages.deleted_at IS NULL
41WHERE p.deleted_at IS NULL
42GROUP BY p.id
43ORDER BY p.updated_at DESC, p.id DESC
44"#,
45 user_id
46 )
47 .fetch_all(conn)
48 .await?;
49 Ok(plans)
50}
51
52pub async fn get_plan_for_user(
54 conn: &mut PgConnection,
55 plan_id: Uuid,
56 user_id: Uuid,
57) -> ModelResult<CourseDesignerPlan> {
58 let plan = sqlx::query_as!(
59 CourseDesignerPlan,
60 r#"
61SELECT
62 p.id,
63 p.created_at,
64 p.updated_at,
65 p.deleted_at,
66 p.created_by_user_id,
67 p.name,
68 p.status,
69 p.active_stage,
70 p.last_weekly_stage_email_sent_at
71FROM course_designer_plans p
72JOIN course_designer_plan_members m
73 ON m.course_designer_plan_id = p.id
74 AND m.user_id = $2
75 AND m.deleted_at IS NULL
76WHERE p.id = $1
77 AND p.deleted_at IS NULL
78"#,
79 plan_id,
80 user_id
81 )
82 .fetch_one(conn)
83 .await?;
84 Ok(plan)
85}
86
87pub async fn get_plan_members_for_user(
89 conn: &mut PgConnection,
90 plan_id: Uuid,
91 user_id: Uuid,
92) -> ModelResult<Vec<CourseDesignerPlanMember>> {
93 let members = sqlx::query_as!(
94 CourseDesignerPlanMember,
95 r#"
96SELECT
97 members.id,
98 members.created_at,
99 members.updated_at,
100 members.user_id
101FROM course_designer_plan_members members
102JOIN course_designer_plan_members self_member
103 ON self_member.course_designer_plan_id = members.course_designer_plan_id
104 AND self_member.user_id = $2
105 AND self_member.deleted_at IS NULL
106WHERE members.course_designer_plan_id = $1
107 AND members.deleted_at IS NULL
108ORDER BY members.created_at ASC, members.id ASC
109"#,
110 plan_id,
111 user_id
112 )
113 .fetch_all(conn)
114 .await?;
115 Ok(members)
116}
117
118pub async fn get_plan_stages_for_user(
120 conn: &mut PgConnection,
121 plan_id: Uuid,
122 user_id: Uuid,
123) -> ModelResult<Vec<CourseDesignerPlanStage>> {
124 let stages = sqlx::query_as!(
125 CourseDesignerPlanStage,
126 r#"
127SELECT
128 stages.id,
129 stages.created_at,
130 stages.updated_at,
131 stages.stage,
132 stages.status,
133 stages.planned_starts_on,
134 stages.planned_ends_on,
135 stages.actual_started_at,
136 stages.actual_completed_at,
137 stages.workspace_data
138FROM course_designer_plan_stages stages
139JOIN course_designer_plan_members self_member
140 ON self_member.course_designer_plan_id = stages.course_designer_plan_id
141 AND self_member.user_id = $2
142 AND self_member.deleted_at IS NULL
143WHERE stages.course_designer_plan_id = $1
144 AND stages.deleted_at IS NULL
145ORDER BY
146 CASE stages.stage
147 WHEN 'analysis' THEN 1
148 WHEN 'design' THEN 2
149 WHEN 'development' THEN 3
150 WHEN 'implementation' THEN 4
151 WHEN 'evaluation' THEN 5
152 END,
153 stages.id
154"#,
155 plan_id,
156 user_id
157 )
158 .fetch_all(conn)
159 .await?;
160 Ok(stages)
161}
162
163pub async fn get_plan_tasks_for_user(
165 conn: &mut PgConnection,
166 plan_id: Uuid,
167 user_id: Uuid,
168) -> ModelResult<Vec<CourseDesignerPlanStageTask>> {
169 let tasks = sqlx::query_as!(
170 CourseDesignerPlanStageTask,
171 r#"
172SELECT
173 t.id,
174 t.created_at,
175 t.updated_at,
176 t.course_designer_plan_stage_id,
177 t.title,
178 t.description,
179 t.order_number,
180 t.is_completed,
181 t.completed_at,
182 t.completed_by_user_id,
183 t.is_auto_generated,
184 t.created_by_user_id
185FROM course_designer_plan_stage_tasks t
186JOIN course_designer_plan_stages s ON s.id = t.course_designer_plan_stage_id AND s.deleted_at IS NULL
187JOIN course_designer_plan_members m ON m.course_designer_plan_id = s.course_designer_plan_id
188 AND m.user_id = $2
189 AND m.deleted_at IS NULL
190WHERE s.course_designer_plan_id = $1
191 AND t.deleted_at IS NULL
192ORDER BY s.id, t.order_number, t.id
193"#,
194 plan_id,
195 user_id
196 )
197 .fetch_all(conn)
198 .await?;
199 Ok(tasks)
200}
201
202pub async fn create_stage_task_for_user(
204 conn: &mut PgConnection,
205 plan_id: Uuid,
206 stage_id: Uuid,
207 user_id: Uuid,
208 title: String,
209 description: Option<String>,
210) -> ModelResult<CourseDesignerPlanStageTask> {
211 let title = title.trim();
212 if title.is_empty() {
213 return Err(model_err!(
214 InvalidRequest,
215 "Task title cannot be empty.".to_string()
216 ));
217 }
218 let mut tx = conn.begin().await?;
219 let stage_ok = sqlx::query_scalar!(
220 r#"
221SELECT s.id AS "id?"
222FROM course_designer_plan_stages s
223JOIN course_designer_plan_members m ON m.course_designer_plan_id = s.course_designer_plan_id
224 AND m.user_id = $2 AND m.deleted_at IS NULL
225WHERE s.id = $3 AND s.course_designer_plan_id = $1 AND s.deleted_at IS NULL
226FOR UPDATE
227"#,
228 plan_id,
229 user_id,
230 stage_id
231 )
232 .fetch_optional(&mut *tx)
233 .await?
234 .flatten();
235 if stage_ok.is_none() {
236 tx.rollback().await?;
237 return Err(ModelError::new(
238 ModelErrorType::PreconditionFailed,
239 "Stage not found or user is not a plan member.".to_string(),
240 None,
241 ));
242 }
243 let max_order: Option<i32> = sqlx::query_scalar!(
244 r#"
245SELECT MAX(order_number)::INTEGER
246FROM course_designer_plan_stage_tasks
247WHERE course_designer_plan_stage_id = $1 AND deleted_at IS NULL
248"#,
249 stage_id
250 )
251 .fetch_one(&mut *tx)
252 .await?;
253 let order_number = max_order.unwrap_or(0) + 1;
254 let task: CourseDesignerPlanStageTask = sqlx::query_as!(
255 CourseDesignerPlanStageTask,
256 r#"
257INSERT INTO course_designer_plan_stage_tasks (
258 course_designer_plan_stage_id,
259 title,
260 description,
261 order_number,
262 is_auto_generated,
263 created_by_user_id
264)
265VALUES ($1, $2, $3, $4, FALSE, $5)
266RETURNING
267 id,
268 created_at,
269 updated_at,
270 course_designer_plan_stage_id,
271 title,
272 description,
273 order_number,
274 is_completed,
275 completed_at,
276 completed_by_user_id,
277 is_auto_generated,
278 created_by_user_id
279"#,
280 stage_id,
281 title,
282 description,
283 order_number,
284 user_id
285 )
286 .fetch_one(&mut *tx)
287 .await?;
288 tx.commit().await?;
289 Ok(task)
290}
291
292pub async fn update_stage_task_for_user(
294 conn: &mut PgConnection,
295 plan_id: Uuid,
296 task_id: Uuid,
297 user_id: Uuid,
298 title: Option<String>,
299 description: Option<String>,
300 is_completed: Option<bool>,
301) -> ModelResult<CourseDesignerPlanStageTask> {
302 let row = sqlx::query!(
303 r#"
304SELECT
305 t.title,
306 t.description,
307 t.is_completed,
308 t.completed_at,
309 t.completed_by_user_id
310FROM course_designer_plan_stage_tasks t
311JOIN course_designer_plan_stages s ON s.id = t.course_designer_plan_stage_id AND s.deleted_at IS NULL
312JOIN course_designer_plan_members m ON m.course_designer_plan_id = s.course_designer_plan_id
313 AND m.user_id = $2 AND m.deleted_at IS NULL
314WHERE t.id = $3 AND s.course_designer_plan_id = $1 AND t.deleted_at IS NULL
315"#,
316 plan_id,
317 user_id,
318 task_id
319 )
320 .fetch_optional(&mut *conn)
321 .await?;
322 let (
323 current_title,
324 current_description,
325 current_completed,
326 current_completed_at,
327 current_completed_by_user_id,
328 ) = match row {
329 Some(r) => (
330 r.title,
331 r.description,
332 r.is_completed,
333 r.completed_at,
334 r.completed_by_user_id,
335 ),
336 None => {
337 return Err(ModelError::new(
338 ModelErrorType::PreconditionFailed,
339 "Task not found or user is not a plan member.".to_string(),
340 None,
341 ));
342 }
343 };
344 let new_title = title.map(|t| t.trim().to_string()).unwrap_or(current_title);
345 if new_title.is_empty() {
346 return Err(ModelError::new(
347 ModelErrorType::InvalidRequest,
348 "Task title cannot be empty.".to_string(),
349 None,
350 ));
351 }
352 let new_description = description.or(current_description);
353 let new_completed = is_completed.unwrap_or(current_completed);
354 let (completed_at, completed_by) = match is_completed {
355 Some(_) if !current_completed && new_completed => {
356 let now = Utc::now();
357 (Some(now), Some(user_id))
358 }
359 Some(_) if current_completed && !new_completed => (None, None),
360 _ => (current_completed_at, current_completed_by_user_id),
361 };
362 let task: CourseDesignerPlanStageTask = sqlx::query_as!(
363 CourseDesignerPlanStageTask,
364 r#"
365UPDATE course_designer_plan_stage_tasks t
366SET
367 title = $2,
368 description = $3,
369 is_completed = $4,
370 completed_at = $5,
371 completed_by_user_id = $6
372FROM course_designer_plan_stages s
373JOIN course_designer_plan_members m ON m.course_designer_plan_id = s.course_designer_plan_id
374 AND m.user_id = $7 AND m.deleted_at IS NULL
375WHERE t.course_designer_plan_stage_id = s.id AND s.course_designer_plan_id = $1
376 AND t.id = $8 AND t.deleted_at IS NULL
377RETURNING
378 t.id,
379 t.created_at,
380 t.updated_at,
381 t.course_designer_plan_stage_id,
382 t.title,
383 t.description,
384 t.order_number,
385 t.is_completed,
386 t.completed_at,
387 t.completed_by_user_id,
388 t.is_auto_generated,
389 t.created_by_user_id
390"#,
391 plan_id,
392 new_title,
393 new_description,
394 new_completed,
395 completed_at,
396 completed_by,
397 user_id,
398 task_id
399 )
400 .fetch_one(&mut *conn)
401 .await
402 .map_err(|e| {
403 if let sqlx::Error::RowNotFound = e {
404 ModelError::new(
405 ModelErrorType::PreconditionFailed,
406 "Task not found or user is not a plan member.".to_string(),
407 None,
408 )
409 } else {
410 e.into()
411 }
412 })?;
413 Ok(task)
414}
415
416pub async fn delete_stage_task_for_user(
418 conn: &mut PgConnection,
419 plan_id: Uuid,
420 task_id: Uuid,
421 user_id: Uuid,
422) -> ModelResult<()> {
423 let updated = sqlx::query!(
424 r#"
425UPDATE course_designer_plan_stage_tasks t
426SET deleted_at = $4
427FROM course_designer_plan_stages s
428JOIN course_designer_plan_members m ON m.course_designer_plan_id = s.course_designer_plan_id
429 AND m.user_id = $2 AND m.deleted_at IS NULL
430WHERE t.course_designer_plan_stage_id = s.id AND s.course_designer_plan_id = $1
431 AND t.id = $3 AND t.deleted_at IS NULL
432"#,
433 plan_id,
434 user_id,
435 task_id,
436 Utc::now()
437 )
438 .execute(conn)
439 .await?;
440 if updated.rows_affected() == 0 {
441 return Err(ModelError::new(
442 ModelErrorType::PreconditionFailed,
443 "Task not found or user is not a plan member.".to_string(),
444 None,
445 ));
446 }
447 Ok(())
448}
449
450pub async fn get_plan_for_stage_extend(
452 tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
453 plan_id: Uuid,
454 user_id: Uuid,
455) -> ModelResult<CourseDesignerPlan> {
456 let plan = sqlx::query_as!(
457 CourseDesignerPlan,
458 r#"
459SELECT
460 p.id,
461 p.created_at,
462 p.updated_at,
463 p.deleted_at,
464 p.created_by_user_id,
465 p.name,
466 p.status,
467 p.active_stage,
468 p.last_weekly_stage_email_sent_at
469FROM course_designer_plans p
470JOIN course_designer_plan_members m ON m.course_designer_plan_id = p.id
471 AND m.user_id = $2 AND m.deleted_at IS NULL
472WHERE p.id = $1 AND p.deleted_at IS NULL
473FOR UPDATE
474"#,
475 plan_id,
476 user_id
477 )
478 .fetch_one(&mut **tx)
479 .await?;
480 Ok(plan)
481}
482
483pub async fn get_plan_for_stage_advance(
485 tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
486 plan_id: Uuid,
487 user_id: Uuid,
488) -> ModelResult<CourseDesignerPlan> {
489 get_plan_for_stage_extend(tx, plan_id, user_id).await
490}
491
492pub async fn get_plan_for_schedule_finalize(
494 tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
495 plan_id: Uuid,
496 user_id: Uuid,
497) -> ModelResult<CourseDesignerPlan> {
498 get_plan_for_stage_extend(tx, plan_id, user_id).await
499}
500
501pub async fn get_plan_members_with_details(
503 conn: &mut PgConnection,
504 plan_id: Uuid,
505 requesting_user_id: Uuid,
506) -> ModelResult<Vec<PlanMemberWithDetails>> {
507 get_plan_for_user(conn, plan_id, requesting_user_id).await?;
508
509 let members = sqlx::query_as!(
510 PlanMemberWithDetails,
511 r#"
512SELECT
513 m.id,
514 m.user_id,
515 ud.first_name,
516 ud.last_name,
517 ud.email,
518 m.created_at
519FROM course_designer_plan_members m
520JOIN user_details ud ON ud.user_id = m.user_id
521WHERE m.course_designer_plan_id = $1
522 AND m.deleted_at IS NULL
523ORDER BY m.created_at ASC, m.id ASC
524"#,
525 plan_id
526 )
527 .fetch_all(conn)
528 .await?;
529 Ok(members)
530}
531
532pub async fn add_plan_member_by_email(
534 conn: &mut PgConnection,
535 plan_id: Uuid,
536 requesting_user_id: Uuid,
537 email: &str,
538) -> ModelResult<PlanMemberWithDetails> {
539 let target_user_id: Uuid = sqlx::query_scalar!(
540 r#"
541SELECT users.id
542FROM user_details ud
543JOIN users ON users.id = ud.user_id
544WHERE lower(ud.email) = lower($1)
545 AND users.deleted_at IS NULL
546"#,
547 email
548 )
549 .fetch_optional(&mut *conn)
550 .await?
551 .ok_or_else(|| {
552 ModelError::new(
553 ModelErrorType::InvalidRequest,
554 "No user found with that email address.".to_string(),
555 None,
556 )
557 })?;
558
559 let restored = sqlx::query_scalar!(
560 r#"
561UPDATE course_designer_plan_members
562SET deleted_at = NULL, updated_at = NOW()
563WHERE id = (
564 SELECT id
565 FROM course_designer_plan_members
566 WHERE course_designer_plan_id = $1
567 AND user_id = $2
568 AND deleted_at IS NOT NULL
569 AND EXISTS (
570 SELECT 1
571 FROM course_designer_plan_members requester
572 WHERE requester.course_designer_plan_id = $1
573 AND requester.user_id = $3
574 AND requester.deleted_at IS NULL
575 )
576 ORDER BY deleted_at DESC
577 LIMIT 1
578)
579RETURNING id
580"#,
581 plan_id,
582 target_user_id,
583 requesting_user_id
584 )
585 .fetch_optional(&mut *conn)
586 .await?;
587
588 if restored.is_none() {
589 let inserted = sqlx::query!(
590 r#"
591INSERT INTO course_designer_plan_members (course_designer_plan_id, user_id)
592SELECT $1, $2
593WHERE EXISTS (
594 SELECT 1
595 FROM course_designer_plan_members requester
596 WHERE requester.course_designer_plan_id = $1
597 AND requester.user_id = $3
598 AND requester.deleted_at IS NULL
599)
600ON CONFLICT DO NOTHING
601"#,
602 plan_id,
603 target_user_id,
604 requesting_user_id
605 )
606 .execute(&mut *conn)
607 .await?;
608 if inserted.rows_affected() == 0 {
609 return Err(ModelError::new(
610 ModelErrorType::PreconditionFailed,
611 "Plan not found, requester is not a plan member, or member already exists."
612 .to_string(),
613 None,
614 ));
615 }
616 }
617
618 let member = sqlx::query_as!(
619 PlanMemberWithDetails,
620 r#"
621SELECT
622 m.id,
623 m.user_id,
624 ud.first_name,
625 ud.last_name,
626 ud.email,
627 m.created_at
628FROM course_designer_plan_members m
629JOIN user_details ud ON ud.user_id = m.user_id
630WHERE m.course_designer_plan_id = $1
631 AND m.user_id = $2
632 AND m.deleted_at IS NULL
633 AND EXISTS (
634 SELECT 1
635 FROM course_designer_plan_members requester
636 WHERE requester.course_designer_plan_id = $1
637 AND requester.user_id = $3
638 AND requester.deleted_at IS NULL
639 )
640"#,
641 plan_id,
642 target_user_id,
643 requesting_user_id
644 )
645 .fetch_one(&mut *conn)
646 .await?;
647
648 Ok(member)
649}
650
651pub async fn remove_plan_member(
653 conn: &mut PgConnection,
654 plan_id: Uuid,
655 requesting_user_id: Uuid,
656 target_user_id: Uuid,
657) -> ModelResult<()> {
658 let updated = sqlx::query!(
659 r#"
660UPDATE course_designer_plan_members
661SET deleted_at = NOW(), updated_at = NOW()
662WHERE course_designer_plan_id = $1
663 AND user_id = $2
664 AND deleted_at IS NULL
665 AND EXISTS (
666 SELECT 1
667 FROM course_designer_plan_members requester
668 WHERE requester.course_designer_plan_id = $1
669 AND requester.user_id = $3
670 AND requester.deleted_at IS NULL
671 )
672"#,
673 plan_id,
674 target_user_id,
675 requesting_user_id
676 )
677 .execute(conn)
678 .await?;
679 if updated.rows_affected() == 0 {
680 return Err(ModelError::new(
681 ModelErrorType::PreconditionFailed,
682 "Member not found or user is not a plan member.".to_string(),
683 None,
684 ));
685 }
686 Ok(())
687}
688
689pub async fn replace_schedule_for_user(
691 conn: &mut PgConnection,
692 plan_id: Uuid,
693 user_id: Uuid,
694 name: Option<String>,
695 stages: &[CourseDesignerScheduleStageInput],
696) -> ModelResult<CourseDesignerPlanDetails> {
697 let mut tx = conn.begin().await?;
698 let locked_plan = get_plan_for_stage_extend(&mut tx, plan_id, user_id).await?;
699 if matches!(
700 locked_plan.status,
701 CourseDesignerPlanStatus::InProgress
702 | CourseDesignerPlanStatus::Completed
703 | CourseDesignerPlanStatus::Archived
704 ) {
705 tx.rollback().await?;
706 return Err(ModelError::new(
707 ModelErrorType::PreconditionFailed,
708 "Cannot edit schedule for a plan that has already started or is closed.".to_string(),
709 None,
710 ));
711 }
712 let existing_stage_count: i64 = sqlx::query_scalar!(
713 r#"
714SELECT COUNT(*)::BIGINT AS "count!"
715FROM course_designer_plan_stages
716WHERE course_designer_plan_id = $1
717 AND deleted_at IS NULL
718"#,
719 plan_id
720 )
721 .fetch_one(&mut *tx)
722 .await?;
723 let updated_status = CourseDesignerPlanStatus::Scheduling;
724 let updated_plan: CourseDesignerPlan = sqlx::query_as!(
725 CourseDesignerPlan,
726 r#"
727UPDATE course_designer_plans
728SET
729 name = $2,
730 status = $3
731WHERE id = $1
732 AND deleted_at IS NULL
733RETURNING *
734"#,
735 plan_id,
736 name,
737 updated_status as CourseDesignerPlanStatus
738 )
739 .fetch_one(&mut *tx)
740 .await?;
741 for stage in stages {
742 sqlx::query!(
743 r#"
744INSERT INTO course_designer_plan_stages (
745 course_designer_plan_id,
746 stage,
747 planned_starts_on,
748 planned_ends_on
749)
750VALUES ($1, $2, $3, $4)
751ON CONFLICT ON CONSTRAINT course_designer_plan_stages_plan_stage_unique DO UPDATE
752SET
753 planned_starts_on = EXCLUDED.planned_starts_on,
754 planned_ends_on = EXCLUDED.planned_ends_on
755"#,
756 plan_id,
757 stage.stage as CourseDesignerStage,
758 stage.planned_starts_on,
759 stage.planned_ends_on
760 )
761 .execute(&mut *tx)
762 .await?;
763 }
764 let event_type = if existing_stage_count == 0 {
765 "schedule_created"
766 } else {
767 "schedule_updated"
768 };
769 crate::course_designer_plans::insert_plan_event(
770 &mut tx,
771 plan_id,
772 Some(user_id),
773 event_type,
774 None,
775 json!({ "name": updated_plan.name, "stages": stages }),
776 )
777 .await?;
778 tx.commit().await?;
779 crate::course_designer_plans::get_plan_details_for_user(conn, plan_id, user_id).await
780}
781
782pub async fn extend_stage_for_user(
784 conn: &mut PgConnection,
785 plan_id: Uuid,
786 stage: CourseDesignerStage,
787 months: u32,
788 user_id: Uuid,
789) -> ModelResult<CourseDesignerPlanDetails> {
790 if months == 0 {
791 return Err(ModelError::new(
792 ModelErrorType::InvalidRequest,
793 "Months must be at least 1.".to_string(),
794 None,
795 ));
796 }
797 let mut tx = conn.begin().await?;
798 let plan = get_plan_for_stage_extend(&mut tx, plan_id, user_id).await?;
799 if plan.status != CourseDesignerPlanStatus::InProgress {
800 tx.rollback().await?;
801 return Err(ModelError::new(
802 ModelErrorType::PreconditionFailed,
803 "Can only extend a stage when plan is in progress.".to_string(),
804 None,
805 ));
806 }
807 if plan.active_stage != Some(stage) {
808 tx.rollback().await?;
809 return Err(ModelError::new(
810 ModelErrorType::PreconditionFailed,
811 "Can only extend the current active stage.".to_string(),
812 None,
813 ));
814 }
815 let stage_row: CourseDesignerPlanStage = sqlx::query_as!(
816 CourseDesignerPlanStage,
817 r#"
818SELECT id, created_at, updated_at,
819 stage,
820 status,
821 planned_starts_on,
822 planned_ends_on,
823 actual_started_at,
824 actual_completed_at,
825 workspace_data
826FROM course_designer_plan_stages
827WHERE course_designer_plan_id = $1 AND stage = $2 AND deleted_at IS NULL
828"#,
829 plan_id,
830 stage as CourseDesignerStage
831 )
832 .fetch_one(&mut *tx)
833 .await?;
834 let new_ends_on =
835 crate::course_designer_plans::add_months_clamped(stage_row.planned_ends_on, months)?;
836 sqlx::query!(
837 r#"
838UPDATE course_designer_plan_stages
839SET planned_ends_on = $2
840WHERE id = $1 AND deleted_at IS NULL
841"#,
842 stage_row.id,
843 new_ends_on
844 )
845 .execute(&mut *tx)
846 .await?;
847 let stage_order = crate::course_designer_plans::fixed_stage_order();
848 let current_idx = stage_order
849 .iter()
850 .position(|s| *s == stage)
851 .ok_or_else(|| {
852 ModelError::new(ModelErrorType::Generic, "Invalid stage.".to_string(), None)
853 })?;
854 let all_stages: Vec<CourseDesignerPlanStage> = sqlx::query_as!(
855 CourseDesignerPlanStage,
856 r#"
857SELECT id, created_at, updated_at,
858 stage,
859 status,
860 planned_starts_on,
861 planned_ends_on,
862 actual_started_at,
863 actual_completed_at,
864 workspace_data
865FROM course_designer_plan_stages
866WHERE course_designer_plan_id = $1 AND deleted_at IS NULL
867ORDER BY CASE stage WHEN 'analysis' THEN 1 WHEN 'design' THEN 2 WHEN 'development' THEN 3 WHEN 'implementation' THEN 4 WHEN 'evaluation' THEN 5 END
868"#,
869 plan_id
870 )
871 .fetch_all(&mut *tx)
872 .await?;
873 let later_stage_rows: Vec<_> = all_stages
874 .into_iter()
875 .enumerate()
876 .filter(|(i, _)| *i > current_idx)
877 .map(|(_, s)| (s.id, s.planned_starts_on, s.planned_ends_on))
878 .collect();
879 let mut prev_end = new_ends_on;
880 for (st_id, old_start, old_end) in later_stage_rows {
881 let new_start = prev_end + Duration::days(1);
882 let duration_days = (old_end - old_start).num_days();
883 let new_ends = new_start + Duration::days(duration_days);
884 sqlx::query!(
885 r#"
886UPDATE course_designer_plan_stages
887SET planned_starts_on = $2, planned_ends_on = $3
888WHERE id = $1 AND deleted_at IS NULL
889"#,
890 st_id,
891 new_start,
892 new_ends
893 )
894 .execute(&mut *tx)
895 .await?;
896 prev_end = new_ends;
897 }
898 crate::course_designer_plans::insert_plan_event(
899 &mut tx,
900 plan_id,
901 Some(user_id),
902 "stage_extended",
903 Some(stage),
904 json!({ "stage_id": stage_row.id, "months": months, "new_ends_on": new_ends_on }),
905 )
906 .await?;
907 tx.commit().await?;
908 crate::course_designer_plans::get_plan_details_for_user(conn, plan_id, user_id).await
909}
910
911pub async fn advance_to_next_stage_for_user(
913 conn: &mut PgConnection,
914 plan_id: Uuid,
915 user_id: Uuid,
916) -> ModelResult<CourseDesignerPlanDetails> {
917 let mut tx = conn.begin().await?;
918 let plan = get_plan_for_stage_advance(&mut tx, plan_id, user_id).await?;
919 if plan.status != CourseDesignerPlanStatus::InProgress {
920 tx.rollback().await?;
921 return Err(ModelError::new(
922 ModelErrorType::PreconditionFailed,
923 "Plan must be in progress to advance.".to_string(),
924 None,
925 ));
926 }
927 let current_stage = match plan.active_stage {
928 Some(stage) => stage,
929 None => {
930 tx.rollback().await?;
931 return Err(ModelError::new(
932 ModelErrorType::PreconditionFailed,
933 "Plan has no active stage.".to_string(),
934 None,
935 ));
936 }
937 };
938 let now = Utc::now();
939 sqlx::query!(
940 r#"
941UPDATE course_designer_plan_stages
942SET status = $2, actual_completed_at = $3
943WHERE course_designer_plan_id = $1 AND stage = $4 AND deleted_at IS NULL
944"#,
945 plan_id,
946 CourseDesignerPlanStageStatus::Completed as CourseDesignerPlanStageStatus,
947 now,
948 current_stage as CourseDesignerStage
949 )
950 .execute(&mut *tx)
951 .await?;
952 crate::course_designer_plans::insert_plan_event(
953 &mut tx,
954 plan_id,
955 Some(user_id),
956 "stage_completed",
957 Some(current_stage),
958 json!({}),
959 )
960 .await?;
961 let stage_order = crate::course_designer_plans::fixed_stage_order();
962 let current_idx = stage_order
963 .iter()
964 .position(|s| *s == current_stage)
965 .ok_or_else(|| {
966 ModelError::new(ModelErrorType::Generic, "Invalid stage.".to_string(), None)
967 })?;
968 let next_stage = if current_idx + 1 < stage_order.len() {
969 Some(stage_order[current_idx + 1])
970 } else {
971 None
972 };
973 match next_stage {
974 Some(next) => {
975 sqlx::query!(
976 r#"
977UPDATE course_designer_plans
978SET active_stage = $2
979WHERE id = $1 AND deleted_at IS NULL
980"#,
981 plan_id,
982 next as CourseDesignerStage
983 )
984 .execute(&mut *tx)
985 .await?;
986 sqlx::query!(
987 r#"
988UPDATE course_designer_plan_stages
989SET status = $2, actual_started_at = $3
990WHERE course_designer_plan_id = $1 AND stage = $4 AND deleted_at IS NULL
991"#,
992 plan_id,
993 CourseDesignerPlanStageStatus::InProgress as CourseDesignerPlanStageStatus,
994 now,
995 next as CourseDesignerStage
996 )
997 .execute(&mut *tx)
998 .await?;
999 crate::course_designer_plans::insert_plan_event(
1000 &mut tx,
1001 plan_id,
1002 Some(user_id),
1003 "stage_started",
1004 Some(next),
1005 json!({}),
1006 )
1007 .await?;
1008 }
1009 None => {
1010 sqlx::query!(
1011 r#"
1012UPDATE course_designer_plans
1013SET status = $2, active_stage = NULL
1014WHERE id = $1 AND deleted_at IS NULL
1015"#,
1016 plan_id,
1017 CourseDesignerPlanStatus::Completed as CourseDesignerPlanStatus
1018 )
1019 .execute(&mut *tx)
1020 .await?;
1021 crate::course_designer_plans::insert_plan_event(
1022 &mut tx,
1023 plan_id,
1024 Some(user_id),
1025 "plan_completed",
1026 None,
1027 json!({}),
1028 )
1029 .await?;
1030 }
1031 }
1032 tx.commit().await?;
1033 crate::course_designer_plans::get_plan_details_for_user(conn, plan_id, user_id).await
1034}
1035
1036pub async fn finalize_schedule_for_user(
1038 conn: &mut PgConnection,
1039 plan_id: Uuid,
1040 user_id: Uuid,
1041) -> ModelResult<CourseDesignerPlan> {
1042 let mut tx = conn.begin().await?;
1043 let plan = get_plan_for_schedule_finalize(&mut tx, plan_id, user_id).await?;
1044 if matches!(
1045 plan.status,
1046 CourseDesignerPlanStatus::InProgress
1047 | CourseDesignerPlanStatus::Completed
1048 | CourseDesignerPlanStatus::Archived
1049 ) {
1050 tx.rollback().await?;
1051 return Err(ModelError::new(
1052 ModelErrorType::PreconditionFailed,
1053 "Cannot finalize schedule for a plan that has already started or is closed."
1054 .to_string(),
1055 None,
1056 ));
1057 }
1058 let active_stage_count: i64 = sqlx::query_scalar!(
1059 r#"
1060SELECT COUNT(*)::BIGINT AS "count!"
1061FROM course_designer_plan_stages
1062WHERE course_designer_plan_id = $1
1063 AND deleted_at IS NULL
1064"#,
1065 plan_id
1066 )
1067 .fetch_one(&mut *tx)
1068 .await?;
1069 if active_stage_count != 5 {
1070 tx.rollback().await?;
1071 return Err(ModelError::new(
1072 ModelErrorType::PreconditionFailed,
1073 "Schedule must contain all 5 stages before finalizing.".to_string(),
1074 None,
1075 ));
1076 }
1077 let first_stage = CourseDesignerStage::Analysis;
1078 let now = Utc::now();
1079 sqlx::query!(
1080 r#"
1081UPDATE course_designer_plans
1082SET status = $2, active_stage = $3
1083WHERE id = $1 AND deleted_at IS NULL
1084"#,
1085 plan_id,
1086 CourseDesignerPlanStatus::InProgress as CourseDesignerPlanStatus,
1087 first_stage as CourseDesignerStage
1088 )
1089 .execute(&mut *tx)
1090 .await?;
1091 sqlx::query!(
1092 r#"
1093UPDATE course_designer_plan_stages
1094SET status = $2, actual_started_at = $3
1095WHERE course_designer_plan_id = $1 AND stage = $4 AND deleted_at IS NULL
1096"#,
1097 plan_id,
1098 CourseDesignerPlanStageStatus::InProgress as CourseDesignerPlanStageStatus,
1099 now,
1100 first_stage as CourseDesignerStage
1101 )
1102 .execute(&mut *tx)
1103 .await?;
1104 let schedule_snapshot = sqlx::query_as!(
1105 CourseDesignerPlanStage,
1106 r#"
1107SELECT
1108 id,
1109 created_at,
1110 updated_at,
1111 stage,
1112 status,
1113 planned_starts_on,
1114 planned_ends_on,
1115 actual_started_at,
1116 actual_completed_at,
1117 workspace_data
1118FROM course_designer_plan_stages
1119WHERE course_designer_plan_id = $1
1120 AND deleted_at IS NULL
1121ORDER BY
1122 CASE stage
1123 WHEN 'analysis' THEN 1
1124 WHEN 'design' THEN 2
1125 WHEN 'development' THEN 3
1126 WHEN 'implementation' THEN 4
1127 WHEN 'evaluation' THEN 5
1128 END,
1129 id
1130"#,
1131 plan_id
1132 )
1133 .fetch_all(&mut *tx)
1134 .await?;
1135 crate::course_designer_plans::insert_plan_event(
1136 &mut tx,
1137 plan_id,
1138 Some(user_id),
1139 "schedule_finalized",
1140 Some(first_stage),
1141 json!({ "stages": schedule_snapshot, "active_stage": first_stage, "activated_at": now }),
1142 )
1143 .await?;
1144 tx.commit().await?;
1145 get_plan_for_user(conn, plan_id, user_id).await
1146}