//! Custom-page drafts and live-source updates (Custom Pages, Phase 3). //! //! Drafts let a creator experiment without touching the live page. There is one //! draft per `(owner, page_kind, page)`; the editor upserts it on every //! keystroke (debounced) and the preview renders from it. Saving promotes the //! draft to the live `custom_html`/`custom_css` columns and deletes the draft. use sqlx::PgPool; use uuid::Uuid; use super::UserId; use crate::error::Result; /// `page_kind` for a profile draft. pub const KIND_USER: &str = "user"; /// `page_kind` for a project draft. pub const KIND_PROJECT: &str = "project"; /// A row from `custom_page_drafts`. #[derive(Debug, Clone, sqlx::FromRow)] pub struct CustomPageDraft { pub id: Uuid, pub owner_id: UserId, pub page_kind: String, pub page_id: Uuid, pub custom_html: String, pub custom_css: String, } /// Fetch a draft by its (capability) id. Used by the preview route. pub async fn get_draft(pool: &PgPool, id: Uuid) -> Result> { let draft = sqlx::query_as::<_, CustomPageDraft>("SELECT * FROM custom_page_drafts WHERE id = $1") .bind(id) .fetch_optional(pool) .await?; Ok(draft) } /// Return the existing draft for this page, or create one seeded from the /// current live source. The seed only applies on first creation -- an existing /// in-progress draft is returned untouched so the creator resumes where they /// left off. pub async fn get_or_create_draft( pool: &PgPool, owner_id: UserId, page_kind: &str, page_id: Uuid, seed_html: &str, seed_css: &str, ) -> Result { let draft = sqlx::query_as::<_, CustomPageDraft>( r#" INSERT INTO custom_page_drafts (owner_id, page_kind, page_id, custom_html, custom_css) VALUES ($1, $2, $3, $4, $5) ON CONFLICT (owner_id, page_kind, page_id) DO UPDATE SET updated_at = custom_page_drafts.updated_at RETURNING * "#, ) .bind(owner_id) .bind(page_kind) .bind(page_id) .bind(seed_html) .bind(seed_css) .fetch_one(pool) .await?; Ok(draft) } /// Write the draft's content (autosave). Upserts on the page key and returns the /// stored row (so the caller has the stable draft id). pub async fn upsert_draft( pool: &PgPool, owner_id: UserId, page_kind: &str, page_id: Uuid, custom_html: &str, custom_css: &str, ) -> Result { let draft = sqlx::query_as::<_, CustomPageDraft>( r#" INSERT INTO custom_page_drafts (owner_id, page_kind, page_id, custom_html, custom_css) VALUES ($1, $2, $3, $4, $5) ON CONFLICT (owner_id, page_kind, page_id) DO UPDATE SET custom_html = EXCLUDED.custom_html, custom_css = EXCLUDED.custom_css, updated_at = now() RETURNING * "#, ) .bind(owner_id) .bind(page_kind) .bind(page_id) .bind(custom_html) .bind(custom_css) .fetch_one(pool) .await?; Ok(draft) } /// Delete a page's draft (after a successful save). pub async fn delete_draft<'e>( executor: impl sqlx::PgExecutor<'e>, owner_id: UserId, page_kind: &str, page_id: Uuid, ) -> Result<()> { sqlx::query("DELETE FROM custom_page_drafts WHERE owner_id = $1 AND page_kind = $2 AND page_id = $3") .bind(owner_id) .bind(page_kind) .bind(page_id) .execute(executor) .await?; Ok(()) } /// Delete drafts older than the given number of days (scheduled cleanup). pub async fn delete_drafts_older_than(pool: &PgPool, days: i64) -> Result { // Bind the interval rather than interpolating it: `days` is job-supplied // today, but a bound `$1 * interval '1 day'` keeps this the one query in the // module that can't drift into string interpolation. let result = sqlx::query( "DELETE FROM custom_page_drafts WHERE created_at < now() - ($1 * interval '1 day')", ) .bind(days) .execute(pool) .await?; Ok(result.rows_affected()) }