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}