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}