main
Colby Knox Publish 0.1.0 fb6e12f 11d ago
use anyhow::{Context, Result};
use rusqlite::{Connection, OptionalExtension, params};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use std::sync::Mutex;

/// One template variable. `key`, `type`, and `fallback` are what Resend
/// stores; `sample` and `description` are local only (preview data and docs).
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct VariableSpec {
    pub key: String,
    #[serde(rename = "type", default = "default_type")]
    pub var_type: String,
    #[serde(default)]
    pub fallback: Option<Value>,
    #[serde(default)]
    pub sample: Value,
    #[serde(default)]
    pub description: Option<String>,
}

fn default_type() -> String {
    "string".into()
}

/// A local working copy of a template. Edits live here until they are
/// uploaded to Resend (which only touches Resend's draft) and, separately,
/// published.
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Draft {
    /// Stable local identity: the alias for anything created here, or the
    /// Resend id for a remote template that had no alias when first opened.
    pub key: String,
    pub resend_id: Option<String>,
    pub name: String,
    pub alias: Option<String>,
    #[serde(default)]
    pub subject: String,
    pub from: Option<String>,
    pub reply_to: Option<String>,
    #[serde(default)]
    pub html: String,
    #[serde(default)]
    pub variables: Vec<VariableSpec>,
    /// Where it came from, e.g. "sendgrid:d-b6c7...".
    pub source: Option<String>,
    /// Free-form notes for whoever picks this up next (migration decisions).
    pub notes: Option<String>,
    pub uploaded_at: Option<String>,
    /// The exact payload last uploaded, so the UI can tell whether there are
    /// local edits Resend hasn't seen.
    pub uploaded_snapshot: Option<String>,
    pub updated_at: Option<String>,
}

impl Draft {
    /// The body Resend's create/update template endpoints take.
    pub fn resend_payload(&self) -> Value {
        let variables: Vec<Value> = self
            .variables
            .iter()
            .filter(|v| !v.key.is_empty())
            .map(|v| {
                let mut o = json!({"key": v.key, "type": v.var_type});
                if let Some(fb) = v.fallback.as_ref().filter(|f| !f.is_null()) {
                    o["fallback_value"] = if v.var_type == "number" {
                        fb.as_str().and_then(|s| s.trim().parse::<f64>().ok()).map(|n| json!(n)).unwrap_or(fb.clone())
                    } else {
                        fb.clone()
                    };
                }
                o
            })
            .collect();
        let mut p = json!({
            "name": self.name,
            "html": self.html,
            "subject": self.subject,
            "variables": variables,
        });
        if let Some(a) = self.alias.as_ref().filter(|a| !a.is_empty()) {
            p["alias"] = json!(a);
        }
        if let Some(f) = self.from.as_ref().filter(|f| !f.is_empty()) {
            p["from"] = json!(f);
        }
        if let Some(r) = self.reply_to.as_ref().filter(|r| !r.is_empty()) {
            p["reply_to"] = json!(r);
        }
        p
    }

    pub fn is_dirty(&self) -> bool {
        match &self.uploaded_snapshot {
            Some(s) => *s != self.resend_payload().to_string(),
            None => true,
        }
    }
}

pub struct Store {
    conn: Mutex<Connection>,
}

const COLS: &str = "key, resend_id, name, alias, subject, from_addr, reply_to, html, variables_json, source, notes, uploaded_at, uploaded_snapshot, updated_at";

fn row_to_draft(r: &rusqlite::Row) -> rusqlite::Result<Draft> {
    let vars: String = r.get(8)?;
    Ok(Draft {
        key: r.get(0)?,
        resend_id: r.get(1)?,
        name: r.get(2)?,
        alias: r.get(3)?,
        subject: r.get(4)?,
        from: r.get(5)?,
        reply_to: r.get(6)?,
        html: r.get(7)?,
        variables: serde_json::from_str(&vars).unwrap_or_default(),
        source: r.get(9)?,
        notes: r.get(10)?,
        uploaded_at: r.get(11)?,
        uploaded_snapshot: r.get(12)?,
        updated_at: r.get(13)?,
    })
}

impl Store {
    pub fn open(path: &str) -> Result<Self> {
        let conn = Connection::open(path).with_context(|| format!("opening sqlite db at {path}"))?;
        conn.execute_batch(
            "CREATE TABLE IF NOT EXISTS drafts (
                key TEXT PRIMARY KEY,
                resend_id TEXT,
                name TEXT NOT NULL,
                alias TEXT,
                subject TEXT NOT NULL DEFAULT '',
                from_addr TEXT,
                reply_to TEXT,
                html TEXT NOT NULL DEFAULT '',
                variables_json TEXT NOT NULL DEFAULT '[]',
                source TEXT,
                notes TEXT,
                uploaded_at TEXT,
                uploaded_snapshot TEXT,
                updated_at TEXT NOT NULL
            );",
        )?;
        Ok(Self { conn: Mutex::new(conn) })
    }

    pub fn all(&self) -> Result<Vec<Draft>> {
        let conn = self.conn.lock().unwrap();
        let mut stmt = conn.prepare(&format!("SELECT {COLS} FROM drafts ORDER BY name"))?;
        let rows = stmt.query_map([], row_to_draft)?.collect::<rusqlite::Result<Vec<_>>>()?;
        Ok(rows)
    }

    pub fn get(&self, key: &str) -> Result<Option<Draft>> {
        let conn = self.conn.lock().unwrap();
        let d = conn
            .query_row(&format!("SELECT {COLS} FROM drafts WHERE key = ?1"), params![key], row_to_draft)
            .optional()?;
        Ok(d)
    }

    /// Finds the draft for a remote template: by linked id first, then by alias.
    pub fn find_for_remote(&self, id: &str, alias: Option<&str>) -> Result<Option<Draft>> {
        let all = self.all()?;
        if let Some(d) = all.iter().find(|d| d.resend_id.as_deref() == Some(id) || d.key == id) {
            return Ok(Some(d.clone()));
        }
        if let Some(a) = alias.filter(|a| !a.is_empty()) {
            if let Some(d) = all.iter().find(|d| d.resend_id.is_none() && (d.alias.as_deref() == Some(a) || d.key == a)) {
                return Ok(Some(d.clone()));
            }
        }
        Ok(None)
    }

    pub fn save(&self, d: &Draft) -> Result<()> {
        let conn = self.conn.lock().unwrap();
        let now = chrono::Utc::now().to_rfc3339();
        conn.execute(
            &format!(
                "INSERT INTO drafts ({COLS}) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)
                 ON CONFLICT(key) DO UPDATE SET
                    resend_id = excluded.resend_id, name = excluded.name, alias = excluded.alias,
                    subject = excluded.subject, from_addr = excluded.from_addr, reply_to = excluded.reply_to,
                    html = excluded.html, variables_json = excluded.variables_json, source = excluded.source,
                    notes = excluded.notes, uploaded_at = excluded.uploaded_at,
                    uploaded_snapshot = excluded.uploaded_snapshot, updated_at = excluded.updated_at"
            ),
            params![
                d.key,
                d.resend_id,
                d.name,
                d.alias,
                d.subject,
                d.from,
                d.reply_to,
                d.html,
                serde_json::to_string(&d.variables)?,
                d.source,
                d.notes,
                d.uploaded_at,
                d.uploaded_snapshot,
                now
            ],
        )?;
        Ok(())
    }

    pub fn delete(&self, key: &str) -> Result<bool> {
        let conn = self.conn.lock().unwrap();
        Ok(conn.execute("DELETE FROM drafts WHERE key = ?1", params![key])? > 0)
    }
}