1use std::borrow::Cow;
4use std::collections::{HashMap, HashSet};
5
6use futures::future::BoxFuture;
7use headless_lms_utils::document_schema_processor::walk;
8use url::Url;
9use utoipa::ToSchema;
10
11use crate::{
12 SpecFetcher, agent_changeset_calls,
13 agent_changeset_entities::{self, AgentChangesetEntity, ChangesetEntitySummary},
14 agent_changesets::{self, AgentChangeset, AgentChangesetStatus},
15 courses::{Course, TextEmbeddings},
16 entity_history::{self, HistoryAuthor},
17 error::missing_model_error,
18 exercise_service_info::ExerciseServiceInfoApi,
19 feedback,
20 library::{
21 agent_changesets::{
22 conflicts::{PublishConflict, conflicts_of},
23 course_state::{
24 CourseParts, PublishBlocker, copy_course_default_review_config, load_live_course,
25 },
26 locking::{ChangedSinceReview, LockRejection, lock_open_changeset},
27 risk::{ChangesetState, reclassify},
28 },
29 entity_snapshots::{
30 ChapterSnapshot, ContentEntity, ContentEntityType, CourseDefaultReviewConfigSnapshot,
31 CourseSettingsSnapshot, EntityKey, EntityRef, EntitySnapshot, GlossaryTermSnapshot,
32 ModuleSnapshot, PageSnapshot, ReferenceSnapshot, with_snapshot_type,
33 },
34 },
35 page_language_groups,
36 pages::{self, PageBlockReferences},
37 peer_or_self_review_configs::CmsPeerOrSelfReviewConfiguration,
38 prelude::*,
39 proposed_page_edits,
40 url_redirections::CreatedRedirect,
41};
42
43#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ToSchema)]
45#[serde(tag = "reason", rename_all = "snake_case")]
46pub enum PublishRejection {
47 Conflicts { conflicts: Vec<PublishConflict> },
50 NeedsConfirmation {
52 risky_entities: Vec<ChangesetEntitySummary>,
53 },
54 GlobalAdminOnlyExerciseServices { service_names: Vec<String> },
56 Blockers { blockers: Vec<PublishBlocker> },
59 ChangedSinceReview(ChangedSinceReview),
61 NotOpen { status: AgentChangesetStatus },
63 SaveFailed {
66 entity: Option<EntityRef>,
67 message: String,
68 },
69}
70
71impl From<LockRejection> for PublishRejection {
72 fn from(rejection: LockRejection) -> Self {
73 match rejection {
74 LockRejection::NotOpen(status) => Self::NotOpen { status },
75 LockRejection::ChangedSinceReview(changed) => Self::ChangedSinceReview(changed),
76 }
77 }
78}
79
80#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ToSchema)]
81#[serde(tag = "outcome", rename_all = "snake_case")]
82pub enum PublishOutcome {
83 Published {
84 changeset: AgentChangeset,
85 redirects: Vec<CreatedRedirect>,
86 },
87 Rejected {
88 rejection: PublishRejection,
89 },
90}
91
92#[derive(Debug, Clone, Copy)]
95pub struct PublishOptions {
96 pub can_add_global_admin_only_exercise_services: bool,
98 pub reviewed_revision: Option<i32>,
101 pub seen_revision: Option<i32>,
104}
105
106const LOCK_ORDER: [ContentEntityType; 7] = [
109 ContentEntityType::CourseSettings,
110 ContentEntityType::Module,
111 ContentEntityType::Chapter,
112 ContentEntityType::Page,
113 ContentEntityType::GlossaryTerm,
114 ContentEntityType::Reference,
115 ContentEntityType::CourseDefaultReviewConfig,
116];
117
118async fn lock_live_rows(
121 conn: &mut PgConnection,
122 entities: &[AgentChangesetEntity],
123) -> ModelResult<()> {
124 for entity_type in LOCK_ORDER {
125 let mut ids: Vec<Uuid> = entities
126 .iter()
127 .filter(|entity| entity.entity_type == entity_type)
128 .map(|entity| entity.entity_id)
129 .collect();
130 ids.sort();
131 with_snapshot_type!(entity_type, T => T::lock_rows(conn, &ids).await?);
132 }
133 Ok(())
134}
135
136struct Refusal {
138 rejection: PublishRejection,
139 keeps_reclassification: bool,
141}
142
143pub async fn publish(
155 conn: &mut PgConnection,
156 changeset_id: Uuid,
157 options: PublishOptions,
158 app_conf: &ApplicationConfiguration,
159 spec_fetcher: impl SpecFetcher,
160 fetch_service_info: impl Fn(Url) -> BoxFuture<'static, ModelResult<ExerciseServiceInfoApi>>,
161) -> ModelResult<PublishOutcome> {
162 let embeddings = embed_staged_settings(conn, changeset_id, app_conf).await?;
163 let mut tx = conn.begin().await?;
164 let expected_revision = options.reviewed_revision.or(options.seen_revision);
165 let changeset = match lock_open_changeset(&mut tx, changeset_id, expected_revision).await? {
166 Ok(changeset) => changeset,
167 Err(rejection) => {
168 tx.rollback().await?;
169 return Ok(PublishOutcome::Rejected {
170 rejection: rejection.into(),
171 });
172 }
173 };
174 let course = crate::courses::get_course(&mut tx, changeset.course_id).await?;
175 let publishable = match check_publishable(&mut tx, &changeset, &course, options).await? {
176 Ok(publishable) => publishable,
177 Err(refusal) => {
178 if refusal.keeps_reclassification {
179 tx.commit().await?;
180 } else {
181 tx.rollback().await?;
182 }
183 return Ok(PublishOutcome::Rejected {
184 rejection: refusal.rejection,
185 });
186 }
187 };
188 let context = PublishContext {
189 changeset: &changeset,
190 course: &course,
191 app_conf,
192 embeddings: &embeddings,
193 spec_fetcher: &spec_fetcher,
194 fetch_service_info: &fetch_service_info,
195 };
196 let writes = StagedWrites::of(&publishable.entities, &publishable.bases)?;
197 let written = write_staged(&mut tx, &context, writes).await;
198 let redirects = match written {
199 Ok(redirects) => redirects,
200 Err(stop) => {
201 tx.rollback().await?;
202 return stop.into_outcome();
203 }
204 };
205 let changeset = agent_changesets::mark_published(&mut tx, changeset_id).await?;
206 if let Err(stop) = saved(|| None, tx.commit().await.map_err(ModelError::from)) {
208 return stop.into_outcome();
209 }
210 Ok(PublishOutcome::Published {
211 changeset,
212 redirects,
213 })
214}
215
216async fn embed_staged_settings(
218 conn: &mut PgConnection,
219 changeset_id: Uuid,
220 app_conf: &ApplicationConfiguration,
221) -> ModelResult<TextEmbeddings> {
222 let keys: Vec<EntityKey> = agent_changesets::get_by_id(conn, changeset_id)
223 .await
224 .optional()?
225 .map(|changeset| EntityKey::new(ContentEntityType::CourseSettings, changeset.course_id))
226 .into_iter()
227 .collect();
228 let entities = agent_changeset_entities::get_by_keys(conn, changeset_id, &keys).await?;
229 let bases = agent_changeset_entities::get_bases(conn, changeset_id, &keys).await?;
230 let texts = written::<CourseSettingsSnapshot>(&entities, &bases)
231 .into_iter()
232 .flat_map(|(settings, base)| match base {
233 Some(base) => crate::courses::changed_settings_texts(settings, base),
234 None => crate::courses::settings_texts(settings),
235 })
236 .collect();
237 TextEmbeddings::create(app_conf, texts).await
238}
239
240struct Publishable {
242 entities: Vec<AgentChangesetEntity>,
243 bases: HashMap<EntityKey, EntitySnapshot>,
244}
245
246async fn check_publishable(
250 conn: &mut PgConnection,
251 changeset: &AgentChangeset,
252 course: &Course,
253 options: PublishOptions,
254) -> ModelResult<Result<Publishable, Refusal>> {
255 let entities = agent_changeset_entities::get_by_changeset_id(conn, changeset.id).await?;
256 lock_live_rows(conn, &entities).await?;
259 let keys: Vec<EntityKey> = entities.iter().map(AgentChangesetEntity::key).collect();
260 let bases = agent_changeset_entities::get_bases(conn, changeset.id, &keys).await?;
261
262 let conflicts = conflicts_of(conn, &entities, &bases).await?;
263 if !conflicts.is_empty() {
264 return Ok(Err(Refusal {
265 rejection: PublishRejection::Conflicts { conflicts },
266 keeps_reclassification: false,
267 }));
268 }
269
270 let live = load_live_course(conn, course, CourseParts::All).await?;
271 let state = ChangesetState::of_rows(Cow::Owned(live), &entities);
272 let blockers = state.staged.blockers_added_since(&state.live);
273 if !blockers.is_empty() {
274 return Ok(Err(Refusal {
275 rejection: PublishRejection::Blockers { blockers },
276 keeps_reclassification: false,
277 }));
278 }
279
280 let reclassified = reclassify(conn, changeset.id, &state).await?;
281 if reclassified.has_changed_reasons {
282 let revision = agent_changesets::increment_revision(conn, changeset.id).await?;
283 if options.reviewed_revision.is_some() && reclassified.has_new_reasons {
284 return Ok(Err(Refusal {
285 rejection: PublishRejection::ChangedSinceReview(ChangedSinceReview { revision }),
286 keeps_reclassification: true,
287 }));
288 }
289 }
290 if options.reviewed_revision.is_none() {
291 let risky_entities: Vec<ChangesetEntitySummary> = entities
292 .iter()
293 .filter_map(|entity| {
294 Some(ChangesetEntitySummary {
295 risk_reasons: reclassified.reasons.get(&entity.key())?.clone(),
296 ..ChangesetEntitySummary::from(entity)
297 })
298 })
299 .collect();
300 if !risky_entities.is_empty() {
301 return Ok(Err(Refusal {
302 rejection: PublishRejection::NeedsConfirmation { risky_entities },
303 keeps_reclassification: true,
304 }));
305 }
306 }
307
308 if !options.can_add_global_admin_only_exercise_services {
309 let tasks: Vec<_> = entities
310 .iter()
311 .filter_map(|entity| {
312 entity
313 .after_snapshot
314 .as_ref()
315 .and_then(PageSnapshot::narrow)
316 })
317 .flat_map(|page| page.exercise_tasks.iter())
318 .cloned()
319 .collect();
320 let service_names = pages::get_global_admin_only_exercise_service_names_added_by_tasks(
321 conn,
322 pages::ExistingTasks::InCourse(changeset.course_id),
323 &tasks,
324 )
325 .await?;
326 if !service_names.is_empty() {
327 return Ok(Err(Refusal {
328 rejection: PublishRejection::GlobalAdminOnlyExerciseServices { service_names },
329 keeps_reclassification: false,
330 }));
331 }
332 }
333 Ok(Ok(Publishable { entities, bases }))
334}
335
336struct StagedWrites<'a> {
338 modules: Vec<&'a ModuleSnapshot>,
339 chapters: Vec<&'a ChapterSnapshot>,
340 pages: Vec<(&'a PageSnapshot, Option<&'a PageSnapshot>)>,
342 course_settings: Vec<(&'a CourseSettingsSnapshot, &'a CourseSettingsSnapshot)>,
344 glossary_terms: Vec<&'a GlossaryTermSnapshot>,
345 references: Vec<&'a ReferenceSnapshot>,
346 course_default_review_configs: Vec<&'a CourseDefaultReviewConfigSnapshot>,
347 deleted: HashMap<ContentEntityType, Vec<&'a AgentChangesetEntity>>,
348}
349
350fn written<'a, T: ContentEntity>(
352 entities: &'a [AgentChangesetEntity],
353 bases: &'a HashMap<EntityKey, EntitySnapshot>,
354) -> Vec<(&'a T, Option<&'a T>)> {
355 entities
356 .iter()
357 .filter_map(|entity| {
358 let after = T::narrow(entity.after_snapshot.as_ref()?)?;
359 Some((after, bases.get(&entity.key()).and_then(T::narrow)))
360 })
361 .collect()
362}
363
364fn afters<T>(written: Vec<(T, Option<T>)>) -> Vec<T> {
365 written.into_iter().map(|(after, _)| after).collect()
366}
367
368impl<'a> StagedWrites<'a> {
369 fn of(
370 entities: &'a [AgentChangesetEntity],
371 bases: &'a HashMap<EntityKey, EntitySnapshot>,
372 ) -> ModelResult<Self> {
373 let mut deleted: HashMap<ContentEntityType, Vec<&AgentChangesetEntity>> = HashMap::new();
374 for entity in entities.iter().filter(|e| e.after_snapshot.is_none()) {
375 deleted.entry(entity.entity_type).or_default().push(entity);
376 }
377 let course_settings = written::<CourseSettingsSnapshot>(entities, bases)
378 .into_iter()
379 .map(|(settings, live)| {
380 let live = live.ok_or_else(missing_model_error(
381 ModelErrorType::Generic,
382 "Course settings were staged without their live state",
383 ))?;
384 Ok((settings, live))
385 })
386 .collect::<ModelResult<_>>()?;
387 Ok(Self {
388 modules: afters(written(entities, bases)),
389 chapters: afters(written(entities, bases)),
390 pages: written(entities, bases),
391 course_settings,
392 glossary_terms: afters(written(entities, bases)),
393 references: afters(written(entities, bases)),
394 course_default_review_configs: afters(written(entities, bases)),
395 deleted,
396 })
397 }
398
399 fn deleted(&self, entity_type: ContentEntityType) -> &[&'a AgentChangesetEntity] {
400 self.deleted.get(&entity_type).map_or(&[], Vec::as_slice)
401 }
402}
403
404fn exercises_switched_to_own_review_config(
406 pages: &[(&PageSnapshot, Option<&PageSnapshot>)],
407) -> HashSet<Uuid> {
408 let used_default: HashSet<Uuid> = pages
409 .iter()
410 .filter_map(|(_, base)| *base)
411 .flat_map(|base| base.exercises.iter())
412 .filter(|exercise| exercise.use_course_default_peer_or_self_review_config)
413 .map(|exercise| exercise.id)
414 .collect();
415 pages
416 .iter()
417 .flat_map(|(page, _)| page.exercises.iter())
418 .filter(|exercise| {
419 !exercise.use_course_default_peer_or_self_review_config
420 && used_default.contains(&exercise.id)
421 })
422 .map(|exercise| exercise.id)
423 .collect()
424}
425
426fn with_course_default_copies<'a>(
429 page: &'a PageSnapshot,
430 switched: &HashSet<Uuid>,
431 course_default: Option<&CmsPeerOrSelfReviewConfiguration>,
432) -> Cow<'a, PageSnapshot> {
433 let Some(course_default) = course_default else {
434 return Cow::Borrowed(page);
435 };
436 if !page
437 .exercises
438 .iter()
439 .any(|exercise| switched.contains(&exercise.id))
440 {
441 return Cow::Borrowed(page);
442 }
443 let mut page = page.clone();
444 for exercise in page
445 .exercises
446 .iter_mut()
447 .filter(|exercise| switched.contains(&exercise.id))
448 {
449 if let Some(own) = exercise.peer_or_self_review_config.take() {
450 copy_course_default_review_config(
451 exercise,
452 course_default,
453 own.id,
454 own.review_instructions,
455 Uuid::new_v4,
456 );
457 }
458 }
459 Cow::Owned(page)
460}
461
462struct Authors {
465 user_id: Uuid,
466 call_ids: HashMap<EntityKey, Uuid>,
467}
468
469impl Authors {
470 fn of(&self, key: EntityKey) -> ModelResult<HistoryAuthor> {
471 let call_id = self.call_ids.get(&key).ok_or_else(missing_model_error(
472 ModelErrorType::Generic,
473 format!("No call of the changeset staged {key:?}"),
474 ))?;
475 Ok(HistoryAuthor::agent(self.user_id, *call_id))
476 }
477
478 async fn record_saved<T: ContentEntity>(
480 &self,
481 conn: &mut PgConnection,
482 snapshot: &T,
483 ) -> ModelResult<()> {
484 let author = self.of(EntityKey::new(T::ENTITY_TYPE, snapshot.id()))?;
485 entity_history::record_saved(conn, &[snapshot.clone().into()], author).await
486 }
487}
488
489enum WriteStop {
491 Refused(PublishRejection),
492 Failed(ModelError),
493}
494
495impl From<ModelError> for WriteStop {
496 fn from(error: ModelError) -> Self {
497 Self::Failed(error)
498 }
499}
500
501impl WriteStop {
502 fn into_outcome(self) -> ModelResult<PublishOutcome> {
503 match self {
504 Self::Refused(rejection) => Ok(PublishOutcome::Rejected { rejection }),
505 Self::Failed(error) => Err(error),
506 }
507 }
508}
509
510fn saved<T>(
513 entity: impl FnOnce() -> Option<EntityRef>,
514 result: ModelResult<T>,
515) -> Result<T, WriteStop> {
516 result.map_err(|error| match error.error_type() {
517 ModelErrorType::PreconditionFailed
518 | ModelErrorType::PreconditionFailedWithCMSAnchorBlockId { .. }
519 | ModelErrorType::InvalidRequest
520 | ModelErrorType::DatabaseConstraint { .. } => {
521 WriteStop::Refused(PublishRejection::SaveFailed {
522 entity: entity(),
523 message: error.message().to_string(),
524 })
525 }
526 _ => WriteStop::Failed(error),
527 })
528}
529
530fn entity_ref<T: ContentEntity>(snapshot: &T) -> Option<EntityRef> {
531 Some(EntityRef {
532 entity_type: T::ENTITY_TYPE,
533 entity_id: snapshot.id(),
534 label: snapshot.label().to_string(),
535 })
536}
537
538struct PublishContext<'a, S, F> {
540 changeset: &'a AgentChangeset,
541 course: &'a Course,
542 app_conf: &'a ApplicationConfiguration,
543 embeddings: &'a TextEmbeddings,
545 spec_fetcher: &'a S,
546 fetch_service_info: &'a F,
547}
548
549async fn write_staged<S, F>(
552 conn: &mut PgConnection,
553 context: &PublishContext<'_, S, F>,
554 writes: StagedWrites<'_>,
555) -> Result<Vec<CreatedRedirect>, WriteStop>
556where
557 S: SpecFetcher,
558 F: Fn(Url) -> BoxFuture<'static, ModelResult<ExerciseServiceInfoApi>>,
559{
560 let PublishContext {
561 changeset,
562 course,
563 app_conf,
564 embeddings,
565 spec_fetcher,
566 fetch_service_info,
567 } = *context;
568 let authors = Authors {
569 user_id: changeset.user_id,
570 call_ids: agent_changeset_calls::get_latest_by_entity(conn, changeset.id).await?,
571 };
572 crate::courses::defer_structure_constraints(conn).await?;
575
576 for module in &writes.modules {
577 let author = authors.of(EntityKey::new(ContentEntityType::Module, module.id))?;
578 saved(
579 || entity_ref(*module),
580 crate::course_modules::upsert_from_snapshot(conn, module, author.user_id).await,
581 )?;
582 authors.record_saved(conn, *module).await?;
583 }
584
585 for chapter in &writes.chapters {
586 saved(
587 || entity_ref(*chapter),
588 crate::chapters::upsert_from_snapshot(conn, chapter).await,
589 )?;
590 authors.record_saved(conn, *chapter).await?;
591 }
592
593 let switched = exercises_switched_to_own_review_config(&writes.pages);
594 let course_default = if switched.is_empty() {
595 None
596 } else {
597 crate::peer_or_self_review_configs::get_course_default_cms_peer_review_configuration(
598 conn, course.id,
599 )
600 .await?
601 };
602 let mut redirects = Vec::new();
603 for (page, live) in &writes.pages {
604 let page = &with_course_default_copies(page, &switched, course_default.as_ref());
605 let saved_page = pages::upsert_from_snapshot(
606 conn,
607 course,
608 page,
609 live.is_none(),
610 authors.of(EntityKey::new(ContentEntityType::Page, page.id))?,
611 spec_fetcher,
612 fetch_service_info,
613 )
614 .await;
615 redirects.extend(saved(|| entity_ref(&**page), saved_page)?);
616 }
617
618 for (settings, live) in &writes.course_settings {
619 saved(
621 || entity_ref(*settings),
622 crate::courses::write_settings(conn, app_conf, settings, live, false, embeddings).await,
623 )?;
624 authors.record_saved(conn, *settings).await?;
625 }
626 for term in &writes.glossary_terms {
627 saved(
628 || entity_ref(*term),
629 crate::glossary::upsert_from_snapshot(conn, term).await,
630 )?;
631 }
632 for reference in &writes.references {
633 saved(
634 || entity_ref(*reference),
635 crate::material_references::upsert_from_snapshot(conn, reference).await,
636 )?;
637 }
638
639 for config in &writes.course_default_review_configs {
640 saved(
641 || entity_ref(*config),
642 crate::peer_or_self_review_configs::set_course_default_review_instructions(
643 conn,
644 config.id,
645 config.course_id,
646 config.review_instructions.as_ref(),
647 )
648 .await,
649 )?;
650 authors.record_saved(conn, *config).await?;
651 }
652
653 move_block_references(conn, changeset.course_id, &writes).await?;
654 delete_removed(conn, &writes, &authors).await?;
655 for (page, _) in writes.pages.iter().filter(|(_, live)| live.is_none()) {
658 let Some(group) = page.joins_page_language_group_id else {
659 continue;
660 };
661 if page_language_groups::course_has_other_page(conn, group, course.id, page.id).await? {
662 return Err(WriteStop::Refused(PublishRejection::SaveFailed {
663 entity: entity_ref(*page),
664 message:
665 "This course already has its version of the page this page was paired with"
666 .to_string(),
667 }));
668 }
669 }
670 Ok(redirects)
671}
672
673async fn move_block_references(
679 conn: &mut PgConnection,
680 course_id: Uuid,
681 writes: &StagedWrites<'_>,
682) -> ModelResult<()> {
683 let mut changed_page_ids: Vec<Uuid> = writes
684 .deleted(ContentEntityType::Page)
685 .iter()
686 .map(|page| page.entity_id)
687 .collect();
688 let mut page_of_block = HashMap::new();
689 for (page, live) in &writes.pages {
690 if live.is_some() {
691 changed_page_ids.push(page.id);
692 }
693 walk(&page.content, &mut |block, _| {
694 page_of_block.insert(block.client_id, page.id);
695 });
696 }
697 if changed_page_ids.is_empty() {
698 return Ok(());
699 }
700 let moves = |records: &[PageBlockReferences]| {
701 let mut ids = Vec::new();
702 let mut page_ids = Vec::new();
703 for record in records {
704 let mut pages = record
705 .block_ids
706 .iter()
707 .filter_map(|id| page_of_block.get(id).copied());
708 if pages.clone().any(|page_id| page_id == record.page_id) {
709 continue;
710 }
711 if let Some(page_id) = pages.next() {
712 ids.push(record.id);
713 page_ids.push(page_id);
714 }
715 }
716 (ids, page_ids)
717 };
718
719 let (ids, page_ids) =
720 moves(&feedback::get_block_ids_by_page_ids(conn, course_id, &changed_page_ids).await?);
721 feedback::set_page_ids(conn, &ids, &page_ids).await?;
722 let (ids, page_ids) = moves(
723 &proposed_page_edits::get_block_ids_by_page_ids(conn, course_id, &changed_page_ids).await?,
724 );
725 proposed_page_edits::set_page_ids(conn, &ids, &page_ids).await?;
726 Ok(())
727}
728
729async fn delete_removed(
732 conn: &mut PgConnection,
733 writes: &StagedWrites<'_>,
734 authors: &Authors,
735) -> Result<(), WriteStop> {
736 for chapter in writes.deleted(ContentEntityType::Chapter) {
737 let author = authors.of(chapter.key())?;
738 saved(
739 || Some(chapter.entity_ref()),
740 crate::chapters::delete_chapter(conn, chapter.entity_id, author).await,
741 )?;
742 }
743 for page in writes.deleted(ContentEntityType::Page) {
744 if pages::exists(conn, page.entity_id).await? {
746 let author = authors.of(page.key())?;
747 saved(
748 || Some(page.entity_ref()),
749 pages::delete_page_and_exercises(conn, page.entity_id, author).await,
750 )?;
751 }
752 }
753 for module in writes.deleted(ContentEntityType::Module) {
754 entity_history::record_deleted(
755 conn,
756 module.entity_type,
757 module.entity_id,
758 authors.of(module.key())?,
759 )
760 .await?;
761 saved(
762 || Some(module.entity_ref()),
763 crate::course_modules::delete(conn, module.entity_id).await,
764 )?;
765 }
766 for term in writes.deleted(ContentEntityType::GlossaryTerm) {
767 saved(
768 || Some(term.entity_ref()),
769 crate::glossary::delete(conn, term.entity_id).await,
770 )?;
771 }
772 for reference in writes.deleted(ContentEntityType::Reference) {
773 saved(
774 || Some(reference.entity_ref()),
775 crate::material_references::delete_reference(conn, reference.entity_id).await,
776 )?;
777 }
778 Ok(())
779}