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