Skip to main content

headless_lms_models/
course_designer_plans.rs

1use crate::course_designer_plan_members;
2use crate::prelude::*;
3use chrono::{Datelike, Duration, NaiveDate};
4use serde_json::{Value, json};
5use utoipa::ToSchema;
6
7#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy, Type, ToSchema)]
8#[sqlx(type_name = "course_designer_stage", rename_all = "snake_case")]
9pub enum CourseDesignerStage {
10    Analysis,
11    Design,
12    Development,
13    Implementation,
14    Evaluation,
15}
16
17#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy, Type, ToSchema)]
18#[sqlx(type_name = "course_designer_plan_status", rename_all = "snake_case")]
19pub enum CourseDesignerPlanStatus {
20    Draft,
21    Scheduling,
22    InProgress,
23    Completed,
24    Archived,
25}
26
27#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy, Type, ToSchema)]
28#[sqlx(
29    type_name = "course_designer_plan_stage_status",
30    rename_all = "snake_case"
31)]
32pub enum CourseDesignerPlanStageStatus {
33    NotStarted,
34    InProgress,
35    Completed,
36}
37
38#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy, ToSchema)]
39#[serde(rename_all = "snake_case")]
40pub enum CourseDesignerCourseSize {
41    Small,
42    Medium,
43    Large,
44}
45
46#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, FromRow, ToSchema)]
47pub struct CourseDesignerPlan {
48    pub id: Uuid,
49    pub created_at: DateTime<Utc>,
50    pub updated_at: DateTime<Utc>,
51    pub deleted_at: Option<DateTime<Utc>>,
52    pub created_by_user_id: Uuid,
53    pub name: Option<String>,
54    pub status: CourseDesignerPlanStatus,
55    pub active_stage: Option<CourseDesignerStage>,
56    pub last_weekly_stage_email_sent_at: Option<DateTime<Utc>>,
57}
58
59#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, FromRow, ToSchema)]
60pub struct CourseDesignerPlanSummary {
61    pub id: Uuid,
62    pub created_at: DateTime<Utc>,
63    pub updated_at: DateTime<Utc>,
64    pub created_by_user_id: Uuid,
65    pub name: Option<String>,
66    pub status: CourseDesignerPlanStatus,
67    pub active_stage: Option<CourseDesignerStage>,
68    pub last_weekly_stage_email_sent_at: Option<DateTime<Utc>>,
69    pub member_count: i64,
70    pub stage_count: i64,
71}
72
73#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, FromRow, ToSchema)]
74pub struct CourseDesignerPlanMember {
75    pub id: Uuid,
76    pub created_at: DateTime<Utc>,
77    pub updated_at: DateTime<Utc>,
78    pub user_id: Uuid,
79}
80
81#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, FromRow, ToSchema)]
82pub struct CourseDesignerPlanStage {
83    pub id: Uuid,
84    pub created_at: DateTime<Utc>,
85    pub updated_at: DateTime<Utc>,
86    pub stage: CourseDesignerStage,
87    pub status: CourseDesignerPlanStageStatus,
88    pub planned_starts_on: NaiveDate,
89    pub planned_ends_on: NaiveDate,
90    pub actual_started_at: Option<DateTime<Utc>>,
91    pub actual_completed_at: Option<DateTime<Utc>>,
92    pub workspace_data: Option<serde_json::Value>,
93}
94
95#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, FromRow, ToSchema)]
96pub struct CourseDesignerPlanStageTask {
97    pub id: Uuid,
98    pub created_at: DateTime<Utc>,
99    pub updated_at: DateTime<Utc>,
100    pub course_designer_plan_stage_id: Uuid,
101    pub title: String,
102    pub description: Option<String>,
103    pub order_number: i32,
104    pub is_completed: bool,
105    pub completed_at: Option<DateTime<Utc>>,
106    pub completed_by_user_id: Option<Uuid>,
107    pub is_auto_generated: bool,
108    pub created_by_user_id: Option<Uuid>,
109}
110
111#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
112pub struct CourseDesignerPlanStageWithTasks {
113    #[serde(flatten)]
114    pub stage: CourseDesignerPlanStage,
115    pub tasks: Vec<CourseDesignerPlanStageTask>,
116}
117
118#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
119pub struct CourseDesignerPlanDetails {
120    pub plan: CourseDesignerPlan,
121    pub members: Vec<CourseDesignerPlanMember>,
122    pub stages: Vec<CourseDesignerPlanStageWithTasks>,
123}
124
125#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, ToSchema)]
126pub struct CourseDesignerScheduleStageInput {
127    pub stage: CourseDesignerStage,
128    pub planned_starts_on: NaiveDate,
129    pub planned_ends_on: NaiveDate,
130}
131
132pub fn fixed_stage_order() -> [CourseDesignerStage; 5] {
133    [
134        CourseDesignerStage::Analysis,
135        CourseDesignerStage::Design,
136        CourseDesignerStage::Development,
137        CourseDesignerStage::Implementation,
138        CourseDesignerStage::Evaluation,
139    ]
140}
141
142pub fn validate_schedule_input(stages: &[CourseDesignerScheduleStageInput]) -> ModelResult<()> {
143    let expected_order = fixed_stage_order();
144    if stages.len() != expected_order.len() {
145        return Err(ModelError::new(
146            ModelErrorType::InvalidRequest,
147            "Schedule must contain exactly 5 stages.".to_string(),
148            None,
149        ));
150    }
151
152    for (idx, stage) in stages.iter().enumerate() {
153        if stage.stage != expected_order[idx] {
154            return Err(ModelError::new(
155                ModelErrorType::InvalidRequest,
156                "Schedule stages must be in the fixed order: analysis, design, development, implementation, evaluation."
157                    .to_string(),
158                None,
159            ));
160        }
161        if stage.planned_starts_on > stage.planned_ends_on {
162            return Err(ModelError::new(
163                ModelErrorType::InvalidRequest,
164                format!("Stage {:?} starts after it ends.", stage.stage),
165                None,
166            ));
167        }
168        if idx > 0 {
169            let prev = &stages[idx - 1];
170            if !no_gap_between(prev.planned_ends_on, stage.planned_starts_on) {
171                return Err(ModelError::new(
172                    ModelErrorType::InvalidRequest,
173                    "Schedule must have no gaps or overlaps between consecutive stages."
174                        .to_string(),
175                    None,
176                ));
177            }
178        }
179    }
180
181    Ok(())
182}
183
184fn first_day_of_month(date: NaiveDate) -> ModelResult<NaiveDate> {
185    NaiveDate::from_ymd_opt(date.year(), date.month(), 1).ok_or_else(|| {
186        ModelError::new(
187            ModelErrorType::InvalidRequest,
188            "Invalid date while generating schedule suggestion.".to_string(),
189            None,
190        )
191    })
192}
193
194fn last_day_of_month(year: i32, month: u32) -> ModelResult<u32> {
195    for day in (28..=31).rev() {
196        if NaiveDate::from_ymd_opt(year, month, day).is_some() {
197            return Ok(day);
198        }
199    }
200
201    Err(ModelError::new(
202        ModelErrorType::InvalidRequest,
203        "Invalid month while generating schedule suggestion.".to_string(),
204        None,
205    ))
206}
207
208pub(crate) fn add_months_clamped(date: NaiveDate, months: u32) -> ModelResult<NaiveDate> {
209    let total_months = date.year() * 12 + date.month0() as i32 + months as i32;
210    let target_year = total_months.div_euclid(12);
211    let target_month0 = total_months.rem_euclid(12) as u32;
212    let target_month = target_month0 + 1;
213    let target_day = date
214        .day()
215        .min(last_day_of_month(target_year, target_month)?);
216
217    NaiveDate::from_ymd_opt(target_year, target_month, target_day).ok_or_else(|| {
218        ModelError::new(
219            ModelErrorType::InvalidRequest,
220            "Failed to generate schedule suggestion date.".to_string(),
221            None,
222        )
223    })
224}
225
226fn suggestion_months(size: CourseDesignerCourseSize) -> [u32; 5] {
227    match size {
228        CourseDesignerCourseSize::Small => [1, 1, 2, 1, 1],
229        CourseDesignerCourseSize::Medium => [1, 2, 3, 2, 1],
230        CourseDesignerCourseSize::Large => [2, 2, 4, 3, 1],
231    }
232}
233
234pub fn build_schedule_suggestion(
235    size: CourseDesignerCourseSize,
236    starts_on: NaiveDate,
237) -> ModelResult<Vec<CourseDesignerScheduleStageInput>> {
238    let mut current_start = first_day_of_month(starts_on)?;
239    let stage_order = fixed_stage_order();
240    let month_durations = suggestion_months(size);
241    let mut out = Vec::with_capacity(stage_order.len());
242
243    for (stage, months) in stage_order.into_iter().zip(month_durations) {
244        let next_stage_start = add_months_clamped(current_start, months)?;
245        let planned_ends_on = next_stage_start - Duration::days(1);
246        out.push(CourseDesignerScheduleStageInput {
247            stage,
248            planned_starts_on: current_start,
249            planned_ends_on,
250        });
251        current_start = planned_ends_on + Duration::days(1);
252    }
253
254    Ok(out)
255}
256
257pub(crate) async fn insert_plan_event(
258    tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
259    plan_id: Uuid,
260    actor_user_id: Option<Uuid>,
261    event_type: &str,
262    stage: Option<CourseDesignerStage>,
263    payload: Value,
264) -> ModelResult<()> {
265    sqlx::query!(
266        r#"
267INSERT INTO course_designer_plan_events (
268  course_designer_plan_id,
269  actor_user_id,
270  event_type,
271  stage,
272  payload
273)
274VALUES ($1, $2, $3, $4, $5)
275"#,
276        plan_id,
277        actor_user_id,
278        event_type,
279        stage as Option<CourseDesignerStage>,
280        payload
281    )
282    .execute(&mut **tx)
283    .await?;
284    Ok(())
285}
286
287pub async fn create_plan(
288    conn: &mut PgConnection,
289    user_id: Uuid,
290    name: Option<String>,
291) -> ModelResult<CourseDesignerPlan> {
292    let mut tx = conn.begin().await?;
293
294    let plan: CourseDesignerPlan = sqlx::query_as!(
295        CourseDesignerPlan,
296        r#"
297INSERT INTO course_designer_plans (created_by_user_id, name)
298VALUES ($1, $2)
299RETURNING *
300"#,
301        user_id,
302        name
303    )
304    .fetch_one(&mut *tx)
305    .await?;
306
307    sqlx::query!(
308        r#"
309INSERT INTO course_designer_plan_members (course_designer_plan_id, user_id)
310VALUES ($1, $2)
311"#,
312        plan.id,
313        user_id
314    )
315    .execute(&mut *tx)
316    .await?;
317
318    insert_plan_event(
319        &mut tx,
320        plan.id,
321        Some(user_id),
322        "plan_created",
323        None,
324        json!({ "name": plan.name }),
325    )
326    .await?;
327
328    tx.commit().await?;
329    Ok(plan)
330}
331
332pub async fn get_plan_details_for_user(
333    conn: &mut PgConnection,
334    plan_id: Uuid,
335    user_id: Uuid,
336) -> ModelResult<CourseDesignerPlanDetails> {
337    let plan = course_designer_plan_members::get_plan_for_user(conn, plan_id, user_id).await?;
338    let members =
339        course_designer_plan_members::get_plan_members_for_user(conn, plan_id, user_id).await?;
340    let stages =
341        course_designer_plan_members::get_plan_stages_for_user(conn, plan_id, user_id).await?;
342    let tasks =
343        course_designer_plan_members::get_plan_tasks_for_user(conn, plan_id, user_id).await?;
344    let mut tasks_by_stage: std::collections::HashMap<Uuid, Vec<CourseDesignerPlanStageTask>> =
345        tasks
346            .into_iter()
347            .fold(std::collections::HashMap::new(), |mut acc, t| {
348                acc.entry(t.course_designer_plan_stage_id)
349                    .or_default()
350                    .push(t);
351                acc
352            });
353    let stages_with_tasks: Vec<CourseDesignerPlanStageWithTasks> = stages
354        .into_iter()
355        .map(|stage| {
356            let stage_id = stage.id;
357            let stage_tasks = tasks_by_stage.remove(&stage_id).unwrap_or_default();
358            CourseDesignerPlanStageWithTasks {
359                stage,
360                tasks: stage_tasks,
361            }
362        })
363        .collect();
364    Ok(CourseDesignerPlanDetails {
365        plan,
366        members,
367        stages: stages_with_tasks,
368    })
369}
370
371pub async fn update_stage_workspace_for_user(
372    conn: &mut PgConnection,
373    plan_id: Uuid,
374    user_id: Uuid,
375    stage: CourseDesignerStage,
376    workspace: crate::course_designer_analysis_workspace::CourseDesignerStageWorkspace,
377) -> ModelResult<CourseDesignerPlanDetails> {
378    if stage != CourseDesignerStage::Analysis {
379        return Err(ModelError::new(
380            ModelErrorType::InvalidRequest,
381            "Workspace payload is only supported for the analysis stage.".to_string(),
382            None,
383        ));
384    }
385    let workspace_json = crate::course_designer_analysis_workspace::workspace_to_json(Some(
386        workspace,
387    ))?
388    .ok_or_else(|| {
389        ModelError::new(
390            ModelErrorType::InvalidRequest,
391            "Workspace serialization produced no data.".to_string(),
392            None,
393        )
394    })?;
395    let updated: Option<Uuid> = sqlx::query_scalar!(
396        r#"
397UPDATE course_designer_plan_stages s
398SET workspace_data = $3
399FROM course_designer_plan_members m
400WHERE s.course_designer_plan_id = $1
401  AND s.stage = $2
402  AND s.deleted_at IS NULL
403  AND m.course_designer_plan_id = s.course_designer_plan_id
404  AND m.user_id = $4
405  AND m.deleted_at IS NULL
406RETURNING s.id
407"#,
408        plan_id,
409        stage as CourseDesignerStage,
410        workspace_json,
411        user_id
412    )
413    .fetch_optional(&mut *conn)
414    .await?;
415    if updated.is_none() {
416        return Err(ModelError::new(
417            ModelErrorType::PreconditionFailed,
418            "Stage not found or user is not a plan member.".to_string(),
419            None,
420        ));
421    }
422    get_plan_details_for_user(conn, plan_id, user_id).await
423}
424
425pub fn no_gap_between(previous_end: NaiveDate, next_start: NaiveDate) -> bool {
426    previous_end + Duration::days(1) == next_start
427}
428
429#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, FromRow, ToSchema)]
430pub struct PlanMemberWithDetails {
431    pub id: Uuid,
432    pub user_id: Uuid,
433    pub first_name: Option<String>,
434    pub last_name: Option<String>,
435    #[schema(value_type = String, format = Email)]
436    pub email: String,
437    pub created_at: DateTime<Utc>,
438}