Skip to main content

headless_lms_models/
pages.rs

1use std::collections::{HashMap, hash_map};
2
3use futures::future::{BoxFuture, OptionFuture};
4use headless_lms_utils::document_schema_processor::{
5    GutenbergBlock, contains_blocks_not_allowed_in_top_level_pages, filter_lock_chapter_blocks,
6    replace_duplicate_client_ids,
7};
8use itertools::Itertools;
9use percent_encoding::{AsciiSet, CONTROLS, utf8_percent_encode};
10use serde_json::{Value, json};
11use sqlx::{AssertSqlSafe, Postgres, QueryBuilder, Row};
12use url::Url;
13use utoipa::ToSchema;
14
15use crate::{
16    CourseOrExamId, SpecFetcher,
17    chapters::{
18        self, Chapter, DatabaseChapter, get_chapter, get_chapter_by_page_id, get_course_chapters,
19    },
20    course_instances::{self, CourseInstance},
21    courses::{self, Course, CourseContextData, CourseMaterialCourse},
22    exercise_service_info::{self, ExerciseServiceInfoApi},
23    exercise_services::{get_internal_public_spec_url, get_model_solution_url},
24    exercise_slides::ExerciseSlide,
25    exercise_tasks::ExerciseTask,
26    exercises::Exercise,
27    organizations::Organization,
28    page_history::{self, HistoryChangeReason, PageHistoryContent},
29    peer_or_self_review_configs::CmsPeerOrSelfReviewConfig,
30    peer_or_self_review_questions::{
31        CmsPeerOrSelfReviewQuestion, normalize_cms_peer_or_self_review_questions,
32    },
33    prelude::*,
34    user_chapter_locking_statuses,
35    user_course_settings::{self, UserCourseSettings},
36    user_exercise_states,
37};
38
39#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
40
41pub struct Page {
42    pub id: Uuid,
43    pub created_at: DateTime<Utc>,
44    pub updated_at: DateTime<Utc>,
45    pub course_id: Option<Uuid>,
46    pub exam_id: Option<Uuid>,
47    pub chapter_id: Option<Uuid>,
48    pub url_path: String,
49    pub title: String,
50    pub deleted_at: Option<DateTime<Utc>>,
51    // should always be a Vec<GutenbergBlock>, but is more convenient to keep as Value for sqlx
52    pub content: serde_json::Value,
53    pub order_number: i32,
54    pub copied_from: Option<Uuid>,
55    pub hidden: bool,
56    pub page_language_group_id: Option<Uuid>,
57}
58
59#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
60
61pub struct PageInfo {
62    pub page_id: Uuid,
63    pub page_title: String,
64    pub course_id: Option<Uuid>,
65    pub course_name: Option<String>,
66    pub course_slug: Option<String>,
67    pub organization_slug: Option<String>,
68}
69
70#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
71pub struct PageInfoSpecial {
72    pub page_id: Uuid,
73    pub url_path: String,
74    pub page_title: String,
75    pub order_number: i32,
76    pub chapter_title: Option<String>,
77    pub chapter_number: Option<i32>,
78    pub module_name: Option<String>,
79    pub module_number: Option<i32>,
80    pub content: serde_json::Value,
81}
82
83impl PageInfoSpecial {
84    pub fn blocks_cloned(&self) -> ModelResult<Vec<GutenbergBlock>> {
85        serde_json::from_value(self.content.clone()).map_err(Into::into)
86    }
87}
88
89#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
90
91pub struct PageAudioFiles {
92    pub id: Uuid,
93    pub page_id: Uuid,
94    pub created_at: DateTime<Utc>,
95    pub deleted_at: Option<DateTime<Utc>>,
96    pub path: String,
97    pub mime_type: String,
98}
99
100impl Page {
101    pub fn blocks_cloned(&self) -> ModelResult<Vec<GutenbergBlock>> {
102        serde_json::from_value(self.content.clone()).map_err(Into::into)
103    }
104}
105
106#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, Copy, ToSchema)]
107#[serde(rename_all = "snake_case")]
108pub enum LockChapterContentState {
109    NotLocked,
110    WaitingTeacherReview,
111    Visible,
112}
113
114#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
115
116pub struct CoursePageWithUserData {
117    pub page: Page,
118    pub instance: Option<CourseInstance>,
119    pub settings: Option<UserCourseSettings>,
120    pub course: Option<CourseMaterialCourse>,
121    pub organization: Option<Organization>,
122    pub lock_chapter_content_state: Option<LockChapterContentState>,
123    /// If true, the frontend needs to update the url in the browser to match the path in the page object without reloading the page.
124    pub was_redirected: bool,
125    pub is_test_mode: bool,
126}
127
128#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
129
130pub struct PageWithExercises {
131    #[serde(flatten)]
132    pub page: Page,
133    pub exercises: Vec<Exercise>,
134}
135
136#[derive(Default)]
137struct CourseMaterialPageContentFilterCache {
138    chapter_lock_content_states: HashMap<Uuid, LockChapterContentState>,
139    course_chapter_locking_enabled: HashMap<Uuid, bool>,
140}
141
142const URL_PATH_ENCODE_SET: &AsciiSet = &CONTROLS
143    .add(b' ')
144    .add(b'!')
145    .add(b'"')
146    .add(b'#')
147    .add(b'$')
148    .add(b'%')
149    .add(b'&')
150    .add(b'\'')
151    .add(b'(')
152    .add(b')')
153    .add(b'*')
154    .add(b'+')
155    .add(b',')
156    .add(b':')
157    .add(b';')
158    .add(b'<')
159    .add(b'=')
160    .add(b'>')
161    .add(b'?')
162    .add(b'@')
163    .add(b'[')
164    .add(b'\\')
165    .add(b']')
166    .add(b'^')
167    .add(b'`')
168    .add(b'{')
169    .add(b'|')
170    .add(b'}');
171
172/// Decode each maximal run of `%XX` escapes as one UTF-8 unit, dropping a run that isn't valid
173/// UTF-8 (rather than emitting U+FFFD). Non-escape characters pass through. Mirrors the migration's
174/// `safe_percent_decode`, so storage and lookup agree on inputs like `/%FF`.
175fn decode_percent_runs(input: &str) -> String {
176    let chars: Vec<char> = input.chars().collect();
177    let mut out = String::with_capacity(input.len());
178    let mut i = 0;
179    while i < chars.len() {
180        if percent_escape_byte_at(&chars, i).is_some() {
181            let mut bytes = Vec::new();
182            while let Some(byte) = percent_escape_byte_at(&chars, i) {
183                bytes.push(byte);
184                i += 3;
185            }
186            if let Ok(decoded) = std::str::from_utf8(&bytes) {
187                out.push_str(decoded);
188            }
189        } else {
190            out.push(chars[i]);
191            i += 1;
192        }
193    }
194    out
195}
196
197/// The byte a `%XX` escape at `chars[i]` decodes to, or `None` if there isn't one there.
198fn percent_escape_byte_at(chars: &[char], i: usize) -> Option<u8> {
199    if chars.get(i) != Some(&'%') {
200        return None;
201    }
202    let hi = chars.get(i + 1)?.to_digit(16)?;
203    let lo = chars.get(i + 2)?.to_digit(16)?;
204    Some((hi * 16 + lo) as u8)
205}
206
207/// Canonical storage/lookup form: decode `%xx`, then per `/`-segment turn whitespace into `-`,
208/// strip the unsafe ASCII in `URL_PATH_ENCODE_SET`, and collapse/trim dashes. Case, non-ASCII and
209/// `/`, `-`, `.`, `_`, `~` are kept. Unsafe chars are removed, not `%xx`-encoded, so every save
210/// funnels through here and paths can't re-accumulate escapes. Mirrors `clean_url_path` (page
211/// migration) and the frontend `cleanUrlPath`; keep the three in agreement.
212pub(crate) fn normalize_url_path_for_storage(url_path: &str) -> String {
213    decode_percent_runs(url_path)
214        .split('/')
215        .map(clean_url_path_segment)
216        .collect::<Vec<_>>()
217        .join("/")
218}
219
220/// Whether `ch` is an ASCII char that `URL_PATH_ENCODE_SET` would encode. The set's membership
221/// test is crate-private, so probe it via `utf8_percent_encode` (which encodes an ASCII char iff
222/// it's in the set). ASCII-only: the caller keeps non-ASCII verbatim.
223fn is_unsafe_ascii(ch: char) -> bool {
224    let mut buf = [0u8; 4];
225    let encoded = ch.encode_utf8(&mut buf);
226    utf8_percent_encode(encoded, URL_PATH_ENCODE_SET).next() != Some(encoded)
227}
228
229/// Cleans a single path segment (no `/`): whitespace runs become a single `-`, unsafe ASCII is
230/// dropped, runs of `-` collapse to one, and leading/trailing `-` are trimmed.
231fn clean_url_path_segment(segment: &str) -> String {
232    let mut out = String::with_capacity(segment.len());
233    for ch in segment.chars() {
234        // Whitespace becomes a dash; unsafe ASCII is dropped; everything else is kept.
235        let mapped = if ch.is_whitespace() {
236            Some('-')
237        } else if ch.is_ascii() && is_unsafe_ascii(ch) {
238            None
239        } else {
240            Some(ch)
241        };
242        match mapped {
243            // Collapse a run of dashes, whether from whitespace or a literal '-'.
244            Some('-') if out.ends_with('-') => {}
245            Some(c) => out.push(c),
246            None => {}
247        }
248    }
249    out.trim_matches('-').to_string()
250}
251
252/// Legacy lookup candidate: unsafe ASCII percent-encoded, non-ASCII kept decoded — the form used
253/// before paths were stored stripped. Lets pages written that way (notably exam pages, which the
254/// cleanup migration skips) still resolve.
255fn legacy_partially_encoded_url_path(url_path: &str) -> String {
256    let decoded = decode_percent_runs(url_path);
257    let trimmed = decoded.trim();
258    let mut normalized = String::with_capacity(trimmed.len());
259    let mut buf = [0u8; 4];
260    for ch in trimmed.chars() {
261        if ch.is_ascii() {
262            // For an ASCII char, utf8_percent_encode encodes it iff it is in the set.
263            normalized.extend(utf8_percent_encode(
264                ch.encode_utf8(&mut buf),
265                URL_PATH_ENCODE_SET,
266            ));
267        } else {
268            normalized.push(ch);
269        }
270    }
271    normalized
272}
273
274/// Legacy lookup candidate: the oldest form, with non-ASCII percent-encoded too. Lets pages stored
275/// that way still resolve.
276fn legacy_fully_encoded_url_path(url_path: &str) -> String {
277    let decoded = decode_percent_runs(url_path);
278    utf8_percent_encode(decoded.trim(), URL_PATH_ENCODE_SET).to_string()
279}
280
281/// Stored `url_path` forms to try for a requested path: the strip-canonical form plus the two
282/// legacy encoded forms, deduped (an ASCII-only safe path yields a single candidate).
283fn url_path_lookup_candidates(url_path: &str) -> Vec<String> {
284    let mut candidates = vec![normalize_url_path_for_storage(url_path)];
285    for form in [
286        legacy_partially_encoded_url_path(url_path),
287        legacy_fully_encoded_url_path(url_path),
288    ] {
289        if !candidates.contains(&form) {
290            candidates.push(form);
291        }
292    }
293    candidates
294}
295
296async fn get_lock_chapter_content_state_for_page(
297    conn: &mut PgConnection,
298    user_id: Option<Uuid>,
299    page: &Page,
300    cache: &mut CourseMaterialPageContentFilterCache,
301) -> ModelResult<Option<LockChapterContentState>> {
302    let Some(chapter_id) = page.chapter_id else {
303        return Ok(None);
304    };
305
306    if let Some(state) = cache.chapter_lock_content_states.get(&chapter_id).copied() {
307        return Ok(Some(state));
308    }
309
310    let state = match (user_id, page.course_id) {
311        (Some(user_id), Some(course_id)) => {
312            let course_chapter_locking_enabled = if let Some(enabled) = cache
313                .course_chapter_locking_enabled
314                .get(&course_id)
315                .copied()
316            {
317                enabled
318            } else {
319                let enabled = courses::get_course(conn, course_id)
320                    .await?
321                    .chapter_locking_enabled;
322                cache
323                    .course_chapter_locking_enabled
324                    .insert(course_id, enabled);
325                enabled
326            };
327
328            if !course_chapter_locking_enabled {
329                LockChapterContentState::NotLocked
330            } else {
331                let is_locked = matches!(
332                    user_chapter_locking_statuses::get_or_init_status(
333                        conn,
334                        user_id,
335                        chapter_id,
336                        Some(course_id),
337                        Some(course_chapter_locking_enabled),
338                    )
339                    .await?,
340                    Some(user_chapter_locking_statuses::ChapterLockingStatus::CompletedAndLocked)
341                );
342
343                if !is_locked {
344                    LockChapterContentState::NotLocked
345                } else if user_exercise_states::has_pending_manual_reviews_in_chapter(
346                    conn, user_id, chapter_id,
347                )
348                .await?
349                {
350                    LockChapterContentState::WaitingTeacherReview
351                } else {
352                    LockChapterContentState::Visible
353                }
354            }
355        }
356        _ => LockChapterContentState::NotLocked,
357    };
358
359    cache.chapter_lock_content_states.insert(chapter_id, state);
360    Ok(Some(state))
361}
362
363async fn filter_course_material_page_with_cache(
364    conn: &mut PgConnection,
365    user_id: Option<Uuid>,
366    page: Page,
367    cache: &mut CourseMaterialPageContentFilterCache,
368) -> ModelResult<(Page, Option<LockChapterContentState>)> {
369    let mut blocks = match page.blocks_cloned() {
370        Ok(blocks) => blocks,
371        Err(e) => {
372            tracing::warn!(
373                "Failed to deserialize page content for page {}: {}. Falling back to empty blocks.",
374                page.id,
375                e
376            );
377            vec![]
378        }
379    };
380
381    blocks = replace_duplicate_client_ids(blocks);
382
383    let lock_chapter_content_state =
384        get_lock_chapter_content_state_for_page(conn, user_id, &page, cache).await?;
385    if let Some(lock_state) = lock_chapter_content_state {
386        blocks = filter_lock_chapter_blocks(
387            blocks,
388            matches!(lock_state, LockChapterContentState::Visible),
389        );
390    }
391
392    let mut filtered_page = page;
393    filtered_page.content = serde_json::to_value(blocks)?;
394    Ok((filtered_page, lock_chapter_content_state))
395}
396
397pub async fn filter_course_material_page(
398    conn: &mut PgConnection,
399    user_id: Option<Uuid>,
400    page: Page,
401) -> ModelResult<Page> {
402    let mut cache = CourseMaterialPageContentFilterCache::default();
403    Ok(
404        filter_course_material_page_with_cache(conn, user_id, page, &mut cache)
405            .await?
406            .0,
407    )
408}
409
410pub async fn filter_course_material_pages(
411    conn: &mut PgConnection,
412    user_id: Option<Uuid>,
413    pages: Vec<Page>,
414) -> ModelResult<Vec<Page>> {
415    let mut cache = CourseMaterialPageContentFilterCache::default();
416    let mut filtered_pages = Vec::with_capacity(pages.len());
417    for page in pages {
418        filtered_pages.push(
419            filter_course_material_page_with_cache(conn, user_id, page, &mut cache)
420                .await?
421                .0,
422        );
423    }
424    Ok(filtered_pages)
425}
426
427pub async fn filter_course_material_pages_with_exercises(
428    conn: &mut PgConnection,
429    user_id: Option<Uuid>,
430    pages_with_exercises: Vec<PageWithExercises>,
431) -> ModelResult<Vec<PageWithExercises>> {
432    let mut cache = CourseMaterialPageContentFilterCache::default();
433    let mut filtered_pages = Vec::with_capacity(pages_with_exercises.len());
434    for page_with_exercises in pages_with_exercises {
435        let (page, _) = filter_course_material_page_with_cache(
436            conn,
437            user_id,
438            page_with_exercises.page,
439            &mut cache,
440        )
441        .await?;
442        filtered_pages.push(PageWithExercises {
443            page,
444            exercises: page_with_exercises.exercises,
445        });
446    }
447    Ok(filtered_pages)
448}
449
450/// Represents the subset of page fields that are required to create a new page.
451#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
452
453pub struct NewPage {
454    pub exercises: Vec<CmsPageExercise>,
455    pub exercise_slides: Vec<CmsPageExerciseSlide>,
456    pub exercise_tasks: Vec<CmsPageExerciseTask>,
457    pub content: Vec<GutenbergBlock>,
458    pub url_path: String,
459    pub title: String,
460    pub course_id: Option<Uuid>,
461    pub exam_id: Option<Uuid>,
462    pub chapter_id: Option<Uuid>,
463    /// If set, set this page to be the front page of this course part.
464    pub front_page_of_chapter_id: Option<Uuid>,
465    /// Read from the course's settings if None. If course_id is None as well, defaults to "simple"
466    pub content_search_language: Option<String>,
467    pub hidden: bool,
468}
469
470/// Represents the subset of page fields that can be updated from the main frontend dialog "Edit page details".
471#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
472
473pub struct PageDetailsUpdate {
474    pub title: String,
475    pub url_path: String,
476}
477
478#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
479
480pub struct NormalizedCmsExerciseTask {
481    pub id: Uuid,
482    pub exercise_type: String,
483    pub assignment: serde_json::Value,
484    pub private_spec: Option<serde_json::Value>,
485}
486
487#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
488
489pub struct PageRoutingData {
490    pub url_path: String,
491    pub title: String,
492    pub page_id: Uuid,
493    pub chapter_number: i32,
494    pub chapter_id: Uuid,
495    pub chapter_opens_at: Option<DateTime<Utc>>,
496    pub chapter_front_page_id: Option<Uuid>,
497}
498
499#[derive(Debug, Serialize, Deserialize, FromRow, PartialEq, Clone)]
500pub struct PageMetadata {
501    pub page_id: Uuid,
502    pub order_number: i32,
503    pub chapter_id: Option<Uuid>,
504    pub chapter_number: Option<i32>,
505    pub course_id: Option<Uuid>,
506    pub exam_id: Option<Uuid>,
507}
508
509#[derive(Debug, Serialize, Deserialize, FromRow, PartialEq, Clone, ToSchema)]
510
511pub struct PageChapterAndCourseInformation {
512    pub chapter_name: Option<String>,
513    pub chapter_number: Option<i32>,
514    pub course_name: Option<String>,
515    pub course_slug: Option<String>,
516    pub chapter_front_page_id: Option<Uuid>,
517    pub chapter_front_page_url_path: Option<String>,
518    pub organization_slug: Option<String>,
519}
520
521#[derive(Debug, Serialize, Deserialize, FromRow, PartialEq, Clone, ToSchema)]
522
523pub struct PageSearchResult {
524    pub id: Uuid,
525    pub title_headline: Option<String>,
526    pub rank: Option<f32>,
527    pub content_headline: Option<String>,
528    pub url_path: String,
529    pub chapter_name: Option<String>,
530}
531
532#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
533
534pub struct ContentManagementPage {
535    pub page: Page,
536    pub exercises: Vec<CmsPageExercise>,
537    pub exercise_slides: Vec<CmsPageExerciseSlide>,
538    pub exercise_tasks: Vec<CmsPageExerciseTask>,
539    pub peer_or_self_review_configs: Vec<CmsPeerOrSelfReviewConfig>,
540    pub peer_or_self_review_questions: Vec<CmsPeerOrSelfReviewQuestion>,
541    pub organization_id: Uuid,
542}
543
544#[derive(Debug, Serialize, Deserialize, FromRow, PartialEq, Clone, ToSchema)]
545
546pub struct SearchRequest {
547    pub query: String,
548}
549#[derive(Debug, Serialize, Deserialize, FromRow, PartialEq, Clone, ToSchema)]
550
551pub struct PageNavigationInformation {
552    pub chapter_front_page: Option<PageRoutingData>,
553    pub next_page: Option<PageRoutingData>,
554    pub previous_page: Option<PageRoutingData>,
555}
556
557#[derive(Debug, Serialize, Deserialize, FromRow, PartialEq, Clone)]
558
559pub struct ExerciseWithExerciseTasks {
560    id: Uuid,
561    created_at: DateTime<Utc>,
562    updated_at: DateTime<Utc>,
563    course_id: Uuid,
564    deleted_at: Option<DateTime<Utc>>,
565    name: String,
566    deadline: Option<DateTime<Utc>>,
567    page_id: Uuid,
568    exercise_tasks: Vec<ExerciseTask>,
569    score_maximum: i32,
570}
571
572#[derive(Debug, Serialize, Deserialize, FromRow, PartialEq, Clone, ToSchema)]
573
574pub struct IsChapterFrontPage {
575    pub is_chapter_front_page: bool,
576}
577
578#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy, ToSchema)]
579
580pub struct HistoryRestoreData {
581    pub history_id: Uuid,
582}
583
584#[derive(Debug, Serialize, Deserialize)]
585pub struct NewCoursePage<'a> {
586    pub content: Vec<GutenbergBlock>,
587    pub course_id: Uuid,
588    pub order_number: i32,
589    pub title: &'a str,
590    pub hidden: bool,
591    pub url_path: &'a str,
592}
593
594impl<'a> NewCoursePage<'a> {
595    /// Creates `NewCoursePage` with provided values that is public by default.
596    pub fn new(course_id: Uuid, order_number: i32, url_path: &'a str, title: &'a str) -> Self {
597        Self {
598            content: Default::default(),
599            course_id,
600            order_number,
601            title,
602            hidden: false,
603            url_path,
604        }
605    }
606
607    /// Creates a new `NewCoursePage` for the same course as this one and increments the page number.
608    pub fn followed_by(&self, url_path: &'a str, title: &'a str) -> Self {
609        Self::new(self.course_id, self.order_number + 1, url_path, title)
610    }
611
612    /// Sets the content of this page.
613    pub fn set_content(mut self, content: Vec<GutenbergBlock>) -> Self {
614        self.content = content;
615        self
616    }
617
618    /// Sets the hidden status of this page.
619    pub fn set_hidden(mut self, hidden: bool) -> Self {
620        self.hidden = hidden;
621        self
622    }
623}
624
625pub async fn insert_course_page(
626    conn: &mut PgConnection,
627    new_course_page: &NewCoursePage<'_>,
628    author: Uuid,
629) -> ModelResult<(Uuid, Uuid)> {
630    let course = crate::courses::get_course(&mut *conn, new_course_page.course_id).await?;
631    let page_language_group_id = crate::page_language_groups::insert(
632        &mut *conn,
633        crate::PKeyPolicy::Generate,
634        course.course_language_group_id,
635    )
636    .await?;
637
638    let mut tx = conn.begin().await?;
639    let page_res = sqlx::query!(
640        "
641INSERT INTO pages (
642    course_id,
643    content,
644    url_path,
645    title,
646    order_number,
647    hidden,
648    page_language_group_id
649  )
650VALUES ($1, $2, $3, $4, $5, $6, $7)
651RETURNING id
652",
653        new_course_page.course_id,
654        serde_json::to_value(new_course_page.content.clone())?,
655        new_course_page.url_path,
656        new_course_page.title,
657        new_course_page.order_number,
658        new_course_page.hidden,
659        page_language_group_id,
660    )
661    .fetch_one(&mut *tx)
662    .await?;
663    let history_id = crate::page_history::insert(
664        &mut tx,
665        PKeyPolicy::Generate,
666        page_res.id,
667        new_course_page.title,
668        &PageHistoryContent {
669            content: serde_json::Value::Array(vec![]),
670            exercises: vec![],
671            exercise_slides: vec![],
672            exercise_tasks: vec![],
673            peer_or_self_review_configs: Vec::new(),
674            peer_or_self_review_questions: Vec::new(),
675        },
676        HistoryChangeReason::PageSaved,
677        author,
678        None,
679    )
680    .await?;
681    tx.commit().await?;
682    Ok((page_res.id, history_id))
683}
684
685pub async fn insert_exam_page(
686    conn: &mut PgConnection,
687    exam_id: Uuid,
688    page: NewPage,
689    author: Uuid,
690) -> ModelResult<(Uuid, Uuid)> {
691    let mut tx = conn.begin().await?;
692    let page_res = sqlx::query!(
693        "
694INSERT INTO pages (
695    content,
696    url_path,
697    title,
698    exam_id,
699    content_search_language,
700    hidden,
701    order_number
702  )
703VALUES ($1, $2, $3, $4, $5, $6, $7)
704RETURNING id
705",
706        serde_json::to_value(page.content.clone())?,
707        page.url_path,
708        page.title,
709        exam_id,
710        page.content_search_language
711            .unwrap_or_else(|| "simple".to_string()),
712        page.hidden,
713        0_i32
714    )
715    .fetch_one(&mut *tx)
716    .await?;
717
718    let history_id = crate::page_history::insert(
719        &mut tx,
720        PKeyPolicy::Generate,
721        page_res.id,
722        page.title.as_str(),
723        &PageHistoryContent {
724            content: serde_json::to_value(page.content)?,
725            exercises: page.exercises,
726            exercise_slides: page.exercise_slides,
727            exercise_tasks: page.exercise_tasks,
728            peer_or_self_review_configs: Vec::new(),
729            peer_or_self_review_questions: Vec::new(),
730        },
731        HistoryChangeReason::PageSaved,
732        author,
733        None,
734    )
735    .await?;
736    tx.commit().await?;
737    Ok((page_res.id, history_id))
738}
739
740pub async fn get_course_and_exam_id(
741    conn: &mut PgConnection,
742    id: Uuid,
743) -> ModelResult<CourseOrExamId> {
744    let res = sqlx::query!(
745        "
746SELECT course_id, exam_id
747FROM pages
748WHERE id = $1
749  AND deleted_at IS NULL;
750        ",
751        id
752    )
753    .fetch_one(conn)
754    .await?;
755    CourseOrExamId::from_course_and_exam_ids(res.course_id, res.exam_id)
756}
757
758#[derive(Copy, Clone, Debug, PartialEq, Eq)]
759pub enum PageVisibility {
760    Any,
761    Public,
762    Hidden,
763}
764
765impl PageVisibility {
766    /// Hacky way to implement a nullable boolean filter. Based on the idea that
767    /// `null IS DISTINCT FROM anything` in PostgreSQL.
768    ///
769    /// More information at: <https://www.postgresql.org/docs/current/functions-comparison.html>
770    ///
771    /// # Examples
772    ///
773    /// ```ignore
774    /// # use headless_lms_models::{ModelResult, pages::PageVisibility};
775    /// # use sqlx::PgConnection;
776    /// # async fn random_function_1(conn: &mut PgConnection) -> ModelResult<()> {
777    /// // Evaluates to "hidden <> NULL"
778    /// let visibility = PageVisibility::Any;
779    /// sqlx::query!(
780    ///     "SELECT id FROM pages WHERE hidden IS DISTINCT FROM $1",
781    ///     visibility.get_inverse_visibility_filter(),
782    /// )
783    /// .fetch_all(conn)
784    /// .await?;
785    /// # Ok(())
786    /// # }
787    ///
788    /// # async fn random_function_2(conn: &mut PgConnection) -> ModelResult<()> {
789    /// // Evaluates to "hidden <> true"
790    /// let visibility = PageVisibility::Public;
791    /// sqlx::query!(
792    ///     "SELECT id FROM pages WHERE hidden IS DISTINCT FROM $1",
793    ///     visibility.get_inverse_visibility_filter(),
794    /// )
795    /// .fetch_all(conn)
796    /// .await?;
797    /// # Ok(())
798    /// # }
799    /// ```
800    fn get_inverse_visibility_filter(&self) -> Option<bool> {
801        match self {
802            PageVisibility::Any => None,
803            PageVisibility::Public => Some(true),
804            PageVisibility::Hidden => Some(false),
805        }
806    }
807}
808
809/// Gets all pages that belong to the given course that match the visibility filter.
810pub async fn get_all_by_course_id_and_visibility(
811    conn: &mut PgConnection,
812    course_id: Uuid,
813    page_visibility: PageVisibility,
814) -> ModelResult<Vec<Page>> {
815    let inverse_visibility_filter = page_visibility.get_inverse_visibility_filter();
816    let res = sqlx::query_as!(
817        Page,
818        "
819SELECT id,
820  created_at,
821  updated_at,
822  course_id,
823  exam_id,
824  chapter_id,
825  url_path,
826  title,
827  deleted_at,
828  content,
829  order_number,
830  copied_from,
831  hidden,
832  page_language_group_id
833FROM pages
834WHERE course_id = $1
835  AND hidden IS DISTINCT FROM $2
836  AND deleted_at IS NULL
837    ",
838        course_id,
839        inverse_visibility_filter,
840    )
841    .fetch_all(conn)
842    .await?;
843    Ok(res)
844}
845
846/// Gets all pages that belong to the given course but not in any chapter.
847pub async fn get_course_top_level_pages_by_course_id_and_visibility(
848    conn: &mut PgConnection,
849    course_id: Uuid,
850    page_visibility: PageVisibility,
851) -> ModelResult<Vec<Page>> {
852    let inverse_visibility_filter = page_visibility.get_inverse_visibility_filter();
853    let pages = sqlx::query_as!(
854        Page,
855        "
856SELECT id,
857  created_at,
858  updated_at,
859  course_id,
860  exam_id,
861  chapter_id,
862  url_path,
863  title,
864  deleted_at,
865  content,
866  order_number,
867  copied_from,
868  hidden,
869  page_language_group_id
870FROM pages p
871WHERE course_id = $1
872  AND hidden IS DISTINCT FROM $2
873  AND p.chapter_id IS NULL
874  AND p.deleted_at IS NULL
875        ",
876        course_id,
877        inverse_visibility_filter,
878    )
879    .fetch_all(conn)
880    .await?;
881    Ok(pages)
882}
883
884/// Gets all pages that belong to the given chapter that match the visibility filter.
885pub async fn get_course_pages_by_chapter_id_and_visibility(
886    conn: &mut PgConnection,
887    chapter_id: Uuid,
888    page_visibility: PageVisibility,
889) -> ModelResult<Vec<Page>> {
890    let inverse_visibility_filter = page_visibility.get_inverse_visibility_filter();
891    let res = sqlx::query_as!(
892        Page,
893        "
894SELECT id,
895  created_at,
896  updated_at,
897  course_id,
898  exam_id,
899  chapter_id,
900  url_path,
901  title,
902  deleted_at,
903  content,
904  order_number,
905  copied_from,
906  hidden,
907  page_language_group_id
908FROM pages
909WHERE chapter_id = $1
910  AND hidden IS DISTINCT FROM $2
911  AND deleted_at IS NULL
912    ",
913        chapter_id,
914        inverse_visibility_filter,
915    )
916    .fetch_all(conn)
917    .await?;
918    Ok(res)
919}
920
921/// Titles for a set of pages, for annotating rows that only carry a page id.
922pub async fn get_titles_by_ids(
923    conn: &mut PgConnection,
924    page_ids: &[Uuid],
925) -> ModelResult<HashMap<Uuid, String>> {
926    let rows = sqlx::query!(
927        "
928SELECT id,
929  title
930FROM pages
931WHERE id = ANY($1)
932  AND deleted_at IS NULL
933        ",
934        page_ids,
935    )
936    .fetch_all(conn)
937    .await?;
938    Ok(rows.into_iter().map(|r| (r.id, r.title)).collect())
939}
940
941pub async fn get_page(conn: &mut PgConnection, page_id: Uuid) -> ModelResult<Page> {
942    let page = sqlx::query_as!(
943        Page,
944        "
945SELECT id,
946  created_at,
947  updated_at,
948  course_id,
949  exam_id,
950  chapter_id,
951  url_path,
952  title,
953  deleted_at,
954  content,
955  order_number,
956  copied_from,
957  hidden,
958  page_language_group_id
959FROM pages
960WHERE id = $1;
961",
962        page_id
963    )
964    .fetch_one(conn)
965    .await?;
966    Ok(page)
967}
968
969/// The fields the chatbot needs to describe a page: enough to build the page-context message,
970/// none of the Gutenberg `content` JSONB.
971pub struct PageChatbotContext {
972    pub id: Uuid,
973    pub title: String,
974    pub chapter_name: Option<String>,
975    pub course_id: Option<Uuid>,
976    pub hidden: bool,
977    pub deleted_at: Option<DateTime<Utc>>,
978}
979
980/// Like [get_page], but projects only the columns the chatbot needs and folds in the chapter name,
981/// so the caller does not also have to fetch the whole page (including `content`) or the chapter.
982///
983/// `chapter_name` is `None` when the page has no chapter or when its chapter has been deleted, the
984/// same as looking up the chapter separately and discarding a `NotFound` error would give.
985pub async fn get_page_chatbot_context(
986    conn: &mut PgConnection,
987    page_id: Uuid,
988) -> ModelResult<PageChatbotContext> {
989    let page = sqlx::query_as!(
990        PageChatbotContext,
991        r#"
992SELECT pages.id AS "id!",
993  pages.title AS "title!",
994  chapters.name AS "chapter_name?",
995  pages.course_id,
996  pages.hidden AS "hidden!",
997  pages.deleted_at
998FROM pages
999LEFT JOIN chapters ON chapters.id = pages.chapter_id AND chapters.deleted_at IS NULL
1000WHERE pages.id = $1;
1001"#,
1002        page_id
1003    )
1004    .fetch_one(conn)
1005    .await?;
1006    Ok(page)
1007}
1008
1009pub async fn get_front_page_by_chapter_id(
1010    conn: &mut PgConnection,
1011    chapter_id: Uuid,
1012) -> ModelResult<Option<Page>> {
1013    let page = sqlx::query_as!(
1014        Page,
1015        r#"
1016SELECT p.id,
1017  p.created_at,
1018  p.updated_at,
1019  p.course_id,
1020  p.exam_id,
1021  p.chapter_id,
1022  p.url_path,
1023  p.title,
1024  p.deleted_at,
1025  p.content,
1026  p.order_number,
1027  p.copied_from,
1028  p.hidden,
1029  p.page_language_group_id
1030FROM chapters c
1031  JOIN pages p ON p.id = c.front_page_id
1032WHERE c.id = $1
1033  AND c.deleted_at IS NULL
1034  AND p.deleted_at IS NULL
1035        "#,
1036        chapter_id
1037    )
1038    .fetch_optional(conn)
1039    .await?;
1040    Ok(page)
1041}
1042
1043pub async fn get_page_info(conn: &mut PgConnection, page_id: Uuid) -> ModelResult<PageInfo> {
1044    let res = sqlx::query_as!(
1045        PageInfo,
1046        r#"
1047    SELECT
1048        p.id as "page_id!",
1049        p.title as "page_title!",
1050        c.id as "course_id?",
1051        c.name as "course_name?",
1052        c.slug as "course_slug?",
1053        o.slug as "organization_slug?"
1054    FROM pages p
1055    LEFT JOIN courses c
1056        on c.id = p.course_id
1057    LEFT JOIN organizations o
1058        on o.id = c.organization_id
1059    WHERE p.id = $1;
1060        "#,
1061        page_id
1062    )
1063    .fetch_one(conn)
1064    .await?;
1065
1066    Ok(res)
1067}
1068
1069pub async fn get_page_info_special_for_course(
1070    conn: &mut PgConnection,
1071    course_id: Uuid,
1072) -> ModelResult<Vec<PageInfoSpecial>> {
1073    let inverse_visibility_filter = PageVisibility::Public.get_inverse_visibility_filter();
1074    // if chapter_id is null then chapter_number and title should be null
1075    let res = sqlx::query_as!(
1076        PageInfoSpecial,
1077        r#"
1078    SELECT
1079        p.id AS "page_id!",
1080        p.url_path AS "url_path!",
1081        p.title AS "page_title!",
1082        p.order_number AS "order_number!",
1083        p.content AS "content!",
1084        c.name AS "chapter_title?",
1085        c.chapter_number AS "chapter_number?",
1086        m.name AS "module_name?",
1087        m.order_number AS "module_number?"
1088    FROM pages p
1089    LEFT JOIN chapters c
1090        ON c.id = p.chapter_id
1091    LEFT JOIN course_modules m
1092        ON m.id = c.course_module_id
1093    WHERE p.course_id = $1
1094    AND p.deleted_at IS NULL
1095    AND c.deleted_at IS NULL
1096    AND m.deleted_at IS NULL
1097    AND p.hidden IS DISTINCT FROM $2
1098        "#,
1099        course_id,
1100        inverse_visibility_filter,
1101    )
1102    .fetch_all(conn)
1103    .await?;
1104
1105    Ok(res)
1106}
1107
1108async fn get_page_by_stored_path(
1109    conn: &mut PgConnection,
1110    course_id: Uuid,
1111    url_path: &str,
1112) -> ModelResult<Option<Page>> {
1113    let page = sqlx::query_as!(
1114        Page,
1115        "
1116SELECT pages.id,
1117  pages.created_at,
1118  pages.updated_at,
1119  pages.course_id,
1120  pages.exam_id,
1121  pages.chapter_id,
1122  pages.url_path,
1123  pages.title,
1124  pages.deleted_at,
1125  pages.content,
1126  pages.order_number,
1127  pages.copied_from,
1128  pages.hidden,
1129  pages.page_language_group_id
1130FROM pages
1131WHERE pages.course_id = $1
1132  AND url_path = $2
1133  AND pages.deleted_at IS NULL;
1134        ",
1135        course_id,
1136        url_path
1137    )
1138    .fetch_optional(conn)
1139    .await?;
1140    Ok(page)
1141}
1142
1143/// Looks up a non-deleted page in `course_id` whose stored `url_path` matches `url_path` in
1144/// either the decoded-canonical or the legacy fully-encoded form (see
1145/// [`url_path_lookup_candidates`]). Shared by the page lookup and its tests so both exercise
1146/// the same candidate-matching logic.
1147async fn find_page_by_requested_path(
1148    conn: &mut PgConnection,
1149    course_id: Uuid,
1150    url_path: &str,
1151) -> ModelResult<Option<Page>> {
1152    for candidate in url_path_lookup_candidates(url_path) {
1153        if let Some(page) = get_page_by_stored_path(conn, course_id, &candidate).await? {
1154            return Ok(Some(page));
1155        }
1156    }
1157    Ok(None)
1158}
1159
1160pub async fn get_page_with_user_data_by_path(
1161    conn: &mut PgConnection,
1162    user_id: Option<Uuid>,
1163    course_data: &CourseContextData,
1164    url_path: &str,
1165    file_store: &dyn FileStore,
1166    app_conf: &ApplicationConfiguration,
1167) -> ModelResult<CoursePageWithUserData> {
1168    if let Some(page) = find_page_by_requested_path(conn, course_data.id, url_path).await? {
1169        return get_course_page_with_user_data_from_selected_page(
1170            conn,
1171            user_id,
1172            page,
1173            false,
1174            course_data.is_test_mode,
1175            file_store,
1176            app_conf,
1177        )
1178        .await;
1179    }
1180
1181    if let Some(redirected_page) =
1182        try_to_find_redirected_page(conn, course_data.id, url_path).await?
1183    {
1184        return get_course_page_with_user_data_from_selected_page(
1185            conn,
1186            user_id,
1187            redirected_page,
1188            true,
1189            course_data.is_test_mode,
1190            file_store,
1191            app_conf,
1192        )
1193        .await;
1194    }
1195
1196    Err(model_err!(NotFound, "Page not found".to_string()))
1197}
1198
1199pub async fn try_to_find_redirected_page(
1200    conn: &mut PgConnection,
1201    course_id: Uuid,
1202    url_path: &str,
1203) -> ModelResult<Option<Page>> {
1204    for candidate in url_path_lookup_candidates(url_path) {
1205        if let Some(page) =
1206            try_to_find_redirected_page_by_stored_path(conn, course_id, &candidate).await?
1207        {
1208            return Ok(Some(page));
1209        }
1210    }
1211    Ok(None)
1212}
1213
1214async fn try_to_find_redirected_page_by_stored_path(
1215    conn: &mut PgConnection,
1216    course_id: Uuid,
1217    url_path: &str,
1218) -> ModelResult<Option<Page>> {
1219    let page = sqlx::query_as!(
1220        Page,
1221        "
1222SELECT pages.id,
1223  pages.created_at,
1224  pages.updated_at,
1225  pages.course_id,
1226  pages.exam_id,
1227  pages.chapter_id,
1228  pages.url_path,
1229  pages.title,
1230  pages.deleted_at,
1231  pages.content,
1232  pages.order_number,
1233  pages.copied_from,
1234  pages.hidden,
1235  pages.page_language_group_id
1236FROM url_redirections
1237  JOIN pages on pages.id = url_redirections.destination_page_id
1238WHERE url_redirections.course_id = $1
1239  AND old_url_path = $2
1240  AND url_redirections.deleted_at IS NULL
1241  AND pages.deleted_at IS NULL;
1242    ",
1243        course_id,
1244        url_path
1245    )
1246    .fetch_optional(conn)
1247    .await?;
1248    Ok(page)
1249}
1250
1251pub async fn get_course_page_with_user_data_from_selected_page(
1252    conn: &mut PgConnection,
1253    user_id: Option<Uuid>,
1254    page: Page,
1255    was_redirected: bool,
1256    is_test_mode: bool,
1257    file_store: &dyn FileStore,
1258    app_conf: &ApplicationConfiguration,
1259) -> ModelResult<CoursePageWithUserData> {
1260    let mut content_filter_cache = CourseMaterialPageContentFilterCache::default();
1261    let (filtered_page, lock_chapter_content_state) =
1262        filter_course_material_page_with_cache(conn, user_id, page, &mut content_filter_cache)
1263            .await?;
1264
1265    if let Some(course_id) = filtered_page.course_id
1266        && let Some(user_id) = user_id
1267    {
1268        let instance =
1269            course_instances::current_course_instance_of_user(conn, user_id, course_id).await?;
1270        let settings =
1271            user_course_settings::get_user_course_settings_by_course_id(conn, user_id, course_id)
1272                .await?;
1273        let course = courses::get_course(conn, course_id).await?;
1274        let organization = Organization::from_database_organization(
1275            crate::organizations::get_organization(conn, course.organization_id).await?,
1276            file_store,
1277            app_conf,
1278        );
1279        return Ok(CoursePageWithUserData {
1280            page: filtered_page,
1281            instance,
1282            settings,
1283            course: Some(course.into()),
1284            was_redirected,
1285            is_test_mode,
1286            organization: Some(organization),
1287            lock_chapter_content_state,
1288        });
1289    }
1290    Ok(CoursePageWithUserData {
1291        page: filtered_page,
1292        instance: None,
1293        settings: None,
1294        course: None,
1295        was_redirected,
1296        is_test_mode,
1297        organization: None,
1298        lock_chapter_content_state,
1299    })
1300}
1301
1302pub async fn get_page_with_exercises(
1303    conn: &mut PgConnection,
1304    page_id: Uuid,
1305) -> ModelResult<ContentManagementPage> {
1306    let page = get_page(&mut *conn, page_id).await?;
1307
1308    let peer_or_self_review_configs =
1309        crate::peer_or_self_review_configs::get_peer_reviews_by_page_id(conn, page.id)
1310            .await?
1311            .into_iter()
1312            .flat_map(|pr| pr.exercise_id.map(|id| (id, pr)))
1313            .collect::<HashMap<_, _>>();
1314
1315    let peer_or_self_review_questions =
1316        crate::peer_or_self_review_questions::get_by_page_id(conn, page.id)
1317            .await?
1318            .into_iter()
1319            .into_group_map_by(|prq| prq.peer_or_self_review_config_id)
1320            .into_iter()
1321            .collect::<HashMap<_, _>>();
1322
1323    let exercises = crate::exercises::get_exercises_by_page_id(&mut *conn, page.id)
1324        .await?
1325        .into_iter()
1326        .map(|exercise| {
1327            let (a, b) = if exercise.needs_peer_review
1328                && exercise.use_course_default_peer_or_self_review_config
1329            {
1330                (None, None)
1331            } else {
1332                let peer_or_self_review_config =
1333                    peer_or_self_review_configs.get(&exercise.id).cloned();
1334                let peer_or_self_review_questions = peer_or_self_review_config
1335                    .as_ref()
1336                    .and_then(|prc| peer_or_self_review_questions.get(&prc.id).cloned());
1337                (peer_or_self_review_config, peer_or_self_review_questions)
1338            };
1339
1340            Ok(CmsPageExercise::from_exercise_and_peer_review_data(
1341                exercise, a, b,
1342            ))
1343        })
1344        .collect::<ModelResult<Vec<_>>>()?;
1345
1346    let exercise_slides: Vec<CmsPageExerciseSlide> =
1347        crate::exercise_slides::get_exercise_slides_by_exercise_ids(
1348            &mut *conn,
1349            &exercises.iter().map(|x| x.id).collect::<Vec<_>>(),
1350        )
1351        .await?
1352        .into_iter()
1353        .map(|x| x.into())
1354        .collect();
1355
1356    let tasks = crate::exercise_tasks::get_exercise_tasks_by_exercise_slide_ids(
1357        &mut *conn,
1358        &exercise_slides.iter().map(|x| x.id).collect::<Vec<Uuid>>(),
1359    )
1360    .await?;
1361    let mut spec_files_by_task = crate::exercise_task_spec_files::get_by_exercise_task_ids(
1362        &mut *conn,
1363        &tasks.iter().map(|task| task.id).collect::<Vec<Uuid>>(),
1364        crate::exercise_task_spec_files::SpecKind::Private,
1365    )
1366    .await?;
1367    let exercise_tasks: Vec<CmsPageExerciseTask> = tasks
1368        .into_iter()
1369        .map(|task| {
1370            let private_spec_files = spec_files_by_task.remove(&task.id).unwrap_or_default();
1371            CmsPageExerciseTask::from_task(task, private_spec_files)
1372        })
1373        .collect();
1374
1375    let organization_id = get_organization_id(&mut *conn, page_id).await?;
1376    Ok(ContentManagementPage {
1377        page,
1378        exercises,
1379        exercise_slides,
1380        exercise_tasks,
1381        peer_or_self_review_configs: peer_or_self_review_configs.into_values().collect(),
1382        peer_or_self_review_questions: peer_or_self_review_questions
1383            .into_values()
1384            .flatten()
1385            .collect(),
1386        organization_id,
1387    })
1388}
1389
1390/// Gets the page that belongs to the given exam. For exams, the page visibility is ignored.
1391pub async fn get_by_exam_id(conn: &mut PgConnection, exam_id: Uuid) -> ModelResult<Page> {
1392    let res = sqlx::query_as!(
1393        Page,
1394        "
1395SELECT pages.id,
1396  pages.created_at,
1397  pages.updated_at,
1398  pages.course_id,
1399  pages.exam_id,
1400  pages.chapter_id,
1401  pages.url_path,
1402  pages.title,
1403  pages.deleted_at,
1404  pages.content,
1405  pages.order_number,
1406  pages.copied_from,
1407  pages.hidden,
1408  pages.page_language_group_id
1409FROM pages
1410WHERE exam_id = $1
1411AND pages.deleted_at IS NULL
1412",
1413        exam_id
1414    )
1415    .fetch_one(conn)
1416    .await?;
1417    Ok(res)
1418}
1419
1420pub async fn get_pages_by_course_id(
1421    conn: &mut PgConnection,
1422    course_id: Uuid,
1423) -> ModelResult<Vec<Page>> {
1424    let res = sqlx::query_as!(
1425        Page,
1426        "
1427SELECT pages.id,
1428  pages.created_at,
1429  pages.updated_at,
1430  pages.course_id,
1431  pages.exam_id,
1432  pages.chapter_id,
1433  pages.url_path,
1434  pages.title,
1435  pages.deleted_at,
1436  pages.content,
1437  pages.order_number,
1438  pages.copied_from,
1439  pages.hidden,
1440  pages.page_language_group_id
1441FROM pages
1442WHERE course_id = $1
1443AND pages.deleted_at IS NULL
1444",
1445        course_id
1446    )
1447    .fetch_all(conn)
1448    .await?;
1449    Ok(res)
1450}
1451
1452#[derive(Debug, Serialize, Deserialize, FromRow, PartialEq, Clone, ToSchema)]
1453
1454pub struct CmsPageExercise {
1455    pub id: Uuid,
1456    pub name: String,
1457    pub order_number: i32,
1458    pub score_maximum: i32,
1459    pub max_tries_per_slide: Option<i32>,
1460    pub limit_number_of_tries: bool,
1461    pub deadline: Option<DateTime<Utc>>,
1462    pub needs_peer_review: bool,
1463    pub needs_self_review: bool,
1464    pub peer_or_self_review_config: Option<CmsPeerOrSelfReviewConfig>,
1465    pub peer_or_self_review_questions: Option<Vec<CmsPeerOrSelfReviewQuestion>>,
1466    pub use_course_default_peer_or_self_review_config: bool,
1467    pub teacher_reviews_answer_after_locking: bool,
1468}
1469
1470impl CmsPageExercise {
1471    fn from_exercise_and_peer_review_data(
1472        exercise: Exercise,
1473        peer_or_self_review_config: Option<CmsPeerOrSelfReviewConfig>,
1474        peer_or_self_review_questions: Option<Vec<CmsPeerOrSelfReviewQuestion>>,
1475    ) -> Self {
1476        Self {
1477            id: exercise.id,
1478            name: exercise.name,
1479            order_number: exercise.order_number,
1480            score_maximum: exercise.score_maximum,
1481            max_tries_per_slide: exercise.max_tries_per_slide,
1482            limit_number_of_tries: exercise.limit_number_of_tries,
1483            deadline: exercise.deadline,
1484            needs_peer_review: exercise.needs_peer_review,
1485            needs_self_review: exercise.needs_self_review,
1486            use_course_default_peer_or_self_review_config: exercise
1487                .use_course_default_peer_or_self_review_config,
1488            teacher_reviews_answer_after_locking: exercise.teacher_reviews_answer_after_locking,
1489            peer_or_self_review_config,
1490            peer_or_self_review_questions,
1491        }
1492    }
1493}
1494
1495#[derive(Debug, Serialize, Deserialize, FromRow, PartialEq, Clone, ToSchema)]
1496
1497pub struct CmsPageExerciseSlide {
1498    pub id: Uuid,
1499    pub exercise_id: Uuid,
1500    pub order_number: i32,
1501}
1502
1503impl From<ExerciseSlide> for CmsPageExerciseSlide {
1504    fn from(slide: ExerciseSlide) -> Self {
1505        Self {
1506            id: slide.id,
1507            exercise_id: slide.exercise_id,
1508            order_number: slide.order_number,
1509        }
1510    }
1511}
1512
1513#[derive(Debug, Serialize, Deserialize, FromRow, PartialEq, Clone, ToSchema)]
1514
1515pub struct CmsPageExerciseTask {
1516    pub id: Uuid,
1517    pub exercise_slide_id: Uuid,
1518    pub assignment: serde_json::Value,
1519    pub exercise_type: String,
1520    pub private_spec: Option<serde_json::Value>,
1521    pub order_number: i32,
1522    /// The stored files this task's private spec references, as the exercise service declared them
1523    /// in the editor. The host cannot read the spec, so this list is the only thing keeping the
1524    /// files from being reclaimed as abandoned uploads — and the CMS round-trips it through a block
1525    /// attribute, so a load that leaves it empty makes the next save drop the references.
1526    ///
1527    /// Only the private spec's. Each derived spec declares its own files in the response of the
1528    /// endpoint that produced it, since a derivation may upload files of its own.
1529    #[serde(default)]
1530    pub private_spec_files: Vec<Uuid>,
1531}
1532
1533impl CmsPageExerciseTask {
1534    /// Deliberately not a `From<ExerciseTask>`: the declared spec files live in their own table, so
1535    /// a conversion that could not reach them would quietly produce an empty list and the next save
1536    /// would drop every reference.
1537    pub fn from_task(task: ExerciseTask, private_spec_files: Vec<Uuid>) -> Self {
1538        CmsPageExerciseTask {
1539            id: task.id,
1540            exercise_slide_id: task.exercise_slide_id,
1541            assignment: task.assignment,
1542            exercise_type: task.exercise_type,
1543            private_spec: task.private_spec,
1544            order_number: task.order_number,
1545            private_spec_files,
1546        }
1547    }
1548}
1549
1550#[derive(Debug, Serialize, Deserialize, FromRow, PartialEq, Clone, ToSchema)]
1551
1552pub struct CmsPageUpdate {
1553    pub content: Vec<GutenbergBlock>,
1554    pub exercises: Vec<CmsPageExercise>,
1555    pub exercise_slides: Vec<CmsPageExerciseSlide>,
1556    pub exercise_tasks: Vec<CmsPageExerciseTask>,
1557    pub url_path: String,
1558    pub title: String,
1559    pub chapter_id: Option<Uuid>,
1560    pub hidden: bool,
1561}
1562
1563impl CmsPageUpdate {
1564    /// Checks that each exercise has at least one slide and each slide has at least one task.
1565    pub fn validate_exercise_data(&self) -> ModelResult<()> {
1566        let mut exercise_ids: HashMap<Uuid, bool> =
1567            self.exercises.iter().map(|x| (x.id, false)).collect();
1568        let mut slide_ids = self
1569            .exercise_slides
1570            .iter()
1571            .map(|x| {
1572                if let hash_map::Entry::Occupied(mut e) = exercise_ids.entry(x.exercise_id) {
1573                    e.insert(true);
1574                    Ok((x.id, false))
1575                } else {
1576                    Err(model_err!(
1577                        PreconditionFailed,
1578                        "Exercide ids in slides don't match.".to_string()
1579                    ))
1580                }
1581            })
1582            .collect::<ModelResult<HashMap<Uuid, bool>>>()?;
1583
1584        if let Some((exercise_id, _)) = exercise_ids.into_iter().find(|(_, x)| !x) {
1585            return Err(model_err!(
1586                PreconditionFailedWithCMSAnchorBlockId {
1587                    id: exercise_id,
1588                    description: "Exercise must have at least one slide.",
1589                },
1590                "Exercise must have at least one slide.".to_string()
1591            ));
1592        }
1593
1594        for task in self.exercise_tasks.iter() {
1595            if let hash_map::Entry::Occupied(mut e) = slide_ids.entry(task.exercise_slide_id) {
1596                e.insert(true);
1597            } else {
1598                return Err(model_err!(
1599                    PreconditionFailed,
1600                    "Exercise slide ids in tasks don't match.".to_string()
1601                ));
1602            }
1603        }
1604        if let Some((slide_id, _)) = slide_ids.into_iter().find(|(_, x)| !x) {
1605            return Err(model_err!(
1606                PreconditionFailedWithCMSAnchorBlockId {
1607                    id: slide_id,
1608                    description: "Exercise slide must have at least one task.",
1609                },
1610                "Exercise slide must have at least one task.".to_string()
1611            ));
1612        }
1613        Ok(())
1614    }
1615
1616    /// Detects exercises that share the same id within this single page and gives the duplicate
1617    /// occurrences fresh ids so they become independent exercises (#148).
1618    ///
1619    /// Duplicating an exercise block in the CMS (Gutenberg) keeps the original block's id (and the
1620    /// ids of its nested slides/tasks), so a saved page can contain several exercises that reference
1621    /// the same backend exercise. Without this, the upsert collapses them into a single row through
1622    /// `ON CONFLICT (id) DO UPDATE` and editing one secretly edits the others.
1623    ///
1624    /// The same id legitimately appearing once per page is how exercises move between pages, so only
1625    /// repeats *within the same page* are remapped. The duplicate's whole subtree (slides, tasks and
1626    /// any custom peer-review config/questions) is remapped consistently. Occurrences are matched in
1627    /// document order, which `normalizeDocument` guarantees is identical across the content and the
1628    /// flat exercise/slide/task lists.
1629    ///
1630    /// Returns a map from each regenerated duplicate's new exercise id to its original id, so callers
1631    /// can recover data still keyed by the original id (e.g. custom peer-review config/questions).
1632    pub fn regenerate_duplicate_exercise_ids(&mut self) -> HashMap<Uuid, Uuid> {
1633        // For every original exercise id, the new id to use for each occurrence (index 0 is the
1634        // first occurrence and keeps the original id).
1635        let mut exercise_ids_by_occurrence: HashMap<Uuid, Vec<Uuid>> = HashMap::new();
1636        // Maps each regenerated duplicate's new id back to its original exercise id.
1637        let mut original_exercise_id_by_new_id: HashMap<Uuid, Uuid> = HashMap::new();
1638        let mut found_duplicate = false;
1639        for exercise in self.exercises.iter_mut() {
1640            let original_id = exercise.id;
1641            let occurrences = exercise_ids_by_occurrence.entry(original_id).or_default();
1642            let new_id = if occurrences.is_empty() {
1643                original_id
1644            } else {
1645                found_duplicate = true;
1646                Uuid::new_v4()
1647            };
1648            occurrences.push(new_id);
1649
1650            if new_id != original_id {
1651                exercise.id = new_id;
1652                original_exercise_id_by_new_id.insert(new_id, original_id);
1653                // Remap the duplicate's inline custom peer-review config/questions so they don't
1654                // collapse onto the original exercise's config.
1655                if let Some(config) = exercise.peer_or_self_review_config.as_mut() {
1656                    let questions = exercise
1657                        .peer_or_self_review_questions
1658                        .as_deref_mut()
1659                        .unwrap_or(&mut []);
1660                    fork_peer_or_self_review_config(config, questions, new_id);
1661                }
1662            }
1663        }
1664
1665        if !found_duplicate {
1666            return original_exercise_id_by_new_id;
1667        }
1668
1669        // Remap slides. The m-th occurrence of a slide id belongs to the m-th occurrence of its
1670        // exercise, because each duplicated exercise carries an identical copy of its slides.
1671        let mut slide_ids_by_occurrence: HashMap<Uuid, Vec<Uuid>> = HashMap::new();
1672        for slide in self.exercise_slides.iter_mut() {
1673            let original_slide_id = slide.id;
1674            let occurrence = slide_ids_by_occurrence
1675                .get(&original_slide_id)
1676                .map_or(0, |occurrences| occurrences.len());
1677
1678            let new_exercise_id = exercise_ids_by_occurrence
1679                .get(&slide.exercise_id)
1680                .and_then(|occurrences| occurrences.get(occurrence))
1681                .copied()
1682                .unwrap_or(slide.exercise_id);
1683            let new_slide_id = if occurrence == 0 {
1684                original_slide_id
1685            } else {
1686                Uuid::new_v4()
1687            };
1688            slide_ids_by_occurrence
1689                .entry(original_slide_id)
1690                .or_default()
1691                .push(new_slide_id);
1692            slide.id = new_slide_id;
1693            slide.exercise_id = new_exercise_id;
1694        }
1695
1696        // Remap tasks the same way against the slide occurrences.
1697        let mut task_ids_by_occurrence: HashMap<Uuid, usize> = HashMap::new();
1698        for task in self.exercise_tasks.iter_mut() {
1699            let original_task_id = task.id;
1700            let occurrence = task_ids_by_occurrence.entry(original_task_id).or_insert(0);
1701
1702            let new_slide_id = slide_ids_by_occurrence
1703                .get(&task.exercise_slide_id)
1704                .and_then(|occurrences| occurrences.get(*occurrence))
1705                .copied()
1706                .unwrap_or(task.exercise_slide_id);
1707            if *occurrence > 0 {
1708                task.id = Uuid::new_v4();
1709            }
1710            task.exercise_slide_id = new_slide_id;
1711            *occurrence += 1;
1712        }
1713
1714        // Finally rewrite the exercise ids embedded in the page content so the duplicated block
1715        // points at its newly created exercise. Exercise blocks only exist at the top level.
1716        let mut content_occurrence: HashMap<Uuid, usize> = HashMap::new();
1717        for block in self.content.iter_mut() {
1718            if block.name != "moocfi/exercise" {
1719                continue;
1720            }
1721            let Some(id) = block
1722                .attributes
1723                .get("id")
1724                .and_then(|value| value.as_str())
1725                .and_then(|value| Uuid::parse_str(value).ok())
1726            else {
1727                continue;
1728            };
1729            let occurrence = content_occurrence.entry(id).or_insert(0);
1730            if let Some(new_id) = exercise_ids_by_occurrence
1731                .get(&id)
1732                .and_then(|occurrences| occurrences.get(*occurrence))
1733                && *new_id != id
1734            {
1735                block
1736                    .attributes
1737                    .insert("id".to_string(), Value::String(new_id.to_string()));
1738            }
1739            *occurrence += 1;
1740        }
1741
1742        original_exercise_id_by_new_id
1743    }
1744}
1745
1746/// Gives `questions` fresh ids pointing at `target_config_id` when it differs from `source_config_id`
1747/// (i.e. they are being cloned onto a different config); otherwise returns them untouched so the
1748/// upsert reuses the existing rows.
1749fn rekey_peer_or_self_review_questions(
1750    questions: Vec<CmsPeerOrSelfReviewQuestion>,
1751    source_config_id: Uuid,
1752    target_config_id: Uuid,
1753) -> Vec<CmsPeerOrSelfReviewQuestion> {
1754    if source_config_id == target_config_id {
1755        return questions;
1756    }
1757    questions
1758        .into_iter()
1759        .map(|mut question| {
1760            question.id = Uuid::new_v4();
1761            question.peer_or_self_review_config_id = target_config_id;
1762            question
1763        })
1764        .collect()
1765}
1766
1767/// Re-keys a custom peer-review config and its questions onto `new_exercise_id` with a fresh config id
1768/// and fresh question ids, producing an independent copy for a within-page exercise duplicate so it
1769/// does not collapse onto the original exercise's config (#148). All `questions` must belong to
1770/// `config`.
1771fn fork_peer_or_self_review_config(
1772    config: &mut CmsPeerOrSelfReviewConfig,
1773    questions: &mut [CmsPeerOrSelfReviewQuestion],
1774    new_exercise_id: Uuid,
1775) {
1776    let new_config_id = Uuid::new_v4();
1777    config.id = new_config_id;
1778    config.exercise_id = Some(new_exercise_id);
1779    for question in questions.iter_mut() {
1780        question.id = Uuid::new_v4();
1781        question.peer_or_self_review_config_id = new_config_id;
1782    }
1783}
1784
1785#[derive(Debug, Clone)]
1786pub struct PageUpdateArgs {
1787    pub page_id: Uuid,
1788    pub author: Uuid,
1789    pub cms_page_update: CmsPageUpdate,
1790    pub retain_ids: bool,
1791    pub history_change_reason: HistoryChangeReason,
1792    pub is_exam_page: bool,
1793}
1794
1795pub async fn update_page(
1796    conn: &mut PgConnection,
1797    page_update: PageUpdateArgs,
1798    spec_fetcher: impl SpecFetcher,
1799    fetch_service_info: impl Fn(Url) -> BoxFuture<'static, ModelResult<ExerciseServiceInfoApi>>,
1800) -> ModelResult<ContentManagementPage> {
1801    let mut cms_page_update = page_update.cms_page_update;
1802    // Give a fresh id to any exercise duplicated within this page so duplicates don't collapse into
1803    // a single backend exercise (#148). Must run before validation and the upsert below.
1804    let original_exercise_id_by_new_id = cms_page_update.regenerate_duplicate_exercise_ids();
1805    cms_page_update.validate_exercise_data()?;
1806
1807    for exercise in cms_page_update.exercises.iter_mut() {
1808        if let Some(peer_or_self_review_questions) = exercise.peer_or_self_review_questions.as_mut()
1809        {
1810            normalize_cms_peer_or_self_review_questions(peer_or_self_review_questions);
1811        }
1812    }
1813
1814    let content = replace_duplicate_client_ids(cms_page_update.content.clone());
1815    let normalized_url_path = normalize_url_path_for_storage(&cms_page_update.url_path);
1816
1817    if !page_update.is_exam_page
1818        && cms_page_update.chapter_id.is_none()
1819        && contains_blocks_not_allowed_in_top_level_pages(&content)
1820    {
1821        return Err(model_err!(Generic, "Top level non-exam pages cannot contain exercises, exercise tasks or list of exercises in the chapter".to_string()));
1822    }
1823
1824    let mut tx = conn.begin().await?;
1825
1826    // Updating page
1827    let page = sqlx::query_as!(
1828        Page,
1829        r"
1830UPDATE pages
1831SET content = $2,
1832  url_path = $3,
1833  title = $4,
1834  chapter_id = $5,
1835  hidden = $6
1836WHERE id = $1
1837RETURNING id,
1838  created_at,
1839  updated_at,
1840  course_id,
1841  exam_id,
1842  chapter_id,
1843  url_path,
1844  title,
1845  deleted_at,
1846  content,
1847  order_number,
1848  copied_from,
1849  pages.hidden,
1850  pages.page_language_group_id
1851        ",
1852        page_update.page_id,
1853        serde_json::to_value(&content)?,
1854        normalized_url_path,
1855        cms_page_update.title.trim(),
1856        cms_page_update.chapter_id,
1857        cms_page_update.hidden
1858    )
1859    .fetch_one(&mut *tx)
1860    .await?;
1861
1862    // Fetch the existing custom peer-review configs/questions before deleting exercises below. Both
1863    // queries join `exercises` with `deleted_at IS NULL`, so they must run before
1864    // `delete_exercises_by_page_id` or they would return nothing. They are used as a fallback to
1865    // preserve custom peer-review settings for exercises whose payload does not explicitly include
1866    // them (e.g. an inconsistent editor state) (#129).
1867    let existing_peer_or_self_review_configs_by_exercise_id =
1868        crate::peer_or_self_review_configs::get_peer_reviews_by_page_id(&mut tx, page.id)
1869            .await?
1870            .into_iter()
1871            .filter_map(|pr| pr.exercise_id.map(|id| (id, pr)))
1872            .collect::<HashMap<Uuid, CmsPeerOrSelfReviewConfig>>();
1873    let existing_peer_or_self_review_questions_by_config_id =
1874        crate::peer_or_self_review_questions::get_by_page_id(&mut tx, page.id)
1875            .await?
1876            .into_iter()
1877            .into_group_map_by(|prq| prq.peer_or_self_review_config_id);
1878
1879    // Exercises
1880    let existing_exercise_ids =
1881        crate::exercises::delete_exercises_by_page_id(&mut tx, page.id).await?;
1882    let remapped_exercises = upsert_exercises(
1883        &mut tx,
1884        &page,
1885        &existing_exercise_ids,
1886        &cms_page_update.exercises,
1887        page_update.retain_ids,
1888    )
1889    .await?;
1890
1891    // Exercise slides
1892    let existing_exercise_slide_ids =
1893        crate::exercise_slides::delete_exercise_slides_by_exercise_ids(
1894            &mut tx,
1895            &existing_exercise_ids,
1896        )
1897        .await?;
1898    let remapped_exercise_slides = upsert_exercise_slides(
1899        &mut tx,
1900        &remapped_exercises,
1901        &existing_exercise_slide_ids,
1902        &cms_page_update.exercise_slides,
1903        page_update.retain_ids,
1904    )
1905    .await?;
1906
1907    // Peer reviews
1908    let existing_peer_or_self_review_config_ids =
1909        crate::peer_or_self_review_configs::delete_peer_reviews_by_exrcise_ids(
1910            &mut tx,
1911            &existing_exercise_ids,
1912        )
1913        .await?;
1914
1915    // Configs present in this save's payload, keyed by exercise id. Lets a regenerated within-page
1916    // duplicate that omits its own config recover its original's *current* config — preferring the
1917    // edit the user just made over the pre-save DB snapshot (#148).
1918    let payload_peer_or_self_review_by_exercise_id: HashMap<
1919        Uuid,
1920        (
1921            CmsPeerOrSelfReviewConfig,
1922            Option<Vec<CmsPeerOrSelfReviewQuestion>>,
1923        ),
1924    > = cms_page_update
1925        .exercises
1926        .iter()
1927        .filter(|e| !e.use_course_default_peer_or_self_review_config)
1928        .filter_map(|e| {
1929            e.peer_or_self_review_config.as_ref().map(|config| {
1930                (
1931                    e.id,
1932                    (config.clone(), e.peer_or_self_review_questions.clone()),
1933                )
1934            })
1935        })
1936        .collect();
1937
1938    // Recovers the custom config + questions stored/sent for `exercise_id`, preferring this save's
1939    // payload over the pre-save DB snapshot and resolving omitted questions from the snapshot.
1940    let recover_peer_or_self_review = |exercise_id: Uuid| -> Option<(
1941        CmsPeerOrSelfReviewConfig,
1942        Vec<CmsPeerOrSelfReviewQuestion>,
1943    )> {
1944        if let Some((config, questions)) =
1945            payload_peer_or_self_review_by_exercise_id.get(&exercise_id)
1946        {
1947            let questions = match questions {
1948                Some(questions) => questions.clone(),
1949                None => existing_peer_or_self_review_questions_by_config_id
1950                    .get(&config.id)
1951                    .cloned()
1952                    .unwrap_or_default(),
1953            };
1954            return Some((config.clone(), questions));
1955        }
1956        let config = existing_peer_or_self_review_configs_by_exercise_id
1957            .get(&exercise_id)?
1958            .clone();
1959        let questions = existing_peer_or_self_review_questions_by_config_id
1960            .get(&config.id)
1961            .cloned()
1962            .unwrap_or_default();
1963        Some((config, questions))
1964    };
1965
1966    let (peer_or_self_review_configs, peer_or_self_review_questions) = cms_page_update
1967        .exercises
1968        .into_iter()
1969        .filter(|e| !e.use_course_default_peer_or_self_review_config)
1970        .filter_map(|e| {
1971            let exercise_id = e.id;
1972            // For a within-page duplicate that was regenerated a fresh id (#148), its settings still
1973            // live under the original exercise/config id; otherwise this is the exercise's own id.
1974            let original_exercise_id = original_exercise_id_by_new_id.get(&exercise_id).copied();
1975            // Prefer the config/questions sent in the payload. If the payload omits them (e.g. an
1976            // inconsistent editor state where a custom exercise is saved without its config or
1977            // questions), fall back to the existing custom settings so they are not lost (#129). An
1978            // exercise switching to the course default is filtered out above, so its config is
1979            // correctly left deleted.
1980            match e.peer_or_self_review_config {
1981                Some(config) => {
1982                    // The config is present, but the questions may be omitted. `None` means "keep the
1983                    // existing questions"; `Some(vec![])` is an explicit "remove all questions". The
1984                    // existing questions live under the source config id — this exercise's own for a
1985                    // normal save, or the original's for a regenerated duplicate whose config id was
1986                    // freshly minted, so they are re-keyed onto the new config id.
1987                    let questions = match e.peer_or_self_review_questions {
1988                        Some(questions) => questions,
1989                        None => {
1990                            recover_peer_or_self_review(original_exercise_id.unwrap_or(exercise_id))
1991                                .map(|(source_config, source_questions)| {
1992                                    rekey_peer_or_self_review_questions(
1993                                        source_questions,
1994                                        source_config.id,
1995                                        config.id,
1996                                    )
1997                                })
1998                                .unwrap_or_default()
1999                        }
2000                    };
2001                    Some((config, questions))
2002                }
2003                None => {
2004                    // The whole config is omitted. Recover this exercise's own stored config (#129);
2005                    // if it is a regenerated duplicate with nothing of its own, recover the original's
2006                    // current config and fork it onto this duplicate with fresh ids (#148).
2007                    if let Some((config, questions)) = recover_peer_or_self_review(exercise_id) {
2008                        return Some((config, questions));
2009                    }
2010                    let (mut config, mut questions) =
2011                        original_exercise_id.and_then(&recover_peer_or_self_review)?;
2012                    fork_peer_or_self_review_config(&mut config, &mut questions, exercise_id);
2013                    Some((config, questions))
2014                }
2015            }
2016        })
2017        .fold((vec![], vec![]), |(mut a, mut b), (pr, prq)| {
2018            a.push(pr);
2019            b.extend(prq);
2020            (a, b)
2021        });
2022
2023    let remapped_peer_or_self_review_configs = upsert_peer_or_self_review_configs(
2024        &mut tx,
2025        &existing_peer_or_self_review_config_ids,
2026        &peer_or_self_review_configs,
2027        &remapped_exercises,
2028        page_update.retain_ids,
2029    )
2030    .await?;
2031
2032    // Peer review questions
2033    let existing_peer_or_self_review_questions =
2034        crate::peer_or_self_review_questions::delete_peer_or_self_review_questions_by_peer_or_self_review_config_ids(
2035            &mut tx,
2036            &existing_peer_or_self_review_config_ids,
2037        )
2038        .await?;
2039
2040    let remapped_peer_or_self_review_questions = upsert_peer_or_self_review_questions(
2041        &mut tx,
2042        &existing_peer_or_self_review_questions,
2043        &peer_or_self_review_questions,
2044        &remapped_peer_or_self_review_configs,
2045        page_update.retain_ids,
2046    )
2047    .await?;
2048
2049    // Set as deleted and get existing specs
2050    let existing_exercise_task_specs = sqlx::query_as!(
2051        ExerciseTaskIdAndSpec,
2052        "
2053UPDATE exercise_tasks
2054SET deleted_at = now()
2055WHERE exercise_slide_id = ANY($1)
2056AND deleted_at IS NULL
2057RETURNING id,
2058  private_spec,
2059  public_spec,
2060  model_solution_spec;
2061        ",
2062        &existing_exercise_slide_ids,
2063    )
2064    .fetch_all(&mut *tx)
2065    .await?;
2066    let final_tasks = upsert_exercise_tasks(
2067        &mut tx,
2068        &remapped_exercise_slides,
2069        &existing_exercise_task_specs,
2070        &cms_page_update.exercise_tasks,
2071        page_update.retain_ids,
2072        &spec_fetcher,
2073        fetch_service_info,
2074    )
2075    .await?;
2076
2077    // Now, we might have changed some of the exercise ids and need to do the same changes in the page content as well
2078    let new_content = headless_lms_utils::document_schema_processor::remap_ids_in_content(
2079        &page.content,
2080        remapped_exercises
2081            .iter()
2082            .map(|(id, e)| (*id, e.id))
2083            .collect::<HashMap<Uuid, Uuid>>(),
2084    )?;
2085
2086    let page = sqlx::query_as!(
2087        Page,
2088        "
2089UPDATE pages
2090SET content = $1
2091WHERE id = $2
2092RETURNING id,
2093  created_at,
2094  updated_at,
2095  course_id,
2096  exam_id,
2097  chapter_id,
2098  url_path,
2099  title,
2100  deleted_at,
2101  content,
2102  order_number,
2103  copied_from,
2104  hidden,
2105  page_language_group_id
2106        ",
2107        new_content,
2108        page.id
2109    )
2110    .fetch_one(&mut *tx)
2111    .await?;
2112
2113    let x = remapped_exercises.into_values().collect::<Vec<_>>();
2114    let final_exercises = x
2115        .iter()
2116        .map(|e| {
2117            let peer_or_self_review_config = remapped_peer_or_self_review_configs
2118                .values()
2119                .find(|prc| prc.exercise_id == Some(e.id));
2120            if let Some(prc) = peer_or_self_review_config {
2121                let peer_or_self_review_questions = remapped_peer_or_self_review_questions
2122                    .values()
2123                    .filter(|prq| prq.peer_or_self_review_config_id == prc.id)
2124                    .cloned()
2125                    .collect::<Vec<_>>();
2126                return CmsPageExercise::from_exercise_and_peer_review_data(
2127                    e.clone(),
2128                    Some(prc.clone()),
2129                    Some(peer_or_self_review_questions),
2130                );
2131            }
2132            CmsPageExercise::from_exercise_and_peer_review_data(e.clone(), None, None)
2133        })
2134        .collect();
2135    let final_slides: Vec<CmsPageExerciseSlide> = remapped_exercise_slides.into_values().collect();
2136    let final_peer_reviews: Vec<CmsPeerOrSelfReviewConfig> =
2137        remapped_peer_or_self_review_configs.into_values().collect();
2138    let final_peer_or_self_review_questions: Vec<CmsPeerOrSelfReviewQuestion> =
2139        remapped_peer_or_self_review_questions
2140            .into_values()
2141            .collect();
2142    let history_content = PageHistoryContent {
2143        content: page.content.clone(),
2144        exercises: final_exercises,
2145        exercise_slides: final_slides,
2146        exercise_tasks: final_tasks,
2147        peer_or_self_review_configs: final_peer_reviews,
2148        peer_or_self_review_questions: final_peer_or_self_review_questions,
2149    };
2150    crate::page_history::insert(
2151        &mut tx,
2152        PKeyPolicy::Generate,
2153        page_update.page_id,
2154        &cms_page_update.title,
2155        &history_content,
2156        page_update.history_change_reason,
2157        page_update.author,
2158        None,
2159    )
2160    .await?;
2161    let organization_id = get_organization_id(&mut tx, page.id).await?;
2162
2163    tx.commit().await?;
2164
2165    Ok(ContentManagementPage {
2166        page,
2167        exercises: history_content.exercises,
2168        exercise_slides: history_content.exercise_slides,
2169        exercise_tasks: history_content.exercise_tasks,
2170        peer_or_self_review_configs: history_content.peer_or_self_review_configs,
2171        peer_or_self_review_questions: history_content.peer_or_self_review_questions,
2172        organization_id,
2173    })
2174}
2175
2176pub async fn update_by_id_in_parent_context(
2177    conn: &mut PgConnection,
2178    page_update: PageUpdateArgs,
2179    expected_course_id: Option<Uuid>,
2180    expected_exam_id: Option<Uuid>,
2181    spec_fetcher: impl SpecFetcher,
2182    fetch_service_info: impl Fn(Url) -> BoxFuture<'static, ModelResult<ExerciseServiceInfoApi>>,
2183) -> ModelResult<ContentManagementPage> {
2184    let mut tx = conn.begin().await?;
2185    sqlx::query!(
2186        r#"
2187SELECT p.id
2188FROM pages p
2189LEFT JOIN chapters c
2190  ON c.id = $4
2191WHERE p.id = $1
2192  AND p.deleted_at IS NULL
2193  AND (p.course_id = $2 OR p.exam_id = $3)
2194  AND (
2195    $4::uuid IS NULL
2196    OR (
2197      p.course_id IS NOT NULL
2198      AND c.id = $4
2199      AND c.course_id = p.course_id
2200      AND c.deleted_at IS NULL
2201    )
2202  )
2203 FOR UPDATE OF p
2204        "#,
2205        page_update.page_id,
2206        expected_course_id,
2207        expected_exam_id,
2208        page_update.cms_page_update.chapter_id
2209    )
2210    .fetch_one(&mut *tx)
2211    .await?;
2212
2213    let updated = update_page(&mut tx, page_update, spec_fetcher, fetch_service_info).await?;
2214    tx.commit().await?;
2215    Ok(updated)
2216}
2217
2218/// Remaps ids from updates to exercises that may have their ids regenerated.
2219async fn upsert_exercises(
2220    conn: &mut PgConnection,
2221    page: &Page,
2222    existing_exercise_ids: &[Uuid],
2223    exercise_updates: &[CmsPageExercise],
2224    retain_exercise_ids: bool,
2225) -> ModelResult<HashMap<Uuid, Exercise>> {
2226    let mut remapped_exercises = HashMap::new();
2227    for exercise_update in exercise_updates.iter() {
2228        let exercise_exists = existing_exercise_ids.contains(&exercise_update.id);
2229        let safe_for_db_exercise_id = if retain_exercise_ids || exercise_exists {
2230            exercise_update.id
2231        } else {
2232            Uuid::new_v4()
2233        };
2234
2235        // check if exercise exits
2236        let db_exercise = crate::exercises::get_by_id(&mut *conn, safe_for_db_exercise_id)
2237            .await
2238            .optional()?;
2239
2240        let mut exercise_language_group_id = None;
2241
2242        if let Some(db_exercise) = db_exercise {
2243            exercise_language_group_id = db_exercise.exercise_language_group_id;
2244        }
2245        if let Some(course_id) = page.course_id {
2246            let course = crate::courses::get_course(&mut *conn, course_id).await?;
2247
2248            exercise_language_group_id = Some(
2249                crate::exercise_language_groups::insert(
2250                    &mut *conn,
2251                    PKeyPolicy::Generate,
2252                    course.course_language_group_id,
2253                )
2254                .await?,
2255            );
2256        }
2257
2258        let exercise = sqlx::query_as!(
2259            Exercise,
2260            "
2261INSERT INTO exercises(
2262    id,
2263    course_id,
2264    name,
2265    order_number,
2266    page_id,
2267    chapter_id,
2268    exam_id,
2269    score_maximum,
2270    max_tries_per_slide,
2271    limit_number_of_tries,
2272    deadline,
2273    needs_peer_review,
2274    needs_self_review,
2275    use_course_default_peer_or_self_review_config,
2276    exercise_language_group_id,
2277    teacher_reviews_answer_after_locking
2278  )
2279VALUES (
2280    $1,
2281    $2,
2282    $3,
2283    $4,
2284    $5,
2285    $6,
2286    $7,
2287    $8,
2288    $9,
2289    $10,
2290    $11,
2291    $12,
2292    $13,
2293    $14,
2294    $15,
2295    $16
2296  ) ON CONFLICT (id) DO
2297UPDATE
2298SET course_id = $2,
2299  name = $3,
2300  order_number = $4,
2301  page_id = $5,
2302  chapter_id = $6,
2303  exam_id = $7,
2304  score_maximum = $8,
2305  max_tries_per_slide = $9,
2306  limit_number_of_tries = $10,
2307  deadline = $11,
2308  needs_peer_review = $12,
2309  needs_self_review = $13,
2310  use_course_default_peer_or_self_review_config = $14,
2311  exercise_language_group_id = $15,
2312  teacher_reviews_answer_after_locking = $16,
2313  deleted_at = NULL
2314RETURNING *;
2315            ",
2316            safe_for_db_exercise_id,
2317            page.course_id,
2318            exercise_update.name,
2319            exercise_update.order_number,
2320            page.id,
2321            page.chapter_id,
2322            page.exam_id,
2323            exercise_update.score_maximum,
2324            exercise_update.max_tries_per_slide,
2325            exercise_update.limit_number_of_tries,
2326            exercise_update.deadline,
2327            exercise_update.needs_peer_review,
2328            exercise_update.needs_self_review,
2329            exercise_update.use_course_default_peer_or_self_review_config,
2330            exercise_language_group_id,
2331            exercise_update.teacher_reviews_answer_after_locking,
2332        )
2333        .fetch_one(&mut *conn)
2334        .await?;
2335
2336        remapped_exercises.insert(exercise_update.id, exercise);
2337    }
2338    Ok(remapped_exercises)
2339}
2340
2341/// Remaps ids from updates to exercise slides that may have their ids changed.
2342async fn upsert_exercise_slides(
2343    conn: &mut PgConnection,
2344    remapped_exercises: &HashMap<Uuid, Exercise>,
2345    existing_slide_ids: &[Uuid],
2346    slide_updates: &[CmsPageExerciseSlide],
2347    retain_exercise_ids: bool,
2348) -> ModelResult<HashMap<Uuid, CmsPageExerciseSlide>> {
2349    let mut remapped_exercise_slides = HashMap::new();
2350    for slide_update in slide_updates.iter() {
2351        let slide_exists = existing_slide_ids.contains(&slide_update.id);
2352        let safe_for_db_slide_id = if retain_exercise_ids || slide_exists {
2353            slide_update.id
2354        } else {
2355            Uuid::new_v4()
2356        };
2357        let safe_for_db_exercise_id = remapped_exercises
2358            .get(&slide_update.exercise_id)
2359            .ok_or_else(|| {
2360                model_err!(
2361                    InvalidRequest,
2362                    "Illegal exercise id for exercise slide.".to_string()
2363                )
2364            })?
2365            .id;
2366
2367        let exercise_slide = sqlx::query_as!(
2368            CmsPageExerciseSlide,
2369            "
2370INSERT INTO exercise_slides (id, exercise_id, order_number)
2371VALUES ($1, $2, $3) ON CONFLICT (id) DO
2372UPDATE
2373SET exercise_id = $2,
2374  order_number = $3,
2375  deleted_at = NULL
2376RETURNING id,
2377  exercise_id,
2378  order_number;
2379            ",
2380            safe_for_db_slide_id,
2381            safe_for_db_exercise_id,
2382            slide_update.order_number,
2383        )
2384        .fetch_one(&mut *conn)
2385        .await?;
2386
2387        remapped_exercise_slides.insert(slide_update.id, exercise_slide);
2388    }
2389    Ok(remapped_exercise_slides)
2390}
2391
2392/// Remaps ids from updates to exercise tasks that may have their ids changed.
2393async fn upsert_exercise_tasks(
2394    conn: &mut PgConnection,
2395    remapped_slides: &HashMap<Uuid, CmsPageExerciseSlide>,
2396    existing_task_specs: &[ExerciseTaskIdAndSpec],
2397    task_updates: &[CmsPageExerciseTask],
2398    retain_exercise_ids: bool,
2399    spec_fetcher: impl SpecFetcher,
2400    fetch_service_info: impl Fn(Url) -> BoxFuture<'static, ModelResult<ExerciseServiceInfoApi>>,
2401) -> ModelResult<Vec<CmsPageExerciseTask>> {
2402    // For generating public specs for exercises.
2403    let exercise_types: Vec<String> = task_updates
2404        .iter()
2405        .map(|task| task.exercise_type.clone())
2406        .unique()
2407        .collect();
2408    let exercise_service_hashmap = exercise_service_info::get_selected_exercise_services_by_type(
2409        &mut *conn,
2410        &exercise_types,
2411        fetch_service_info,
2412    )
2413    .await?;
2414    let public_spec_urls_by_exercise_type = exercise_service_hashmap
2415        .iter()
2416        .map(|(key, (service, info))| Ok((key, get_internal_public_spec_url(service, info)?)))
2417        .collect::<ModelResult<HashMap<&String, Url>>>()?;
2418    let model_solution_urls_by_exercise_type = exercise_service_hashmap
2419        .iter()
2420        .map(|(key, (service, info))| Ok((key, get_model_solution_url(service, info)?)))
2421        .collect::<ModelResult<HashMap<&String, Url>>>()?;
2422
2423    let mut remapped_exercise_tasks = Vec::new();
2424    for task_update in task_updates.iter() {
2425        let existing_exercise_task = existing_task_specs.iter().find(|o| o.id == task_update.id);
2426        let safe_for_db_exercise_task_id = match existing_exercise_task {
2427            Some(_) => task_update.id,
2428            _ if retain_exercise_ids => task_update.id,
2429            None => Uuid::new_v4(),
2430        };
2431
2432        let task_exists = existing_task_specs
2433            .iter()
2434            .any(|task| task.id == task_update.id);
2435        let safe_for_db_task_id = if retain_exercise_ids || task_exists {
2436            task_update.id
2437        } else {
2438            Uuid::new_v4()
2439        };
2440        let normalized_task = NormalizedCmsExerciseTask {
2441            id: safe_for_db_task_id,
2442            assignment: task_update.assignment.clone(),
2443            exercise_type: task_update.exercise_type.clone(),
2444            private_spec: task_update.private_spec.clone(),
2445        };
2446        let declares_spec_files = exercise_service_hashmap
2447            .get(&task_update.exercise_type)
2448            .is_some_and(|(_service, info)| info.declares_spec_files);
2449        let model_solution = fetch_derived_spec(
2450            existing_exercise_task,
2451            &normalized_task,
2452            &model_solution_urls_by_exercise_type,
2453            &spec_fetcher,
2454            existing_exercise_task.and_then(|value| value.model_solution_spec.clone()),
2455            task_update.id,
2456            declares_spec_files,
2457        )
2458        .await?;
2459        let public = fetch_derived_spec(
2460            existing_exercise_task,
2461            &normalized_task,
2462            &public_spec_urls_by_exercise_type,
2463            &spec_fetcher,
2464            existing_exercise_task.and_then(|value| value.public_spec.clone()),
2465            task_update.id,
2466            declares_spec_files,
2467        )
2468        .await?;
2469        let model_solution_spec = model_solution.spec;
2470        let public_spec = public.spec;
2471        let safe_for_db_exercise_slide_id = remapped_slides
2472            .get(&task_update.exercise_slide_id)
2473            .ok_or_else(|| {
2474                model_err!(
2475                    InvalidRequest,
2476                    "Illegal exercise slide id for exercise task.".to_string()
2477                )
2478            })?
2479            .id;
2480
2481        // Upsert
2482        let upserted = sqlx::query!(
2483            "
2484INSERT INTO exercise_tasks(
2485    id,
2486    exercise_slide_id,
2487    exercise_type,
2488    assignment,
2489    public_spec,
2490    private_spec,
2491    model_solution_spec,
2492    order_number
2493  )
2494VALUES ($1, $2, $3, $4, $5, $6, $7, $8) ON CONFLICT (id) DO
2495UPDATE
2496SET exercise_slide_id = $2,
2497  exercise_type = $3,
2498  assignment = $4,
2499  public_spec = $5,
2500  private_spec = $6,
2501  model_solution_spec = $7,
2502  order_number = $8,
2503  deleted_at = NULL
2504RETURNING id,
2505  exercise_slide_id,
2506  assignment,
2507  exercise_type,
2508  private_spec,
2509  order_number
2510                ",
2511            safe_for_db_exercise_task_id,
2512            safe_for_db_exercise_slide_id,
2513            task_update.exercise_type,
2514            task_update.assignment,
2515            public_spec,
2516            task_update.private_spec,
2517            model_solution_spec,
2518            task_update.order_number,
2519        )
2520        .fetch_one(&mut *conn)
2521        .await?;
2522        // The exercise service declares these; the host cannot read them out of the specs it just
2523        // stored. Rewritten in full per kind, so dropping a file from a spec releases it.
2524        crate::exercise_task_spec_files::replace_for_exercise_task(
2525            &mut *conn,
2526            upserted.id,
2527            crate::exercise_task_spec_files::SpecKind::Private,
2528            &task_update.private_spec_files,
2529        )
2530        .await?;
2531        // Only when this save actually re-derived and the service declared: a skipped derivation
2532        // leaves the previous spec in place, so its declarations must stay in place too.
2533        if let Some(declared) = public.declared_files.as_ref() {
2534            crate::exercise_task_spec_files::replace_for_exercise_task(
2535                &mut *conn,
2536                upserted.id,
2537                crate::exercise_task_spec_files::SpecKind::Public,
2538                declared,
2539            )
2540            .await?;
2541        }
2542        if let Some(declared) = model_solution.declared_files.as_ref() {
2543            crate::exercise_task_spec_files::replace_for_exercise_task(
2544                &mut *conn,
2545                upserted.id,
2546                crate::exercise_task_spec_files::SpecKind::ModelSolution,
2547                declared,
2548            )
2549            .await?;
2550        }
2551        remapped_exercise_tasks.push(CmsPageExerciseTask {
2552            id: upserted.id,
2553            exercise_slide_id: upserted.exercise_slide_id,
2554            assignment: upserted.assignment,
2555            exercise_type: upserted.exercise_type,
2556            private_spec: upserted.private_spec,
2557            order_number: upserted.order_number,
2558            private_spec_files: task_update.private_spec_files.clone(),
2559        })
2560    }
2561    Ok(remapped_exercise_tasks)
2562}
2563
2564pub async fn upsert_peer_or_self_review_configs(
2565    conn: &mut PgConnection,
2566    existing_peer_reviews: &[Uuid],
2567    peer_reviews: &[CmsPeerOrSelfReviewConfig],
2568    remapped_exercises: &HashMap<Uuid, Exercise>,
2569    retain_ids: bool,
2570) -> ModelResult<HashMap<Uuid, CmsPeerOrSelfReviewConfig>> {
2571    if peer_reviews.is_empty() {
2572        Ok(HashMap::new())
2573    } else {
2574        let mut new_peer_or_self_review_config_id_to_old_id = HashMap::new();
2575
2576        let mut sql: QueryBuilder<Postgres> = QueryBuilder::new(
2577            "INSERT INTO peer_or_self_review_configs (
2578        id,
2579        course_id,
2580        exercise_id,
2581        peer_reviews_to_give,
2582        peer_reviews_to_receive,
2583        processing_strategy,
2584        accepting_threshold,
2585        points_are_all_or_nothing,
2586        reset_answer_if_zero_points_from_review,
2587        review_instructions,
2588        deleted_at
2589      ) ",
2590        );
2591
2592        // No way to return from push_values, we can use this to detect an error after the push_values
2593        let mut illegal_exercise_id = None;
2594
2595        sql.push_values(peer_reviews.iter().take(1000), |mut x, pr| {
2596            let peer_review_exists = existing_peer_reviews.contains(&pr.id);
2597            let safe_for_db_peer_or_self_review_config_id = if retain_ids || peer_review_exists {
2598                pr.id
2599            } else {
2600                Uuid::new_v4()
2601            };
2602            new_peer_or_self_review_config_id_to_old_id
2603                .insert(safe_for_db_peer_or_self_review_config_id, pr.id);
2604
2605            let exercise = pr.exercise_id.and_then(|id| {
2606                let res = remapped_exercises.get(&id);
2607                if res.is_none() {
2608                    error!("Illegal exercise id {:?}", id);
2609                    illegal_exercise_id = Some(id);
2610                }
2611                res
2612            });
2613            // The client's course_id is stale when the exercise block was pasted from another
2614            // course, and course copying breaks if it disagrees with the exercise's course.
2615            let safe_for_db_course_id = exercise.and_then(|e| e.course_id).unwrap_or(pr.course_id);
2616
2617            x.push_bind(safe_for_db_peer_or_self_review_config_id)
2618                .push_bind(safe_for_db_course_id)
2619                .push_bind(exercise.map(|e| e.id))
2620                .push_bind(pr.peer_reviews_to_give)
2621                .push_bind(pr.peer_reviews_to_receive)
2622                .push_bind(pr.processing_strategy)
2623                .push_bind(pr.accepting_threshold)
2624                .push_bind(pr.points_are_all_or_nothing)
2625                .push_bind(pr.reset_answer_if_zero_points_from_review)
2626                .push_bind(pr.review_instructions.clone())
2627                .push("NULL");
2628        });
2629
2630        if let Some(illegal_exercise_id) = illegal_exercise_id {
2631            return Err(model_err!(
2632                InvalidRequest,
2633                format!("Illegal exercise id {:?}", illegal_exercise_id)
2634            ));
2635        }
2636
2637        sql.push(
2638            " ON CONFLICT (id) DO
2639UPDATE
2640SET course_id = excluded.course_id,
2641  exercise_id = excluded.exercise_id,
2642  peer_reviews_to_give = excluded.peer_reviews_to_give,
2643  peer_reviews_to_receive = excluded.peer_reviews_to_receive,
2644  processing_strategy = excluded.processing_strategy,
2645  accepting_threshold = excluded.accepting_threshold,
2646  points_are_all_or_nothing = excluded.points_are_all_or_nothing,
2647  review_instructions = excluded.review_instructions,
2648  deleted_at = NULL
2649RETURNING id;
2650",
2651        );
2652
2653        let ids = sql
2654            .build()
2655            .fetch_all(&mut *conn)
2656            .await?
2657            .iter()
2658            .map(|x| x.get(0))
2659            .collect::<Vec<_>>();
2660
2661        let prs = sqlx::query_as!(
2662            CmsPeerOrSelfReviewConfig,
2663            r#"
2664SELECT id as "id!",
2665  course_id as "course_id!",
2666  exercise_id,
2667  peer_reviews_to_give as "peer_reviews_to_give!",
2668  peer_reviews_to_receive as "peer_reviews_to_receive!",
2669  processing_strategy AS "processing_strategy!",
2670  accepting_threshold "accepting_threshold!",
2671  points_are_all_or_nothing "points_are_all_or_nothing!",
2672  reset_answer_if_zero_points_from_review,
2673  review_instructions
2674FROM peer_or_self_review_configs
2675WHERE id IN (
2676    SELECT UNNEST($1::uuid [])
2677  )
2678  AND deleted_at IS NULL;
2679    "#,
2680            &ids
2681        )
2682        .fetch_all(&mut *conn)
2683        .await?;
2684
2685        let mut remapped_peer_reviews = HashMap::new();
2686
2687        for pr in prs {
2688            let old_id = new_peer_or_self_review_config_id_to_old_id
2689                .get(&pr.id)
2690                .ok_or_else(|| {
2691                    model_err!(Generic, "Inserted peer reviews not found".to_string())
2692                })?;
2693            remapped_peer_reviews.insert(*old_id, pr);
2694        }
2695
2696        Ok(remapped_peer_reviews)
2697    }
2698}
2699
2700pub async fn upsert_peer_or_self_review_questions(
2701    conn: &mut PgConnection,
2702    existing_peer_or_self_review_questions: &[Uuid],
2703    peer_or_self_review_questions: &[CmsPeerOrSelfReviewQuestion],
2704    remapped_peer_or_self_review_config_ids: &HashMap<Uuid, CmsPeerOrSelfReviewConfig>,
2705    retain_ids: bool,
2706) -> ModelResult<HashMap<Uuid, CmsPeerOrSelfReviewQuestion>> {
2707    if peer_or_self_review_questions.is_empty() {
2708        Ok(HashMap::new())
2709    } else {
2710        let mut new_peer_or_self_review_question_id_to_old_id = HashMap::new();
2711
2712        let mut sql: QueryBuilder<Postgres> = QueryBuilder::new(
2713            "INSERT INTO peer_or_self_review_questions (
2714        id,
2715        peer_or_self_review_config_id,
2716        order_number,
2717        question,
2718        question_type,
2719        answer_required,
2720        weight,
2721        deleted_at
2722      ) ",
2723        );
2724
2725        let peer_or_self_review_questions = peer_or_self_review_questions
2726            .iter()
2727            .take(1000)
2728            .map(|prq| {
2729                remapped_peer_or_self_review_config_ids
2730                    .get(&prq.peer_or_self_review_config_id)
2731                    .map(|r| (prq, r.id))
2732                    .ok_or_else(|| {
2733                        model_err!(
2734                            Generic,
2735                            "No peer review found for peer review questions".to_string()
2736                        )
2737                    })
2738            })
2739            .collect::<Result<Vec<_>, _>>()?;
2740
2741        sql.push_values(
2742            peer_or_self_review_questions,
2743            |mut x, (prq, peer_or_self_review_config_id)| {
2744                let peer_review_question_exists =
2745                    existing_peer_or_self_review_questions.contains(&prq.id);
2746                let safe_for_db_peer_or_self_review_question_id =
2747                    if retain_ids || peer_review_question_exists {
2748                        prq.id
2749                    } else {
2750                        Uuid::new_v4()
2751                    };
2752                new_peer_or_self_review_question_id_to_old_id
2753                    .insert(safe_for_db_peer_or_self_review_question_id, prq.id);
2754
2755                x.push_bind(safe_for_db_peer_or_self_review_question_id)
2756                    .push_bind(peer_or_self_review_config_id)
2757                    .push_bind(prq.order_number)
2758                    .push_bind(prq.question.as_str())
2759                    .push_bind(prq.question_type)
2760                    .push_bind(prq.answer_required)
2761                    .push_bind(prq.weight)
2762                    .push("NULL");
2763            },
2764        );
2765
2766        sql.push(
2767            " ON CONFLICT (id) DO
2768UPDATE
2769SET peer_or_self_review_config_id = excluded.peer_or_self_review_config_id,
2770    order_number = excluded.order_number,
2771    question = excluded.question,
2772    question_type = excluded.question_type,
2773    answer_required = excluded.answer_required,
2774    weight = excluded.weight,
2775    deleted_at = NULL
2776RETURNING id;
2777",
2778        );
2779
2780        let ids = sql
2781            .build()
2782            .fetch_all(&mut *conn)
2783            .await?
2784            .iter()
2785            .map(|x| x.get(0))
2786            .collect::<Vec<_>>();
2787
2788        let prqs = sqlx::query_as!(
2789            CmsPeerOrSelfReviewQuestion,
2790            r#"
2791SELECT id AS "id!",
2792  answer_required AS "answer_required!",
2793  order_number AS "order_number!",
2794  peer_or_self_review_config_id AS "peer_or_self_review_config_id!",
2795  question AS "question!",
2796  question_type AS "question_type!",
2797  weight AS "weight!"
2798FROM peer_or_self_review_questions
2799WHERE id IN (
2800    SELECT UNNEST($1::uuid [])
2801  )
2802  AND deleted_at is null;
2803        "#,
2804            &ids
2805        )
2806        .fetch_all(&mut *conn)
2807        .await?;
2808
2809        let mut remapped_peer_or_self_review_questions = HashMap::new();
2810
2811        for prq in prqs {
2812            let old_id = new_peer_or_self_review_question_id_to_old_id
2813                .get(&prq.id)
2814                .ok_or_else(|| {
2815                    model_err!(Generic, "Inserted peer reviews not found".to_string())
2816                })?;
2817            remapped_peer_or_self_review_questions.insert(*old_id, prq);
2818        }
2819
2820        Ok(remapped_peer_or_self_review_questions)
2821    }
2822}
2823
2824/// Only used when testing.
2825pub async fn update_page_content(
2826    conn: &mut PgConnection,
2827    page_id: Uuid,
2828    content: &serde_json::Value,
2829) -> ModelResult<()> {
2830    sqlx::query!(
2831        "
2832UPDATE pages
2833SET content = $1
2834WHERE id = $2;
2835",
2836        content,
2837        page_id
2838    )
2839    .execute(conn)
2840    .await?;
2841    Ok(())
2842}
2843
2844#[derive(Debug)]
2845struct ExerciseTaskIdAndSpec {
2846    pub id: Uuid,
2847    pub private_spec: Option<serde_json::Value>,
2848    pub public_spec: Option<serde_json::Value>,
2849    pub model_solution_spec: Option<serde_json::Value>,
2850}
2851
2852/// A derived spec, with the stored files the exercise service declared it references.
2853#[derive(Debug)]
2854struct DerivedSpec {
2855    spec: Option<serde_json::Value>,
2856    /// `None` when this save declared nothing about the spec: either it was not re-derived, so the
2857    /// stored declarations still stand, or the service does not declare its spec files at all.
2858    /// Distinct from `Some(vec![])`, which says the spec references no files and releases whatever
2859    /// it referenced before.
2860    declared_files: Option<Vec<Uuid>>,
2861}
2862
2863/// The body a service that declares its spec files returns from the public-spec and
2864/// model-solution endpoints, in place of the bare spec.
2865///
2866/// Both keys are required on purpose: serde reads a missing field of either an `Option` or a
2867/// `#[serde(default)]` type without complaint, so a lenient envelope would swallow a bare spec
2868/// object as "no spec, no files" and store `NULL` over a working exercise.
2869#[derive(Deserialize)]
2870struct DerivedSpecResponse {
2871    /// `null` for a kind the service has no spec for, such as a model solution it cannot show.
2872    spec: serde_json::Value,
2873    files: Vec<Uuid>,
2874}
2875
2876async fn fetch_derived_spec(
2877    existing_exercise_task: Option<&ExerciseTaskIdAndSpec>,
2878    task_update: &NormalizedCmsExerciseTask,
2879    urls_by_exercise_type: &HashMap<&String, Url>,
2880    spec_fetcher: impl SpecFetcher,
2881    previous_spec: Option<serde_json::Value>,
2882    cms_block_id: Uuid,
2883    declares_spec_files: bool,
2884) -> Result<DerivedSpec, ModelError> {
2885    match existing_exercise_task {
2886        Some(exercise_task) if exercise_task.private_spec == task_update.private_spec => {
2887            // Skip generating public spec for an existing exercise again if private spec is still the same.
2888            Ok(DerivedSpec {
2889                spec: previous_spec,
2890                declared_files: None,
2891            })
2892        }
2893        _ => {
2894            let url = urls_by_exercise_type
2895                .get(&task_update.exercise_type)
2896                .ok_or_else(|| {
2897                    model_err!(
2898                        PreconditionFailedWithCMSAnchorBlockId {
2899                            id: cms_block_id,
2900                            description: "Missing exercise type for exercise task.",
2901                        },
2902                        "Missing exercise type for exercise task.".to_string()
2903                    )
2904                })?
2905                .clone();
2906            let res = spec_fetcher(
2907                url,
2908                &task_update.exercise_type,
2909                task_update.private_spec.as_ref(),
2910            )
2911            .await?;
2912            if !declares_spec_files {
2913                return Ok(DerivedSpec {
2914                    spec: Some(res),
2915                    declared_files: None,
2916                });
2917            }
2918            // A declaring service answers with an envelope, so the same body cannot be read both
2919            // ways: failing loudly beats storing an envelope as if it were the spec.
2920            let response: DerivedSpecResponse = serde_json::from_value(res).map_err(|err| {
2921                model_err!(
2922                    PreconditionFailedWithCMSAnchorBlockId {
2923                        id: cms_block_id,
2924                        description: "Exercise service declares spec files but did not answer with a spec and its files.",
2925                    },
2926                    format!(
2927                        "Exercise service {} declares spec files, so its derived spec must be a spec and its files: {err}",
2928                        task_update.exercise_type
2929                    )
2930                )
2931            })?;
2932            Ok(DerivedSpec {
2933                spec: match response.spec {
2934                    serde_json::Value::Null => None,
2935                    spec => Some(spec),
2936                },
2937                declared_files: Some(response.files),
2938            })
2939        }
2940    }
2941}
2942
2943pub async fn insert_new_content_page(
2944    conn: &mut PgConnection,
2945    new_page: NewPage,
2946    user: Uuid,
2947    spec_fetcher: impl SpecFetcher,
2948    fetch_service_info: impl Fn(Url) -> BoxFuture<'static, ModelResult<ExerciseServiceInfoApi>>,
2949) -> ModelResult<Page> {
2950    let mut tx = conn.begin().await?;
2951
2952    let course_material_content = vec![GutenbergBlock::hero_section(new_page.title.trim(), "")];
2953
2954    let content_page = NewPage {
2955        chapter_id: new_page.chapter_id,
2956        content: course_material_content,
2957        course_id: new_page.course_id,
2958        exam_id: None,
2959        front_page_of_chapter_id: None,
2960        title: new_page.title,
2961        url_path: new_page.url_path,
2962        exercises: vec![],
2963        exercise_slides: vec![],
2964        exercise_tasks: vec![],
2965        content_search_language: None,
2966        hidden: new_page.hidden,
2967    };
2968    let page = crate::pages::insert_page(
2969        &mut tx,
2970        content_page,
2971        user,
2972        spec_fetcher,
2973        fetch_service_info,
2974    )
2975    .await?;
2976
2977    tx.commit().await?;
2978    Ok(page)
2979}
2980
2981pub async fn insert_page(
2982    conn: &mut PgConnection,
2983    new_page: NewPage,
2984    author: Uuid,
2985    spec_fetcher: impl SpecFetcher,
2986    fetch_service_info: impl Fn(Url) -> BoxFuture<'static, ModelResult<ExerciseServiceInfoApi>>,
2987) -> ModelResult<Page> {
2988    let normalized_url_path = normalize_url_path_for_storage(&new_page.url_path);
2989    let mut page_language_group_id = None;
2990    if let Some(course_id) = new_page.course_id {
2991        // insert language group
2992        let course = crate::courses::get_course(&mut *conn, course_id).await?;
2993        let new_language_group_id = crate::page_language_groups::insert(
2994            &mut *conn,
2995            crate::PKeyPolicy::Generate,
2996            course.course_language_group_id,
2997        )
2998        .await?;
2999        page_language_group_id = Some(new_language_group_id);
3000    }
3001
3002    let next_order_number = match (new_page.chapter_id, new_page.course_id) {
3003        (Some(id), _) => get_next_page_order_number_in_chapter(conn, id).await?,
3004        (None, Some(course_id)) => {
3005            get_next_order_number_for_courses_top_level_pages(conn, course_id).await?
3006        }
3007        (None, None) => 1,
3008    };
3009
3010    let course: OptionFuture<_> = new_page
3011        .course_id
3012        .map(|id| crate::courses::get_course(conn, id))
3013        .into();
3014    let course = course.await.transpose()?;
3015
3016    let content = replace_duplicate_client_ids(new_page.content.clone());
3017
3018    let mut tx = conn.begin().await?;
3019
3020    let content_search_language = course
3021        .and_then(|c| c.content_search_language)
3022        .or(new_page.content_search_language)
3023        .unwrap_or_else(|| "simple".to_string());
3024    let page = sqlx::query_as!(
3025        Page,
3026        r#"
3027INSERT INTO pages(
3028    course_id,
3029    exam_id,
3030    content,
3031    url_path,
3032    title,
3033    order_number,
3034    chapter_id,
3035    content_search_language,
3036    page_language_group_id,
3037    hidden
3038  )
3039VALUES($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
3040RETURNING id,
3041  created_at,
3042  updated_at,
3043  course_id,
3044  exam_id,
3045  chapter_id,
3046  url_path,
3047  title,
3048  deleted_at,
3049  content,
3050  order_number,
3051  copied_from,
3052  pages.hidden,
3053  page_language_group_id
3054          "#,
3055        new_page.course_id,
3056        new_page.exam_id,
3057        serde_json::to_value(content)?,
3058        normalized_url_path,
3059        new_page.title.trim(),
3060        next_order_number,
3061        new_page.chapter_id,
3062        content_search_language as _,
3063        page_language_group_id,
3064        new_page.hidden,
3065    )
3066    .fetch_one(&mut *tx)
3067    .await?;
3068
3069    let parsed_content: Vec<GutenbergBlock> = serde_json::from_value(page.content)?;
3070
3071    let cms_page = update_page(
3072        &mut tx,
3073        PageUpdateArgs {
3074            page_id: page.id,
3075            author,
3076            cms_page_update: CmsPageUpdate {
3077                content: parsed_content,
3078                exercises: new_page.exercises,
3079                exercise_slides: new_page.exercise_slides,
3080                exercise_tasks: new_page.exercise_tasks,
3081                url_path: page.url_path,
3082                title: page.title,
3083                chapter_id: page.chapter_id,
3084                hidden: new_page.hidden,
3085            },
3086            retain_ids: false,
3087            history_change_reason: HistoryChangeReason::PageSaved,
3088            is_exam_page: new_page.exam_id.is_some(),
3089        },
3090        spec_fetcher,
3091        fetch_service_info,
3092    )
3093    .await?;
3094
3095    if let Some(front_page_of_chapter_id) = new_page.front_page_of_chapter_id {
3096        let _res = sqlx::query_as!(
3097            DatabaseChapter,
3098            r#"
3099UPDATE chapters
3100SET front_page_id = $1
3101WHERE id = $2
3102RETURNING *;
3103        "#,
3104            page.id,
3105            front_page_of_chapter_id
3106        )
3107        // this should fail if no rows returned
3108        .fetch_one(&mut *tx)
3109        .await?;
3110    }
3111
3112    tx.commit().await?;
3113    Ok(Page {
3114        content: cms_page.page.content,
3115        course_id: page.course_id,
3116        exam_id: page.exam_id,
3117        created_at: page.created_at,
3118        updated_at: page.updated_at,
3119        deleted_at: page.deleted_at,
3120        id: page.id,
3121        title: cms_page.page.title,
3122        url_path: cms_page.page.url_path,
3123        order_number: page.order_number,
3124        chapter_id: page.chapter_id,
3125        copied_from: page.copied_from,
3126        hidden: page.hidden,
3127        page_language_group_id: page.page_language_group_id,
3128    })
3129}
3130
3131pub async fn create_for_course_id(
3132    conn: &mut PgConnection,
3133    course_id: Uuid,
3134    new_page: NewPage,
3135    author: Uuid,
3136    spec_fetcher: impl SpecFetcher,
3137    fetch_service_info: impl Fn(Url) -> BoxFuture<'static, ModelResult<ExerciseServiceInfoApi>>,
3138) -> ModelResult<Page> {
3139    if new_page.course_id != Some(course_id) || new_page.exam_id.is_some() {
3140        return Err(model_err!(
3141            PreconditionFailed,
3142            "Page must be created for the expected course".to_string()
3143        ));
3144    }
3145
3146    let mut tx = conn.begin().await?;
3147
3148    if let Some(chapter_id) = new_page.chapter_id {
3149        let chapter = get_chapter(&mut tx, chapter_id).await?;
3150        if chapter.course_id != course_id {
3151            return Err(model_err!(
3152                PreconditionFailed,
3153                "Chapter must belong to the expected course".to_string()
3154            ));
3155        }
3156    }
3157
3158    if let Some(front_page_of_chapter_id) = new_page.front_page_of_chapter_id {
3159        let chapter = get_chapter(&mut tx, front_page_of_chapter_id).await?;
3160        if chapter.course_id != course_id {
3161            return Err(model_err!(
3162                PreconditionFailed,
3163                "Chapter must belong to the expected course".to_string()
3164            ));
3165        }
3166        if new_page.chapter_id.is_none() || new_page.chapter_id != new_page.front_page_of_chapter_id
3167        {
3168            return Err(model_err!(
3169                PreconditionFailed,
3170                "Page chapter_id must match front_page_of_chapter_id".to_string()
3171            ));
3172        }
3173    }
3174
3175    let mut new_page = new_page;
3176    if new_page.content.is_empty() {
3177        new_page.content = vec![GutenbergBlock::hero_section(new_page.title.trim(), "")];
3178    }
3179
3180    let page = insert_page(&mut tx, new_page, author, spec_fetcher, fetch_service_info).await?;
3181    tx.commit().await?;
3182    Ok(page)
3183}
3184
3185pub async fn delete_page_and_exercises(
3186    conn: &mut PgConnection,
3187    page_id: Uuid,
3188    author: Uuid,
3189) -> ModelResult<Page> {
3190    let mut tx = conn.begin().await?;
3191
3192    let is_chapter_front_page = sqlx::query_scalar!(
3193        r#"
3194SELECT EXISTS(
3195  SELECT 1 FROM chapters WHERE front_page_id = $1 AND deleted_at IS NULL
3196) AS "exists!"
3197        "#,
3198        page_id
3199    )
3200    .fetch_one(&mut *tx)
3201    .await?;
3202    if is_chapter_front_page {
3203        return Err(model_err!(
3204            InvalidRequest,
3205            "Cannot delete a chapter's front page".to_string()
3206        ));
3207    }
3208
3209    let is_course_front_page = sqlx::query_scalar!(
3210        r#"
3211SELECT EXISTS(
3212  SELECT 1 FROM pages
3213  WHERE id = $1
3214    AND deleted_at IS NULL
3215    AND chapter_id IS NULL
3216    AND course_id IS NOT NULL
3217    AND trim(url_path) = '/'
3218) AS "exists!"
3219        "#,
3220        page_id
3221    )
3222    .fetch_one(&mut *tx)
3223    .await?;
3224    if is_course_front_page {
3225        return Err(model_err!(
3226            InvalidRequest,
3227            "Cannot delete a course's front page".to_string()
3228        ));
3229    }
3230
3231    let page = sqlx::query_as!(
3232        Page,
3233        r#"
3234UPDATE pages
3235SET deleted_at = now()
3236WHERE id = $1
3237AND deleted_at IS NULL
3238RETURNING id,
3239  created_at,
3240  updated_at,
3241  course_id,
3242  exam_id,
3243  chapter_id,
3244  url_path,
3245  title,
3246  deleted_at,
3247  content,
3248  order_number,
3249  copied_from,
3250  hidden,
3251  page_language_group_id
3252          "#,
3253        page_id,
3254    )
3255    .fetch_one(&mut *tx)
3256    .await?;
3257
3258    sqlx::query!(
3259        r#"
3260  UPDATE exercises
3261  SET deleted_at = now()
3262  WHERE page_id = $1
3263  AND deleted_at IS NULL
3264          "#,
3265        page_id,
3266    )
3267    .execute(&mut *tx)
3268    .await?;
3269
3270    sqlx::query!(
3271        "
3272UPDATE exercise_slides
3273SET deleted_at = now()
3274WHERE exercise_id IN (
3275    SELECT id
3276    FROM exercises
3277    WHERE page_id = $1
3278  )
3279  AND deleted_at IS NULL;
3280        ",
3281        page.id
3282    )
3283    .execute(&mut *tx)
3284    .await?;
3285
3286    sqlx::query!(
3287        r#"
3288UPDATE exercise_tasks
3289SET deleted_at = now()
3290WHERE exercise_slide_id IN (
3291    SELECT s.id
3292    FROM exercise_slides s
3293      JOIN exercises e ON (s.exercise_id = e.id)
3294    WHERE e.page_id = $1
3295  )
3296  AND deleted_at IS NULL;
3297            "#,
3298        page.id
3299    )
3300    .execute(&mut *tx)
3301    .await?;
3302
3303    let history_content = PageHistoryContent {
3304        content: json!(null),
3305        exercises: Vec::new(),
3306        exercise_slides: Vec::new(),
3307        exercise_tasks: Vec::new(),
3308        peer_or_self_review_configs: Vec::new(),
3309        peer_or_self_review_questions: Vec::new(),
3310    };
3311    crate::page_history::insert(
3312        &mut tx,
3313        PKeyPolicy::Generate,
3314        page.id,
3315        &page.title,
3316        &history_content,
3317        HistoryChangeReason::PageDeleted,
3318        author,
3319        None,
3320    )
3321    .await?;
3322
3323    tx.commit().await?;
3324    Ok(page)
3325}
3326
3327pub async fn get_chapters_pages_with_exercises(
3328    conn: &mut PgConnection,
3329    chapters_id: Uuid,
3330) -> ModelResult<Vec<PageWithExercises>> {
3331    let chapter_pages = sqlx::query_as!(
3332        Page,
3333        r#"
3334SELECT id,
3335  created_at,
3336  updated_at,
3337  course_id,
3338  exam_id,
3339  chapter_id,
3340  url_path,
3341  title,
3342  deleted_at,
3343  content,
3344  order_number,
3345  copied_from,
3346  hidden,
3347  page_language_group_id
3348FROM pages
3349WHERE chapter_id = $1
3350  AND deleted_at IS NULL
3351        "#,
3352        chapters_id
3353    )
3354    .fetch_all(&mut *conn)
3355    .await?;
3356    let page_ids: Vec<Uuid> = chapter_pages.iter().map(|page| page.id).collect();
3357    let pages_exercises = sqlx::query_as!(
3358        Exercise,
3359        r#"
3360SELECT *
3361FROM exercises
3362WHERE page_id IN (
3363    SELECT UNNEST($1::uuid [])
3364  )
3365  AND deleted_at IS NULL
3366        "#,
3367        &page_ids
3368    )
3369    .fetch_all(&mut *conn)
3370    .await?;
3371
3372    let mut page_to_exercises: HashMap<Uuid, Vec<Exercise>> = pages_exercises
3373        .into_iter()
3374        .into_group_map_by(|exercise| exercise.page_id);
3375    let mut chapter_pages_with_exercises: Vec<PageWithExercises> = chapter_pages
3376        .into_iter()
3377        .map(|page| {
3378            let page_id = page.id;
3379            let mut exercises = page_to_exercises.remove(&page_id).unwrap_or_default();
3380
3381            exercises.sort_by_key(|a| a.order_number);
3382            PageWithExercises { page, exercises }
3383        })
3384        .collect();
3385
3386    chapter_pages_with_exercises.sort_by_key(|a| a.page.order_number);
3387
3388    Ok(chapter_pages_with_exercises)
3389}
3390
3391pub async fn get_next_page(
3392    conn: &mut PgConnection,
3393    page_id: Uuid,
3394    visibility: PageVisibility,
3395) -> ModelResult<Option<PageRoutingData>> {
3396    let page_metadata = get_current_page_metadata(conn, page_id).await?;
3397    let next_page = get_next_page_by_order_number(conn, &page_metadata, visibility).await?;
3398
3399    match next_page {
3400        Some(next_page) => Ok(Some(next_page)),
3401        None => {
3402            let first_page =
3403                get_next_page_by_chapter_number(conn, &page_metadata, visibility).await?;
3404            Ok(first_page)
3405        }
3406    }
3407}
3408
3409async fn get_current_page_metadata(
3410    conn: &mut PgConnection,
3411    page_id: Uuid,
3412) -> ModelResult<PageMetadata> {
3413    let page_metadata = sqlx::query_as!(
3414        PageMetadata,
3415        r#"
3416-- Nullability is stated per column rather than inferred: sqlx-cli 0.9.0 derives a reversed
3417-- nullable array for this query, which makes every offline build of it fail.
3418SELECT p.id as "page_id!",
3419  p.order_number as "order_number!",
3420  p.course_id as "course_id?",
3421  p.exam_id as "exam_id?",
3422  c.id as "chapter_id?",
3423  c.chapter_number as "chapter_number?"
3424FROM pages p
3425  LEFT JOIN chapters c ON p.chapter_id = c.id
3426WHERE p.id = $1;
3427"#,
3428        page_id
3429    )
3430    .fetch_one(conn)
3431    .await?;
3432
3433    if page_metadata.chapter_number.is_none() {
3434        return Err(model_err!(
3435            InvalidRequest,
3436            "Page is not related to any chapter".to_string()
3437        ));
3438    }
3439
3440    Ok(page_metadata)
3441}
3442
3443async fn get_next_page_by_order_number(
3444    conn: &mut PgConnection,
3445    current_page_metadata: &PageMetadata,
3446    visibility: PageVisibility,
3447) -> ModelResult<Option<PageRoutingData>> {
3448    let inverse_visibility_filter = visibility.get_inverse_visibility_filter();
3449    let next_page = sqlx::query_as!(
3450        PageRoutingData,
3451        r#"
3452SELECT p.url_path as url_path,
3453  p.title as title,
3454  p.id as page_id,
3455  c.chapter_number as chapter_number,
3456  c.id as chapter_id,
3457  c.opens_at as chapter_opens_at,
3458  c.front_page_id as chapter_front_page_id
3459FROM pages p
3460  LEFT JOIN chapters c ON p.chapter_id = c.id
3461WHERE p.order_number = (
3462    SELECT MIN(pa.order_number)
3463    FROM pages pa
3464    WHERE pa.order_number > $1
3465      AND pa.deleted_at IS NULL
3466      AND pa.hidden IS DISTINCT FROM $4
3467      AND pa.course_id = p.course_id
3468      AND pa.chapter_id IS NOT DISTINCT FROM p.chapter_id
3469  )
3470  AND p.course_id = $2
3471  AND c.chapter_number = $3
3472  AND p.deleted_at IS NULL
3473  AND p.hidden IS DISTINCT FROM $4;
3474        "#,
3475        current_page_metadata.order_number,
3476        current_page_metadata.course_id,
3477        current_page_metadata.chapter_number,
3478        inverse_visibility_filter,
3479    )
3480    .fetch_optional(conn)
3481    .await?;
3482
3483    Ok(next_page)
3484}
3485
3486async fn get_next_page_by_chapter_number(
3487    conn: &mut PgConnection,
3488    current_page_metadata: &PageMetadata,
3489    visibility: PageVisibility,
3490) -> ModelResult<Option<PageRoutingData>> {
3491    let inverse_visibility_filter = visibility.get_inverse_visibility_filter();
3492    let next_page = sqlx::query_as!(
3493        PageRoutingData,
3494        r#"
3495SELECT p.url_path as url_path,
3496  p.title as title,
3497  p.id as page_id,
3498  c.chapter_number as chapter_number,
3499  c.id as chapter_id,
3500  c.opens_at as chapter_opens_at,
3501  c.front_page_id as chapter_front_page_id
3502FROM chapters c
3503  INNER JOIN pages p on c.id = p.chapter_id
3504WHERE c.chapter_number = (
3505    SELECT MIN(ca.chapter_number)
3506    FROM chapters ca
3507    WHERE ca.chapter_number > $1
3508      AND ca.deleted_at IS NULL
3509  )
3510  AND c.course_id = $2
3511  AND p.deleted_at IS NULL
3512  AND p.hidden IS DISTINCT FROM $3
3513ORDER BY p.order_number
3514LIMIT 1;
3515        "#,
3516        current_page_metadata.chapter_number,
3517        current_page_metadata.course_id,
3518        inverse_visibility_filter,
3519    )
3520    .fetch_optional(conn)
3521    .await?;
3522
3523    Ok(next_page)
3524}
3525
3526async fn get_next_page_order_number_in_chapter(
3527    conn: &mut PgConnection,
3528    chapter_id: Uuid,
3529) -> ModelResult<i32> {
3530    let next_order_number = sqlx::query!(
3531        "
3532select max(p.order_number) as order_number
3533from pages p
3534where p.chapter_id = $1
3535  and p.deleted_at is null;
3536",
3537        chapter_id
3538    )
3539    .fetch_one(conn)
3540    .await?;
3541
3542    match next_order_number.order_number {
3543        Some(order_number) => Ok(order_number + 1),
3544        None => Ok(0),
3545    }
3546}
3547
3548pub async fn get_page_navigation_data(
3549    conn: &mut PgConnection,
3550    page_id: Uuid,
3551    visibility: PageVisibility,
3552) -> ModelResult<PageNavigationInformation> {
3553    let previous_page_data = get_previous_page(conn, page_id, visibility).await?;
3554
3555    let next_page_data = get_next_page(conn, page_id, visibility).await?;
3556
3557    let chapter_front_page = get_chapter_front_page_by_page_id(conn, page_id).await?;
3558    // This may be different from the chapter of the previous page and the chapter of the next page so we need to fetch it to be sure.
3559    let chapter_front_page_chapter = OptionFuture::from(
3560        chapter_front_page
3561            .clone()
3562            .and_then(|front_page| front_page.chapter_id)
3563            .map(|chapter_id| chapters::get_chapter(conn, chapter_id)),
3564    )
3565    .await
3566    .transpose()?;
3567
3568    let chapter_front_page = chapter_front_page
3569        .map(|front_page| -> ModelResult<_> {
3570            if let Some(chapter_front_page_chapter) = chapter_front_page_chapter {
3571                Ok(PageRoutingData {
3572                    url_path: front_page.url_path,
3573                    title: front_page.title,
3574                    page_id: front_page.id,
3575                    chapter_number: chapter_front_page_chapter.chapter_number,
3576                    chapter_id: chapter_front_page_chapter.id,
3577                    chapter_opens_at: chapter_front_page_chapter.opens_at,
3578                    chapter_front_page_id: Some(front_page.id),
3579                })
3580            } else {
3581                Err(model_err!(
3582                    InvalidRequest,
3583                    "Chapter front page chapter not found".to_string()
3584                ))
3585            }
3586        })
3587        .transpose()?;
3588    Ok(PageNavigationInformation {
3589        chapter_front_page,
3590        next_page: next_page_data,
3591        previous_page: previous_page_data,
3592    })
3593}
3594
3595pub async fn get_previous_page(
3596    conn: &mut PgConnection,
3597    page_id: Uuid,
3598    visibility: PageVisibility,
3599) -> ModelResult<Option<PageRoutingData>> {
3600    let page_metadata = get_current_page_metadata(conn, page_id).await?;
3601    let previous_page = get_previous_page_by_order_number(conn, &page_metadata, visibility).await?;
3602
3603    match previous_page {
3604        Some(previous_page) => Ok(Some(previous_page)),
3605        None => {
3606            let first_page =
3607                get_previous_page_by_chapter_number(conn, &page_metadata, visibility).await?;
3608            Ok(first_page)
3609        }
3610    }
3611}
3612
3613pub async fn get_chapter_front_page_by_page_id(
3614    conn: &mut PgConnection,
3615    page_id: Uuid,
3616) -> ModelResult<Option<Page>> {
3617    let page_metadata = get_current_page_metadata(conn, page_id).await?;
3618    let chapter = chapters::get_chapter_info_by_page_metadata(conn, &page_metadata).await?;
3619    let page_option_future: OptionFuture<_> = chapter
3620        .chapter_front_page_id
3621        .map(|chapter_front_page_id| get_page(conn, chapter_front_page_id))
3622        .into();
3623    let page = page_option_future.await.transpose()?;
3624    Ok(page)
3625}
3626
3627async fn get_previous_page_by_order_number(
3628    conn: &mut PgConnection,
3629    current_page_metadata: &PageMetadata,
3630    visibility: PageVisibility,
3631) -> ModelResult<Option<PageRoutingData>> {
3632    let inverse_visibility_filter = visibility.get_inverse_visibility_filter();
3633    let previous_page = sqlx::query_as!(
3634        PageRoutingData,
3635        r#"
3636SELECT p.url_path as url_path,
3637  p.title as title,
3638  c.chapter_number as chapter_number,
3639  p.id as page_id,
3640  c.id as chapter_id,
3641  c.opens_at as chapter_opens_at,
3642  c.front_page_id as chapter_front_page_id
3643FROM pages p
3644  LEFT JOIN chapters c ON p.chapter_id = c.id
3645WHERE p.order_number = (
3646    SELECT MAX(pa.order_number)
3647    FROM pages pa
3648    WHERE pa.order_number < $1
3649      AND pa.deleted_at IS NULL
3650      AND pa.hidden IS DISTINCT FROM $4
3651      AND pa.course_id = p.course_id
3652      AND pa.chapter_id IS NOT DISTINCT FROM p.chapter_id
3653  )
3654  AND p.course_id = $2
3655  AND c.chapter_number = $3
3656  AND p.deleted_at IS NULL
3657  AND p.hidden IS DISTINCT FROM $4;
3658        "#,
3659        current_page_metadata.order_number,
3660        current_page_metadata.course_id,
3661        current_page_metadata.chapter_number,
3662        inverse_visibility_filter,
3663    )
3664    .fetch_optional(conn)
3665    .await?;
3666
3667    Ok(previous_page)
3668}
3669
3670async fn get_previous_page_by_chapter_number(
3671    conn: &mut PgConnection,
3672    current_page_metadata: &PageMetadata,
3673    visibility: PageVisibility,
3674) -> ModelResult<Option<PageRoutingData>> {
3675    let inverse_visibility_filter = visibility.get_inverse_visibility_filter();
3676    let previous_page = sqlx::query_as!(
3677        PageRoutingData,
3678        r#"
3679SELECT p.url_path AS url_path,
3680  p.title AS title,
3681  p.id AS page_id,
3682  c.chapter_number AS chapter_number,
3683  c.id AS chapter_id,
3684  c.opens_at AS chapter_opens_at,
3685  c.front_page_id AS chapter_front_page_id
3686FROM chapters c
3687  INNER JOIN pages p ON c.id = p.chapter_id
3688WHERE c.chapter_number = (
3689    SELECT MAX(ca.chapter_number)
3690    FROM chapters ca
3691    WHERE ca.chapter_number < $1
3692      AND ca.deleted_at IS NULL
3693  )
3694  AND c.course_id = $2
3695  AND p.deleted_at IS NULL
3696  AND p.hidden IS DISTINCT FROM $3
3697ORDER BY p.order_number DESC
3698LIMIT 1;
3699        "#,
3700        current_page_metadata.chapter_number,
3701        current_page_metadata.course_id,
3702        inverse_visibility_filter,
3703    )
3704    .fetch_optional(conn)
3705    .await?;
3706
3707    Ok(previous_page)
3708}
3709
3710async fn get_next_order_number_for_courses_top_level_pages(
3711    conn: &mut PgConnection,
3712    course_id: Uuid,
3713) -> ModelResult<i32> {
3714    let next_order_number = sqlx::query!(
3715        "
3716select max(p.order_number) as order_number
3717from pages p
3718where p.course_id = $1
3719  and p.chapter_id is null
3720  and p.deleted_at is null;
3721",
3722        course_id
3723    )
3724    .fetch_one(conn)
3725    .await?;
3726
3727    match next_order_number.order_number {
3728        Some(order_number) => Ok(order_number + 1),
3729        None => Ok(0),
3730    }
3731}
3732
3733pub async fn get_chapter_pages(
3734    conn: &mut PgConnection,
3735    chapter_id: Uuid,
3736) -> ModelResult<Vec<Page>> {
3737    let pages = sqlx::query_as!(
3738        Page,
3739        "
3740SELECT id,
3741  created_at,
3742  updated_at,
3743  course_id,
3744  exam_id,
3745  chapter_id,
3746  url_path,
3747  title,
3748  deleted_at,
3749  content,
3750  order_number,
3751  copied_from,
3752  hidden,
3753  page_language_group_id
3754FROM pages p
3755WHERE p.chapter_id = $1
3756  AND p.deleted_at IS NULL;
3757    ",
3758        chapter_id
3759    )
3760    .fetch_all(conn)
3761    .await?;
3762
3763    Ok(pages)
3764}
3765
3766pub struct PageIdAndUrlPath {
3767    pub id: Uuid,
3768    pub url_path: String,
3769}
3770
3771/// Like [get_chapter_pages], projected down to `id` and `url_path` for callers that only rewrite
3772/// paths and would otherwise pull every page's `content` JSONB for nothing.
3773pub async fn get_chapter_page_paths(
3774    conn: &mut PgConnection,
3775    chapter_id: Uuid,
3776) -> ModelResult<Vec<PageIdAndUrlPath>> {
3777    let pages = sqlx::query_as!(
3778        PageIdAndUrlPath,
3779        "
3780SELECT id,
3781  url_path
3782FROM pages
3783WHERE chapter_id = $1
3784  AND deleted_at IS NULL;
3785    ",
3786        chapter_id
3787    )
3788    .fetch_all(conn)
3789    .await?;
3790
3791    Ok(pages)
3792}
3793
3794pub async fn get_chapters_visible_pages_exclude_main_frontpage(
3795    conn: &mut PgConnection,
3796    chapter_id: Uuid,
3797) -> ModelResult<Vec<Page>> {
3798    let pages = sqlx::query_as!(
3799        Page,
3800        "
3801SELECT id,
3802  created_at,
3803  updated_at,
3804  course_id,
3805  exam_id,
3806  chapter_id,
3807  url_path,
3808  title,
3809  deleted_at,
3810  content,
3811  order_number,
3812  copied_from,
3813  hidden,
3814  page_language_group_id
3815FROM pages p
3816WHERE p.chapter_id = $1
3817  AND p.deleted_at IS NULL
3818  AND p.hidden IS FALSE
3819  AND p.url_path IS NOT NULL
3820  AND p.id NOT IN (
3821    SELECT front_page_id
3822    FROM chapters c
3823    WHERE c.front_page_id = p.id
3824  );
3825    ",
3826        chapter_id
3827    )
3828    .fetch_all(conn)
3829    .await?;
3830    Ok(pages)
3831}
3832
3833/**
3834Returns search results for a phrase i.e. looks for matches where the words come up right after each other
3835*/
3836pub async fn get_page_search_results_for_phrase(
3837    conn: &mut PgConnection,
3838    course_id: Uuid,
3839    page_search_request: &SearchRequest,
3840) -> ModelResult<Vec<PageSearchResult>> {
3841    get_page_search_results(
3842        conn,
3843        course_id,
3844        page_search_request,
3845        PageSearchQueryType::Phrase,
3846    )
3847    .await
3848}
3849
3850/**
3851Returns search results for the given words. The words can appear in the source document in any order.
3852*/
3853pub async fn get_page_search_results_for_words(
3854    conn: &mut PgConnection,
3855    course_id: Uuid,
3856    page_search_request: &SearchRequest,
3857) -> ModelResult<Vec<PageSearchResult>> {
3858    get_page_search_results(
3859        conn,
3860        course_id,
3861        page_search_request,
3862        PageSearchQueryType::Words,
3863    )
3864    .await
3865}
3866
3867#[derive(Debug, sqlx::FromRow)]
3868struct RawPageSearchResult {
3869    id: Uuid,
3870    title_headline: Option<String>,
3871    rank: Option<f32>,
3872    url_path: String,
3873    chapter_name: Option<String>,
3874    content: Value,
3875}
3876
3877#[derive(Debug, sqlx::FromRow)]
3878struct SearchContentHeadlineRow {
3879    content_headline: Option<String>,
3880}
3881
3882#[derive(Clone, Copy)]
3883enum PageSearchQueryType {
3884    Phrase,
3885    Words,
3886}
3887
3888fn extract_searchable_text_from_content_value(content: &Value) -> String {
3889    let mut searchable_parts = Vec::new();
3890    collect_searchable_text_from_content_value(content, None, &mut searchable_parts);
3891    searchable_parts.join(" ")
3892}
3893
3894fn collect_searchable_text_from_content_value(
3895    value: &Value,
3896    current_key: Option<&str>,
3897    searchable_parts: &mut Vec<String>,
3898) {
3899    match value {
3900        Value::Object(map) => {
3901            for (key, value) in map {
3902                collect_searchable_text_from_content_value(value, Some(key), searchable_parts);
3903            }
3904        }
3905        Value::Array(values) => {
3906            for value in values {
3907                collect_searchable_text_from_content_value(value, None, searchable_parts);
3908            }
3909        }
3910        Value::String(value) if matches!(current_key, Some("content" | "title" | "subtitle")) => {
3911            searchable_parts.push(value.clone());
3912        }
3913        _ => {}
3914    }
3915}
3916
3917fn sanitized_searchable_text_for_public_page(content: &Value) -> String {
3918    let Ok(blocks) = serde_json::from_value::<Vec<GutenbergBlock>>(content.clone()) else {
3919        tracing::warn!(
3920            "Failed to deserialize page content for search snippet sanitization. Falling back to raw searchable text."
3921        );
3922        return extract_searchable_text_from_content_value(content);
3923    };
3924
3925    let filtered_blocks = filter_lock_chapter_blocks(blocks, false);
3926    match serde_json::to_value(filtered_blocks) {
3927        Ok(filtered_content) => extract_searchable_text_from_content_value(&filtered_content),
3928        Err(error) => {
3929            tracing::warn!(
3930                "Failed to serialize filtered page content for search snippet sanitization: {}. Falling back to raw searchable text.",
3931                error
3932            );
3933            extract_searchable_text_from_content_value(content)
3934        }
3935    }
3936}
3937
3938async fn get_page_search_results(
3939    conn: &mut PgConnection,
3940    course_id: Uuid,
3941    page_search_request: &SearchRequest,
3942    query_type: PageSearchQueryType,
3943) -> ModelResult<Vec<PageSearchResult>> {
3944    let course = crate::courses::get_course(&mut *conn, course_id).await?;
3945    let content_search_language = course
3946        .content_search_language
3947        .as_deref()
3948        .unwrap_or("simple");
3949
3950    // Last word of the search term needed so that the sql statement can change it to a prefix match.
3951    // Allows the last word to not be fully typed.
3952    let last_word = if let Some(last) = page_search_request
3953        .query
3954        .trim()
3955        .split_ascii_whitespace()
3956        .last()
3957    {
3958        last.to_string()
3959    } else {
3960        return Ok(Vec::new());
3961    };
3962
3963    let query_builder = match query_type {
3964        PageSearchQueryType::Phrase => "phraseto_tsquery",
3965        PageSearchQueryType::Words => "plainto_tsquery",
3966    };
3967    let query_builder_comment = match query_type {
3968        PageSearchQueryType::Phrase =>
3969            "-- Converts the search term to a phrase search with phraseto_tsquery but appends ':*' to the last word so that it
3970    -- becomes a prefix match. This way the search will also contain results when the last word in the search term
3971    -- is only partially typed. Note that if to_tsquery($4) decides to stem the word, the replacement will be skipped.",
3972        PageSearchQueryType::Words =>
3973            "-- Converts the search term to a word search with ands between the words with plainto_tsquery but appends ':*' to the
3974    -- last word so that it becomes a prefix match. This way the search will also contain results when the last word in
3975    -- the search term is only partially typed. Note that if to_tsquery($4) decides to stem the word, the replacement
3976    -- will be skipped.",
3977    };
3978
3979    let search_results_sql = format!(
3980        r#"
3981WITH cte as (
3982    {query_builder_comment}
3983    SELECT ts_rewrite(
3984        {query_builder}($2::regconfig, $3),
3985        to_tsquery($4),
3986        to_tsquery($4 || ':*')
3987    ) as query
3988)
3989SELECT p.id,
3990    ts_rank(
3991        p.content_search,
3992        (
3993            SELECT query
3994            from cte
3995        )
3996    ) as rank,
3997    ts_headline(
3998        $2::regconfig,
3999        p.title,
4000        (
4001            SELECT query
4002            from cte
4003        ),
4004        'MaxFragments=1, MaxWords=20, MinWords=1'
4005    ) as title_headline,
4006    COALESCE(p.url_path, '') as url_path,
4007    c.name as chapter_name,
4008    p.content
4009FROM pages p
4010LEFT JOIN chapters c ON p.chapter_id = c.id
4011WHERE p.course_id = $1
4012    AND p.deleted_at IS NULL
4013    AND p.hidden IS FALSE
4014    AND p.url_path IS NOT NULL
4015    AND p.content_search @@ (
4016        SELECT query
4017        from cte
4018    )
4019ORDER BY rank DESC
4020LIMIT 50;
4021        "#
4022    );
4023
4024    // The formatted fragments are fixed strings selected from PageSearchQueryType; all request data
4025    // remains passed through bind parameters below.
4026    let raw_results = sqlx::query_as::<_, RawPageSearchResult>(AssertSqlSafe(search_results_sql))
4027        .bind(course_id)
4028        .bind(content_search_language)
4029        .bind(&page_search_request.query)
4030        .bind(&last_word)
4031        .fetch_all(&mut *conn)
4032        .await?;
4033
4034    let sanitized_search_texts = raw_results
4035        .iter()
4036        .map(|result| sanitized_searchable_text_for_public_page(&result.content))
4037        .collect::<Vec<_>>();
4038    let content_headlines = build_public_search_content_headlines(
4039        conn,
4040        content_search_language,
4041        &page_search_request.query,
4042        &last_word,
4043        query_type,
4044        &sanitized_search_texts,
4045    )
4046    .await?;
4047
4048    let search_results = raw_results
4049        .into_iter()
4050        .zip(content_headlines)
4051        .map(|(result, content_headline)| PageSearchResult {
4052            id: result.id,
4053            title_headline: result.title_headline,
4054            rank: result.rank,
4055            content_headline,
4056            url_path: result.url_path,
4057            chapter_name: result.chapter_name,
4058        })
4059        .collect::<Vec<_>>();
4060
4061    Ok(add_course_url_prefix_to_search_results(
4062        search_results,
4063        &course,
4064    ))
4065}
4066
4067async fn build_public_search_content_headlines(
4068    conn: &mut PgConnection,
4069    content_search_language: &str,
4070    query: &str,
4071    last_word: &str,
4072    query_type: PageSearchQueryType,
4073    sanitized_search_texts: &[String],
4074) -> ModelResult<Vec<Option<String>>> {
4075    if sanitized_search_texts.is_empty() {
4076        return Ok(Vec::new());
4077    }
4078
4079    let query_builder = match query_type {
4080        PageSearchQueryType::Phrase => "phraseto_tsquery",
4081        PageSearchQueryType::Words => "plainto_tsquery",
4082    };
4083    let query_builder_comment = match query_type {
4084        PageSearchQueryType::Phrase =>
4085            "-- Converts the search term to a phrase search with phraseto_tsquery but appends ':*' to the last word so that it
4086    -- becomes a prefix match. This way the search will also contain results when the last word in the search term
4087    -- is only partially typed. Note that if to_tsquery($3) decides to stem the word, the replacement will be skipped.",
4088        PageSearchQueryType::Words =>
4089            "-- Converts the search term to a word search with ands between the words with plainto_tsquery but appends ':*' to the
4090    -- last word so that it becomes a prefix match. This way the search will also contain results when the last word in
4091    -- the search term is only partially typed. Note that if to_tsquery($3) decides to stem the word, the replacement
4092    -- will be skipped.",
4093    };
4094
4095    let content_headline_sql = format!(
4096        r#"
4097WITH cte as (
4098    {query_builder_comment}
4099    SELECT ts_rewrite(
4100        {query_builder}($1::regconfig, $2),
4101        to_tsquery($3),
4102        to_tsquery($3 || ':*')
4103    ) as query
4104)
4105SELECT ts_headline(
4106    $1::regconfig,
4107    input.content,
4108    (
4109        SELECT query
4110        from cte
4111    ),
4112    'MaxFragments=1, MaxWords=30, MinWords=10'
4113) as content_headline
4114FROM unnest($4::text[]) WITH ORDINALITY AS input(content, ord)
4115ORDER BY ord;
4116        "#
4117    );
4118
4119    Ok(
4120        // The formatted fragments are fixed strings selected from PageSearchQueryType; all request data
4121        // remains passed through bind parameters below.
4122        sqlx::query_as::<_, SearchContentHeadlineRow>(AssertSqlSafe(content_headline_sql))
4123            .bind(content_search_language)
4124            .bind(query)
4125            .bind(last_word)
4126            .bind(sanitized_search_texts.to_vec())
4127            .fetch_all(&mut *conn)
4128            .await?
4129            .into_iter()
4130            .map(|row| row.content_headline)
4131            .collect(),
4132    )
4133}
4134
4135fn add_course_url_prefix_to_search_results(
4136    search_results: Vec<PageSearchResult>,
4137    course: &Course,
4138) -> Vec<PageSearchResult> {
4139    search_results
4140        .into_iter()
4141        .map(|mut sr| {
4142            let optional_slash = if sr.url_path.starts_with('/') {
4143                ""
4144            } else {
4145                "/"
4146            };
4147            sr.url_path = format!("/{}{}{}", course.slug, optional_slash, sr.url_path);
4148            sr
4149        })
4150        .collect()
4151}
4152
4153/// Restore page contents and exercises to a previous revision
4154pub async fn restore(
4155    conn: &mut PgConnection,
4156    page_id: Uuid,
4157    history_id: Uuid,
4158    author: Uuid,
4159    spec_fetcher: impl SpecFetcher,
4160    fetch_service_info: impl Fn(Url) -> BoxFuture<'static, ModelResult<ExerciseServiceInfoApi>>,
4161) -> ModelResult<Uuid> {
4162    // fetch old content
4163    let page = get_page(conn, page_id).await?;
4164    let history_data = page_history::get_history_data(conn, history_id).await?;
4165
4166    let parsed_content: Vec<GutenbergBlock> = serde_json::from_value(history_data.content.content)?;
4167
4168    update_page(
4169        conn,
4170        PageUpdateArgs {
4171            page_id: page.id,
4172            author,
4173            cms_page_update: CmsPageUpdate {
4174                content: parsed_content,
4175                exercises: history_data.content.exercises,
4176                exercise_slides: history_data.content.exercise_slides,
4177                exercise_tasks: history_data.content.exercise_tasks,
4178                url_path: page.url_path,
4179                title: history_data.title,
4180                chapter_id: page.chapter_id,
4181                hidden: page.hidden,
4182            },
4183            retain_ids: true,
4184            history_change_reason: HistoryChangeReason::HistoryRestored,
4185            is_exam_page: history_data.exam_id.is_some(),
4186        },
4187        spec_fetcher,
4188        fetch_service_info,
4189    )
4190    .await?;
4191
4192    Ok(history_id)
4193}
4194
4195#[allow(clippy::too_many_arguments)]
4196pub async fn restore_from_history_for_page_id(
4197    conn: &mut PgConnection,
4198    page_id: Uuid,
4199    history_id: Uuid,
4200    authorized_source_page_id: Option<Uuid>,
4201    expected_course_id: Option<Uuid>,
4202    expected_exam_id: Option<Uuid>,
4203    author: Uuid,
4204    spec_fetcher: impl SpecFetcher,
4205    fetch_service_info: impl Fn(Url) -> BoxFuture<'static, ModelResult<ExerciseServiceInfoApi>>,
4206) -> ModelResult<Uuid> {
4207    let mut tx = conn.begin().await?;
4208    let validated_source_page_id = if let Some(source_page_id) = authorized_source_page_id {
4209        let source_page = sqlx::query!(
4210            r#"
4211SELECT p.id
4212FROM pages p
4213WHERE p.id = $1
4214  AND p.deleted_at IS NULL
4215  AND (p.course_id = $2 OR p.exam_id = $3)
4216FOR UPDATE
4217            "#,
4218            source_page_id,
4219            expected_course_id,
4220            expected_exam_id
4221        )
4222        .fetch_optional(&mut *tx)
4223        .await?;
4224        source_page.map(|row| row.id)
4225    } else {
4226        None
4227    };
4228
4229    sqlx::query!(
4230        r#"
4231SELECT p.id
4232FROM pages p
4233WHERE p.id = $1
4234  AND p.deleted_at IS NULL
4235  AND (p.course_id = $2 OR p.exam_id = $3)
4236FOR UPDATE
4237        "#,
4238        page_id,
4239        expected_course_id,
4240        expected_exam_id
4241    )
4242    .fetch_one(&mut *tx)
4243    .await?;
4244
4245    sqlx::query!(
4246        r#"
4247SELECT ph.id
4248FROM page_history ph
4249WHERE ph.id = $1
4250  AND ph.deleted_at IS NULL
4251  AND (
4252    ph.page_id = $2
4253    OR ph.page_id = $3
4254  )
4255FOR UPDATE
4256        "#,
4257        history_id,
4258        page_id,
4259        validated_source_page_id
4260    )
4261    .fetch_one(&mut *tx)
4262    .await?;
4263
4264    let restored = restore(
4265        &mut tx,
4266        page_id,
4267        history_id,
4268        author,
4269        spec_fetcher,
4270        fetch_service_info,
4271    )
4272    .await?;
4273    tx.commit().await?;
4274    Ok(restored)
4275}
4276
4277pub async fn get_organization_id(conn: &mut PgConnection, page_id: Uuid) -> ModelResult<Uuid> {
4278    let res = sqlx::query!(
4279        "
4280SELECT organizations.id
4281FROM pages
4282  LEFT OUTER JOIN courses ON courses.id = pages.course_id
4283  LEFT OUTER JOIN exams ON exams.id = pages.exam_id
4284  JOIN organizations ON organizations.id = courses.organization_id
4285  OR organizations.id = exams.organization_id
4286WHERE pages.id = $1
4287",
4288        page_id,
4289    )
4290    .fetch_one(&mut *conn)
4291    .await?;
4292    Ok(res.id)
4293}
4294
4295pub async fn get_page_chapter_and_course_information(
4296    conn: &mut PgConnection,
4297    page_id: Uuid,
4298) -> ModelResult<PageChapterAndCourseInformation> {
4299    let res = sqlx::query_as!(
4300        PageChapterAndCourseInformation,
4301        r#"
4302SELECT chapters.name as "chapter_name?",
4303  chapters.chapter_number as "chapter_number?",
4304  courses.name as "course_name?",
4305  courses.slug as "course_slug?",
4306  chapters.front_page_id as "chapter_front_page_id?",
4307  p2.url_path as "chapter_front_page_url_path?",
4308  organizations.slug as "organization_slug?"
4309FROM pages
4310  LEFT JOIN chapters on pages.chapter_id = chapters.id
4311  LEFT JOIN courses on pages.course_id = courses.id
4312  LEFT JOIN pages p2 ON chapters.front_page_id = p2.id
4313  LEFT JOIN organizations on courses.organization_id = organizations.id
4314WHERE pages.id = $1
4315"#,
4316        page_id,
4317    )
4318    .fetch_one(&mut *conn)
4319    .await?;
4320    Ok(res)
4321}
4322
4323pub async fn get_page_by_course_id_and_language_group(
4324    conn: &mut PgConnection,
4325    course_id: Uuid,
4326    page_language_group_id: Uuid,
4327) -> ModelResult<Page> {
4328    let page = sqlx::query_as!(
4329        Page,
4330        "
4331SELECT id,
4332    created_at,
4333    updated_at,
4334    course_id,
4335    exam_id,
4336    chapter_id,
4337    url_path,
4338    title,
4339    deleted_at,
4340    content,
4341    order_number,
4342    copied_from,
4343    hidden,
4344    page_language_group_id
4345FROM pages p
4346WHERE p.course_id = $1
4347    AND p.page_language_group_id = $2
4348    AND p.deleted_at IS NULL
4349    ",
4350        course_id,
4351        page_language_group_id
4352    )
4353    .fetch_one(&mut *conn)
4354    .await?;
4355    Ok(page)
4356}
4357
4358/// Makes the order numbers and chapter ids to match in the db what's in the page objects
4359/// Assumes that all pages belong to the given course id
4360/// Also assumes the list of pages includes all nondeleted pages in the course, except chapter front pages, which cannot be moved, otherwise we will end up with random order numbers
4361pub async fn reorder_pages(
4362    conn: &mut PgConnection,
4363    pages: &[Page],
4364    course_id: Uuid,
4365) -> ModelResult<()> {
4366    let db_pages =
4367        get_all_by_course_id_and_visibility(conn, course_id, PageVisibility::Any).await?;
4368    let chapters = get_course_chapters(conn, course_id).await?;
4369
4370    let mut chapter_pages: HashMap<Option<Uuid>, Vec<&Page>> = HashMap::new();
4371
4372    for page in pages {
4373        chapter_pages.entry(page.chapter_id).or_default().push(page);
4374    }
4375
4376    let mut normalized_pages = Vec::with_capacity(pages.len());
4377
4378    for (_, chapter_pages) in chapter_pages.iter() {
4379        // Sort by order_number and then by id for consistency
4380        let mut sorted_pages = chapter_pages.to_vec();
4381        sorted_pages.sort_by(|a, b| {
4382            a.order_number
4383                .cmp(&b.order_number)
4384                .then_with(|| a.id.cmp(&b.id))
4385        });
4386
4387        // Create normalized pages with sequential order numbers
4388        for (idx, &page) in sorted_pages.iter().enumerate() {
4389            let mut normalized_page = page.clone();
4390            normalized_page.order_number = (idx as i32) + 1; // Start at 1, zero is reserved for chapter front pages which cannot be moved and are not passed to this function
4391            normalized_pages.push(normalized_page);
4392        }
4393    }
4394
4395    let mut tx = conn.begin().await?;
4396
4397    // First, randomize ALL page order numbers to avoid conflicts (except chapter front pages, which cannot be moved)
4398    // This is necessary because unique indexes cannot be deferred in PostgreSQL
4399    // The random numbers are temporary and will be replaced with the correct order numbers
4400    sqlx::query!(
4401        "
4402UPDATE pages
4403SET order_number = floor(random() * (2000000 -200000 + 1) + 200000)
4404WHERE course_id = $1
4405  AND order_number != 0
4406  AND deleted_at IS NULL
4407        ",
4408        course_id
4409    )
4410    .execute(&mut *tx)
4411    .await?;
4412
4413    // Now update each page to its corrected order_number, using the normalized pages
4414    for page in normalized_pages {
4415        if let Some(matching_db_page) = db_pages.iter().find(|p| p.id == page.id) {
4416            if matching_db_page.chapter_id == page.chapter_id {
4417                // Chapter not changing - just set the corrected order number
4418                sqlx::query!(
4419                    "UPDATE pages SET order_number = $2 WHERE id = $1",
4420                    page.id,
4421                    page.order_number
4422                )
4423                .execute(&mut *tx)
4424                .await?;
4425            } else {
4426                // Chapter changes - handle URL paths and redirections
4427                if let Some(old_chapter_id) = matching_db_page.chapter_id {
4428                    if let Some(new_chapter_id) = page.chapter_id {
4429                        // Moving page to another chapter
4430                        if let Some(old_chapter) = chapters.iter().find(|o| o.id == old_chapter_id)
4431                        {
4432                            if let Some(new_chapter) =
4433                                chapters.iter().find(|o| o.id == new_chapter_id)
4434                            {
4435                                let old_path = &page.url_path;
4436                                let new_path = old_path.replacen(
4437                                    &old_chapter.chapter_number.to_string(),
4438                                    &new_chapter.chapter_number.to_string(),
4439                                    1,
4440                                );
4441                                sqlx::query!(
4442                                    "UPDATE pages SET url_path = $2, chapter_id = $3, order_number = $4 WHERE pages.id = $1",
4443                                    page.id,
4444                                    new_path,
4445                                    new_chapter.id,
4446                                    page.order_number
4447                                )
4448                                .execute(&mut *tx)
4449                                .await?;
4450                                sqlx::query!(
4451                                    "UPDATE exercises SET chapter_id = $2 WHERE page_id = $1 AND deleted_at IS NULL",
4452                                    page.id,
4453                                    new_chapter.id
4454                                )
4455                                .execute(&mut *tx)
4456                                .await?;
4457                                sqlx::query!(
4458                                    "INSERT INTO url_redirections(destination_page_id, old_url_path, course_id) VALUES ($1, $2, $3)",
4459                                    page.id,
4460                                    old_path,
4461                                    course_id
4462                                )
4463                                .execute(&mut *tx)
4464                                .await?;
4465                            } else {
4466                                return Err(model_err!(
4467                                    InvalidRequest,
4468                                    "New chapter not found".to_string()
4469                                ));
4470                            }
4471                        } else {
4472                            return Err(model_err!(
4473                                InvalidRequest,
4474                                "Old chapter not found".to_string()
4475                            ));
4476                        }
4477                    } else {
4478                        // Moving page from a chapter to a top level page
4479                        return Err(model_err!(
4480                            InvalidRequest,
4481                            "Making a chapter page a top level page is not supported yet"
4482                                .to_string()
4483                        ));
4484                    }
4485                } else {
4486                    error!(
4487                        "Cannot move a top level page to a chapter. matching_db_page.chapter_id: {:?} page.chapter_id: {:?}",
4488                        matching_db_page.chapter_id, page.chapter_id
4489                    );
4490                    // Moving page from the top level to a chapter
4491                    return Err(model_err!(
4492                        InvalidRequest,
4493                        "Moving a top level page to a chapter is not supported yet".to_string()
4494                    ));
4495                }
4496            }
4497        } else {
4498            return Err(model_err!(
4499                InvalidRequest,
4500                format!("Page {} does exist in course {}", page.id, course_id)
4501            ));
4502        }
4503    }
4504    tx.commit().await?;
4505    Ok(())
4506}
4507
4508/// Writes pages' url paths as given, without normalizing them or adding redirections.
4509///
4510/// [reorder_chapters] rewrites paths in two passes and creates the redirections itself, so neither
4511/// belongs here. Use [insert_page] or [update_page] for a path that comes from a user, since those
4512/// canonicalize it.
4513///
4514/// Does not filter on `deleted_at`: a deleted page's path is written too.
4515async fn set_url_paths(
4516    conn: &mut PgConnection,
4517    ids: &[Uuid],
4518    url_paths: &[String],
4519) -> ModelResult<()> {
4520    if ids.is_empty() {
4521        return Ok(());
4522    }
4523    sqlx::query!(
4524        "
4525UPDATE pages
4526SET url_path = v.path
4527FROM unnest($1::uuid[], $2::text[]) AS v(id, path)
4528WHERE pages.id = v.id
4529",
4530        ids,
4531        url_paths
4532    )
4533    .execute(conn)
4534    .await?;
4535    Ok(())
4536}
4537
4538pub async fn reorder_chapters(
4539    conn: &mut PgConnection,
4540    chapters: &[Chapter],
4541    course_id: Uuid,
4542) -> ModelResult<()> {
4543    let db_chapters = get_course_chapters(conn, course_id).await?;
4544    let mut tx = conn.begin().await?;
4545    // Look for the modified chapter in the existing database
4546
4547    // TODO USE CHAPTER ID FOR THE LOOP
4548    for chapter in chapters {
4549        if let Some(matching_db_chapter) = db_chapters.iter().find(|c| c.id == chapter.id)
4550            && let Some(old_chapter) = db_chapters.iter().find(|o| o.id == matching_db_chapter.id)
4551        {
4552            // to avoid conflicting chapter_number when chapter is modified
4553            //Assign random number to modified chapters
4554            sqlx::query!(
4555                "UPDATE chapters
4556                SET chapter_number = floor(random() * (20000000 - 2000000 + 1) + 200000)
4557                WHERE chapters.id = $1
4558                  AND chapters.course_id = $2
4559                  AND deleted_at IS NULL",
4560                matching_db_chapter.id,
4561                course_id
4562            )
4563            .execute(&mut *tx)
4564            .await?;
4565
4566            // get newly modified chapter
4567            let chapter_with_randomized_chapter_number =
4568                get_chapter(&mut tx, matching_db_chapter.id).await?;
4569            let random_chapter_number = chapter_with_randomized_chapter_number.chapter_number;
4570            let pages =
4571                get_chapter_page_paths(&mut tx, chapter_with_randomized_chapter_number.id).await?;
4572
4573            let mut ids = Vec::with_capacity(pages.len());
4574            let mut new_paths = Vec::with_capacity(pages.len());
4575            for page in pages {
4576                let new_path = page.url_path.replacen(
4577                    &old_chapter.chapter_number.to_string(),
4578                    &random_chapter_number.to_string(),
4579                    1,
4580                );
4581                ids.push(page.id);
4582                new_paths.push(new_path);
4583            }
4584            set_url_paths(&mut tx, &ids, &new_paths).await?;
4585        }
4586    }
4587
4588    for chapter in chapters {
4589        if let Some(matching_db_chapter) = db_chapters.iter().find(|c| c.id == chapter.id) {
4590            if let Some(new_chapter) = chapters.iter().find(|o| o.id == matching_db_chapter.id) {
4591                let new_chapter_number = &new_chapter.chapter_number;
4592
4593                let randomized_chapter = get_chapter(&mut tx, chapter.id).await?;
4594
4595                let randomized_chapter_number = randomized_chapter.chapter_number;
4596
4597                // update chapter_number
4598                sqlx::query!(
4599                    "UPDATE chapters SET chapter_number = $2 WHERE chapters.id = $1",
4600                    chapter.id,
4601                    new_chapter_number
4602                )
4603                .execute(&mut *tx)
4604                .await?;
4605
4606                // update all pages url in the modified chapter
4607                let pages = get_chapter_page_paths(&mut tx, chapter.id).await?;
4608
4609                let mut ids = Vec::with_capacity(pages.len());
4610                let mut new_paths = Vec::with_capacity(pages.len());
4611                let mut old_paths = Vec::with_capacity(pages.len());
4612                for page in pages {
4613                    let new_path = page.url_path.replacen(
4614                        &randomized_chapter_number.to_string(),
4615                        &new_chapter_number.to_string(),
4616                        1,
4617                    );
4618                    let old_path = page.url_path.replacen(
4619                        &randomized_chapter_number.to_string(),
4620                        &chapter.chapter_number.to_string(),
4621                        1,
4622                    );
4623                    ids.push(page.id);
4624                    new_paths.push(new_path);
4625                    old_paths.push(old_path);
4626                }
4627                set_url_paths(&mut tx, &ids, &new_paths).await?;
4628
4629                for (page_id, old_path) in ids.iter().zip(old_paths.iter()) {
4630                    crate::url_redirections::upsert(
4631                        &mut tx,
4632                        PKeyPolicy::Generate,
4633                        *page_id,
4634                        old_path,
4635                        course_id,
4636                    )
4637                    .await?;
4638                }
4639            } else {
4640                return Err(model_err!(
4641                    InvalidRequest,
4642                    "New chapter not found".to_string()
4643                ));
4644            }
4645        } else {
4646            return Err(model_err!(
4647                InvalidRequest,
4648                "Matching DB chapters not found".to_string()
4649            ));
4650        }
4651    }
4652
4653    tx.commit().await?;
4654    Ok(())
4655}
4656
4657pub async fn is_chapter_front_page(
4658    conn: &mut PgConnection,
4659    page_id: Uuid,
4660) -> ModelResult<IsChapterFrontPage> {
4661    let chapter = get_chapter_by_page_id(conn, page_id).await?;
4662
4663    Ok(chapter.front_page_id.map_or(
4664        IsChapterFrontPage {
4665            is_chapter_front_page: false,
4666        },
4667        |id| IsChapterFrontPage {
4668            is_chapter_front_page: id == page_id,
4669        },
4670    ))
4671}
4672
4673pub async fn update_page_details(
4674    conn: &mut PgConnection,
4675    page_id: Uuid,
4676    page_details_update: &PageDetailsUpdate,
4677) -> ModelResult<()> {
4678    let normalized_url_path = normalize_url_path_for_storage(&page_details_update.url_path);
4679    let mut tx = conn.begin().await?;
4680    let page_before_update = get_page(&mut tx, page_id).await?;
4681    sqlx::query!(
4682        "
4683UPDATE pages
4684SET title = $2,
4685  url_path = $3
4686WHERE id = $1
4687",
4688        page_id,
4689        page_details_update.title,
4690        normalized_url_path,
4691    )
4692    .execute(&mut *tx)
4693    .await?;
4694
4695    if let Some(course_id) = page_before_update.course_id
4696        && page_before_update.url_path != normalized_url_path
4697    {
4698        // Some students might be trying to reach the page with the old url path, so let's redirect them to the new one
4699        crate::url_redirections::upsert(
4700            &mut tx,
4701            PKeyPolicy::Generate,
4702            page_id,
4703            &page_before_update.url_path,
4704            course_id,
4705        )
4706        .await?;
4707    }
4708
4709    tx.commit().await?;
4710    Ok(())
4711}
4712
4713pub async fn get_by_ids_and_visibility(
4714    conn: &mut PgConnection,
4715    ids: &[Uuid],
4716    page_visibility: PageVisibility,
4717) -> ModelResult<Vec<Page>> {
4718    let inverse_visibility_filter = page_visibility.get_inverse_visibility_filter();
4719    let pages = sqlx::query_as!(
4720        Page,
4721        "
4722SELECT id,
4723    created_at,
4724    updated_at,
4725    course_id,
4726    exam_id,
4727    chapter_id,
4728    url_path,
4729    title,
4730    deleted_at,
4731    content,
4732    order_number,
4733    copied_from,
4734    hidden,
4735    page_language_group_id
4736FROM pages
4737WHERE id = ANY($1)
4738    AND hidden IS DISTINCT FROM $2
4739    AND deleted_at IS NULL
4740    ",
4741        ids,
4742        inverse_visibility_filter
4743    )
4744    .fetch_all(conn)
4745    .await?;
4746    Ok(pages)
4747}
4748
4749pub async fn get_by_ids_deleted_and_visibility(
4750    conn: &mut PgConnection,
4751    ids: &[Uuid],
4752    page_visibility: PageVisibility,
4753) -> ModelResult<Vec<Page>> {
4754    let inverse_visibility_filter = page_visibility.get_inverse_visibility_filter();
4755    let pages = sqlx::query_as!(
4756        Page,
4757        "
4758SELECT id,
4759    created_at,
4760    updated_at,
4761    course_id,
4762    exam_id,
4763    chapter_id,
4764    url_path,
4765    title,
4766    deleted_at,
4767    content,
4768    order_number,
4769    copied_from,
4770    hidden,
4771    page_language_group_id
4772FROM pages
4773WHERE id = ANY($1)
4774    AND hidden IS DISTINCT FROM $2
4775    AND deleted_at IS NOT NULL
4776    ",
4777        ids,
4778        inverse_visibility_filter
4779    )
4780    .fetch_all(conn)
4781    .await?;
4782    Ok(pages)
4783}
4784
4785#[cfg(test)]
4786mod test {
4787    use chrono::TimeZone;
4788
4789    use super::*;
4790    use crate::{exams::NewExam, test_helper::*};
4791
4792    #[test]
4793    fn normalizes_decoded_page_paths_for_storage_and_lookup() {
4794        // Unsafe ASCII is stripped (not percent-encoded); whitespace becomes a dash.
4795        assert_eq!(
4796            normalize_url_path_for_storage("/foo bar#part"),
4797            "/foo-barpart"
4798        );
4799        assert_eq!(
4800            normalize_url_path_for_storage("/foo%20bar%23part"),
4801            "/foo-barpart"
4802        );
4803        // A literal (undecodable) percent is part of the unsafe set, so it is stripped too.
4804        assert_eq!(
4805            normalize_url_path_for_storage(" /literal%percent "),
4806            "/literalpercent"
4807        );
4808        assert_eq!(
4809            normalize_url_path_for_storage(" /literal%25percent "),
4810            "/literalpercent"
4811        );
4812    }
4813
4814    #[test]
4815    fn keeps_non_ascii_characters_decoded_for_storage() {
4816        // Valid non-ASCII (Cyrillic) is kept verbatim, regardless of whether the input
4817        // arrives decoded or percent-encoded.
4818        assert_eq!(
4819            normalize_url_path_for_storage("/chapter-1/як"),
4820            "/chapter-1/як"
4821        );
4822        assert_eq!(
4823            normalize_url_path_for_storage("/chapter-1/%D1%8F%D0%BA"),
4824            "/chapter-1/як"
4825        );
4826        // Unsafe ASCII mixed with non-ASCII: the space becomes a dash, the non-ASCII is kept.
4827        assert_eq!(
4828            normalize_url_path_for_storage("/chapter 1/як"),
4829            "/chapter-1/як"
4830        );
4831    }
4832
4833    #[test]
4834    fn drops_invalid_percent_escape_runs_like_the_migration() {
4835        // A `%xx` run that isn't valid UTF-8 is dropped whole (matching safe_percent_decode), not
4836        // turned into U+FFFD, so storage and lookup stay aligned.
4837        assert_eq!(normalize_url_path_for_storage("/%FF"), "/");
4838        assert_eq!(normalize_url_path_for_storage("/%41%FF"), "/");
4839    }
4840
4841    #[test]
4842    fn legacy_fully_encoded_form_encodes_non_ascii() {
4843        assert_eq!(
4844            legacy_fully_encoded_url_path("/chapter-1/як"),
4845            "/chapter-1/%D1%8F%D0%BA"
4846        );
4847        assert_eq!(
4848            legacy_fully_encoded_url_path("/chapter-1/%D1%8F%D0%BA"),
4849            "/chapter-1/%D1%8F%D0%BA"
4850        );
4851    }
4852
4853    #[test]
4854    fn legacy_partially_encoded_form_encodes_only_unsafe_ascii() {
4855        // Unsafe ASCII percent-encoded, non-ASCII kept decoded — the pre-strip storage form.
4856        assert_eq!(
4857            legacy_partially_encoded_url_path("/chapter 1/як"),
4858            "/chapter%201/як"
4859        );
4860    }
4861
4862    #[test]
4863    fn lookup_candidates_cover_stored_forms() {
4864        // ASCII-only safe paths have a single candidate (all forms are identical).
4865        assert_eq!(
4866            url_path_lookup_candidates("/chapter-1/foo"),
4867            vec!["/chapter-1/foo".to_string()]
4868        );
4869        // Pure non-ASCII paths: strip-canonical (== partially-encoded) plus the fully-encoded
4870        // legacy form.
4871        assert_eq!(
4872            url_path_lookup_candidates("/chapter-1/як"),
4873            vec![
4874                "/chapter-1/як".to_string(),
4875                "/chapter-1/%D1%8F%D0%BA".to_string(),
4876            ]
4877        );
4878        // A path mixing unsafe ASCII and non-ASCII has three distinct stored forms to try.
4879        assert_eq!(
4880            url_path_lookup_candidates("/chapter 1/як"),
4881            vec![
4882                "/chapter-1/як".to_string(),
4883                "/chapter%201/як".to_string(),
4884                "/chapter%201/%D1%8F%D0%BA".to_string(),
4885            ]
4886        );
4887    }
4888
4889    #[tokio::test]
4890    async fn finds_pages_with_non_ascii_paths_in_either_stored_form() {
4891        insert_data!(:tx, :user, :org, :course, instance: _instance, :course_module, :chapter);
4892
4893        let new_page = |url_path: &str, title: &str| NewPage {
4894            exercises: vec![],
4895            exercise_slides: vec![],
4896            exercise_tasks: vec![],
4897            content: vec![],
4898            url_path: url_path.to_string(),
4899            title: title.to_string(),
4900            course_id: Some(course),
4901            exam_id: None,
4902            chapter_id: Some(chapter),
4903            front_page_of_chapter_id: None,
4904            content_search_language: None,
4905            hidden: false,
4906        };
4907
4908        // Stored in the decoded-canonical form (raw Cyrillic), as insert_page now stores it.
4909        let raw_page = crate::pages::insert_page(
4910            tx.as_mut(),
4911            new_page("/chapter-1/як", "Raw"),
4912            user,
4913            |_, _, _| unimplemented!(),
4914            |_| unimplemented!(),
4915        )
4916        .await
4917        .unwrap();
4918
4919        // Stored in the legacy fully-encoded form, simulating data written before the
4920        // decoded-canonical form was adopted (insert_page normalizes, so update directly).
4921        let legacy_page = crate::pages::insert_page(
4922            tx.as_mut(),
4923            new_page("/chapter-1/іспит", "Legacy"),
4924            user,
4925            |_, _, _| unimplemented!(),
4926            |_| unimplemented!(),
4927        )
4928        .await
4929        .unwrap();
4930        set_url_paths(
4931            tx.as_mut(),
4932            &[legacy_page.id],
4933            &["/chapter-1/%D1%96%D1%81%D0%BF%D0%B8%D1%82".to_string()],
4934        )
4935        .await
4936        .unwrap();
4937
4938        // Looks the page up through the same helper the production lookup uses, so a
4939        // regression in the candidate matching is caught here.
4940        async fn lookup(
4941            conn: &mut sqlx::PgConnection,
4942            course_id: uuid::Uuid,
4943            requested: &str,
4944        ) -> Option<uuid::Uuid> {
4945            super::find_page_by_requested_path(conn, course_id, requested)
4946                .await
4947                .unwrap()
4948                .map(|page| page.id)
4949        }
4950
4951        // The raw-Cyrillic page resolves whether the request arrives decoded or encoded.
4952        assert_eq!(
4953            lookup(tx.as_mut(), course, "/chapter-1/як").await,
4954            Some(raw_page.id)
4955        );
4956        assert_eq!(
4957            lookup(tx.as_mut(), course, "/chapter-1/%D1%8F%D0%BA").await,
4958            Some(raw_page.id)
4959        );
4960
4961        // The legacy fully-encoded page resolves via the legacy lookup candidate, whether
4962        // the request arrives decoded or encoded.
4963        assert_eq!(
4964            lookup(tx.as_mut(), course, "/chapter-1/іспит").await,
4965            Some(legacy_page.id)
4966        );
4967        assert_eq!(
4968            lookup(
4969                tx.as_mut(),
4970                course,
4971                "/chapter-1/%D1%96%D1%81%D0%BF%D0%B8%D1%82"
4972            )
4973            .await,
4974            Some(legacy_page.id)
4975        );
4976    }
4977
4978    #[tokio::test]
4979    async fn gets_organization_id() {
4980        insert_data!(:tx, :user, :org, :course, instance: _instance, :course_module, chapter: _chapter, :page);
4981
4982        let course_page_org = get_organization_id(tx.as_mut(), page).await.unwrap();
4983        assert_eq!(org, course_page_org);
4984
4985        let new_exam_id = crate::exams::insert(
4986            tx.as_mut(),
4987            PKeyPolicy::Generate,
4988            &NewExam {
4989                name: "name".to_string(),
4990                starts_at: None,
4991                ends_at: None,
4992                time_minutes: 120,
4993                organization_id: org,
4994                minimum_points_treshold: 24,
4995                grade_manually: false,
4996            },
4997        )
4998        .await
4999        .unwrap();
5000        let page = crate::pages::insert_page(
5001            tx.as_mut(),
5002            NewPage {
5003                exercises: vec![],
5004                exercise_slides: vec![],
5005                exercise_tasks: vec![],
5006                content: vec![],
5007                url_path: "url".to_string(),
5008                title: "title".to_string(),
5009                course_id: None,
5010                exam_id: Some(new_exam_id),
5011                chapter_id: None,
5012                front_page_of_chapter_id: None,
5013                content_search_language: None,
5014                hidden: false,
5015            },
5016            user,
5017            |_, _, _| unimplemented!(),
5018            |_| unimplemented!(),
5019        )
5020        .await
5021        .unwrap();
5022        let exam_page_org = get_organization_id(tx.as_mut(), page.id).await.unwrap();
5023        assert_eq!(org, exam_page_org);
5024    }
5025
5026    #[tokio::test]
5027    async fn page_update_validation_works() {
5028        let e1 = CmsPageExercise {
5029            id: Uuid::parse_str("0c9dca80-5904-4d35-a945-8c080446f667").unwrap(),
5030            name: "".to_string(),
5031            order_number: 1,
5032            score_maximum: 1,
5033            max_tries_per_slide: None,
5034            limit_number_of_tries: false,
5035            deadline: Some(Utc.with_ymd_and_hms(2125, 1, 1, 23, 59, 59).unwrap()),
5036            needs_peer_review: false,
5037            needs_self_review: false,
5038            peer_or_self_review_config: None,
5039            peer_or_self_review_questions: None,
5040            use_course_default_peer_or_self_review_config: false,
5041            teacher_reviews_answer_after_locking: true,
5042        };
5043        let e1_s1 = CmsPageExerciseSlide {
5044            id: Uuid::parse_str("43380e81-6ff2-4f46-9f38-af0ac6a8421a").unwrap(),
5045            exercise_id: e1.id,
5046            order_number: 1,
5047        };
5048        let e1_s1_t1 = CmsPageExerciseTask {
5049            id: Uuid::parse_str("6fb19c22-bca0-42cf-8be5-4141e21cc7a9").unwrap(),
5050            exercise_slide_id: e1_s1.id,
5051            assignment: serde_json::json!([]),
5052            exercise_type: "exercise".to_string(),
5053            private_spec: None,
5054            order_number: 1,
5055            private_spec_files: vec![],
5056        };
5057
5058        // Works without exercises
5059        assert!(
5060            create_update(vec![], vec![], vec![])
5061                .validate_exercise_data()
5062                .is_ok()
5063        );
5064
5065        // Works with single valid exercise
5066        assert!(
5067            create_update(
5068                vec![e1.clone()],
5069                vec![e1_s1.clone()],
5070                vec![e1_s1_t1.clone()],
5071            )
5072            .validate_exercise_data()
5073            .is_ok()
5074        );
5075
5076        // Fails with missing slide
5077        assert!(
5078            create_update(vec![e1.clone()], vec![], vec![e1_s1_t1],)
5079                .validate_exercise_data()
5080                .is_err()
5081        );
5082
5083        // Fails with missing task
5084        assert!(
5085            create_update(vec![e1], vec![e1_s1], vec![],)
5086                .validate_exercise_data()
5087                .is_err()
5088        );
5089    }
5090
5091    fn create_update(
5092        exercises: Vec<CmsPageExercise>,
5093        exercise_slides: Vec<CmsPageExerciseSlide>,
5094        exercise_tasks: Vec<CmsPageExerciseTask>,
5095    ) -> CmsPageUpdate {
5096        CmsPageUpdate {
5097            content: vec![],
5098            exercises,
5099            exercise_slides,
5100            exercise_tasks,
5101            url_path: "".to_string(),
5102            title: "".to_string(),
5103            chapter_id: None,
5104            hidden: false,
5105        }
5106    }
5107
5108    fn lock_chapter_test_block(hidden_text: &str) -> GutenbergBlock {
5109        GutenbergBlock::block_with_name_attributes_and_inner_blocks(
5110            "moocfi/lock-chapter",
5111            headless_lms_utils::attributes! {},
5112            vec![GutenbergBlock::paragraph(hidden_text)],
5113        )
5114    }
5115
5116    fn first_lock_chapter_inner_block_count(page: &Page) -> usize {
5117        page.blocks_cloned().unwrap()[0].inner_blocks.len()
5118    }
5119
5120    /// Updates chapter locking on a course for test setup.
5121    async fn set_course_chapter_locking_enabled(
5122        tx: &mut PgConnection,
5123        course_id: Uuid,
5124        chapter_locking_enabled: bool,
5125    ) {
5126        let app_config = init_app_conf().expect("Application Configuration initialization failed");
5127        let course_before_update = courses::get_course(tx, course_id).await.unwrap();
5128        courses::update_course(
5129            tx,
5130            &app_config,
5131            course_id,
5132            courses::CourseUpdate {
5133                chapter_locking_enabled,
5134                name: course_before_update.name,
5135                description: course_before_update.description,
5136                is_draft: course_before_update.is_draft,
5137                is_test_mode: course_before_update.is_test_mode,
5138                can_add_chatbot: course_before_update.can_add_chatbot,
5139                is_unlisted: course_before_update.is_unlisted,
5140                is_joinable_by_code_only: course_before_update.is_joinable_by_code_only,
5141                ask_marketing_consent: course_before_update.ask_marketing_consent,
5142                flagged_answers_threshold: course_before_update
5143                    .flagged_answers_threshold
5144                    .unwrap_or_default(),
5145                flagged_answers_skip_manual_review_and_allow_retry: course_before_update
5146                    .flagged_answers_skip_manual_review_and_allow_retry,
5147                closed_at: course_before_update.closed_at,
5148                closed_additional_message: course_before_update.closed_additional_message,
5149                closed_course_successor_id: course_before_update.closed_course_successor_id,
5150                ai_policy: course_before_update.ai_policy,
5151                course_material_ai_instructions: course_before_update
5152                    .course_material_ai_instructions,
5153            },
5154        )
5155        .await
5156        .unwrap();
5157    }
5158
5159    #[test]
5160    fn sanitized_searchable_text_for_public_page_hides_lock_chapter_inner_blocks() {
5161        let content = serde_json::to_value(vec![
5162            GutenbergBlock::paragraph("Visible introduction"),
5163            lock_chapter_test_block("Model solution"),
5164        ])
5165        .unwrap();
5166
5167        let searchable_text = sanitized_searchable_text_for_public_page(&content);
5168
5169        assert!(searchable_text.contains("Visible introduction"));
5170        assert!(!searchable_text.contains("Model solution"));
5171    }
5172
5173    #[tokio::test]
5174    async fn filter_course_material_pages_hides_lock_chapter_inner_blocks_until_reviews_complete() {
5175        insert_data!(
5176            :tx,
5177            :user,
5178            :org,
5179            :course,
5180            instance: _instance,
5181            :course_module,
5182            chapter: chapter_id,
5183            page: page_id,
5184            exercise: exercise_id,
5185            slide: _exercise_slide_id,
5186            task: _exercise_task_id
5187        );
5188        set_course_chapter_locking_enabled(tx.as_mut(), course, true).await;
5189
5190        update_page_content(
5191            tx.as_mut(),
5192            page_id,
5193            &serde_json::to_value(vec![lock_chapter_test_block("Model solution")]).unwrap(),
5194        )
5195        .await
5196        .unwrap();
5197
5198        crate::exercises::update_teacher_reviews_answer_after_locking(
5199            tx.as_mut(),
5200            exercise_id,
5201            true,
5202        )
5203        .await
5204        .unwrap();
5205        crate::user_exercise_states::get_or_create_user_exercise_state(
5206            tx.as_mut(),
5207            user,
5208            exercise_id,
5209            Some(course),
5210            None,
5211        )
5212        .await
5213        .unwrap();
5214        crate::user_chapter_locking_statuses::complete_and_lock_chapter(
5215            tx.as_mut(),
5216            user,
5217            chapter_id,
5218            course,
5219        )
5220        .await
5221        .unwrap();
5222        crate::user_exercise_states::update_reviewing_stage(
5223            tx.as_mut(),
5224            user,
5225            CourseOrExamId::Course(course),
5226            exercise_id,
5227            crate::user_exercise_states::ReviewingStage::WaitingForManualGrading,
5228        )
5229        .await
5230        .unwrap();
5231
5232        let page = get_page(tx.as_mut(), page_id).await.unwrap();
5233        let filtered_pages = filter_course_material_pages(tx.as_mut(), Some(user), vec![page])
5234            .await
5235            .unwrap();
5236        assert_eq!(first_lock_chapter_inner_block_count(&filtered_pages[0]), 0);
5237
5238        crate::user_exercise_states::update_reviewing_stage(
5239            tx.as_mut(),
5240            user,
5241            CourseOrExamId::Course(course),
5242            exercise_id,
5243            crate::user_exercise_states::ReviewingStage::ReviewedAndLocked,
5244        )
5245        .await
5246        .unwrap();
5247
5248        let page = get_page(tx.as_mut(), page_id).await.unwrap();
5249        let filtered_pages = filter_course_material_pages(tx.as_mut(), Some(user), vec![page])
5250            .await
5251            .unwrap();
5252        assert_eq!(first_lock_chapter_inner_block_count(&filtered_pages[0]), 1);
5253    }
5254
5255    #[tokio::test]
5256    async fn filter_course_material_pages_with_exercises_hides_lock_chapter_inner_blocks() {
5257        insert_data!(
5258            :tx,
5259            :user,
5260            :org,
5261            :course,
5262            instance: _instance,
5263            :course_module,
5264            chapter: _chapter_id,
5265            page: page_id
5266        );
5267
5268        update_page_content(
5269            tx.as_mut(),
5270            page_id,
5271            &serde_json::to_value(vec![lock_chapter_test_block("Model solution")]).unwrap(),
5272        )
5273        .await
5274        .unwrap();
5275
5276        let page = get_page(tx.as_mut(), page_id).await.unwrap();
5277        let filtered_pages = filter_course_material_pages_with_exercises(
5278            tx.as_mut(),
5279            None,
5280            vec![PageWithExercises {
5281                page,
5282                exercises: vec![],
5283            }],
5284        )
5285        .await
5286        .unwrap();
5287
5288        assert_eq!(
5289            first_lock_chapter_inner_block_count(&filtered_pages[0].page),
5290            0
5291        );
5292    }
5293
5294    #[tokio::test]
5295    async fn filter_course_material_pages_does_not_initialize_lock_status_when_course_locking_is_disabled()
5296     {
5297        insert_data!(
5298            :tx,
5299            :user,
5300            :org,
5301            :course,
5302            instance: _instance,
5303            :course_module,
5304            chapter: chapter_id,
5305            page: page_id
5306        );
5307
5308        set_course_chapter_locking_enabled(tx.as_mut(), course, false).await;
5309
5310        update_page_content(
5311            tx.as_mut(),
5312            page_id,
5313            &serde_json::to_value(vec![lock_chapter_test_block("Model solution")]).unwrap(),
5314        )
5315        .await
5316        .unwrap();
5317
5318        let status = user_chapter_locking_statuses::get_or_init_status(
5319            tx.as_mut(),
5320            user,
5321            chapter_id,
5322            Some(course),
5323            Some(false),
5324        )
5325        .await
5326        .unwrap();
5327
5328        let page = get_page(tx.as_mut(), page_id).await.unwrap();
5329        let filtered_pages = filter_course_material_pages(tx.as_mut(), Some(user), vec![page])
5330            .await
5331            .unwrap();
5332
5333        assert_eq!(first_lock_chapter_inner_block_count(&filtered_pages[0]), 0);
5334
5335        assert!(status.is_none());
5336    }
5337
5338    #[tokio::test]
5339    async fn page_search_results_do_not_leak_hidden_lock_chapter_snippet_text() {
5340        insert_data!(
5341            :tx,
5342            :user,
5343            :org,
5344            :course,
5345            instance: _instance,
5346            :course_module,
5347            chapter: _chapter_id,
5348            page: page_id
5349        );
5350
5351        update_page_content(
5352            tx.as_mut(),
5353            page_id,
5354            &serde_json::to_value(vec![
5355                GutenbergBlock::paragraph("Visible introduction"),
5356                lock_chapter_test_block("Model solution"),
5357            ])
5358            .unwrap(),
5359        )
5360        .await
5361        .unwrap();
5362
5363        let results = get_page_search_results_for_words(
5364            tx.as_mut(),
5365            course,
5366            &SearchRequest {
5367                query: "Visible".to_string(),
5368            },
5369        )
5370        .await
5371        .unwrap();
5372
5373        let result = results
5374            .iter()
5375            .find(|result| result.id == page_id)
5376            .expect("page should match visible content query");
5377        let content_headline = result.content_headline.as_deref().unwrap_or_default();
5378
5379        assert!(!content_headline.contains("Model solution"));
5380    }
5381
5382    #[tokio::test]
5383    async fn page_upsert_peer_reviews_work() {
5384        insert_data!(:tx, :user, :org, :course, instance: _instance, :course_module, chapter: _chapter, page: _page, exercise: exercise_id);
5385        let pr_id = Uuid::parse_str("9b69dc5e-0eca-4fcd-8fd2-031a3a65da82").unwrap();
5386        let prq_id = Uuid::parse_str("de18fa14-4ac6-4b57-b9f8-4843fa52d948").unwrap();
5387        let exercise = crate::exercises::get_by_id(tx.as_mut(), exercise_id)
5388            .await
5389            .unwrap();
5390
5391        let pr1 = CmsPeerOrSelfReviewConfig {
5392            id:pr_id,
5393            exercise_id: Some(exercise_id),
5394            course_id: course,
5395            processing_strategy: crate::peer_or_self_review_configs::PeerReviewProcessingStrategy::AutomaticallyGradeOrManualReviewByAverage,
5396            accepting_threshold: 0.5,
5397            peer_reviews_to_give: 2,
5398            peer_reviews_to_receive: 1,
5399            points_are_all_or_nothing: false,
5400            reset_answer_if_zero_points_from_review: false,
5401            review_instructions: None,
5402        };
5403        let prq = CmsPeerOrSelfReviewQuestion {
5404            id: prq_id,
5405            peer_or_self_review_config_id: pr_id,
5406            answer_required: true,
5407            order_number: 0,
5408            question: "juu".to_string(),
5409            question_type:
5410                crate::peer_or_self_review_questions::PeerOrSelfReviewQuestionType::Essay,
5411            weight: 0.31,
5412        };
5413        let mut remapped_exercises = HashMap::new();
5414        remapped_exercises.insert(exercise_id, exercise);
5415        let pr_res = upsert_peer_or_self_review_configs(
5416            tx.as_mut(),
5417            &[],
5418            &[pr1],
5419            &remapped_exercises,
5420            false,
5421        )
5422        .await
5423        .unwrap();
5424        let prq_res =
5425            upsert_peer_or_self_review_questions(tx.as_mut(), &[], &[prq], &pr_res, false)
5426                .await
5427                .unwrap();
5428
5429        assert!(pr_res.get(&pr_id).unwrap().accepting_threshold == 0.5);
5430
5431        assert!(prq_res.get(&prq_id).unwrap().question == *"juu");
5432        assert!(!pr_res.get(&pr_id).unwrap().points_are_all_or_nothing);
5433        assert_eq!(prq_res.get(&prq_id).unwrap().weight, 0.31);
5434    }
5435
5436    #[tokio::test]
5437    async fn page_upsert_peer_reviews_work_retain_ids() {
5438        insert_data!(:tx, :user, :org, :course, instance: _instance, :course_module, chapter: _chapter, page: _page, exercise: exercise_id);
5439        let exercise = crate::exercises::get_by_id(tx.as_mut(), exercise_id)
5440            .await
5441            .unwrap();
5442        let pr_id = Uuid::parse_str("9b69dc5e-0eca-4fcd-8fd2-031a3a65da82").unwrap();
5443        let prq_id = Uuid::parse_str("de18fa14-4ac6-4b57-b9f8-4843fa52d948").unwrap();
5444        let pr1 = CmsPeerOrSelfReviewConfig {
5445            id:pr_id,
5446            exercise_id: Some(exercise_id),
5447            course_id: course,
5448            processing_strategy: crate::peer_or_self_review_configs::PeerReviewProcessingStrategy::AutomaticallyGradeOrManualReviewByAverage,
5449            accepting_threshold: 0.5,
5450            peer_reviews_to_give: 2,
5451            peer_reviews_to_receive: 1,
5452            points_are_all_or_nothing: true,
5453            reset_answer_if_zero_points_from_review: false,
5454            review_instructions: None,
5455        };
5456        let prq = CmsPeerOrSelfReviewQuestion {
5457            id: prq_id,
5458            peer_or_self_review_config_id: pr_id,
5459            answer_required: true,
5460            order_number: 0,
5461            question: "juu".to_string(),
5462            question_type:
5463                crate::peer_or_self_review_questions::PeerOrSelfReviewQuestionType::Essay,
5464            weight: 0.0,
5465        };
5466        let mut remapped_exercises = HashMap::new();
5467        remapped_exercises.insert(exercise_id, exercise);
5468        let pr_res =
5469            upsert_peer_or_self_review_configs(tx.as_mut(), &[], &[pr1], &remapped_exercises, true)
5470                .await
5471                .unwrap();
5472        let prq_res = upsert_peer_or_self_review_questions(tx.as_mut(), &[], &[prq], &pr_res, true)
5473            .await
5474            .unwrap();
5475
5476        assert!(pr_res.get(&pr_id).unwrap().accepting_threshold == 0.5);
5477        assert!(pr_res.get(&pr_id).unwrap().id == pr_id);
5478
5479        assert!(prq_res.get(&prq_id).unwrap().id == prq_id);
5480        assert!(prq_res.get(&prq_id).unwrap().question == *"juu");
5481    }
5482
5483    #[tokio::test]
5484    async fn page_upsert_peer_reviews_work_empty() {
5485        insert_data!(:tx, :user, :org, :course, instance: _instance, :course_module, chapter: _chapter, page: _page, exercise: exercise_id);
5486        let exercise = crate::exercises::get_by_id(tx.as_mut(), exercise_id)
5487            .await
5488            .unwrap();
5489        let mut remapped_exercises = HashMap::new();
5490        remapped_exercises.insert(exercise_id, exercise);
5491        let pr_res =
5492            upsert_peer_or_self_review_configs(tx.as_mut(), &[], &[], &remapped_exercises, true)
5493                .await
5494                .unwrap();
5495        let prq_res = upsert_peer_or_self_review_questions(tx.as_mut(), &[], &[], &pr_res, true)
5496            .await
5497            .unwrap();
5498
5499        assert!(pr_res.is_empty());
5500        assert!(prq_res.is_empty());
5501    }
5502
5503    #[tokio::test]
5504    async fn reorder_top_level_pages_works() {
5505        insert_data!(:tx, :user, :org, :course);
5506
5507        // First, delete any existing pages in this course to ensure a clean slate, except the
5508        // course front page, which cannot be deleted.
5509        let existing_pages =
5510            get_all_by_course_id_and_visibility(tx.as_mut(), course, PageVisibility::Any)
5511                .await
5512                .unwrap();
5513        for page in existing_pages.iter().filter(|p| p.url_path.trim() != "/") {
5514            delete_page_and_exercises(tx.as_mut(), page.id, user)
5515                .await
5516                .unwrap();
5517        }
5518
5519        // Create our test pages. Order numbers start at 1: the course front page (not deleted
5520        // above) already occupies order_number 0.
5521        let page1 = NewCoursePage::new(course, 1, "top-page-1", "Top Page 1");
5522        let (page1_id, _) = insert_course_page(tx.as_mut(), &page1, user).await.unwrap();
5523        let page2 = NewCoursePage::new(course, 2, "top-page-2", "Top Page 2");
5524        let (page2_id, _) = insert_course_page(tx.as_mut(), &page2, user).await.unwrap();
5525        let page3 = NewCoursePage::new(course, 3, "top-page-3", "Top Page 3");
5526        let (page3_id, _) = insert_course_page(tx.as_mut(), &page3, user).await.unwrap();
5527
5528        // The frontend never sends front pages to reorder_pages.
5529        let mut pages: Vec<_> =
5530            get_all_by_course_id_and_visibility(tx.as_mut(), course, PageVisibility::Any)
5531                .await
5532                .unwrap()
5533                .into_iter()
5534                .filter(|p| p.url_path.trim() != "/")
5535                .collect();
5536
5537        let page1_index = pages.iter().position(|p| p.id == page1_id).unwrap();
5538        let page2_index = pages.iter().position(|p| p.id == page2_id).unwrap();
5539        let page3_index = pages.iter().position(|p| p.id == page3_id).unwrap();
5540
5541        pages[page1_index].order_number = 2;
5542        pages[page3_index].order_number = 1;
5543        pages[page2_index].order_number = 3;
5544
5545        // Apply the reordering
5546        reorder_pages(tx.as_mut(), &pages, course).await.unwrap();
5547
5548        // Check that the reordering took effect
5549        let page1_updated = get_page(tx.as_mut(), page1_id).await.unwrap();
5550        let page2_updated = get_page(tx.as_mut(), page2_id).await.unwrap();
5551        let page3_updated = get_page(tx.as_mut(), page3_id).await.unwrap();
5552
5553        assert_eq!(page1_updated.order_number, 2);
5554        assert_eq!(page2_updated.order_number, 3);
5555        assert_eq!(page3_updated.order_number, 1);
5556    }
5557
5558    fn declaring_task() -> NormalizedCmsExerciseTask {
5559        NormalizedCmsExerciseTask {
5560            id: Uuid::new_v4(),
5561            assignment: serde_json::Value::Null,
5562            exercise_type: "declaring-service".to_string(),
5563            private_spec: Some(serde_json::json!({ "prompt": "hi" })),
5564        }
5565    }
5566
5567    async fn derive_from_declaring_service(body: serde_json::Value) -> ModelResult<DerivedSpec> {
5568        let exercise_type = "declaring-service".to_string();
5569        let mut urls = HashMap::new();
5570        urls.insert(
5571            &exercise_type,
5572            Url::parse("http://example.com/spec").unwrap(),
5573        );
5574        fetch_derived_spec(
5575            None,
5576            &declaring_task(),
5577            &urls,
5578            move |_url, _slug, _spec| {
5579                let body = body.clone();
5580                Box::pin(async move { Ok(body) })
5581            },
5582            None,
5583            Uuid::new_v4(),
5584            true,
5585        )
5586        .await
5587    }
5588
5589    #[tokio::test]
5590    async fn reads_the_spec_and_its_files_a_declaring_service_answers_with() {
5591        let file = Uuid::new_v4();
5592        let derived = derive_from_declaring_service(serde_json::json!({
5593            "spec": { "prompt": "hi" },
5594            "files": [file],
5595        }))
5596        .await
5597        .unwrap();
5598
5599        assert_eq!(derived.spec, Some(serde_json::json!({ "prompt": "hi" })));
5600        assert_eq!(derived.declared_files, Some(vec![file]));
5601    }
5602
5603    #[tokio::test]
5604    async fn reads_a_null_spec_a_declaring_service_has_nothing_to_show_for() {
5605        let derived = derive_from_declaring_service(serde_json::json!({
5606            "spec": null,
5607            "files": [],
5608        }))
5609        .await
5610        .unwrap();
5611
5612        assert_eq!(derived.spec, None);
5613        assert_eq!(derived.declared_files, Some(vec![]));
5614    }
5615
5616    #[tokio::test]
5617    async fn refuses_a_bare_spec_from_a_declaring_service() {
5618        // The dangerous shape: a lenient parse reads a bare spec as "no spec, no files".
5619        derive_from_declaring_service(serde_json::json!({ "prompt": "hi", "options": [] }))
5620            .await
5621            .expect_err("a bare spec is not an envelope");
5622        derive_from_declaring_service(serde_json::json!({ "files": [] }))
5623            .await
5624            .expect_err("an envelope without its spec key is not an envelope");
5625    }
5626}