Skip to main content

headless_lms_models/
course_designer_plan_members.rs

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
11/// Lists plans visible to a member.
12pub 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
52/// Loads a single plan if user is a member.
53pub 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
87/// Returns active members of a plan for member user.
88pub 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
118/// Returns stages of a plan for member user.
119pub 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
163/// Returns tasks of a plan for member user.
164pub 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
202/// Creates a task after membership validation.
203pub 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
292/// Updates a task after membership validation.
293pub 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
416/// Soft-deletes a task after membership validation.
417pub 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
450/// Locks and returns plan for extending stage.
451pub 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
483/// Locks and returns plan for stage advance.
484pub 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
492/// Locks and returns plan for finalize.
493pub 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
501/// Returns all members with user details.
502pub 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
532/// Adds a member to a plan by email.
533pub 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
651/// Soft-deletes an active plan membership.
652pub 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
689/// Replaces plan schedule for a member-owned plan.
690pub 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
782/// Extends active stage for a member-owned plan.
783pub 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
911/// Advances to next stage for a member-owned plan.
912pub 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
1036/// Finalizes schedule for a member-owned plan.
1037pub 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}