1use std::collections::{BTreeMap, BTreeSet};
2
3use sqlx::{Postgres, Row, Transaction};
4
5use crate::defaults::{
6 default_monetization_payload, default_navigation_payload, default_page_payload,
7 default_page_templates, default_site_settings_payload, default_tipping_payload,
8 DEFAULT_SITE_NAME,
9};
10use crate::error::{CedrosDataError, Result};
11use crate::models::{
12 Collection, CollectionMode, DefaultPageTemplate, EntryRecord, QueryEntriesRequest,
13 RegisterCollectionRequest, RegisterSiteRequest, SiteBootstrapReport, UpsertEntryRequest,
14};
15
16use super::{load_site_if_configured_with, map_collection_row, CedrosData};
17
18const DEFAULT_COLLECTION_NAMES: [&str; 3] = ["pages", "navigation", "site_settings"];
19
20impl CedrosData {
21 pub async fn ensure_site_exists(&self) -> Result<()> {
22 if load_site_if_configured_with(self.pool()).await?.is_none() {
23 self.register_site(RegisterSiteRequest {
24 display_name: DEFAULT_SITE_NAME.to_string(),
25 metadata: serde_json::json!({ "seeded": true }),
26 })
27 .await?;
28 }
29 Ok(())
30 }
31
32 pub async fn bootstrap_defaults(&self) -> Result<SiteBootstrapReport> {
33 self.ensure_site_exists().await?;
34 let mut tx = self.pool().begin().await?;
35 let report = self.bootstrap_defaults_in_transaction(&mut tx).await?;
36 tx.commit().await?;
37 Ok(report)
38 }
39
40 pub async fn list_default_page_entries(&self) -> Result<Vec<EntryRecord>> {
41 self.query_entries(default_page_entries_request()).await
42 }
43
44 pub async fn upsert_default_page_entry(
45 &self,
46 page_key: &str,
47 payload: serde_json::Value,
48 ) -> Result<EntryRecord> {
49 if !payload.is_object() {
50 return Err(CedrosDataError::InvalidRequest(
51 "page payload must be a JSON object".to_string(),
52 ));
53 }
54
55 self.upsert_entry(UpsertEntryRequest {
56 collection_name: "pages".to_string(),
57 entry_key: page_key.to_string(),
58 payload,
59 })
60 .await
61 }
62
63 pub(super) async fn bootstrap_defaults_in_transaction(
64 &self,
65 tx: &mut Transaction<'_, Postgres>,
66 ) -> Result<SiteBootstrapReport> {
67 let templates = default_page_templates();
68 let mut collections = load_default_collections_with(&mut **tx).await?;
69 let collections_seeded = seed_collections(self, tx, &mut collections).await?;
70 let existing_entries = load_default_entry_keys_with(&mut **tx).await?;
71
72 let pages_seeded = seed_pages(
73 self,
74 tx,
75 require_collection(&collections, "pages")?,
76 existing_entries.get("pages"),
77 &templates,
78 )
79 .await?;
80 let nav_seeded = seed_navigation(
81 self,
82 tx,
83 require_collection(&collections, "navigation")?,
84 existing_entries.get("navigation"),
85 &templates,
86 )
87 .await?;
88 let settings_seeded = seed_site_settings(
89 self,
90 tx,
91 require_collection(&collections, "site_settings")?,
92 existing_entries.get("site_settings"),
93 )
94 .await?;
95 let tipping_seeded = seed_settings_entry(
96 self,
97 tx,
98 require_collection(&collections, "site_settings")?,
99 existing_entries.get("site_settings"),
100 "tipping",
101 default_tipping_payload(),
102 )
103 .await?;
104 let monetization_seeded = seed_settings_entry(
105 self,
106 tx,
107 require_collection(&collections, "site_settings")?,
108 existing_entries.get("site_settings"),
109 "monetization",
110 default_monetization_payload(),
111 )
112 .await?;
113
114 Ok(SiteBootstrapReport {
115 collections_seeded,
116 entries_seeded: pages_seeded
117 + nav_seeded
118 + settings_seeded
119 + tipping_seeded
120 + monetization_seeded,
121 })
122 }
123}
124
125async fn seed_collections(
126 store: &CedrosData,
127 tx: &mut Transaction<'_, Postgres>,
128 collections: &mut BTreeMap<String, Collection>,
129) -> Result<usize> {
130 let mut seeded = 0;
131
132 for collection_name in DEFAULT_COLLECTION_NAMES {
133 if collections.contains_key(collection_name) {
134 continue;
135 }
136 let collection = store
137 .register_collection_in_transaction(
138 tx,
139 RegisterCollectionRequest {
140 collection_name: collection_name.to_string(),
141 mode: CollectionMode::Jsonb,
142 table_name: None,
143 strict_contract: None,
144 },
145 true,
146 )
147 .await?;
148 collections.insert(collection_name.to_string(), collection);
149 seeded += 1;
150 }
151
152 Ok(seeded)
153}
154
155async fn seed_pages(
156 store: &CedrosData,
157 tx: &mut Transaction<'_, Postgres>,
158 collection: &Collection,
159 existing_keys: Option<&BTreeSet<String>>,
160 templates: &[DefaultPageTemplate],
161) -> Result<usize> {
162 let mut seeded = 0;
163 for template in templates {
164 if has_entry_key(existing_keys, &template.key) {
165 continue;
166 }
167 store
168 .upsert_entry_for_collection_in_transaction(
169 tx,
170 collection,
171 &UpsertEntryRequest {
172 collection_name: "pages".to_string(),
173 entry_key: template.key.clone(),
174 payload: default_page_payload(template),
175 },
176 )
177 .await?;
178 seeded += 1;
179 }
180 Ok(seeded)
181}
182
183async fn seed_navigation(
184 store: &CedrosData,
185 tx: &mut Transaction<'_, Postgres>,
186 collection: &Collection,
187 existing_keys: Option<&BTreeSet<String>>,
188 templates: &[DefaultPageTemplate],
189) -> Result<usize> {
190 if has_entry_key(existing_keys, "main") {
191 return Ok(0);
192 }
193
194 store
195 .upsert_entry_for_collection_in_transaction(
196 tx,
197 collection,
198 &UpsertEntryRequest {
199 collection_name: "navigation".to_string(),
200 entry_key: "main".to_string(),
201 payload: default_navigation_payload(templates),
202 },
203 )
204 .await?;
205 Ok(1)
206}
207
208async fn seed_site_settings(
209 store: &CedrosData,
210 tx: &mut Transaction<'_, Postgres>,
211 collection: &Collection,
212 existing_keys: Option<&BTreeSet<String>>,
213) -> Result<usize> {
214 if has_entry_key(existing_keys, "global") {
215 return Ok(0);
216 }
217
218 store
219 .upsert_entry_for_collection_in_transaction(
220 tx,
221 collection,
222 &UpsertEntryRequest {
223 collection_name: "site_settings".to_string(),
224 entry_key: "global".to_string(),
225 payload: default_site_settings_payload(),
226 },
227 )
228 .await?;
229 Ok(1)
230}
231
232async fn seed_settings_entry(
233 store: &CedrosData,
234 tx: &mut Transaction<'_, Postgres>,
235 collection: &Collection,
236 existing_keys: Option<&BTreeSet<String>>,
237 entry_key: &str,
238 payload: serde_json::Value,
239) -> Result<usize> {
240 if has_entry_key(existing_keys, entry_key) {
241 return Ok(0);
242 }
243
244 store
245 .upsert_entry_for_collection_in_transaction(
246 tx,
247 collection,
248 &UpsertEntryRequest {
249 collection_name: "site_settings".to_string(),
250 entry_key: entry_key.to_string(),
251 payload,
252 },
253 )
254 .await?;
255 Ok(1)
256}
257
258fn require_collection<'a>(
259 collections: &'a BTreeMap<String, Collection>,
260 collection_name: &str,
261) -> Result<&'a Collection> {
262 collections
263 .get(collection_name)
264 .ok_or_else(|| CedrosDataError::CollectionNotFound(collection_name.to_string()))
265}
266
267fn has_entry_key(existing_keys: Option<&BTreeSet<String>>, entry_key: &str) -> bool {
268 existing_keys.is_some_and(|keys| keys.contains(entry_key))
269}
270
271async fn load_default_collections_with<'e, E>(executor: E) -> Result<BTreeMap<String, Collection>>
272where
273 E: sqlx::Executor<'e, Database = Postgres>,
274{
275 let rows = sqlx::query(
276 "SELECT collection_name, mode, table_name, strict_contract
277 FROM collections
278 WHERE collection_name = ANY($1)",
279 )
280 .bind(default_collection_names())
281 .fetch_all(executor)
282 .await?;
283
284 rows.into_iter()
285 .map(map_collection_row)
286 .map(|result| result.map(|collection| (collection.collection_name.clone(), collection)))
287 .collect()
288}
289
290async fn load_default_entry_keys_with<'e, E>(
291 executor: E,
292) -> Result<BTreeMap<String, BTreeSet<String>>>
293where
294 E: sqlx::Executor<'e, Database = Postgres>,
295{
296 let rows = sqlx::query(
297 "SELECT collection_name, entry_key
298 FROM entries
299 WHERE collection_name = ANY($1)",
300 )
301 .bind(default_collection_names())
302 .fetch_all(executor)
303 .await?;
304
305 let mut entries = BTreeMap::new();
306 for row in rows {
307 let collection_name = row.get::<String, _>("collection_name");
308 let entry_key = row.get::<String, _>("entry_key");
309 entries
310 .entry(collection_name)
311 .or_insert_with(BTreeSet::new)
312 .insert(entry_key);
313 }
314 Ok(entries)
315}
316
317fn default_collection_names() -> Vec<String> {
318 DEFAULT_COLLECTION_NAMES
319 .iter()
320 .map(|name| (*name).to_string())
321 .collect()
322}
323
324fn default_page_entries_request() -> QueryEntriesRequest {
325 let entry_keys = default_page_templates()
326 .into_iter()
327 .map(|template| template.key)
328 .collect::<Vec<String>>();
329
330 QueryEntriesRequest {
331 collection_name: "pages".to_string(),
332 limit: entry_keys.len() as i64,
333 entry_keys,
334 contains: None,
335 offset: 0,
336 visitor_id: None,
337 }
338}
339
340#[cfg(test)]
341mod tests {
342 use super::default_page_entries_request;
343
344 #[test]
345 fn default_page_entries_request_targets_known_default_page_keys() {
346 let request = default_page_entries_request();
347 assert_eq!(request.collection_name, "pages");
348 assert_eq!(request.limit, request.entry_keys.len() as i64);
349 assert!(request.entry_keys.contains(&"home".to_string()));
350 assert!(request.entry_keys.contains(&"not-found".to_string()));
351 }
352}