headless_lms_server/controllers/course_material/
courses.rs

1//! Controllers for requests starting with `/api/v0/course-material/courses`.
2
3use std::{collections::HashMap, net::IpAddr, path::Path};
4
5use actix_http::header::{self, X_FORWARDED_FOR};
6use actix_web::web::Json;
7use chrono::Utc;
8use futures::{FutureExt, future::OptionFuture};
9use headless_lms_models::courses::CourseMaterialCourse;
10use headless_lms_models::{
11    course_custom_privacy_policy_checkbox_texts::CourseCustomPrivacyPolicyCheckboxText,
12    marketing_consents::UserMarketingConsent,
13};
14use headless_lms_models::{partner_block::PartnersBlock, privacy_link::PrivacyLink};
15use headless_lms_utils::ip_to_country::IpToCountryMapper;
16use isbot::Bots;
17use models::{
18    chapters::ChapterWithStatus,
19    course_instances::CourseInstance,
20    course_modules::CourseModule,
21    courses::{self, get_nondeleted_course_id_by_slug},
22    feedback,
23    feedback::NewFeedback,
24    glossary::Term,
25    material_references::MaterialReference,
26    page_visit_datum::NewPageVisitDatum,
27    page_visit_datum_daily_visit_hashing_keys::{
28        GenerateAnonymousIdentifierInput, generate_anonymous_identifier,
29    },
30    pages::{CoursePageWithUserData, Page, PageSearchResult, PageVisibility, SearchRequest},
31    proposed_page_edits::{self, NewProposedPageEdits},
32    research_forms::{
33        NewResearchFormQuestionAnswer, ResearchForm, ResearchFormQuestion,
34        ResearchFormQuestionAnswer,
35    },
36    student_countries::StudentCountry,
37    user_course_settings::UserCourseSettings,
38};
39
40use crate::{
41    domain::authorization::{
42        authorize_access_to_course_material, can_user_view_chapter, skip_authorize,
43    },
44    prelude::*,
45};
46
47/**
48GET `/api/v0/course-material/courses/:course_id` - Get course.
49*/
50#[instrument(skip(pool))]
51async fn get_course(
52    course_id: web::Path<Uuid>,
53    pool: web::Data<PgPool>,
54) -> ControllerResult<web::Json<CourseMaterialCourse>> {
55    let mut conn = pool.acquire().await?;
56    let course = models::courses::get_course(&mut conn, *course_id).await?;
57    let token = skip_authorize();
58    token.authorized_ok(web::Json(course.into()))
59}
60
61/**
62GET `/:course_slug/page-by-path/...` - Returns a course page by path
63
64If the page has moved and there's a redirection, this will still return the moved page but the field `was_redirected` will indicate that the redirection happened. The new path can be found in the page object. The frontend is supposed to update the url of the page to the new location without reloading the page.
65
66# Example
67GET /api/v0/course-material/courses/introduction-to-everything/page-by-path//part-2/hello-world
68*/
69
70#[instrument(skip(pool, ip_to_country_mapper, req))]
71async fn get_course_page_by_path(
72    params: web::Path<(String, String)>,
73    pool: web::Data<PgPool>,
74    user: Option<AuthUser>,
75    ip_to_country_mapper: web::Data<IpToCountryMapper>,
76    req: HttpRequest,
77) -> ControllerResult<web::Json<CoursePageWithUserData>> {
78    let mut conn = pool.acquire().await?;
79
80    let (course_slug, raw_page_path) = params.into_inner();
81    let path = if raw_page_path.starts_with('/') {
82        raw_page_path
83    } else {
84        format!("/{}", raw_page_path)
85    };
86    let user_id = user.map(|u| u.id);
87    let course_data = get_nondeleted_course_id_by_slug(&mut conn, &course_slug).await?;
88    let page_with_user_data =
89        models::pages::get_page_with_user_data_by_path(&mut conn, user_id, &course_data, &path)
90            .await?;
91
92    // Chapters may be closed
93    if !can_user_view_chapter(
94        &mut conn,
95        user_id,
96        page_with_user_data.page.course_id,
97        page_with_user_data.page.chapter_id,
98    )
99    .await?
100    {
101        return Err(ControllerError::new(
102            ControllerErrorType::Unauthorized,
103            "Chapter is not open yet.".to_string(),
104            None,
105        ));
106    }
107
108    let token = authorize_access_to_course_material(
109        &mut conn,
110        user_id,
111        page_with_user_data.page.course_id.ok_or_else(|| {
112            ControllerError::new(
113                ControllerErrorType::NotFound,
114                "Course not found".to_string(),
115                None,
116            )
117        })?,
118    )
119    .await?;
120
121    let temp_request_information =
122        derive_information_from_requester(req, ip_to_country_mapper).await?;
123
124    let RequestInformation {
125        ip,
126        referrer,
127        utm_source,
128        utm_medium,
129        utm_campaign,
130        utm_term,
131        utm_content,
132        country,
133        user_agent,
134        has_bot_user_agent,
135        browser_admits_its_a_bot,
136        browser,
137        browser_version,
138        operating_system,
139        operating_system_version,
140        device_type,
141    } = temp_request_information.data;
142
143    let course_or_exam_id = page_with_user_data
144        .page
145        .course_id
146        .unwrap_or_else(|| page_with_user_data.page.exam_id.unwrap_or_else(Uuid::nil));
147    let anonymous_identifier = generate_anonymous_identifier(
148        &mut conn,
149        GenerateAnonymousIdentifierInput {
150            user_agent,
151            ip_address: ip.map(|ip| ip.to_string()).unwrap_or_default(),
152            course_id: course_or_exam_id,
153        },
154    )
155    .await?;
156
157    models::page_visit_datum::insert(
158        &mut conn,
159        NewPageVisitDatum {
160            course_id: page_with_user_data.page.course_id,
161            page_id: page_with_user_data.page.id,
162            country,
163            browser,
164            browser_version,
165            operating_system,
166            operating_system_version,
167            device_type,
168            referrer,
169            is_bot: has_bot_user_agent || browser_admits_its_a_bot,
170            utm_source,
171            utm_medium,
172            utm_campaign,
173            utm_term,
174            utm_content,
175            anonymous_identifier,
176            exam_id: page_with_user_data.page.exam_id,
177        },
178    )
179    .await?;
180
181    token.authorized_ok(web::Json(page_with_user_data))
182}
183
184struct RequestInformation {
185    ip: Option<IpAddr>,
186    user_agent: String,
187    referrer: Option<String>,
188    utm_source: Option<String>,
189    utm_medium: Option<String>,
190    utm_campaign: Option<String>,
191    utm_term: Option<String>,
192    utm_content: Option<String>,
193    country: Option<String>,
194    has_bot_user_agent: bool,
195    browser_admits_its_a_bot: bool,
196    browser: Option<String>,
197    browser_version: Option<String>,
198    operating_system: Option<String>,
199    operating_system_version: Option<String>,
200    device_type: Option<String>,
201}
202
203/// Used in get_course_page_by_path for path for anonymous visitor counts
204async fn derive_information_from_requester(
205    req: HttpRequest,
206    ip_to_country_mapper: web::Data<IpToCountryMapper>,
207) -> ControllerResult<RequestInformation> {
208    let mut headers = req.headers().clone();
209    let x_real_ip = headers.get("X-Real-IP");
210    let x_forwarded_for = headers.get(X_FORWARDED_FOR);
211    let connection_info = req.connection_info();
212    let peer_address = connection_info.peer_addr();
213    let headers_clone = headers.clone();
214    let user_agent = headers_clone.get(header::USER_AGENT);
215    let bots = Bots::default();
216    let has_bot_user_agent = user_agent
217        .and_then(|ua| ua.to_str().ok())
218        .map(|ua| bots.is_bot(ua))
219        .unwrap_or(true);
220    // If this header is not set, the requester is considered a bot
221    let header_totally_not_a_bot = headers.get("totally-not-a-bot");
222    let browser_admits_its_a_bot = header_totally_not_a_bot.is_none();
223    if has_bot_user_agent || browser_admits_its_a_bot {
224        warn!(
225            ?has_bot_user_agent,
226            ?browser_admits_its_a_bot,
227            ?user_agent,
228            ?header_totally_not_a_bot,
229            "The requester is a bot"
230        )
231    }
232
233    let user_agent_parser = woothee::parser::Parser::new();
234    let parsed_user_agent = user_agent
235        .and_then(|ua| ua.to_str().ok())
236        .and_then(|ua| user_agent_parser.parse(ua));
237
238    let ip: Option<IpAddr> = connection_info
239        .realip_remote_addr()
240        .and_then(|ip| ip.parse::<IpAddr>().ok());
241
242    info!(
243        "Ip {:?}, x_real_ip {:?}, x_forwarded_for {:?}, peer_address {:?}",
244        ip, x_real_ip, x_forwarded_for, peer_address
245    );
246
247    let country = ip
248        .and_then(|ip| ip_to_country_mapper.map_ip_to_country(&ip))
249        .map(|c| c.to_string());
250
251    let utm_tags = headers
252        .remove("utm-tags")
253        .next()
254        .and_then(|utms| String::from_utf8(utms.as_bytes().to_vec()).ok())
255        .and_then(|utms| serde_json::from_str::<serde_json::Value>(&utms).ok())
256        .and_then(|o| o.as_object().cloned());
257
258    let utm_source = utm_tags
259        .clone()
260        .and_then(|mut tags| tags.remove("utm_source"))
261        .and_then(|v| v.as_str().map(|s| s.to_string()));
262
263    let utm_medium = utm_tags
264        .clone()
265        .and_then(|mut tags| tags.remove("utm_medium"))
266        .and_then(|v| v.as_str().map(|s| s.to_string()));
267
268    let utm_campaign = utm_tags
269        .clone()
270        .and_then(|mut tags| tags.remove("utm_campaign"))
271        .and_then(|v| v.as_str().map(|s| s.to_string()));
272
273    let utm_term = utm_tags
274        .clone()
275        .and_then(|mut tags| tags.remove("utm_term"))
276        .and_then(|v| v.as_str().map(|s| s.to_string()));
277
278    let utm_content = utm_tags
279        .and_then(|mut tags| tags.remove("utm_content"))
280        .and_then(|v| v.as_str().map(|s| s.to_string()));
281
282    let referrer = headers
283        .get("Orignal-Referrer")
284        .and_then(|r| r.to_str().ok())
285        .map(|r| r.to_string());
286
287    let browser = parsed_user_agent.as_ref().map(|ua| ua.name.to_string());
288    let browser_version = parsed_user_agent.as_ref().map(|ua| ua.version.to_string());
289    let operating_system = parsed_user_agent.as_ref().map(|ua| ua.os.to_string());
290    let operating_system_version = parsed_user_agent
291        .as_ref()
292        .map(|ua| ua.os_version.to_string());
293    let device_type = parsed_user_agent.as_ref().map(|ua| ua.category.to_string());
294    let token = skip_authorize();
295    token.authorized_ok(RequestInformation {
296        ip,
297        user_agent: user_agent
298            .and_then(|ua| ua.to_str().ok())
299            .unwrap_or_default()
300            .to_string(),
301        referrer,
302        utm_source,
303        utm_medium,
304        utm_campaign,
305        utm_term,
306        utm_content,
307        country,
308        has_bot_user_agent,
309        browser_admits_its_a_bot,
310        browser,
311        browser_version,
312        operating_system,
313        operating_system_version,
314        device_type,
315    })
316}
317
318/**
319GET `/api/v0/course-material/courses/:course_id/current-instance` - Returns the instance of a course for the current user, if there is one.
320*/
321#[instrument(skip(pool))]
322async fn get_current_course_instance(
323    pool: web::Data<PgPool>,
324    course_id: web::Path<Uuid>,
325    user: Option<AuthUser>,
326) -> ControllerResult<web::Json<Option<CourseInstance>>> {
327    let mut conn = pool.acquire().await?;
328    if let Some(user) = user {
329        let instance = models::course_instances::current_course_instance_of_user(
330            &mut conn, user.id, *course_id,
331        )
332        .await?;
333        let token = skip_authorize();
334        token.authorized_ok(web::Json(instance))
335    } else {
336        Err(ControllerError::new(
337            ControllerErrorType::NotFound,
338            "User not found".to_string(),
339            None,
340        ))
341    }
342}
343
344/**
345GET `/api/v0/course-material/courses/:course_id/course-instances` - Returns all course instances for given course id.
346*/
347async fn get_course_instances(
348    pool: web::Data<PgPool>,
349    course_id: web::Path<Uuid>,
350) -> ControllerResult<web::Json<Vec<CourseInstance>>> {
351    let mut conn = pool.acquire().await?;
352    let instances =
353        models::course_instances::get_course_instances_for_course(&mut conn, *course_id).await?;
354    let token = skip_authorize();
355    token.authorized_ok(web::Json(instances))
356}
357
358/**
359GET `/api/v0/course-material/courses/:course_id/pages` - Returns a list of public pages on a course.
360
361Since anyone can access this endpoint, any unlisted pages are omited from these results.
362*/
363#[instrument(skip(pool))]
364async fn get_public_course_pages(
365    course_id: web::Path<Uuid>,
366    pool: web::Data<PgPool>,
367) -> ControllerResult<web::Json<Vec<Page>>> {
368    let mut conn = pool.acquire().await?;
369    let pages: Vec<Page> = models::pages::get_all_by_course_id_and_visibility(
370        &mut conn,
371        *course_id,
372        PageVisibility::Public,
373    )
374    .await?;
375    let token = skip_authorize();
376    token.authorized_ok(web::Json(pages))
377}
378
379#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
380#[cfg_attr(feature = "ts_rs", derive(TS))]
381pub struct ChaptersWithStatus {
382    pub is_previewable: bool,
383    pub modules: Vec<CourseMaterialCourseModule>,
384}
385
386#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
387#[cfg_attr(feature = "ts_rs", derive(TS))]
388pub struct CourseMaterialCourseModule {
389    pub chapters: Vec<ChapterWithStatus>,
390    pub id: Uuid,
391    pub is_default: bool,
392    pub name: Option<String>,
393    pub order_number: i32,
394}
395
396/**
397GET `/api/v0/course-material/courses/:course_id/chapters` - Returns a list of chapters in a course.
398*/
399
400#[instrument(skip(pool, file_store, app_conf))]
401async fn get_chapters(
402    course_id: web::Path<Uuid>,
403    user: Option<AuthUser>,
404    pool: web::Data<PgPool>,
405    file_store: web::Data<dyn FileStore>,
406    app_conf: web::Data<ApplicationConfiguration>,
407) -> ControllerResult<web::Json<ChaptersWithStatus>> {
408    let mut conn = pool.acquire().await?;
409    let is_previewable = OptionFuture::from(user.map(|u| {
410        authorize(&mut conn, Act::Teach, Some(u.id), Res::Course(*course_id)).map(|r| r.ok())
411    }))
412    .await
413    .is_some();
414    let token = skip_authorize();
415    let course_modules = models::course_modules::get_by_course_id(&mut conn, *course_id).await?;
416    let chapters = models::chapters::course_chapters(&mut conn, *course_id)
417        .await?
418        .into_iter()
419        .map(|chapter| {
420            let chapter_image_url = chapter
421                .chapter_image_path
422                .as_ref()
423                .map(|path| file_store.get_download_url(Path::new(&path), &app_conf));
424            ChapterWithStatus::from_database_chapter_timestamp_and_image_url(
425                chapter,
426                Utc::now(),
427                chapter_image_url,
428            )
429        })
430        .collect();
431    let modules = collect_course_modules(course_modules, chapters)?.data;
432    token.authorized_ok(web::Json(ChaptersWithStatus {
433        is_previewable,
434        modules,
435    }))
436}
437
438/// Combines course modules and chapters, consuming them.
439fn collect_course_modules(
440    course_modules: Vec<CourseModule>,
441    chapters: Vec<ChapterWithStatus>,
442) -> ControllerResult<Vec<CourseMaterialCourseModule>> {
443    let mut course_modules: HashMap<Uuid, CourseMaterialCourseModule> = course_modules
444        .into_iter()
445        .map(|course_module| {
446            (
447                course_module.id,
448                CourseMaterialCourseModule {
449                    chapters: vec![],
450                    id: course_module.id,
451                    is_default: course_module.name.is_none(),
452                    name: course_module.name,
453                    order_number: course_module.order_number,
454                },
455            )
456        })
457        .collect();
458    for chapter in chapters {
459        course_modules
460            .get_mut(&chapter.course_module_id)
461            .ok_or_else(|| {
462                ControllerError::new(
463                    ControllerErrorType::InternalServerError,
464                    "Module data mismatch.".to_string(),
465                    None,
466                )
467            })?
468            .chapters
469            .push(chapter);
470    }
471    let token = skip_authorize();
472    token.authorized_ok(course_modules.into_values().collect())
473}
474
475/**
476GET `/api/v0/course-material/courses/:course_id/user-settings` - Returns user settings for the current course.
477*/
478#[instrument(skip(pool))]
479async fn get_user_course_settings(
480    pool: web::Data<PgPool>,
481    course_id: web::Path<Uuid>,
482    user: Option<AuthUser>,
483) -> ControllerResult<web::Json<Option<UserCourseSettings>>> {
484    let mut conn = pool.acquire().await?;
485    if let Some(user) = user {
486        let settings = models::user_course_settings::get_user_course_settings_by_course_id(
487            &mut conn, user.id, *course_id,
488        )
489        .await?;
490        let token = skip_authorize();
491        token.authorized_ok(web::Json(settings))
492    } else {
493        Err(ControllerError::new(
494            ControllerErrorType::NotFound,
495            "User not found".to_string(),
496            None,
497        ))
498    }
499}
500
501/**
502POST `/api/v0/course-material/courses/:course_id/search-pages-with-phrase` - Returns a list of pages given a search query.
503
504Provided words are supposed to appear right after each other in the source document.
505
506# Example
507
508Request:
509
510```http
511POST /api/v0/course-material/courses/1a68e8b0-d151-4c0e-9307-bb154e9d2be1/search-pages-with-phrase HTTP/1.1
512Content-Type: application/json
513
514{
515  "query": "Everything"
516}
517```
518*/
519#[instrument(skip(pool))]
520async fn search_pages_with_phrase(
521    course_id: web::Path<Uuid>,
522    payload: web::Json<SearchRequest>,
523    pool: web::Data<PgPool>,
524) -> ControllerResult<web::Json<Vec<PageSearchResult>>> {
525    let mut conn = pool.acquire().await?;
526    let res =
527        models::pages::get_page_search_results_for_phrase(&mut conn, *course_id, &payload).await?;
528    let token = skip_authorize();
529    token.authorized_ok(web::Json(res))
530}
531
532/**
533POST `/api/v0/course-material/courses/:course_id/search-pages-with-words` - Returns a list of pages given a search query.
534
535Provided words can appear in any order in the source document.
536
537# Example
538
539Request:
540
541```http
542POST /api/v0/course-material/courses/1a68e8b0-d151-4c0e-9307-bb154e9d2be1/search-pages-with-words HTTP/1.1
543Content-Type: application/json
544
545{
546  "query": "Everything"
547}
548```
549*/
550#[instrument(skip(pool))]
551async fn search_pages_with_words(
552    course_id: web::Path<Uuid>,
553    payload: web::Json<SearchRequest>,
554    pool: web::Data<PgPool>,
555) -> ControllerResult<web::Json<Vec<PageSearchResult>>> {
556    let mut conn = pool.acquire().await?;
557    let res =
558        models::pages::get_page_search_results_for_words(&mut conn, *course_id, &payload).await?;
559    let token = skip_authorize();
560    token.authorized_ok(web::Json(res))
561}
562
563/**
564POST `/api/v0/course-material/courses/:course_id/feedback` - Creates new feedback.
565*/
566pub async fn feedback(
567    course_id: web::Path<Uuid>,
568    new_feedback: web::Json<Vec<NewFeedback>>,
569    pool: web::Data<PgPool>,
570    user: Option<AuthUser>,
571) -> ControllerResult<web::Json<Vec<Uuid>>> {
572    let mut conn = pool.acquire().await?;
573    let fs = new_feedback.into_inner();
574    let user_id = user.as_ref().map(|u| u.id);
575
576    // validate
577    for f in &fs {
578        if f.feedback_given.len() > 1000 {
579            return Err(ControllerError::new(
580                ControllerErrorType::BadRequest,
581                "Feedback given too long: max 1000".to_string(),
582                None,
583            ));
584        }
585        if f.related_blocks.len() > 100 {
586            return Err(ControllerError::new(
587                ControllerErrorType::BadRequest,
588                "Too many related blocks: max 100".to_string(),
589                None,
590            ));
591        }
592        for block in &f.related_blocks {
593            if block.text.as_ref().map(|t| t.len()).unwrap_or_default() > 10000 {
594                return Err(ControllerError::new(
595                    ControllerErrorType::BadRequest,
596                    "Block text too long: max 10000".to_string(),
597                    None,
598                ));
599            }
600        }
601    }
602
603    let mut tx = conn.begin().await?;
604    let mut ids = vec![];
605    for f in fs {
606        let id = feedback::insert(&mut tx, PKeyPolicy::Generate, user_id, *course_id, f).await?;
607        ids.push(id);
608    }
609    tx.commit().await?;
610    let token = skip_authorize();
611    token.authorized_ok(web::Json(ids))
612}
613
614/**
615POST `/api/v0/course-material/courses/:course_slug/edit` - Creates a new edit proposal.
616*/
617async fn propose_edit(
618    course_slug: web::Path<String>,
619    edits: web::Json<NewProposedPageEdits>,
620    pool: web::Data<PgPool>,
621    user: Option<AuthUser>,
622) -> ControllerResult<web::Json<Uuid>> {
623    let mut conn = pool.acquire().await?;
624    let course = courses::get_course_by_slug(&mut conn, course_slug.as_str()).await?;
625    let (id, _) = proposed_page_edits::insert(
626        &mut conn,
627        PKeyPolicy::Generate,
628        course.id,
629        user.map(|u| u.id),
630        &edits.into_inner(),
631    )
632    .await?;
633    let token = skip_authorize();
634    token.authorized_ok(web::Json(id))
635}
636
637#[instrument(skip(pool))]
638async fn glossary(
639    pool: web::Data<PgPool>,
640    course_id: web::Path<Uuid>,
641) -> ControllerResult<web::Json<Vec<Term>>> {
642    let mut conn = pool.acquire().await?;
643    let glossary = models::glossary::fetch_for_course(&mut conn, *course_id).await?;
644    let token = skip_authorize();
645    token.authorized_ok(web::Json(glossary))
646}
647
648#[instrument(skip(pool))]
649async fn get_material_references_by_course_id(
650    course_id: web::Path<Uuid>,
651    pool: web::Data<PgPool>,
652    user: Option<AuthUser>,
653) -> ControllerResult<web::Json<Vec<MaterialReference>>> {
654    let mut conn = pool.acquire().await?;
655    let token =
656        authorize_access_to_course_material(&mut conn, user.map(|u| u.id), *course_id).await?;
657    let res =
658        models::material_references::get_references_by_course_id(&mut conn, *course_id).await?;
659
660    token.authorized_ok(web::Json(res))
661}
662
663/**
664GET /api/v0/course-material/courses/:course_id/top-level-pages
665*/
666#[instrument(skip(pool))]
667async fn get_public_top_level_pages(
668    course_id: web::Path<Uuid>,
669    pool: web::Data<PgPool>,
670) -> ControllerResult<web::Json<Vec<Page>>> {
671    let mut conn = pool.acquire().await?;
672    let page = models::pages::get_course_top_level_pages_by_course_id_and_visibility(
673        &mut conn,
674        *course_id,
675        PageVisibility::Public,
676    )
677    .await?;
678    let token = skip_authorize();
679    token.authorized_ok(web::Json(page))
680}
681
682/**
683GET `/api/v0/course-material/courses/:id/language-versions` - Returns all language versions of the same course. Since this is for course material, this does not include draft courses. To make developing new courses easier, we include draft courses if the course the request for is a draft course and the teacher has a permission to access it.
684*/
685#[instrument(skip(pool))]
686async fn get_all_course_language_versions(
687    pool: web::Data<PgPool>,
688    course_id: web::Path<Uuid>,
689    user: Option<AuthUser>,
690) -> ControllerResult<web::Json<Vec<CourseMaterialCourse>>> {
691    let mut conn = pool.acquire().await?;
692    let token = skip_authorize();
693    let course = models::courses::get_course(&mut conn, *course_id).await?;
694
695    let unfiltered_language_versions =
696        models::courses::get_all_language_versions_of_course(&mut conn, &course).await?;
697
698    let language_versions = unfiltered_language_versions
699        .clone()
700        .into_iter()
701        .filter(|c| !c.is_draft)
702        .collect::<Vec<_>>();
703
704    if !language_versions.iter().any(|c| c.id == course.id) {
705        // The course the language version was requested for is likely a draft course.
706        if let Some(user_id) = user.map(|u| u.id) {
707            let access_draft_course_token =
708                authorize_access_to_course_material(&mut conn, Some(user_id), *course_id).await?;
709            info!(
710                "Course {} the language version was requested for is a draft course. Including all draft courses in the response.",
711                course.id,
712            );
713            return access_draft_course_token.authorized_ok(web::Json(
714                unfiltered_language_versions
715                    .into_iter()
716                    .map(|c| c.into())
717                    .collect(),
718            ));
719        } else {
720            return Err(ControllerError::new(
721                ControllerErrorType::Unauthorized,
722                "Please log in".to_string(),
723                None,
724            ));
725        }
726    }
727    token.authorized_ok(web::Json(
728        language_versions.into_iter().map(|c| c.into()).collect(),
729    ))
730}
731
732/**
733GET `/api/v0/{course_id}/pages/by-language-group-id/{page_language_group_id} - Returns a page with the given course id and language group id.
734 */
735#[instrument(skip(pool))]
736async fn get_page_by_course_id_and_language_group(
737    info: web::Path<(Uuid, Uuid)>,
738    pool: web::Data<PgPool>,
739) -> ControllerResult<web::Json<Page>> {
740    let mut conn = pool.acquire().await?;
741    let (course_id, page_language_group_id) = info.into_inner();
742
743    let page: Page = models::pages::get_page_by_course_id_and_language_group(
744        &mut conn,
745        course_id,
746        page_language_group_id,
747    )
748    .await?;
749    let token = skip_authorize();
750    token.authorized_ok(web::Json(page))
751}
752
753/**
754POST `/api/v0/{course_id}/course-instances/{course_instance_id}/student-countries/{country_code}` - Add a new student's country entry.
755*/
756#[instrument(skip(pool))]
757async fn student_country(
758    query: web::Path<(Uuid, Uuid, String)>,
759    pool: web::Data<PgPool>,
760    user: AuthUser,
761) -> ControllerResult<Json<bool>> {
762    let mut conn = pool.acquire().await?;
763    let (course_id, course_instance_id, country_code) = query.into_inner();
764
765    models::student_countries::insert(
766        &mut conn,
767        user.id,
768        course_id,
769        course_instance_id,
770        &country_code,
771    )
772    .await?;
773    let token = skip_authorize();
774
775    token.authorized_ok(Json(true))
776}
777
778/**
779GET `/api/v0/{course_id}/course-instances/{course_instance_id}/student-countries - Returns countries of student registered in a course.
780 */
781#[instrument(skip(pool))]
782async fn get_student_countries(
783    query: web::Path<(Uuid, Uuid)>,
784    pool: web::Data<PgPool>,
785    user: AuthUser,
786) -> ControllerResult<web::Json<HashMap<String, u32>>> {
787    let mut conn = pool.acquire().await?;
788    let token = skip_authorize();
789    let (course_id, course_instance_id) = query.into_inner();
790
791    let country_codes: Vec<String> =
792        models::student_countries::get_countries(&mut conn, course_id, course_instance_id)
793            .await?
794            .into_iter()
795            .map(|c| (c.country_code))
796            .collect();
797
798    let mut frequency: HashMap<String, u32> = HashMap::new();
799    for code in country_codes {
800        *frequency.entry(code).or_insert(0) += 1
801    }
802
803    token.authorized_ok(web::Json(frequency))
804}
805
806/**
807GET `/api/v0/{course_id}/student-country - Returns country of a student registered in a course.
808 */
809#[instrument(skip(pool))]
810async fn get_student_country(
811    course_instance_id: web::Path<Uuid>,
812    pool: web::Data<PgPool>,
813    user: AuthUser,
814) -> ControllerResult<web::Json<StudentCountry>> {
815    let mut conn = pool.acquire().await?;
816    let token = skip_authorize();
817    let res = models::student_countries::get_selected_country_by_user_id(
818        &mut conn,
819        user.id,
820        *course_instance_id,
821    )
822    .await?;
823
824    token.authorized_ok(web::Json(res))
825}
826
827/**
828GET `/api/v0/course-material/courses/:course_id/research-consent-form` - Fetches courses research form with course id.
829*/
830#[instrument(skip(pool))]
831async fn get_research_form_with_course_id(
832    course_id: web::Path<Uuid>,
833    user: AuthUser,
834    pool: web::Data<PgPool>,
835) -> ControllerResult<web::Json<Option<ResearchForm>>> {
836    let mut conn = pool.acquire().await?;
837    let user_id = Some(user.id);
838
839    let token = authorize_access_to_course_material(&mut conn, user_id, *course_id).await?;
840
841    let res = models::research_forms::get_research_form_with_course_id(&mut conn, *course_id)
842        .await
843        .optional()?;
844
845    token.authorized_ok(web::Json(res))
846}
847
848/**
849GET `/api/v0/course-material/courses/:course_id/research-consent-form-questions` - Fetches courses research form questions with course id.
850*/
851#[instrument(skip(pool))]
852async fn get_research_form_questions_with_course_id(
853    course_id: web::Path<Uuid>,
854    user: AuthUser,
855    pool: web::Data<PgPool>,
856) -> ControllerResult<web::Json<Vec<ResearchFormQuestion>>> {
857    let mut conn = pool.acquire().await?;
858    let user_id = Some(user.id);
859
860    let token = authorize_access_to_course_material(&mut conn, user_id, *course_id).await?;
861    let res =
862        models::research_forms::get_research_form_questions_with_course_id(&mut conn, *course_id)
863            .await?;
864
865    token.authorized_ok(web::Json(res))
866}
867
868/**
869POST `/api/v0/course-material/courses/:course_id/research-consent-form-questions-answer` - Upserts users consent for a courses research form question.
870*/
871
872#[instrument(skip(pool, payload))]
873async fn upsert_course_research_form_answer(
874    payload: web::Json<NewResearchFormQuestionAnswer>,
875    pool: web::Data<PgPool>,
876    course_id: web::Path<Uuid>,
877    user: AuthUser,
878) -> ControllerResult<web::Json<Uuid>> {
879    let mut conn = pool.acquire().await?;
880    let user_id = Some(user.id);
881
882    let token = authorize_access_to_course_material(&mut conn, user_id, *course_id).await?;
883    let answer = payload;
884    let res =
885        models::research_forms::upsert_research_form_anwser(&mut conn, *course_id, &answer).await?;
886
887    token.authorized_ok(web::Json(res))
888}
889
890/**
891GET `/api/v0/course/courses/:course_id/research-consent-form-users-answers` - Fetches users answers for courses research form.
892*/
893#[instrument(skip(pool))]
894async fn get_research_form_answers_with_user_id(
895    course_id: web::Path<Uuid>,
896    user: AuthUser,
897    pool: web::Data<PgPool>,
898) -> ControllerResult<web::Json<Vec<ResearchFormQuestionAnswer>>> {
899    let mut conn = pool.acquire().await?;
900    let user_id = Some(user.id);
901
902    let token = authorize_access_to_course_material(&mut conn, user_id, *course_id).await?;
903
904    let res = models::research_forms::get_research_form_answers_with_user_id(
905        &mut conn, *course_id, user.id,
906    )
907    .await?;
908
909    token.authorized_ok(web::Json(res))
910}
911
912#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
913#[cfg_attr(feature = "ts_rs", derive(TS))]
914pub struct UserMarketingConsentPayload {
915    pub course_language_groups_id: Uuid,
916    pub email_subscription: bool,
917    pub marketing_consent: bool,
918}
919
920/**
921POST `/api/v0/course-material/courses/:course_id/user-marketing-consent` - Adds or updates user's marketing consent for a specific course.
922*/
923#[instrument(skip(pool, payload))]
924async fn update_marketing_consent(
925    payload: web::Json<UserMarketingConsentPayload>,
926    pool: web::Data<PgPool>,
927    course_id: web::Path<Uuid>,
928    user: AuthUser,
929) -> ControllerResult<web::Json<Uuid>> {
930    let mut conn = pool.acquire().await?;
931    let user_id = Some(user.id);
932
933    let token = authorize_access_to_course_material(&mut conn, user_id, *course_id).await?;
934
935    let email_subscription = if payload.email_subscription {
936        "subscribed"
937    } else {
938        "unsubscribed"
939    };
940
941    let result = models::marketing_consents::upsert_marketing_consent(
942        &mut conn,
943        *course_id,
944        payload.course_language_groups_id,
945        &user.id,
946        email_subscription,
947        payload.marketing_consent,
948    )
949    .await?;
950
951    token.authorized_ok(web::Json(result))
952}
953
954/**
955GET `/api/v0/course-material/courses/:course_id/fetch-user-marketing-consent`
956*/
957#[instrument(skip(pool))]
958async fn fetch_user_marketing_consent(
959    pool: web::Data<PgPool>,
960    course_id: web::Path<Uuid>,
961    user: AuthUser,
962) -> ControllerResult<web::Json<Option<UserMarketingConsent>>> {
963    let mut conn = pool.acquire().await?;
964    let user_id = Some(user.id);
965
966    let token = authorize_access_to_course_material(&mut conn, user_id, *course_id).await?;
967
968    let result =
969        models::marketing_consents::fetch_user_marketing_consent(&mut conn, *course_id, &user.id)
970            .await
971            .ok();
972
973    token.authorized_ok(web::Json(result))
974}
975
976/**
977GET /courses/:course_id/partners_blocks - Gets a partners block related to a course
978*/
979#[instrument(skip(pool))]
980async fn get_partners_block(
981    path: web::Path<Uuid>,
982    pool: web::Data<PgPool>,
983) -> ControllerResult<web::Json<PartnersBlock>> {
984    let course_id = path.into_inner();
985    let mut conn = pool.acquire().await?;
986    let partner_block = models::partner_block::get_partner_block(&mut conn, course_id).await?;
987    let token = skip_authorize();
988    token.authorized_ok(web::Json(partner_block))
989}
990
991/**
992GET /courses/:course_id/privacy_link - Gets a privacy link related to a course
993*/
994#[instrument(skip(pool))]
995async fn get_privacy_link(
996    course_id: web::Path<Uuid>,
997    pool: web::Data<PgPool>,
998) -> ControllerResult<web::Json<Vec<PrivacyLink>>> {
999    let mut conn = pool.acquire().await?;
1000    let privacy_link = models::privacy_link::get_privacy_link(&mut conn, *course_id).await?;
1001    let token = skip_authorize();
1002    token.authorized_ok(web::Json(privacy_link))
1003}
1004
1005/**
1006GET /courses/:course_id/custom-privacy-policy-checkbox-texts - Used to get customized checkbox texts for courses that use a different privacy policy than all our other courses (e.g. the Elements of AI course). These texts are shown in the course settings dialog.
1007*/
1008#[instrument(skip(pool))]
1009async fn get_custom_privacy_policy_checkbox_texts(
1010    course_id: web::Path<Uuid>,
1011    pool: web::Data<PgPool>,
1012    user: AuthUser, // Ensure the user is authenticated
1013) -> ControllerResult<web::Json<Vec<CourseCustomPrivacyPolicyCheckboxText>>> {
1014    let mut conn = pool.acquire().await?;
1015
1016    let token = authorize_access_to_course_material(&mut conn, Some(user.id), *course_id).await?;
1017
1018    let texts = models::course_custom_privacy_policy_checkbox_texts::get_all_by_course_id(
1019        &mut conn, *course_id,
1020    )
1021    .await?;
1022
1023    token.authorized_ok(web::Json(texts))
1024}
1025
1026/**
1027Add a route for each controller in this module.
1028
1029The name starts with an underline in order to appear before other functions in the module documentation.
1030
1031We add the routes by calling the route method instead of using the route annotations because this method preserves the function signatures for documentation.
1032*/
1033pub fn _add_routes(cfg: &mut ServiceConfig) {
1034    cfg.route("/{course_id}", web::get().to(get_course))
1035        .route("/{course_id}/chapters", web::get().to(get_chapters))
1036        .route(
1037            "/{course_id}/course-instances",
1038            web::get().to(get_course_instances),
1039        )
1040        .route(
1041            "/{course_id}/current-instance",
1042            web::get().to(get_current_course_instance),
1043        )
1044        .route("/{course_id}/feedback", web::post().to(feedback))
1045        .route(
1046            "/{course_id}/page-by-path/{url_path:.*}",
1047            web::get().to(get_course_page_by_path),
1048        )
1049        .route(
1050            "/{course_id}/search-pages-with-phrase",
1051            web::post().to(search_pages_with_phrase),
1052        )
1053        .route(
1054            "/{course_id}/language-versions",
1055            web::get().to(get_all_course_language_versions),
1056        )
1057        .route(
1058            "/{course_id}/search-pages-with-words",
1059            web::post().to(search_pages_with_words),
1060        )
1061        .route(
1062            "/{course_id}/user-settings",
1063            web::get().to(get_user_course_settings),
1064        )
1065        .route(
1066            "/{course_id}/top-level-pages",
1067            web::get().to(get_public_top_level_pages),
1068        )
1069        .route("/{course_id}/propose-edit", web::post().to(propose_edit))
1070        .route("/{course_id}/glossary", web::get().to(glossary))
1071        .route(
1072            "/{course_id}/references",
1073            web::get().to(get_material_references_by_course_id),
1074        )
1075        .route(
1076            "/{course_id}/pages/by-language-group-id/{page_language_group_id}",
1077            web::get().to(get_page_by_course_id_and_language_group),
1078        )
1079        .route("/{course_id}/pages", web::get().to(get_public_course_pages))
1080        .route(
1081            "/{course_id}/course-instances/{course_instance_id}/student-countries/{country_code}",
1082            web::post().to(student_country),
1083        )
1084        .route(
1085            "/{course_instance_id}/student-country",
1086            web::get().to(get_student_country),
1087        )
1088        .route(
1089            "/{course_id}/course-instances/{course_instance_id}/student-countries",
1090            web::get().to(get_student_countries),
1091        )
1092        .route(
1093            "/{course_id}/research-consent-form-questions-answer",
1094            web::post().to(upsert_course_research_form_answer),
1095        )
1096        .route(
1097            "/{courseId}/research-consent-form-user-answers",
1098            web::get().to(get_research_form_answers_with_user_id),
1099        )
1100        .route(
1101            "/{course_id}/research-consent-form",
1102            web::get().to(get_research_form_with_course_id),
1103        )
1104        .route(
1105            "/{course_id}/partners-block",
1106            web::get().to(get_partners_block),
1107        )
1108        .route("/{course_id}/privacy-link", web::get().to(get_privacy_link))
1109        .route(
1110            "/{course_id}/research-consent-form-questions",
1111            web::get().to(get_research_form_questions_with_course_id),
1112        )
1113        .route(
1114            "/{course_id}/user-marketing-consent",
1115            web::post().to(update_marketing_consent),
1116        )
1117        .route(
1118            "/{course_id}/fetch-user-marketing-consent",
1119            web::get().to(fetch_user_marketing_consent),
1120        )
1121        .route(
1122            "/{course_id}/custom-privacy-policy-checkbox-texts",
1123            web::get().to(get_custom_privacy_policy_checkbox_texts),
1124        );
1125}