Skip to main content

coil_cms/model/
page.rs

1use super::*;
2
3#[derive(Debug, Clone, PartialEq, Eq)]
4pub struct SeoMetadata {
5    pub title: Option<String>,
6    pub description: Option<String>,
7    pub canonical_path: Option<String>,
8}
9
10impl SeoMetadata {
11    pub fn new(
12        title: Option<String>,
13        description: Option<String>,
14        canonical_path: Option<String>,
15    ) -> Result<Self, CmsModelError> {
16        if let Some(path) = canonical_path.as_ref() {
17            validate_path("canonical_path", path.clone())?;
18        }
19
20        Ok(Self {
21            title,
22            description,
23            canonical_path,
24        })
25    }
26}
27
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct PageRevision {
30    pub id: RevisionId,
31    pub title: String,
32    pub slug: Slug,
33    pub template: TemplateHandle,
34    pub body_html: String,
35    pub seo: SeoMetadata,
36    pub media_references: BTreeSet<AssetReference>,
37}
38
39impl PageRevision {
40    pub fn new(
41        id: RevisionId,
42        title: impl Into<String>,
43        slug: Slug,
44        template: TemplateHandle,
45        body_html: impl Into<String>,
46        seo: SeoMetadata,
47    ) -> Result<Self, CmsModelError> {
48        Ok(Self {
49            id,
50            title: require_non_empty("title", title.into())?,
51            slug,
52            template,
53            body_html: require_non_empty("body_html", body_html.into())?,
54            seo,
55            media_references: BTreeSet::new(),
56        })
57    }
58
59    pub fn with_media_reference(mut self, asset: AssetReference) -> Self {
60        self.media_references.insert(asset);
61        self
62    }
63}
64
65#[derive(Debug, Clone, Copy, PartialEq, Eq)]
66pub enum PageWorkflowStatus {
67    DraftOnly,
68    Scheduled,
69    Published,
70    PublishedWithDraft,
71    PublishedWithScheduledDraft,
72    Unpublished,
73}
74
75impl fmt::Display for PageWorkflowStatus {
76    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
77        match self {
78            Self::DraftOnly => f.write_str("draft_only"),
79            Self::Scheduled => f.write_str("scheduled"),
80            Self::Published => f.write_str("published"),
81            Self::PublishedWithDraft => f.write_str("published_with_draft"),
82            Self::PublishedWithScheduledDraft => f.write_str("published_with_scheduled_draft"),
83            Self::Unpublished => f.write_str("unpublished"),
84        }
85    }
86}
87
88#[derive(Debug, Clone, PartialEq, Eq)]
89pub struct PublicationState {
90    live_revision: Option<PageRevision>,
91    scheduled_publish_at: Option<u64>,
92    was_ever_published: bool,
93}
94
95impl PublicationState {
96    pub fn live_revision(&self) -> Option<&PageRevision> {
97        self.live_revision.as_ref()
98    }
99
100    pub fn scheduled_publish_at(&self) -> Option<u64> {
101        self.scheduled_publish_at
102    }
103
104    pub fn is_live(&self) -> bool {
105        self.live_revision.is_some()
106    }
107}
108
109#[derive(Debug, Clone, PartialEq, Eq)]
110pub struct CmsPage {
111    pub id: PageId,
112    pub locale: LocaleCode,
113    pub current_revision: PageRevision,
114    publication: PublicationState,
115}
116
117impl CmsPage {
118    pub fn new(id: PageId, locale: LocaleCode, initial_revision: PageRevision) -> Self {
119        Self {
120            id,
121            locale,
122            current_revision: initial_revision,
123            publication: PublicationState {
124                live_revision: None,
125                scheduled_publish_at: None,
126                was_ever_published: false,
127            },
128        }
129    }
130
131    pub fn publication(&self) -> &PublicationState {
132        &self.publication
133    }
134
135    pub fn workflow_status(&self) -> PageWorkflowStatus {
136        match (
137            self.publication.live_revision.as_ref(),
138            self.publication.scheduled_publish_at,
139            self.publication.was_ever_published,
140            self.publication
141                .live_revision
142                .as_ref()
143                .map(|revision| revision.id != self.current_revision.id)
144                .unwrap_or(false),
145        ) {
146            (Some(_), Some(_), _, _) => PageWorkflowStatus::PublishedWithScheduledDraft,
147            (None, Some(_), _, _) => PageWorkflowStatus::Scheduled,
148            (Some(_), None, _, true) => PageWorkflowStatus::PublishedWithDraft,
149            (Some(_), None, _, false) => PageWorkflowStatus::Published,
150            (None, None, true, _) => PageWorkflowStatus::Unpublished,
151            (None, None, false, _) => PageWorkflowStatus::DraftOnly,
152        }
153    }
154
155    pub fn preview_revision(&self) -> &PageRevision {
156        &self.current_revision
157    }
158
159    pub fn live_revision(&self) -> Result<&PageRevision, CmsModelError> {
160        self.publication
161            .live_revision()
162            .ok_or_else(|| CmsModelError::MissingLiveRevision {
163                page_id: self.id.to_string(),
164            })
165    }
166
167    pub fn replace_draft(&mut self, revision: PageRevision) {
168        self.current_revision = revision;
169    }
170
171    pub fn publish_current(&mut self) {
172        self.publication.live_revision = Some(self.current_revision.clone());
173        self.publication.scheduled_publish_at = None;
174        self.publication.was_ever_published = true;
175    }
176
177    pub fn schedule_current(&mut self, publish_at: u64, now: u64) -> Result<(), CmsModelError> {
178        if publish_at <= now {
179            return Err(CmsModelError::CannotScheduleInThePast { publish_at, now });
180        }
181
182        self.publication.scheduled_publish_at = Some(publish_at);
183        Ok(())
184    }
185
186    pub fn apply_schedule(&mut self, now: u64) -> bool {
187        if self
188            .publication
189            .scheduled_publish_at
190            .is_some_and(|publish_at| publish_at <= now)
191        {
192            self.publish_current();
193            true
194        } else {
195            false
196        }
197    }
198
199    pub fn unpublish(&mut self) -> Result<(), CmsModelError> {
200        self.live_revision()?;
201        self.publication.live_revision = None;
202        self.publication.scheduled_publish_at = None;
203        self.publication.was_ever_published = true;
204        Ok(())
205    }
206
207    pub fn live_path(&self) -> Result<String, CmsModelError> {
208        let live = self.live_revision()?;
209        Ok(format!("/{}/{}", self.locale, live.slug.as_str()))
210    }
211
212    pub fn preview_path(&self) -> String {
213        format!("/{}/{}", self.locale, self.current_revision.slug.as_str())
214    }
215
216    pub fn save_draft_transaction_plan(&self) -> Result<TransactionPlan, CmsModelError> {
217        TransactionPlan::new("cms.page.save_draft", TransactionIsolation::ReadCommitted)?
218            .with_write(DomainWrite::new("cms_page", "upsert")?)
219            .with_write(DomainWrite::new("cms_revision", "insert")?)
220            .with_after_commit_event(format!("cms.page.draft_saved:{}", self.id))
221            .map_err(Into::into)
222    }
223
224    pub fn publish_transaction_plan(&self) -> Result<TransactionPlan, CmsModelError> {
225        TransactionPlan::new("cms.page.publish", TransactionIsolation::Serializable)?
226            .with_write(DomainWrite::new("cms_page", "update")?)
227            .with_write(DomainWrite::new("route_projection", "upsert")?)
228            .with_write(DomainWrite::new("sitemap_entry", "upsert")?)
229            .with_after_commit_job(format!("cms.jobs.cache_invalidate:{}", self.id))
230            .and_then(|plan| {
231                plan.with_after_commit_event(format!("cms.page.published:{}", self.id))
232            })
233            .map_err(Into::into)
234    }
235
236    pub fn schedule_transaction_plan(&self) -> Result<TransactionPlan, CmsModelError> {
237        TransactionPlan::new("cms.page.schedule", TransactionIsolation::Serializable)?
238            .with_write(DomainWrite::new("cms_page", "update")?)
239            .with_write(DomainWrite::new("publication_schedule", "upsert")?)
240            .with_after_commit_job(format!("cms.jobs.publication_schedule.enqueue:{}", self.id))
241            .and_then(|plan| {
242                plan.with_after_commit_event(format!("cms.page.scheduled:{}", self.id))
243            })
244            .map_err(Into::into)
245    }
246
247    pub fn unpublish_transaction_plan(&self) -> Result<TransactionPlan, CmsModelError> {
248        TransactionPlan::new("cms.page.unpublish", TransactionIsolation::Serializable)?
249            .with_write(DomainWrite::new("cms_page", "update")?)
250            .with_write(DomainWrite::new("route_projection", "delete")?)
251            .with_write(DomainWrite::new("sitemap_entry", "delete")?)
252            .with_after_commit_job(format!("cms.jobs.cache_invalidate:{}", self.id))
253            .and_then(|plan| {
254                plan.with_after_commit_event(format!("cms.page.unpublished:{}", self.id))
255            })
256            .map_err(Into::into)
257    }
258}
259
260#[derive(Debug, Clone, PartialEq, Eq)]
261pub struct RedirectRule {
262    pub from_path: String,
263    pub to_path: String,
264    pub permanent: bool,
265}
266
267impl RedirectRule {
268    pub fn new(
269        from_path: impl Into<String>,
270        to_path: impl Into<String>,
271        permanent: bool,
272    ) -> Result<Self, CmsModelError> {
273        Ok(Self {
274            from_path: validate_path("redirect_from", from_path.into())?,
275            to_path: validate_path("redirect_to", to_path.into())?,
276            permanent,
277        })
278    }
279}
280
281#[derive(Debug, Clone, PartialEq, Eq)]
282pub struct CmsPageQuery {
283    pub query: QuerySpec,
284}
285
286#[derive(Debug, Clone, PartialEq, Eq)]
287pub struct RedirectLookupQuery {
288    pub query: QuerySpec,
289}