chore: refactored db into create e0759b6c
Steve · 2026-04-16 20:00 17 file(s) · +611 −903
Cargo.lock +15 −0
71 71
]
72 72
73 73
[[package]]
74 +
name = "andromeda-db"
75 +
version = "0.1.0"
76 +
dependencies = [
77 +
 "axum",
78 +
 "rusqlite",
79 +
 "tracing",
80 +
]
81 +
82 +
[[package]]
74 83
name = "anstream"
75 84
version = "1.0.0"
76 85
source = "registry+https://github.com/rust-lang/crates.io-index"
680 689
version = "0.1.4"
681 690
dependencies = [
682 691
 "andromeda-auth",
692 +
 "andromeda-db",
683 693
 "askama 0.15.6",
684 694
 "askama_web",
685 695
 "axum",
2195 2205
version = "0.2.0"
2196 2206
dependencies = [
2197 2207
 "andromeda-auth",
2208 +
 "andromeda-db",
2198 2209
 "arboard",
2199 2210
 "askama 0.15.6",
2200 2211
 "askama_web",
2900 2911
version = "0.1.3"
2901 2912
dependencies = [
2902 2913
 "andromeda-auth",
2914 +
 "andromeda-db",
2903 2915
 "anyhow",
2904 2916
 "askama 0.12.1",
2905 2917
 "askama_axum",
3117 3129
version = "0.1.4"
3118 3130
dependencies = [
3119 3131
 "andromeda-auth",
3132 +
 "andromeda-db",
3120 3133
 "askama 0.15.6",
3121 3134
 "askama_web",
3122 3135
 "axum",
3128 3141
 "rust-embed",
3129 3142
 "serde",
3130 3143
 "serde_json",
3144 +
 "serde_rusqlite",
3131 3145
 "subtle",
3132 3146
 "tokio",
3133 3147
 "tracing",
4183 4197
name = "sipp-so"
4184 4198
version = "0.1.6"
4185 4199
dependencies = [
4200 +
 "andromeda-db",
4186 4201
 "arboard",
4187 4202
 "askama 0.15.6",
4188 4203
 "askama_web",
Cargo.toml +2 −0
9 9
    "apps/cellar",
10 10
    "apps/posts",
11 11
    "crates/auth",
12 +
    "crates/db",
12 13
]
13 14
resolver = "3"
14 15
50 51
51 52
# Workspace crates
52 53
andromeda-auth = { path = "crates/auth" }
54 +
andromeda-db = { path = "crates/db" }
apps/cellar/Cargo.toml +1 −0
21 21
tracing = { workspace = true }
22 22
tracing-subscriber = { workspace = true }
23 23
andromeda-auth = { workspace = true }
24 +
andromeda-db = { workspace = true, features = ["session"] }
24 25
askama = "0.15"
25 26
askama_web = { version = "0.15", features = ["axum-0.8"] }
26 27
reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false }
apps/cellar/src/db.rs +36 −124
1 1
use nanoid::nanoid;
2 2
use rusqlite::{Connection, params};
3 3
use serde::{Deserialize, Serialize};
4 -
use std::fmt;
5 4
use std::sync::{Arc, Mutex};
6 5
7 -
pub type Db = Arc<Mutex<Connection>>;
8 -
9 -
#[derive(Debug)]
10 -
pub enum DbError {
11 -
    Sqlite(rusqlite::Error),
12 -
    LockPoisoned,
13 -
}
14 -
15 -
impl fmt::Display for DbError {
16 -
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
17 -
        match self {
18 -
            DbError::Sqlite(e) => write!(f, "Database error: {}", e),
19 -
            DbError::LockPoisoned => write!(f, "Database lock poisoned"),
20 -
        }
21 -
    }
22 -
}
23 -
24 -
impl std::error::Error for DbError {}
25 -
26 -
impl From<rusqlite::Error> for DbError {
27 -
    fn from(e: rusqlite::Error) -> Self {
28 -
        DbError::Sqlite(e)
29 -
    }
30 -
}
6 +
pub use andromeda_db::{Db, DbError};
7 +
pub use andromeda_db::session::{insert_session, get_session_expiry, delete_session, prune_expired_sessions};
31 8
32 9
#[derive(Debug, Serialize, Deserialize, Clone)]
33 10
pub struct Wine {
53 30
    pub wishlist: bool,
54 31
}
55 32
33 +
const SCHEMA: &str = "
34 +
    CREATE TABLE IF NOT EXISTS wines (
35 +
        id              INTEGER PRIMARY KEY AUTOINCREMENT,
36 +
        short_id        TEXT NOT NULL UNIQUE,
37 +
        name            TEXT NOT NULL,
38 +
        origin          TEXT NOT NULL,
39 +
        grape           TEXT NOT NULL,
40 +
        notes           TEXT NOT NULL,
41 +
        image           BLOB,
42 +
        image_mime      TEXT,
43 +
        sweetness       INTEGER NOT NULL CHECK(sweetness BETWEEN 1 AND 5),
44 +
        acidity         INTEGER NOT NULL CHECK(acidity BETWEEN 1 AND 5),
45 +
        tannin          INTEGER NOT NULL CHECK(tannin BETWEEN 1 AND 5),
46 +
        alcohol         INTEGER NOT NULL CHECK(alcohol BETWEEN 1 AND 5),
47 +
        body            INTEGER NOT NULL CHECK(body BETWEEN 1 AND 5),
48 +
        clarity         INTEGER NOT NULL DEFAULT 3,
49 +
        color_intensity INTEGER NOT NULL DEFAULT 3,
50 +
        aroma_intensity INTEGER NOT NULL DEFAULT 3,
51 +
        nose_complexity INTEGER NOT NULL DEFAULT 3,
52 +
        background      TEXT NOT NULL DEFAULT '',
53 +
        created_at      TEXT NOT NULL DEFAULT (datetime('now')),
54 +
        wishlist        INTEGER NOT NULL DEFAULT 0
55 +
    );
56 +
57 +
    CREATE TABLE IF NOT EXISTS sessions (
58 +
        id         INTEGER PRIMARY KEY AUTOINCREMENT,
59 +
        token      TEXT NOT NULL UNIQUE,
60 +
        expires_at TEXT NOT NULL
61 +
    );
62 +
";
63 +
56 64
pub fn init_db() -> Db {
57 65
    let path = std::env::var("CELLAR_DB_PATH").unwrap_or_else(|_| "cellar.sqlite".to_string());
58 66
    let conn = Connection::open(&path).expect("Failed to open database");
59 67
60 -
    conn.execute_batch(
61 -
        "CREATE TABLE IF NOT EXISTS wines (
62 -
            id         INTEGER PRIMARY KEY AUTOINCREMENT,
63 -
            short_id   TEXT NOT NULL UNIQUE,
64 -
            name       TEXT NOT NULL,
65 -
            origin     TEXT NOT NULL,
66 -
            grape      TEXT NOT NULL,
67 -
            notes      TEXT NOT NULL,
68 -
            image      BLOB,
69 -
            image_mime TEXT,
70 -
            sweetness  INTEGER NOT NULL CHECK(sweetness BETWEEN 1 AND 5),
71 -
            acidity    INTEGER NOT NULL CHECK(acidity BETWEEN 1 AND 5),
72 -
            tannin     INTEGER NOT NULL CHECK(tannin BETWEEN 1 AND 5),
73 -
            alcohol    INTEGER NOT NULL CHECK(alcohol BETWEEN 1 AND 5),
74 -
            body       INTEGER NOT NULL CHECK(body BETWEEN 1 AND 5),
75 -
            created_at TEXT NOT NULL DEFAULT (datetime('now'))
76 -
        );
68 +
    conn.execute_batch(SCHEMA).expect("Failed to create tables");
77 69
78 -
        CREATE TABLE IF NOT EXISTS sessions (
79 -
            id         INTEGER PRIMARY KEY AUTOINCREMENT,
80 -
            token      TEXT NOT NULL UNIQUE,
81 -
            expires_at TEXT NOT NULL
82 -
        );"
83 -
    )
84 -
    .expect("Failed to create tables");
85 -
86 -
    // Migration: add background column if it doesn't exist
70 +
    // Migrations for existing databases (idempotent — ALTER TABLE fails silently if column exists)
87 71
    let _ = conn.execute("ALTER TABLE wines ADD COLUMN background TEXT NOT NULL DEFAULT ''", []);
88 -
89 -
    // Migration: add appearance and nose tasting attributes
90 72
    let _ = conn.execute("ALTER TABLE wines ADD COLUMN clarity INTEGER NOT NULL DEFAULT 3", []);
91 73
    let _ = conn.execute("ALTER TABLE wines ADD COLUMN color_intensity INTEGER NOT NULL DEFAULT 3", []);
92 74
    let _ = conn.execute("ALTER TABLE wines ADD COLUMN aroma_intensity INTEGER NOT NULL DEFAULT 3", []);
93 75
    let _ = conn.execute("ALTER TABLE wines ADD COLUMN nose_complexity INTEGER NOT NULL DEFAULT 3", []);
94 -
95 -
    // Migration: add wishlist flag
96 76
    let _ = conn.execute("ALTER TABLE wines ADD COLUMN wishlist INTEGER NOT NULL DEFAULT 0", []);
97 77
98 78
    Arc::new(Mutex::new(conn))
298 278
    Ok(rows > 0)
299 279
}
300 280
301 -
// Session functions
302 -
303 -
pub fn insert_session(db: &Db, token: &str, expires_at: &str) -> Result<(), DbError> {
304 -
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
305 -
    conn.execute(
306 -
        "INSERT INTO sessions (token, expires_at) VALUES (?1, ?2)",
307 -
        params![token, expires_at],
308 -
    )?;
309 -
    Ok(())
310 -
}
311 -
312 -
pub fn get_session_expiry(db: &Db, token: &str) -> Result<Option<String>, DbError> {
313 -
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
314 -
    match conn.query_row(
315 -
        "SELECT expires_at FROM sessions WHERE token = ?1",
316 -
        params![token],
317 -
        |row| row.get(0),
318 -
    ) {
319 -
        Ok(val) => Ok(Some(val)),
320 -
        Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
321 -
        Err(e) => Err(DbError::Sqlite(e)),
322 -
    }
323 -
}
324 -
325 -
pub fn delete_session(db: &Db, token: &str) -> Result<(), DbError> {
326 -
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
327 -
    conn.execute("DELETE FROM sessions WHERE token = ?1", params![token])?;
328 -
    Ok(())
329 -
}
330 -
331 -
pub fn prune_expired_sessions(db: &Db) -> Result<(), DbError> {
332 -
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
333 -
    conn.execute(
334 -
        "DELETE FROM sessions WHERE expires_at < datetime('now')",
335 -
        [],
336 -
    )?;
337 -
    Ok(())
338 -
}
339 -
340 281
#[cfg(test)]
341 282
mod tests {
342 283
    use super::*;
343 284
344 285
    fn test_db() -> Db {
345 286
        let conn = Connection::open_in_memory().unwrap();
346 -
        conn.execute_batch(
347 -
            "CREATE TABLE IF NOT EXISTS wines (
348 -
                id              INTEGER PRIMARY KEY AUTOINCREMENT,
349 -
                short_id        TEXT NOT NULL UNIQUE,
350 -
                name            TEXT NOT NULL,
351 -
                origin          TEXT NOT NULL,
352 -
                grape           TEXT NOT NULL,
353 -
                notes           TEXT NOT NULL,
354 -
                image           BLOB,
355 -
                image_mime      TEXT,
356 -
                sweetness       INTEGER NOT NULL CHECK(sweetness BETWEEN 1 AND 5),
357 -
                acidity         INTEGER NOT NULL CHECK(acidity BETWEEN 1 AND 5),
358 -
                tannin          INTEGER NOT NULL CHECK(tannin BETWEEN 1 AND 5),
359 -
                alcohol         INTEGER NOT NULL CHECK(alcohol BETWEEN 1 AND 5),
360 -
                body            INTEGER NOT NULL CHECK(body BETWEEN 1 AND 5),
361 -
                clarity         INTEGER NOT NULL DEFAULT 3,
362 -
                color_intensity INTEGER NOT NULL DEFAULT 3,
363 -
                aroma_intensity INTEGER NOT NULL DEFAULT 3,
364 -
                nose_complexity INTEGER NOT NULL DEFAULT 3,
365 -
                background      TEXT NOT NULL DEFAULT '',
366 -
                created_at      TEXT NOT NULL DEFAULT (datetime('now')),
367 -
                wishlist        INTEGER NOT NULL DEFAULT 0
368 -
            );
369 -
            CREATE TABLE IF NOT EXISTS sessions (
370 -
                id         INTEGER PRIMARY KEY AUTOINCREMENT,
371 -
                token      TEXT NOT NULL UNIQUE,
372 -
                expires_at TEXT NOT NULL
373 -
            );",
374 -
        )
375 -
        .unwrap();
287 +
        conn.execute_batch(SCHEMA).unwrap();
376 288
        Arc::new(Mutex::new(conn))
377 289
    }
378 290
apps/jotts/Cargo.toml +1 −0
21 21
tracing = { workspace = true }
22 22
tracing-subscriber = { workspace = true }
23 23
andromeda-auth = { workspace = true }
24 +
andromeda-db = { workspace = true, features = ["session", "axum"] }
24 25
askama = "0.15"
25 26
askama_web = { version = "0.15", features = ["axum-0.8"] }
26 27
pulldown-cmark = "0.12"
apps/jotts/src/db.rs +2 −63
1 1
use nanoid::nanoid;
2 2
use rusqlite::{Connection, OptionalExtension, Row, params};
3 3
use serde::{Deserialize, Serialize};
4 -
use std::fmt;
5 4
use std::sync::{Arc, Mutex};
6 5
7 -
pub type Db = Arc<Mutex<Connection>>;
6 +
pub use andromeda_db::{Db, DbError};
7 +
pub use andromeda_db::session::{insert_session, get_session_expiry, delete_session, prune_expired_sessions};
8 8
9 9
const NOTE_COLUMNS: &str = "id, short_id, title, content, created_at, updated_at";
10 10
24 24
        expires_at TEXT NOT NULL
25 25
    );
26 26
";
27 -
28 -
#[derive(Debug)]
29 -
pub enum DbError {
30 -
    Sqlite(rusqlite::Error),
31 -
    LockPoisoned,
32 -
}
33 -
34 -
impl fmt::Display for DbError {
35 -
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
36 -
        match self {
37 -
            DbError::Sqlite(e) => write!(f, "Database error: {}", e),
38 -
            DbError::LockPoisoned => write!(f, "Database lock poisoned"),
39 -
        }
40 -
    }
41 -
}
42 -
43 -
impl std::error::Error for DbError {}
44 -
45 -
impl From<rusqlite::Error> for DbError {
46 -
    fn from(e: rusqlite::Error) -> Self {
47 -
        DbError::Sqlite(e)
48 -
    }
49 -
}
50 27
51 28
#[derive(Debug, Serialize, Deserialize)]
52 29
pub struct Note {
156 133
    Ok(rows > 0)
157 134
}
158 135
159 -
// Session functions
160 -
161 -
pub fn insert_session(db: &Db, token: &str, expires_at: &str) -> Result<(), DbError> {
162 -
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
163 -
    conn.execute(
164 -
        "INSERT INTO sessions (token, expires_at) VALUES (?1, ?2)",
165 -
        params![token, expires_at],
166 -
    )?;
167 -
    Ok(())
168 -
}
169 -
170 -
pub fn get_session_expiry(db: &Db, token: &str) -> Result<Option<String>, DbError> {
171 -
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
172 -
    match conn.query_row(
173 -
        "SELECT expires_at FROM sessions WHERE token = ?1",
174 -
        params![token],
175 -
        |row| row.get(0),
176 -
    ) {
177 -
        Ok(val) => Ok(Some(val)),
178 -
        Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
179 -
        Err(e) => Err(DbError::Sqlite(e)),
180 -
    }
181 -
}
182 -
183 -
pub fn delete_session(db: &Db, token: &str) -> Result<(), DbError> {
184 -
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
185 -
    conn.execute("DELETE FROM sessions WHERE token = ?1", params![token])?;
186 -
    Ok(())
187 -
}
188 -
189 -
pub fn prune_expired_sessions(db: &Db) -> Result<(), DbError> {
190 -
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
191 -
    conn.execute(
192 -
        "DELETE FROM sessions WHERE expires_at < datetime('now')",
193 -
        [],
194 -
    )?;
195 -
    Ok(())
196 -
}
197 136
198 137
#[cfg(test)]
199 138
mod tests {
apps/jotts/src/server.rs +0 −7
14 14
use crate::auth;
15 15
use crate::db::{self, Db, DbError, Note, NoteInput};
16 16
17 -
impl IntoResponse for DbError {
18 -
    fn into_response(self) -> Response {
19 -
        tracing::error!("{}", self);
20 -
        (StatusCode::INTERNAL_SERVER_ERROR, "Server error").into_response()
21 -
    }
22 -
}
23 -
24 17
fn redirect_with_cookie(target: &str, cookie: String) -> Response {
25 18
    let mut resp = Redirect::to(target).into_response();
26 19
    resp.headers_mut().insert(
apps/parcels/Cargo.toml +1 −0
22 22
tracing = { workspace = true }
23 23
tracing-subscriber = { workspace = true, features = ["env-filter"] }
24 24
andromeda-auth = { workspace = true }
25 +
andromeda-db = { workspace = true, features = ["session"] }
25 26
rusqlite = { workspace = true }
26 27
reqwest = { version = "0.12", features = ["json"] }
27 28
askama = { version = "0.12", features = ["with-axum"] }
apps/parcels/src/db.rs +83 −184
1 -
use rusqlite::{Connection, params};
2 -
use std::fmt;
1 +
use rusqlite::{Connection, OptionalExtension, params};
3 2
use std::sync::{Arc, Mutex};
4 3
5 -
pub type Db = Arc<Mutex<Connection>>;
6 -
7 -
// ── Error ───────────────────────────────────────────────────────────────────
8 -
9 -
#[derive(Debug)]
10 -
pub enum DbError {
11 -
    Sqlite(rusqlite::Error),
12 -
    LockPoisoned,
13 -
}
14 -
15 -
impl fmt::Display for DbError {
16 -
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
17 -
        match self {
18 -
            DbError::Sqlite(e) => write!(f, "Database error: {}", e),
19 -
            DbError::LockPoisoned => write!(f, "Database lock poisoned"),
20 -
        }
21 -
    }
22 -
}
23 -
24 -
impl std::error::Error for DbError {}
25 -
26 -
impl From<rusqlite::Error> for DbError {
27 -
    fn from(e: rusqlite::Error) -> Self {
28 -
        DbError::Sqlite(e)
29 -
    }
30 -
}
4 +
pub use andromeda_db::{Db, DbError};
5 +
pub use andromeda_db::session::{insert_session, get_session_expiry, delete_session, prune_expired_sessions};
31 6
32 7
// ── Types ───────────────────────────────────────────────────────────────────
33 8
59 34
60 35
// ── Pool Setup ──────────────────────────────────────────────────────────────
61 36
37 +
const SCHEMA: &str = "
38 +
    CREATE TABLE IF NOT EXISTS packages (
39 +
        id                INTEGER PRIMARY KEY AUTOINCREMENT,
40 +
        tracking_number   TEXT NOT NULL UNIQUE,
41 +
        label             TEXT,
42 +
        status            TEXT,
43 +
        status_category   TEXT,
44 +
        status_summary    TEXT,
45 +
        mail_class        TEXT,
46 +
        expected_delivery TEXT,
47 +
        last_refreshed_at TEXT,
48 +
        created_at        TEXT NOT NULL DEFAULT (datetime('now'))
49 +
    );
50 +
    CREATE TABLE IF NOT EXISTS tracking_events (
51 +
        id              INTEGER PRIMARY KEY AUTOINCREMENT,
52 +
        package_id      INTEGER NOT NULL REFERENCES packages(id) ON DELETE CASCADE,
53 +
        event_timestamp TEXT,
54 +
        event_type      TEXT,
55 +
        event_city      TEXT,
56 +
        event_state     TEXT,
57 +
        event_zip       TEXT,
58 +
        event_code      TEXT
59 +
    );
60 +
    CREATE TABLE IF NOT EXISTS sessions (
61 +
        id         INTEGER PRIMARY KEY AUTOINCREMENT,
62 +
        token      TEXT NOT NULL UNIQUE,
63 +
        expires_at TEXT NOT NULL
64 +
    );
65 +
";
66 +
62 67
pub fn init_db() -> Db {
63 68
    let path = "parcels.db";
64 69
    let conn = Connection::open(path).expect("Failed to open database");
65 70
    conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;")
66 71
        .expect("Failed to set PRAGMAs");
67 -
    conn.execute_batch("
68 -
        CREATE TABLE IF NOT EXISTS packages (
69 -
            id                INTEGER PRIMARY KEY AUTOINCREMENT,
70 -
            tracking_number   TEXT NOT NULL UNIQUE,
71 -
            label             TEXT,
72 -
            status            TEXT,
73 -
            status_category   TEXT,
74 -
            status_summary    TEXT,
75 -
            mail_class        TEXT,
76 -
            expected_delivery TEXT,
77 -
            last_refreshed_at TEXT,
78 -
            created_at        TEXT NOT NULL DEFAULT (datetime('now'))
79 -
        );
80 -
        CREATE TABLE IF NOT EXISTS tracking_events (
81 -
            id              INTEGER PRIMARY KEY AUTOINCREMENT,
82 -
            package_id      INTEGER NOT NULL REFERENCES packages(id) ON DELETE CASCADE,
83 -
            event_timestamp TEXT,
84 -
            event_type      TEXT,
85 -
            event_city      TEXT,
86 -
            event_state     TEXT,
87 -
            event_zip       TEXT,
88 -
            event_code      TEXT
89 -
        );
90 -
        CREATE TABLE IF NOT EXISTS sessions (
91 -
            id         INTEGER PRIMARY KEY AUTOINCREMENT,
92 -
            token      TEXT NOT NULL UNIQUE,
93 -
            expires_at TEXT NOT NULL
94 -
        );
95 -
    ").expect("Failed to create tables");
72 +
    conn.execute_batch(SCHEMA).expect("Failed to create tables");
96 73
    Arc::new(Mutex::new(conn))
97 74
}
98 75
76 +
// ── Row Helpers ────────────────────────────────────────────────────────────
77 +
78 +
const PACKAGE_COLS: &str = "id, tracking_number, label, status, status_category, status_summary, mail_class, expected_delivery, last_refreshed_at, created_at";
79 +
80 +
fn package_from_row(row: &rusqlite::Row) -> rusqlite::Result<Package> {
81 +
    Ok(Package {
82 +
        id: row.get(0)?,
83 +
        tracking_number: row.get(1)?,
84 +
        label: row.get(2)?,
85 +
        status: row.get(3)?,
86 +
        status_category: row.get(4)?,
87 +
        status_summary: row.get(5)?,
88 +
        mail_class: row.get(6)?,
89 +
        expected_delivery: row.get(7)?,
90 +
        last_refreshed_at: row.get(8)?,
91 +
        created_at: row.get(9)?,
92 +
    })
93 +
}
94 +
95 +
const EVENT_COLS: &str = "id, package_id, event_timestamp, event_type, event_city, event_state, event_zip, event_code";
96 +
97 +
fn event_from_row(row: &rusqlite::Row) -> rusqlite::Result<TrackingEvent> {
98 +
    Ok(TrackingEvent {
99 +
        id: row.get(0)?,
100 +
        package_id: row.get(1)?,
101 +
        event_timestamp: row.get(2)?,
102 +
        event_type: row.get(3)?,
103 +
        event_city: row.get(4)?,
104 +
        event_state: row.get(5)?,
105 +
        event_zip: row.get(6)?,
106 +
        event_code: row.get(7)?,
107 +
    })
108 +
}
109 +
99 110
// ── Package Queries ─────────────────────────────────────────────────────────
100 111
101 112
pub fn list_packages(db: &Db) -> Result<Vec<Package>, DbError> {
102 113
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
103 114
    let mut stmt = conn.prepare(
104 -
        "SELECT id, tracking_number, label, status, status_category, status_summary,
105 -
                mail_class, expected_delivery, last_refreshed_at, created_at
106 -
         FROM packages ORDER BY created_at DESC",
115 +
        &format!("SELECT {} FROM packages ORDER BY created_at DESC", PACKAGE_COLS),
107 116
    )?;
108 117
    let packages = stmt
109 -
        .query_map([], |row| {
110 -
            Ok(Package {
111 -
                id: row.get(0)?,
112 -
                tracking_number: row.get(1)?,
113 -
                label: row.get(2)?,
114 -
                status: row.get(3)?,
115 -
                status_category: row.get(4)?,
116 -
                status_summary: row.get(5)?,
117 -
                mail_class: row.get(6)?,
118 -
                expected_delivery: row.get(7)?,
119 -
                last_refreshed_at: row.get(8)?,
120 -
                created_at: row.get(9)?,
121 -
            })
122 -
        })?
123 -
        .filter_map(|r| r.ok())
124 -
        .collect();
118 +
        .query_map([], package_from_row)?
119 +
        .collect::<Result<Vec<_>, _>>()?;
125 120
    Ok(packages)
126 121
}
127 122
128 123
pub fn get_package(db: &Db, id: i64) -> Result<Option<Package>, DbError> {
129 124
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
130 -
    match conn.query_row(
131 -
        "SELECT id, tracking_number, label, status, status_category, status_summary,
132 -
                mail_class, expected_delivery, last_refreshed_at, created_at
133 -
         FROM packages WHERE id = ?1",
134 -
        params![id],
135 -
        |row| {
136 -
            Ok(Package {
137 -
                id: row.get(0)?,
138 -
                tracking_number: row.get(1)?,
139 -
                label: row.get(2)?,
140 -
                status: row.get(3)?,
141 -
                status_category: row.get(4)?,
142 -
                status_summary: row.get(5)?,
143 -
                mail_class: row.get(6)?,
144 -
                expected_delivery: row.get(7)?,
145 -
                last_refreshed_at: row.get(8)?,
146 -
                created_at: row.get(9)?,
147 -
            })
148 -
        },
149 -
    ) {
150 -
        Ok(pkg) => Ok(Some(pkg)),
151 -
        Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
152 -
        Err(e) => Err(DbError::Sqlite(e)),
153 -
    }
125 +
    let pkg = conn
126 +
        .query_row(
127 +
            &format!("SELECT {} FROM packages WHERE id = ?1", PACKAGE_COLS),
128 +
            params![id],
129 +
            package_from_row,
130 +
        )
131 +
        .optional()?;
132 +
    Ok(pkg)
154 133
}
155 134
156 135
pub fn insert_package(db: &Db, tracking_number: &str, label: Option<&str>) -> Result<i64, DbError> {
219 198
pub fn get_events_for_package(db: &Db, package_id: i64) -> Result<Vec<TrackingEvent>, DbError> {
220 199
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
221 200
    let mut stmt = conn.prepare(
222 -
        "SELECT id, package_id, event_timestamp, event_type, event_city, event_state,
223 -
                event_zip, event_code
224 -
         FROM tracking_events WHERE package_id = ?1
225 -
         ORDER BY event_timestamp DESC",
201 +
        &format!("SELECT {} FROM tracking_events WHERE package_id = ?1 ORDER BY event_timestamp DESC", EVENT_COLS),
226 202
    )?;
227 203
    let events = stmt
228 -
        .query_map(params![package_id], |row| {
229 -
            Ok(TrackingEvent {
230 -
                id: row.get(0)?,
231 -
                package_id: row.get(1)?,
232 -
                event_timestamp: row.get(2)?,
233 -
                event_type: row.get(3)?,
234 -
                event_city: row.get(4)?,
235 -
                event_state: row.get(5)?,
236 -
                event_zip: row.get(6)?,
237 -
                event_code: row.get(7)?,
238 -
            })
239 -
        })?
240 -
        .filter_map(|r| r.ok())
241 -
        .collect();
204 +
        .query_map(params![package_id], event_from_row)?
205 +
        .collect::<Result<Vec<_>, _>>()?;
242 206
    Ok(events)
243 207
}
244 208
245 -
// ── Session Queries ──────────────────────────────────────────────────────────
246 -
247 -
pub fn insert_session(db: &Db, token: &str, expires_at: &str) -> Result<(), DbError> {
248 -
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
249 -
    conn.execute(
250 -
        "INSERT INTO sessions (token, expires_at) VALUES (?1, ?2)",
251 -
        params![token, expires_at],
252 -
    )?;
253 -
    Ok(())
254 -
}
255 -
256 -
pub fn get_session_expiry(db: &Db, token: &str) -> Result<Option<String>, DbError> {
257 -
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
258 -
    match conn.query_row(
259 -
        "SELECT expires_at FROM sessions WHERE token = ?1",
260 -
        params![token],
261 -
        |row| row.get(0),
262 -
    ) {
263 -
        Ok(expires_at) => Ok(Some(expires_at)),
264 -
        Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
265 -
        Err(e) => Err(DbError::Sqlite(e)),
266 -
    }
267 -
}
268 -
269 -
pub fn delete_session(db: &Db, token: &str) -> Result<(), DbError> {
270 -
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
271 -
    conn.execute("DELETE FROM sessions WHERE token = ?1", params![token])?;
272 -
    Ok(())
273 -
}
274 -
275 -
pub fn prune_expired_sessions(db: &Db) -> Result<(), DbError> {
276 -
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
277 -
    conn.execute("DELETE FROM sessions WHERE expires_at < datetime('now')", [])?;
278 -
    Ok(())
279 -
}
280 -
281 209
#[cfg(test)]
282 210
mod tests {
283 211
    use super::*;
285 213
    fn test_db() -> Db {
286 214
        let conn = Connection::open_in_memory().unwrap();
287 215
        conn.execute_batch("PRAGMA foreign_keys=ON;").unwrap();
288 -
        conn.execute_batch(
289 -
            "CREATE TABLE IF NOT EXISTS packages (
290 -
                id                INTEGER PRIMARY KEY AUTOINCREMENT,
291 -
                tracking_number   TEXT NOT NULL UNIQUE,
292 -
                label             TEXT,
293 -
                status            TEXT,
294 -
                status_category   TEXT,
295 -
                status_summary    TEXT,
296 -
                mail_class        TEXT,
297 -
                expected_delivery TEXT,
298 -
                last_refreshed_at TEXT,
299 -
                created_at        TEXT NOT NULL DEFAULT (datetime('now'))
300 -
            );
301 -
            CREATE TABLE IF NOT EXISTS tracking_events (
302 -
                id              INTEGER PRIMARY KEY AUTOINCREMENT,
303 -
                package_id      INTEGER NOT NULL REFERENCES packages(id) ON DELETE CASCADE,
304 -
                event_timestamp TEXT,
305 -
                event_type      TEXT,
306 -
                event_city      TEXT,
307 -
                event_state     TEXT,
308 -
                event_zip       TEXT,
309 -
                event_code      TEXT
310 -
            );
311 -
            CREATE TABLE IF NOT EXISTS sessions (
312 -
                id         INTEGER PRIMARY KEY AUTOINCREMENT,
313 -
                token      TEXT NOT NULL UNIQUE,
314 -
                expires_at TEXT NOT NULL
315 -
            );",
316 -
        )
317 -
        .unwrap();
216 +
        conn.execute_batch(SCHEMA).unwrap();
318 217
        Arc::new(Mutex::new(conn))
319 218
    }
320 219
apps/posts/Cargo.toml +2 −0
21 21
tracing = { workspace = true }
22 22
tracing-subscriber = { workspace = true }
23 23
andromeda-auth = { workspace = true }
24 +
andromeda-db = { workspace = true, features = ["session"] }
25 +
serde_rusqlite = "0.41"
24 26
askama = "0.15"
25 27
askama_web = { version = "0.15", features = ["axum-0.8"] }
26 28
pulldown-cmark = "0.12"
apps/posts/src/db.rs +245 −413
1 1
use nanoid::nanoid;
2 -
use rusqlite::{Connection, params};
2 +
use rusqlite::{Connection, OptionalExtension, params};
3 3
use serde::{Deserialize, Serialize};
4 -
use std::fmt;
5 4
use std::sync::{Arc, Mutex};
6 5
7 -
pub type Db = Arc<Mutex<Connection>>;
8 -
9 -
#[derive(Debug)]
10 -
pub enum DbError {
11 -
    Sqlite(rusqlite::Error),
12 -
    LockPoisoned,
13 -
}
14 -
15 -
impl fmt::Display for DbError {
16 -
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
17 -
        match self {
18 -
            DbError::Sqlite(e) => write!(f, "Database error: {}", e),
19 -
            DbError::LockPoisoned => write!(f, "Database lock poisoned"),
20 -
        }
21 -
    }
22 -
}
23 -
24 -
impl std::error::Error for DbError {}
6 +
pub use andromeda_db::{Db, DbError};
7 +
pub use andromeda_db::session::{insert_session, get_session_expiry, delete_session, prune_expired_sessions};
25 8
26 -
impl From<rusqlite::Error> for DbError {
27 -
    fn from(e: rusqlite::Error) -> Self {
28 -
        DbError::Sqlite(e)
29 -
    }
9 +
fn from_row<T: serde::de::DeserializeOwned>(row: &rusqlite::Row) -> rusqlite::Result<T> {
10 +
    serde_rusqlite::from_row::<T>(row).map_err(|e| {
11 +
        rusqlite::Error::FromSqlConversionFailure(0, rusqlite::types::Type::Null, Box::new(e))
12 +
    })
30 13
}
31 14
32 15
#[derive(Debug, Serialize, Deserialize, Clone)]
48 31
    pub updated_at: String,
49 32
}
50 33
34 +
#[derive(Serialize)]
35 +
pub struct PostInput<'a> {
36 +
    pub title: &'a str,
37 +
    pub slug: &'a str,
38 +
    pub content: &'a str,
39 +
    pub status: &'a str,
40 +
    pub alias: Option<&'a str>,
41 +
    pub canonical_url: Option<&'a str>,
42 +
    pub published_date: Option<&'a str>,
43 +
    pub meta_description: Option<&'a str>,
44 +
    pub meta_image: Option<&'a str>,
45 +
    pub lang: &'a str,
46 +
    pub tags: Option<&'a str>,
47 +
}
48 +
51 49
#[derive(Debug, Serialize, Deserialize, Clone)]
52 50
pub struct Page {
53 51
    pub id: i64,
72 70
    pub created_at: String,
73 71
}
74 72
75 -
pub fn init_db() -> Db {
76 -
    let path = std::env::var("POSTS_DB_PATH").unwrap_or_else(|_| "posts.sqlite".to_string());
77 -
    let conn = Connection::open(&path).expect("Failed to open database");
73 +
const SCHEMA: &str = "
74 +
    CREATE TABLE IF NOT EXISTS posts (
75 +
        id              INTEGER PRIMARY KEY AUTOINCREMENT,
76 +
        short_id        TEXT NOT NULL UNIQUE,
77 +
        title           TEXT NOT NULL,
78 +
        slug            TEXT NOT NULL UNIQUE,
79 +
        alias           TEXT,
80 +
        canonical_url   TEXT,
81 +
        published_date  TEXT,
82 +
        meta_description TEXT,
83 +
        meta_image      TEXT,
84 +
        lang            TEXT NOT NULL DEFAULT 'en',
85 +
        tags            TEXT,
86 +
        content         TEXT NOT NULL,
87 +
        status          TEXT NOT NULL DEFAULT 'draft',
88 +
        created_at      TEXT NOT NULL DEFAULT (datetime('now')),
89 +
        updated_at      TEXT NOT NULL DEFAULT (datetime('now'))
90 +
    );
91 +
92 +
    CREATE TABLE IF NOT EXISTS pages (
93 +
        id              INTEGER PRIMARY KEY AUTOINCREMENT,
94 +
        short_id        TEXT NOT NULL UNIQUE,
95 +
        title           TEXT NOT NULL,
96 +
        slug            TEXT NOT NULL UNIQUE,
97 +
        content         TEXT NOT NULL,
98 +
        is_published    INTEGER NOT NULL DEFAULT 0,
99 +
        nav_order       INTEGER NOT NULL DEFAULT 0,
100 +
        created_at      TEXT NOT NULL DEFAULT (datetime('now')),
101 +
        updated_at      TEXT NOT NULL DEFAULT (datetime('now'))
102 +
    );
103 +
104 +
    CREATE TABLE IF NOT EXISTS sessions (
105 +
        id              INTEGER PRIMARY KEY AUTOINCREMENT,
106 +
        token           TEXT NOT NULL UNIQUE,
107 +
        expires_at      TEXT NOT NULL
108 +
    );
78 109
79 -
    conn.execute_batch(
80 -
        "CREATE TABLE IF NOT EXISTS posts (
81 -
            id              INTEGER PRIMARY KEY AUTOINCREMENT,
82 -
            short_id        TEXT NOT NULL UNIQUE,
83 -
            title           TEXT NOT NULL,
84 -
            slug            TEXT NOT NULL UNIQUE,
85 -
            alias           TEXT,
86 -
            canonical_url   TEXT,
87 -
            published_date  TEXT,
88 -
            meta_description TEXT,
89 -
            meta_image      TEXT,
90 -
            lang            TEXT NOT NULL DEFAULT 'en',
91 -
            tags            TEXT,
92 -
            content         TEXT NOT NULL,
93 -
            status          TEXT NOT NULL DEFAULT 'draft',
94 -
            created_at      TEXT NOT NULL DEFAULT (datetime('now')),
95 -
            updated_at      TEXT NOT NULL DEFAULT (datetime('now'))
96 -
        );
110 +
    CREATE TABLE IF NOT EXISTS settings (
111 +
        key   TEXT PRIMARY KEY,
112 +
        value TEXT NOT NULL
113 +
    );
97 114
98 -
        CREATE TABLE IF NOT EXISTS pages (
99 -
            id              INTEGER PRIMARY KEY AUTOINCREMENT,
100 -
            short_id        TEXT NOT NULL UNIQUE,
101 -
            title           TEXT NOT NULL,
102 -
            slug            TEXT NOT NULL UNIQUE,
103 -
            content         TEXT NOT NULL,
104 -
            is_published    INTEGER NOT NULL DEFAULT 0,
105 -
            nav_order       INTEGER NOT NULL DEFAULT 0,
106 -
            created_at      TEXT NOT NULL DEFAULT (datetime('now')),
107 -
            updated_at      TEXT NOT NULL DEFAULT (datetime('now'))
108 -
        );
115 +
    CREATE TABLE IF NOT EXISTS files (
116 +
        id            INTEGER PRIMARY KEY AUTOINCREMENT,
117 +
        short_id      TEXT NOT NULL UNIQUE,
118 +
        filename      TEXT NOT NULL UNIQUE,
119 +
        original_name TEXT NOT NULL,
120 +
        content_type  TEXT NOT NULL DEFAULT 'application/octet-stream',
121 +
        size          INTEGER NOT NULL,
122 +
        created_at    TEXT NOT NULL DEFAULT (datetime('now'))
123 +
    );
124 +
";
109 125
110 -
        CREATE TABLE IF NOT EXISTS sessions (
111 -
            id              INTEGER PRIMARY KEY AUTOINCREMENT,
112 -
            token           TEXT NOT NULL UNIQUE,
113 -
            expires_at      TEXT NOT NULL
114 -
        );
126 +
const DEFAULT_SETTINGS: &[(&str, &str)] = &[
127 +
    ("blog_title", "My Blog"),
128 +
    ("blog_description", "A simple blog"),
129 +
    ("intro_content", ""),
130 +
    ("nav_links", "[blog](/) [posts](/posts)"),
131 +
    ("custom_css", ""),
132 +
    ("favicon_url", ""),
133 +
    ("og_image_url", ""),
134 +
    ("custom_header", ""),
135 +
    ("custom_footer", "<div>
136 +
<a href=\"/feed.xml\" class=\"rss-link\" title=\"RSS Feed\"><svg xmlns=\"http://www.w3.org/2000/svg\" width=\"32\" height=\"32\" fill=\"currentColor\" viewBox=\"0 0 256 256\"><path fill=\"currentColor\" d=\"M104.08 151.92A67.52 67.52 0 0 1 124 200a4 4 0 0 1-8 0a60 60 0 0 0-60-60a4 4 0 0 1 0-8a67.52 67.52 0 0 1 48.08 19.92M56 84a4 4 0 0 0 0 8a108 108 0 0 1 108 108a4 4 0 0 0 8 0A116 116 0 0 0 56 84m116 0A162.92 162.92 0 0 0 56 36a4 4 0 0 0 0 8a155 155 0 0 1 110.31 45.69A155 155 0 0 1 212 200a4 4 0 0 0 8 0a162.92 162.92 0 0 0-48-116M60 188a8 8 0 1 0 8 8a8 8 0 0 0-8-8\"/></svg></a>
137 +
</div>"),
138 +
];
115 139
116 -
        CREATE TABLE IF NOT EXISTS settings (
117 -
            key   TEXT PRIMARY KEY,
118 -
            value TEXT NOT NULL
119 -
        );
140 +
pub fn init_db() -> Db {
141 +
    let path = std::env::var("POSTS_DB_PATH").unwrap_or_else(|_| "posts.sqlite".to_string());
142 +
    let conn = Connection::open(&path).expect("Failed to open database");
120 143
121 -
        CREATE TABLE IF NOT EXISTS files (
122 -
            id            INTEGER PRIMARY KEY AUTOINCREMENT,
123 -
            short_id      TEXT NOT NULL UNIQUE,
124 -
            filename      TEXT NOT NULL UNIQUE,
125 -
            original_name TEXT NOT NULL,
126 -
            content_type  TEXT NOT NULL DEFAULT 'application/octet-stream',
127 -
            size          INTEGER NOT NULL,
128 -
            created_at    TEXT NOT NULL DEFAULT (datetime('now'))
129 -
        );"
130 -
    )
131 -
    .expect("Failed to create tables");
144 +
    conn.execute_batch(SCHEMA).expect("Failed to create tables");
132 145
133 -
    // Seed default settings
134 -
    conn.execute(
135 -
        "INSERT OR IGNORE INTO settings (key, value) VALUES ('blog_title', 'My Blog')",
136 -
        [],
137 -
    )
138 -
    .ok();
139 -
    conn.execute(
140 -
        "INSERT OR IGNORE INTO settings (key, value) VALUES ('blog_description', 'A simple blog')",
141 -
        [],
142 -
    )
143 -
    .ok();
144 -
    conn.execute(
145 -
        "INSERT OR IGNORE INTO settings (key, value) VALUES ('intro_content', '')",
146 -
        [],
147 -
    )
148 -
    .ok();
149 -
    conn.execute(
150 -
        "INSERT OR IGNORE INTO settings (key, value) VALUES ('nav_links', '[blog](/) [posts](/posts)')",
151 -
        [],
152 -
    )
153 -
    .ok();
154 -
    conn.execute(
155 -
        "INSERT OR IGNORE INTO settings (key, value) VALUES ('custom_css', '')",
156 -
        [],
157 -
    )
158 -
    .ok();
159 -
    conn.execute(
160 -
        "INSERT OR IGNORE INTO settings (key, value) VALUES ('favicon_url', '')",
161 -
        [],
162 -
    )
163 -
    .ok();
164 -
    conn.execute(
165 -
        "INSERT OR IGNORE INTO settings (key, value) VALUES ('og_image_url', '')",
166 -
        [],
167 -
    )
168 -
    .ok();
169 -
    conn.execute(
170 -
        "INSERT OR IGNORE INTO settings (key, value) VALUES ('custom_header', '')",
171 -
        [],
172 -
    )
173 -
    .ok();
174 -
    conn.execute(
175 -
        "INSERT OR IGNORE INTO settings (key, value) VALUES ('custom_footer', '<div>
176 -
<a href=\"/feed.xml\" class=\"rss-link\" title=\"RSS Feed\"><svg xmlns=\"http://www.w3.org/2000/svg\" width=\"32\" height=\"32\" fill=\"currentColor\" viewBox=\"0 0 256 256\"><path fill=\"currentColor\" d=\"M104.08 151.92A67.52 67.52 0 0 1 124 200a4 4 0 0 1-8 0a60 60 0 0 0-60-60a4 4 0 0 1 0-8a67.52 67.52 0 0 1 48.08 19.92M56 84a4 4 0 0 0 0 8a108 108 0 0 1 108 108a4 4 0 0 0 8 0A116 116 0 0 0 56 84m116 0A162.92 162.92 0 0 0 56 36a4 4 0 0 0 0 8a155 155 0 0 1 110.31 45.69A155 155 0 0 1 212 200a4 4 0 0 0 8 0a162.92 162.92 0 0 0-48-116M60 188a8 8 0 1 0 8 8a8 8 0 0 0-8-8\"/></svg></a>
177 -
</div>')",
178 -
        [],
179 -
    )
180 -
    .ok();
146 +
    for (key, value) in DEFAULT_SETTINGS {
147 +
        conn.execute(
148 +
            "INSERT OR IGNORE INTO settings (key, value) VALUES (?1, ?2)",
149 +
            params![key, value],
150 +
        )
151 +
        .ok();
152 +
    }
181 153
182 154
    Arc::new(Mutex::new(conn))
183 155
}
184 156
185 157
// --- Post CRUD ---
186 158
187 -
fn row_to_post(row: &rusqlite::Row) -> rusqlite::Result<Post> {
188 -
    Ok(Post {
189 -
        id: row.get(0)?,
190 -
        short_id: row.get(1)?,
191 -
        title: row.get(2)?,
192 -
        slug: row.get(3)?,
193 -
        alias: row.get(4)?,
194 -
        canonical_url: row.get(5)?,
195 -
        published_date: row.get(6)?,
196 -
        meta_description: row.get(7)?,
197 -
        meta_image: row.get(8)?,
198 -
        lang: row.get(9)?,
199 -
        tags: row.get(10)?,
200 -
        content: row.get(11)?,
201 -
        status: row.get(12)?,
202 -
        created_at: row.get(13)?,
203 -
        updated_at: row.get(14)?,
204 -
    })
205 -
}
206 -
207 159
const POST_COLS: &str = "id, short_id, title, slug, alias, canonical_url, published_date, meta_description, meta_image, lang, tags, content, status, created_at, updated_at";
208 160
209 -
pub fn create_post(
210 -
    db: &Db,
211 -
    title: &str,
212 -
    slug: &str,
213 -
    content: &str,
214 -
    status: &str,
215 -
    alias: Option<&str>,
216 -
    canonical_url: Option<&str>,
217 -
    published_date: Option<&str>,
218 -
    meta_description: Option<&str>,
219 -
    meta_image: Option<&str>,
220 -
    lang: &str,
221 -
    tags: Option<&str>,
222 -
) -> Result<Post, DbError> {
161 +
pub fn create_post(db: &Db, input: &PostInput) -> Result<Post, DbError> {
223 162
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
224 163
    let short_id = nanoid!(10);
164 +
    let named = serde_rusqlite::to_params_named(input)
165 +
        .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?;
166 +
    let mut bindings = named.to_slice();
167 +
    bindings.push((":short_id", &short_id));
225 168
    conn.execute(
226 169
        "INSERT INTO posts (short_id, title, slug, content, status, alias, canonical_url, published_date, meta_description, meta_image, lang, tags)
227 -
         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
228 -
        params![short_id, title, slug, content, status, alias, canonical_url, published_date, meta_description, meta_image, lang, tags],
170 +
         VALUES (:short_id, :title, :slug, :content, :status, :alias, :canonical_url, :published_date, :meta_description, :meta_image, :lang, :tags)",
171 +
        bindings.as_slice(),
229 172
    )?;
230 173
    let id = conn.last_insert_rowid();
231 174
    let post = conn.query_row(
232 175
        &format!("SELECT {} FROM posts WHERE id = ?1", POST_COLS),
233 176
        params![id],
234 -
        row_to_post,
177 +
        from_row,
235 178
    )?;
236 179
    Ok(post)
237 180
}
238 181
239 182
pub fn get_post_by_short_id(db: &Db, short_id: &str) -> Result<Option<Post>, DbError> {
240 183
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
241 -
    match conn.query_row(
242 -
        &format!("SELECT {} FROM posts WHERE short_id = ?1", POST_COLS),
243 -
        params![short_id],
244 -
        row_to_post,
245 -
    ) {
246 -
        Ok(post) => Ok(Some(post)),
247 -
        Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
248 -
        Err(e) => Err(DbError::Sqlite(e)),
249 -
    }
184 +
    let post = conn
185 +
        .query_row(
186 +
            &format!("SELECT {} FROM posts WHERE short_id = ?1", POST_COLS),
187 +
            params![short_id],
188 +
            from_row,
189 +
        )
190 +
        .optional()?;
191 +
    Ok(post)
250 192
}
251 193
252 194
pub fn get_post_by_slug(db: &Db, slug: &str) -> Result<Option<Post>, DbError> {
253 195
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
254 -
    match conn.query_row(
255 -
        &format!("SELECT {} FROM posts WHERE slug = ?1", POST_COLS),
256 -
        params![slug],
257 -
        row_to_post,
258 -
    ) {
259 -
        Ok(post) => Ok(Some(post)),
260 -
        Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
261 -
        Err(e) => Err(DbError::Sqlite(e)),
262 -
    }
196 +
    let post = conn
197 +
        .query_row(
198 +
            &format!("SELECT {} FROM posts WHERE slug = ?1", POST_COLS),
199 +
            params![slug],
200 +
            from_row,
201 +
        )
202 +
        .optional()?;
203 +
    Ok(post)
263 204
}
264 205
265 206
pub fn get_all_posts(db: &Db) -> Result<Vec<Post>, DbError> {
268 209
        &format!("SELECT {} FROM posts ORDER BY id DESC", POST_COLS),
269 210
    )?;
270 211
    let posts = stmt
271 -
        .query_map([], row_to_post)?
212 +
        .query_map([], from_row)?
272 213
        .collect::<Result<Vec<_>, _>>()?;
273 214
    Ok(posts)
274 215
}
279 220
        &format!("SELECT {} FROM posts WHERE status = 'published' ORDER BY published_date DESC, id DESC", POST_COLS),
280 221
    )?;
281 222
    let posts = stmt
282 -
        .query_map([], row_to_post)?
223 +
        .query_map([], from_row)?
283 224
        .collect::<Result<Vec<_>, _>>()?;
284 225
    Ok(posts)
285 226
}
286 227
287 -
pub fn update_post(
288 -
    db: &Db,
289 -
    short_id: &str,
290 -
    title: &str,
291 -
    slug: &str,
292 -
    content: &str,
293 -
    status: &str,
294 -
    alias: Option<&str>,
295 -
    canonical_url: Option<&str>,
296 -
    published_date: Option<&str>,
297 -
    meta_description: Option<&str>,
298 -
    meta_image: Option<&str>,
299 -
    lang: &str,
300 -
    tags: Option<&str>,
301 -
) -> Result<Option<Post>, DbError> {
228 +
pub fn update_post(db: &Db, short_id: &str, input: &PostInput) -> Result<Option<Post>, DbError> {
302 229
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
303 -
    let effective_published_date = if status == "published" {
304 -
        Some(published_date.unwrap_or(""))
230 +
    let effective_published_date = if input.status == "published" {
231 +
        Some(input.published_date.unwrap_or(""))
305 232
    } else {
306 -
        published_date
233 +
        input.published_date
307 234
    };
308 235
    let rows = conn.execute(
309 236
        "UPDATE posts SET title = ?1, slug = ?2, content = ?3, status = ?4, alias = ?5, canonical_url = ?6,
310 237
         published_date = CASE WHEN ?4 = 'published' THEN COALESCE(?7, published_date, datetime('now')) ELSE ?7 END,
311 238
         meta_description = ?8, meta_image = ?9, lang = ?10, tags = ?11,
312 239
         updated_at = datetime('now') WHERE short_id = ?12",
313 -
        params![title, slug, content, status, alias, canonical_url, effective_published_date, meta_description, meta_image, lang, tags, short_id],
240 +
        params![input.title, input.slug, input.content, input.status, input.alias, input.canonical_url, effective_published_date, input.meta_description, input.meta_image, input.lang, input.tags, short_id],
314 241
    )?;
315 242
    if rows == 0 {
316 243
        return Ok(None);
317 244
    }
318 -
    match conn.query_row(
319 -
        &format!("SELECT {} FROM posts WHERE short_id = ?1", POST_COLS),
320 -
        params![short_id],
321 -
        row_to_post,
322 -
    ) {
323 -
        Ok(post) => Ok(Some(post)),
324 -
        Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
325 -
        Err(e) => Err(DbError::Sqlite(e)),
326 -
    }
245 +
    let post = conn
246 +
        .query_row(
247 +
            &format!("SELECT {} FROM posts WHERE short_id = ?1", POST_COLS),
248 +
            params![short_id],
249 +
            from_row,
250 +
        )
251 +
        .optional()?;
252 +
    Ok(post)
327 253
}
328 254
329 255
pub fn delete_post(db: &Db, short_id: &str) -> Result<bool, DbError> {
334 260
335 261
pub fn toggle_post_status(db: &Db, short_id: &str) -> Result<Option<String>, DbError> {
336 262
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
337 -
    let current: String = match conn.query_row(
338 -
        "SELECT status FROM posts WHERE short_id = ?1",
339 -
        params![short_id],
340 -
        |row| row.get(0),
341 -
    ) {
342 -
        Ok(s) => s,
343 -
        Err(rusqlite::Error::QueryReturnedNoRows) => return Ok(None),
344 -
        Err(e) => return Err(DbError::Sqlite(e)),
263 +
    let current: Option<String> = conn
264 +
        .query_row(
265 +
            "SELECT status FROM posts WHERE short_id = ?1",
266 +
            params![short_id],
267 +
            |row| row.get(0),
268 +
        )
269 +
        .optional()?;
270 +
    let current = match current {
271 +
        Some(s) => s,
272 +
        None => return Ok(None),
345 273
    };
346 274
    let new_status = if current == "published" { "draft" } else { "published" };
347 275
    if new_status == "published" {
360 288
361 289
pub fn find_alias_redirect(db: &Db, alias: &str) -> Result<Option<String>, DbError> {
362 290
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
363 -
    match conn.query_row(
364 -
        "SELECT slug FROM posts WHERE alias = ?1 AND status = 'published'",
365 -
        params![alias],
366 -
        |row| row.get::<_, String>(0),
367 -
    ) {
368 -
        Ok(slug) => Ok(Some(format!("/posts/{}", slug))),
369 -
        Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
370 -
        Err(e) => Err(DbError::Sqlite(e)),
371 -
    }
291 +
    let slug: Option<String> = conn
292 +
        .query_row(
293 +
            "SELECT slug FROM posts WHERE alias = ?1 AND status = 'published'",
294 +
            params![alias],
295 +
            |row| row.get(0),
296 +
        )
297 +
        .optional()?;
298 +
    Ok(slug.map(|s| format!("/posts/{}", s)))
372 299
}
373 300
374 301
// --- Page CRUD ---
375 302
376 -
fn row_to_page(row: &rusqlite::Row) -> rusqlite::Result<Page> {
377 -
    Ok(Page {
378 -
        id: row.get(0)?,
379 -
        short_id: row.get(1)?,
380 -
        title: row.get(2)?,
381 -
        slug: row.get(3)?,
382 -
        content: row.get(4)?,
383 -
        is_published: row.get::<_, i64>(5)? != 0,
384 -
        nav_order: row.get(6)?,
385 -
        created_at: row.get(7)?,
386 -
        updated_at: row.get(8)?,
387 -
    })
388 -
}
389 -
390 303
const PAGE_COLS: &str = "id, short_id, title, slug, content, is_published, nav_order, created_at, updated_at";
391 304
392 305
pub fn create_page(
407 320
    let page = conn.query_row(
408 321
        &format!("SELECT {} FROM pages WHERE id = ?1", PAGE_COLS),
409 322
        params![id],
410 -
        row_to_page,
323 +
        from_row,
411 324
    )?;
412 325
    Ok(page)
413 326
}
414 327
415 328
pub fn get_page_by_short_id(db: &Db, short_id: &str) -> Result<Option<Page>, DbError> {
416 329
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
417 -
    match conn.query_row(
418 -
        &format!("SELECT {} FROM pages WHERE short_id = ?1", PAGE_COLS),
419 -
        params![short_id],
420 -
        row_to_page,
421 -
    ) {
422 -
        Ok(page) => Ok(Some(page)),
423 -
        Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
424 -
        Err(e) => Err(DbError::Sqlite(e)),
425 -
    }
330 +
    let page = conn
331 +
        .query_row(
332 +
            &format!("SELECT {} FROM pages WHERE short_id = ?1", PAGE_COLS),
333 +
            params![short_id],
334 +
            from_row,
335 +
        )
336 +
        .optional()?;
337 +
    Ok(page)
426 338
}
427 339
428 340
pub fn get_page_by_slug(db: &Db, slug: &str) -> Result<Option<Page>, DbError> {
429 341
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
430 -
    match conn.query_row(
431 -
        &format!("SELECT {} FROM pages WHERE slug = ?1", PAGE_COLS),
432 -
        params![slug],
433 -
        row_to_page,
434 -
    ) {
435 -
        Ok(page) => Ok(Some(page)),
436 -
        Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
437 -
        Err(e) => Err(DbError::Sqlite(e)),
438 -
    }
342 +
    let page = conn
343 +
        .query_row(
344 +
            &format!("SELECT {} FROM pages WHERE slug = ?1", PAGE_COLS),
345 +
            params![slug],
346 +
            from_row,
347 +
        )
348 +
        .optional()?;
349 +
    Ok(page)
439 350
}
440 351
441 352
pub fn get_all_pages(db: &Db) -> Result<Vec<Page>, DbError> {
444 355
        &format!("SELECT {} FROM pages ORDER BY nav_order ASC, id ASC", PAGE_COLS),
445 356
    )?;
446 357
    let pages = stmt
447 -
        .query_map([], row_to_page)?
358 +
        .query_map([], from_row)?
448 359
        .collect::<Result<Vec<_>, _>>()?;
449 360
    Ok(pages)
450 361
}
455 366
        &format!("SELECT {} FROM pages WHERE is_published = 1 ORDER BY nav_order ASC, id ASC", PAGE_COLS),
456 367
    )?;
457 368
    let pages = stmt
458 -
        .query_map([], row_to_page)?
369 +
        .query_map([], from_row)?
459 370
        .collect::<Result<Vec<_>, _>>()?;
460 371
    Ok(pages)
461 372
}
477 388
    if rows == 0 {
478 389
        return Ok(None);
479 390
    }
480 -
    match conn.query_row(
481 -
        &format!("SELECT {} FROM pages WHERE short_id = ?1", PAGE_COLS),
482 -
        params![short_id],
483 -
        row_to_page,
484 -
    ) {
485 -
        Ok(page) => Ok(Some(page)),
486 -
        Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
487 -
        Err(e) => Err(DbError::Sqlite(e)),
488 -
    }
391 +
    let page = conn
392 +
        .query_row(
393 +
            &format!("SELECT {} FROM pages WHERE short_id = ?1", PAGE_COLS),
394 +
            params![short_id],
395 +
            from_row,
396 +
        )
397 +
        .optional()?;
398 +
    Ok(page)
489 399
}
490 400
491 401
pub fn delete_page(db: &Db, short_id: &str) -> Result<bool, DbError> {
498 408
499 409
pub fn get_setting(db: &Db, key: &str) -> Result<Option<String>, DbError> {
500 410
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
501 -
    match conn.query_row(
502 -
        "SELECT value FROM settings WHERE key = ?1",
503 -
        params![key],
504 -
        |row| row.get(0),
505 -
    ) {
506 -
        Ok(val) => Ok(Some(val)),
507 -
        Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
508 -
        Err(e) => Err(DbError::Sqlite(e)),
509 -
    }
411 +
    let val = conn
412 +
        .query_row(
413 +
            "SELECT value FROM settings WHERE key = ?1",
414 +
            params![key],
415 +
            |row| row.get(0),
416 +
        )
417 +
        .optional()?;
418 +
    Ok(val)
510 419
}
511 420
512 421
pub fn set_setting(db: &Db, key: &str, value: &str) -> Result<(), DbError> {
527 436
    Ok(settings)
528 437
}
529 438
530 -
// --- Session functions ---
531 -
532 -
pub fn insert_session(db: &Db, token: &str, expires_at: &str) -> Result<(), DbError> {
533 -
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
534 -
    conn.execute(
535 -
        "INSERT INTO sessions (token, expires_at) VALUES (?1, ?2)",
536 -
        params![token, expires_at],
537 -
    )?;
538 -
    Ok(())
539 -
}
540 -
541 -
pub fn get_session_expiry(db: &Db, token: &str) -> Result<Option<String>, DbError> {
542 -
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
543 -
    match conn.query_row(
544 -
        "SELECT expires_at FROM sessions WHERE token = ?1",
545 -
        params![token],
546 -
        |row| row.get(0),
547 -
    ) {
548 -
        Ok(val) => Ok(Some(val)),
549 -
        Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
550 -
        Err(e) => Err(DbError::Sqlite(e)),
551 -
    }
552 -
}
553 -
554 -
pub fn delete_session(db: &Db, token: &str) -> Result<(), DbError> {
555 -
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
556 -
    conn.execute("DELETE FROM sessions WHERE token = ?1", params![token])?;
557 -
    Ok(())
558 -
}
559 -
560 -
pub fn prune_expired_sessions(db: &Db) -> Result<(), DbError> {
561 -
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
562 -
    conn.execute(
563 -
        "DELETE FROM sessions WHERE expires_at < datetime('now')",
564 -
        [],
565 -
    )?;
566 -
    Ok(())
567 -
}
568 -
569 439
// --- File CRUD ---
570 440
571 -
fn row_to_file(row: &rusqlite::Row) -> rusqlite::Result<UploadedFile> {
572 -
    Ok(UploadedFile {
573 -
        id: row.get(0)?,
574 -
        short_id: row.get(1)?,
575 -
        filename: row.get(2)?,
576 -
        original_name: row.get(3)?,
577 -
        content_type: row.get(4)?,
578 -
        size: row.get(5)?,
579 -
        created_at: row.get(6)?,
580 -
    })
581 -
}
582 -
583 441
const FILE_COLS: &str = "id, short_id, filename, original_name, content_type, size, created_at";
584 442
585 443
pub fn create_file(
599 457
    let file = conn.query_row(
600 458
        &format!("SELECT {} FROM files WHERE id = ?1", FILE_COLS),
601 459
        params![id],
602 -
        row_to_file,
460 +
        from_row,
603 461
    )?;
604 462
    Ok(file)
605 463
}
610 468
        &format!("SELECT {} FROM files ORDER BY id DESC", FILE_COLS),
611 469
    )?;
612 470
    let files = stmt
613 -
        .query_map([], row_to_file)?
471 +
        .query_map([], from_row)?
614 472
        .collect::<Result<Vec<_>, _>>()?;
615 473
    Ok(files)
616 474
}
617 475
618 476
pub fn delete_file(db: &Db, short_id: &str) -> Result<Option<UploadedFile>, DbError> {
619 477
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
620 -
    let file = match conn.query_row(
621 -
        &format!("SELECT {} FROM files WHERE short_id = ?1", FILE_COLS),
622 -
        params![short_id],
623 -
        row_to_file,
624 -
    ) {
625 -
        Ok(f) => f,
626 -
        Err(rusqlite::Error::QueryReturnedNoRows) => return Ok(None),
627 -
        Err(e) => return Err(DbError::Sqlite(e)),
628 -
    };
629 -
    conn.execute("DELETE FROM files WHERE short_id = ?1", params![short_id])?;
630 -
    Ok(Some(file))
478 +
    let file: Option<UploadedFile> = conn
479 +
        .query_row(
480 +
            &format!("SELECT {} FROM files WHERE short_id = ?1", FILE_COLS),
481 +
            params![short_id],
482 +
            from_row,
483 +
        )
484 +
        .optional()?;
485 +
    match file {
486 +
        Some(f) => {
487 +
            conn.execute("DELETE FROM files WHERE short_id = ?1", params![short_id])?;
488 +
            Ok(Some(f))
489 +
        }
490 +
        None => Ok(None),
491 +
    }
631 492
}
632 493
633 494
#[cfg(test)]
636 497
637 498
    fn test_db() -> Db {
638 499
        let conn = Connection::open_in_memory().unwrap();
639 -
        conn.execute_batch(
640 -
            "CREATE TABLE IF NOT EXISTS posts (
641 -
                id              INTEGER PRIMARY KEY AUTOINCREMENT,
642 -
                short_id        TEXT NOT NULL UNIQUE,
643 -
                title           TEXT NOT NULL,
644 -
                slug            TEXT NOT NULL UNIQUE,
645 -
                alias           TEXT,
646 -
                canonical_url   TEXT,
647 -
                published_date  TEXT,
648 -
                meta_description TEXT,
649 -
                meta_image      TEXT,
650 -
                lang            TEXT NOT NULL DEFAULT 'en',
651 -
                tags            TEXT,
652 -
                content         TEXT NOT NULL,
653 -
                status          TEXT NOT NULL DEFAULT 'draft',
654 -
                created_at      TEXT NOT NULL DEFAULT (datetime('now')),
655 -
                updated_at      TEXT NOT NULL DEFAULT (datetime('now'))
656 -
            );
657 -
            CREATE TABLE IF NOT EXISTS pages (
658 -
                id              INTEGER PRIMARY KEY AUTOINCREMENT,
659 -
                short_id        TEXT NOT NULL UNIQUE,
660 -
                title           TEXT NOT NULL,
661 -
                slug            TEXT NOT NULL UNIQUE,
662 -
                content         TEXT NOT NULL,
663 -
                is_published    INTEGER NOT NULL DEFAULT 0,
664 -
                nav_order       INTEGER NOT NULL DEFAULT 0,
665 -
                created_at      TEXT NOT NULL DEFAULT (datetime('now')),
666 -
                updated_at      TEXT NOT NULL DEFAULT (datetime('now'))
667 -
            );
668 -
            CREATE TABLE IF NOT EXISTS sessions (
669 -
                id              INTEGER PRIMARY KEY AUTOINCREMENT,
670 -
                token           TEXT NOT NULL UNIQUE,
671 -
                expires_at      TEXT NOT NULL
672 -
            );
673 -
            CREATE TABLE IF NOT EXISTS settings (
674 -
                key   TEXT PRIMARY KEY,
675 -
                value TEXT NOT NULL
676 -
            );
677 -
            CREATE TABLE IF NOT EXISTS files (
678 -
                id            INTEGER PRIMARY KEY AUTOINCREMENT,
679 -
                short_id      TEXT NOT NULL UNIQUE,
680 -
                filename      TEXT NOT NULL UNIQUE,
681 -
                original_name TEXT NOT NULL,
682 -
                content_type  TEXT NOT NULL DEFAULT 'application/octet-stream',
683 -
                size          INTEGER NOT NULL,
684 -
                created_at    TEXT NOT NULL DEFAULT (datetime('now'))
685 -
            );",
686 -
        )
687 -
        .unwrap();
500 +
        conn.execute_batch(SCHEMA).unwrap();
688 501
        Arc::new(Mutex::new(conn))
689 502
    }
690 503
504 +
    fn test_post_input<'a>(title: &'a str, slug: &'a str, content: &'a str, status: &'a str) -> PostInput<'a> {
505 +
        PostInput {
506 +
            title,
507 +
            slug,
508 +
            content,
509 +
            status,
510 +
            alias: None,
511 +
            canonical_url: None,
512 +
            published_date: None,
513 +
            meta_description: None,
514 +
            meta_image: None,
515 +
            lang: "en",
516 +
            tags: None,
517 +
        }
518 +
    }
519 +
691 520
    // ── Post CRUD ──────────────────────────────────────────────────────
692 521
693 522
    #[test]
694 523
    fn create_and_get_post() {
695 524
        let db = test_db();
696 -
        let post = create_post(
697 -
            &db, "Hello World", "hello-world", "# Hello", "draft",
698 -
            None, None, None, None, None, "en", None,
699 -
        )
700 -
        .unwrap();
525 +
        let post = create_post(&db, &test_post_input("Hello World", "hello-world", "# Hello", "draft")).unwrap();
701 526
        assert_eq!(post.title, "Hello World");
702 527
        assert_eq!(post.slug, "hello-world");
703 528
        assert_eq!(post.status, "draft");
709 534
    #[test]
710 535
    fn get_post_by_slug_works() {
711 536
        let db = test_db();
712 -
        create_post(
713 -
            &db, "Test", "test-slug", "content", "published",
714 -
            None, None, Some("2024-01-01"), None, None, "en", None,
715 -
        )
716 -
        .unwrap();
537 +
        let mut input = test_post_input("Test", "test-slug", "content", "published");
538 +
        input.published_date = Some("2024-01-01");
539 +
        create_post(&db, &input).unwrap();
717 540
718 541
        let post = get_post_by_slug(&db, "test-slug").unwrap().unwrap();
719 542
        assert_eq!(post.title, "Test");
722 545
    #[test]
723 546
    fn duplicate_slug_fails() {
724 547
        let db = test_db();
725 -
        create_post(&db, "A", "same-slug", "a", "draft", None, None, None, None, None, "en", None).unwrap();
726 -
        let result = create_post(&db, "B", "same-slug", "b", "draft", None, None, None, None, None, "en", None);
548 +
        create_post(&db, &test_post_input("A", "same-slug", "a", "draft")).unwrap();
549 +
        let result = create_post(&db, &test_post_input("B", "same-slug", "b", "draft"));
727 550
        assert!(result.is_err());
728 551
    }
729 552
730 553
    #[test]
731 554
    fn get_all_posts_ordered_desc() {
732 555
        let db = test_db();
733 -
        create_post(&db, "First", "first", "a", "draft", None, None, None, None, None, "en", None).unwrap();
734 -
        create_post(&db, "Second", "second", "b", "draft", None, None, None, None, None, "en", None).unwrap();
556 +
        create_post(&db, &test_post_input("First", "first", "a", "draft")).unwrap();
557 +
        create_post(&db, &test_post_input("Second", "second", "b", "draft")).unwrap();
735 558
736 559
        let all = get_all_posts(&db).unwrap();
737 560
        assert_eq!(all.len(), 2);
742 565
    #[test]
743 566
    fn get_published_posts_filters() {
744 567
        let db = test_db();
745 -
        create_post(&db, "Draft", "draft", "a", "draft", None, None, None, None, None, "en", None).unwrap();
746 -
        create_post(&db, "Published", "pub", "b", "published", None, None, Some("2024-01-01"), None, None, "en", None).unwrap();
568 +
        create_post(&db, &test_post_input("Draft", "draft", "a", "draft")).unwrap();
569 +
        let mut input = test_post_input("Published", "pub", "b", "published");
570 +
        input.published_date = Some("2024-01-01");
571 +
        create_post(&db, &input).unwrap();
747 572
748 573
        let published = get_published_posts(&db).unwrap();
749 574
        assert_eq!(published.len(), 1);
753 578
    #[test]
754 579
    fn delete_post_works() {
755 580
        let db = test_db();
756 -
        let post = create_post(&db, "Del", "del", "x", "draft", None, None, None, None, None, "en", None).unwrap();
581 +
        let post = create_post(&db, &test_post_input("Del", "del", "x", "draft")).unwrap();
757 582
        assert!(delete_post(&db, &post.short_id).unwrap());
758 583
        assert!(get_post_by_short_id(&db, &post.short_id).unwrap().is_none());
759 584
    }
761 586
    #[test]
762 587
    fn toggle_post_status_draft_to_published() {
763 588
        let db = test_db();
764 -
        let post = create_post(&db, "Toggle", "toggle", "x", "draft", None, None, None, None, None, "en", None).unwrap();
589 +
        let post = create_post(&db, &test_post_input("Toggle", "toggle", "x", "draft")).unwrap();
765 590
        let new_status = toggle_post_status(&db, &post.short_id).unwrap().unwrap();
766 591
        assert_eq!(new_status, "published");
767 592
773 598
    #[test]
774 599
    fn toggle_post_status_published_to_draft() {
775 600
        let db = test_db();
776 -
        let post = create_post(&db, "Toggle", "toggle", "x", "published", None, None, Some("2024-01-01"), None, None, "en", None).unwrap();
601 +
        let mut input = test_post_input("Toggle", "toggle", "x", "published");
602 +
        input.published_date = Some("2024-01-01");
603 +
        let post = create_post(&db, &input).unwrap();
777 604
        let new_status = toggle_post_status(&db, &post.short_id).unwrap().unwrap();
778 605
        assert_eq!(new_status, "draft");
779 606
    }
781 608
    #[test]
782 609
    fn find_alias_redirect_found() {
783 610
        let db = test_db();
784 -
        create_post(&db, "Aliased", "aliased-post", "x", "published", Some("old-url"), None, Some("2024-01-01"), None, None, "en", None).unwrap();
611 +
        let mut input = test_post_input("Aliased", "aliased-post", "x", "published");
612 +
        input.alias = Some("old-url");
613 +
        input.published_date = Some("2024-01-01");
614 +
        create_post(&db, &input).unwrap();
785 615
        let redirect = find_alias_redirect(&db, "old-url").unwrap();
786 616
        assert_eq!(redirect, Some("/posts/aliased-post".to_string()));
787 617
    }
795 625
    #[test]
796 626
    fn find_alias_redirect_only_published() {
797 627
        let db = test_db();
798 -
        create_post(&db, "Draft Alias", "draft-alias", "x", "draft", Some("my-alias"), None, None, None, None, "en", None).unwrap();
628 +
        let mut input = test_post_input("Draft Alias", "draft-alias", "x", "draft");
629 +
        input.alias = Some("my-alias");
630 +
        create_post(&db, &input).unwrap();
799 631
        assert!(find_alias_redirect(&db, "my-alias").unwrap().is_none());
800 632
    }
801 633
apps/posts/src/server/handlers/admin.rs +22 −23
116 116
        attrs.published_date.trim().to_string()
117 117
    };
118 118
119 -
    match db::create_post(
120 -
        &state.db,
119 +
    let input = db::PostInput {
121 120
        title,
122 -
        &slug,
123 -
        &form.content,
121 +
        slug: &slug,
122 +
        content: &form.content,
124 123
        status,
125 -
        opt_str(&attrs.alias),
126 -
        None,
127 -
        Some(&published_date),
128 -
        opt_str(&attrs.meta_description),
129 -
        opt_str(&attrs.meta_image),
124 +
        alias: opt_str(&attrs.alias),
125 +
        canonical_url: None,
126 +
        published_date: Some(&published_date),
127 +
        meta_description: opt_str(&attrs.meta_description),
128 +
        meta_image: opt_str(&attrs.meta_image),
130 129
        lang,
131 -
        opt_str(&attrs.tags),
132 -
    ) {
130 +
        tags: opt_str(&attrs.tags),
131 +
    };
132 +
    match db::create_post(&state.db, &input) {
133 133
        Ok(_) => Redirect::to("/admin").into_response(),
134 134
        Err(e) => {
135 135
            tracing::error!("Failed to create post: {}", e);
184 184
        Some(attrs.published_date.trim().to_string())
185 185
    };
186 186
187 -
    match db::update_post(
188 -
        &state.db,
189 -
        &short_id,
187 +
    let input = db::PostInput {
190 188
        title,
191 -
        &slug,
192 -
        &form.content,
189 +
        slug: &slug,
190 +
        content: &form.content,
193 191
        status,
194 -
        opt_str(&attrs.alias),
195 -
        None,
196 -
        published_date.as_deref(),
197 -
        opt_str(&attrs.meta_description),
198 -
        opt_str(&attrs.meta_image),
192 +
        alias: opt_str(&attrs.alias),
193 +
        canonical_url: None,
194 +
        published_date: published_date.as_deref(),
195 +
        meta_description: opt_str(&attrs.meta_description),
196 +
        meta_image: opt_str(&attrs.meta_image),
199 197
        lang,
200 -
        opt_str(&attrs.tags),
201 -
    ) {
198 +
        tags: opt_str(&attrs.tags),
199 +
    };
200 +
    match db::update_post(&state.db, &short_id, &input) {
202 201
        Ok(Some(_)) => Redirect::to("/admin").into_response(),
203 202
        Ok(None) => (StatusCode::NOT_FOUND, Html("Post not found".to_string())).into_response(),
204 203
        Err(e) => {
apps/sipp/Cargo.toml +1 −0
28 28
dotenvy = { workspace = true }
29 29
subtle = { workspace = true }
30 30
rusqlite = { workspace = true }
31 +
andromeda-db = { workspace = true }
31 32
askama = "0.15.4"
32 33
askama_web = { version = "0.15.1", features = ["axum-0.8"] }
33 34
ratatui = "0.30"
apps/sipp/src/db.rs +46 −89
1 1
use nanoid::nanoid;
2 -
use rusqlite::{Connection, params};
2 +
use rusqlite::{Connection, OptionalExtension, params};
3 3
use serde::{Deserialize, Serialize};
4 -
use std::fmt;
5 4
use std::sync::{Arc, Mutex};
6 5
7 -
pub type Db = Arc<Mutex<Connection>>;
8 -
9 -
#[derive(Debug)]
10 -
pub enum DbError {
11 -
    Sqlite(rusqlite::Error),
12 -
    LockPoisoned,
13 -
}
14 -
15 -
impl fmt::Display for DbError {
16 -
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
17 -
        match self {
18 -
            DbError::Sqlite(e) => write!(f, "Database error: {}", e),
19 -
            DbError::LockPoisoned => write!(f, "Database lock poisoned"),
20 -
        }
21 -
    }
22 -
}
23 -
24 -
impl std::error::Error for DbError {}
25 -
26 -
impl From<rusqlite::Error> for DbError {
27 -
    fn from(e: rusqlite::Error) -> Self {
28 -
        DbError::Sqlite(e)
29 -
    }
30 -
}
6 +
pub use andromeda_db::{Db, DbError};
31 7
32 8
#[derive(Serialize, Deserialize)]
33 9
pub struct Snippet {
37 13
    pub name: String,
38 14
}
39 15
16 +
const SNIPPET_COLS: &str = "id, short_id, content, name";
17 +
18 +
fn snippet_from_row(row: &rusqlite::Row) -> rusqlite::Result<Snippet> {
19 +
    Ok(Snippet {
20 +
        id: row.get(0)?,
21 +
        short_id: row.get(1)?,
22 +
        content: row.get(2)?,
23 +
        name: row.get(3)?,
24 +
    })
25 +
}
26 +
40 27
fn generate_short_id() -> String {
41 28
    nanoid!(10)
42 29
}
45 32
    std::env::var("SIPP_DB_PATH").unwrap_or_else(|_| "sipp.sqlite".to_string())
46 33
}
47 34
35 +
const SCHEMA: &str = "
36 +
    CREATE TABLE IF NOT EXISTS snippets (
37 +
        id INTEGER PRIMARY KEY AUTOINCREMENT,
38 +
        short_id TEXT NOT NULL UNIQUE,
39 +
        content TEXT NOT NULL,
40 +
        name TEXT NOT NULL
41 +
    );
42 +
";
43 +
48 44
pub fn init_db() -> Result<Db, DbError> {
49 45
    let conn = Connection::open(db_path())?;
50 -
    conn.execute(
51 -
        "CREATE TABLE IF NOT EXISTS snippets (
52 -
            id INTEGER PRIMARY KEY AUTOINCREMENT,
53 -
            short_id TEXT NOT NULL UNIQUE,
54 -
            content TEXT NOT NULL,
55 -
            name TEXT NOT NULL
56 -
        )",
57 -
        [],
58 -
    )?;
46 +
    conn.execute_batch(SCHEMA)?;
59 47
    Ok(Arc::new(Mutex::new(conn)))
60 48
}
61 49
77 65
78 66
pub fn get_snippet_by_short_id(db: &Db, short_id: &str) -> Result<Option<Snippet>, DbError> {
79 67
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
80 -
    match conn.query_row(
81 -
        "SELECT id, short_id, content, name FROM snippets WHERE short_id = ?1",
82 -
        params![short_id],
83 -
        |row| {
84 -
            Ok(Snippet {
85 -
                id: row.get(0)?,
86 -
                short_id: row.get(1)?,
87 -
                content: row.get(2)?,
88 -
                name: row.get(3)?,
89 -
            })
90 -
        },
91 -
    ) {
92 -
        Ok(snippet) => Ok(Some(snippet)),
93 -
        Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
94 -
        Err(e) => Err(DbError::Sqlite(e)),
95 -
    }
68 +
    let snippet = conn
69 +
        .query_row(
70 +
            &format!("SELECT {} FROM snippets WHERE short_id = ?1", SNIPPET_COLS),
71 +
            params![short_id],
72 +
            snippet_from_row,
73 +
        )
74 +
        .optional()?;
75 +
    Ok(snippet)
96 76
}
97 77
98 78
pub fn get_all_snippets(db: &Db) -> Result<Vec<Snippet>, DbError> {
99 79
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
100 -
    let mut stmt = conn
101 -
        .prepare("SELECT id, short_id, content, name FROM snippets ORDER BY id DESC")?;
102 -
    let snippets = stmt.query_map([], |row| {
103 -
        Ok(Snippet {
104 -
            id: row.get(0)?,
105 -
            short_id: row.get(1)?,
106 -
            content: row.get(2)?,
107 -
            name: row.get(3)?,
108 -
        })
109 -
    })?
110 -
    .filter_map(|r| r.ok())
111 -
    .collect();
80 +
    let mut stmt = conn.prepare(
81 +
        &format!("SELECT {} FROM snippets ORDER BY id DESC", SNIPPET_COLS),
82 +
    )?;
83 +
    let snippets = stmt
84 +
        .query_map([], snippet_from_row)?
85 +
        .collect::<Result<Vec<_>, _>>()?;
112 86
    Ok(snippets)
113 87
}
114 88
135 109
    if rows_affected == 0 {
136 110
        return Ok(None);
137 111
    }
138 -
    match conn.query_row(
139 -
        "SELECT id, short_id, content, name FROM snippets WHERE short_id = ?1",
140 -
        params![short_id],
141 -
        |row| {
142 -
            Ok(Snippet {
143 -
                id: row.get(0)?,
144 -
                short_id: row.get(1)?,
145 -
                content: row.get(2)?,
146 -
                name: row.get(3)?,
147 -
            })
148 -
        },
149 -
    ) {
150 -
        Ok(snippet) => Ok(Some(snippet)),
151 -
        Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
152 -
        Err(e) => Err(DbError::Sqlite(e)),
153 -
    }
112 +
    let snippet = conn
113 +
        .query_row(
114 +
            &format!("SELECT {} FROM snippets WHERE short_id = ?1", SNIPPET_COLS),
115 +
            params![short_id],
116 +
            snippet_from_row,
117 +
        )
118 +
        .optional()?;
119 +
    Ok(snippet)
154 120
}
155 121
156 122
#[cfg(test)]
159 125
160 126
    fn test_db() -> Db {
161 127
        let conn = Connection::open_in_memory().unwrap();
162 -
        conn.execute(
163 -
            "CREATE TABLE IF NOT EXISTS snippets (
164 -
                id INTEGER PRIMARY KEY AUTOINCREMENT,
165 -
                short_id TEXT NOT NULL UNIQUE,
166 -
                content TEXT NOT NULL,
167 -
                name TEXT NOT NULL
168 -
            )",
169 -
            [],
170 -
        )
171 -
        .unwrap();
128 +
        conn.execute_batch(SCHEMA).unwrap();
172 129
        Arc::new(Mutex::new(conn))
173 130
    }
174 131
crates/db/Cargo.toml (added) +18 −0
1 +
[package]
2 +
name = "andromeda-db"
3 +
version = "0.1.0"
4 +
edition = "2024"
5 +
description = "Shared database types and session management for Andromeda apps"
6 +
license = "MIT"
7 +
repository = "https://github.com/stevedylandev/andromeda"
8 +
homepage = "https://github.com/stevedylandev/andromeda"
9 +
10 +
[dependencies]
11 +
rusqlite = { workspace = true }
12 +
axum = { workspace = true, optional = true }
13 +
tracing = { workspace = true, optional = true }
14 +
15 +
[features]
16 +
default = []
17 +
session = []
18 +
axum = ["dep:axum", "dep:tracing"]
crates/db/src/lib.rs (added) +47 −0
1 +
use rusqlite::Connection;
2 +
use std::fmt;
3 +
use std::sync::{Arc, Mutex};
4 +
5 +
pub type Db = Arc<Mutex<Connection>>;
6 +
7 +
pub trait HasDb {
8 +
    fn db(&self) -> &Db;
9 +
}
10 +
11 +
#[derive(Debug)]
12 +
pub enum DbError {
13 +
    Sqlite(rusqlite::Error),
14 +
    LockPoisoned,
15 +
}
16 +
17 +
impl fmt::Display for DbError {
18 +
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
19 +
        match self {
20 +
            DbError::Sqlite(e) => write!(f, "Database error: {}", e),
21 +
            DbError::LockPoisoned => write!(f, "Database lock poisoned"),
22 +
        }
23 +
    }
24 +
}
25 +
26 +
impl std::error::Error for DbError {}
27 +
28 +
impl From<rusqlite::Error> for DbError {
29 +
    fn from(e: rusqlite::Error) -> Self {
30 +
        DbError::Sqlite(e)
31 +
    }
32 +
}
33 +
34 +
#[cfg(feature = "axum")]
35 +
impl axum::response::IntoResponse for DbError {
36 +
    fn into_response(self) -> axum::response::Response {
37 +
        tracing::error!("{}", self);
38 +
        (
39 +
            axum::http::StatusCode::INTERNAL_SERVER_ERROR,
40 +
            "Server error",
41 +
        )
42 +
            .into_response()
43 +
    }
44 +
}
45 +
46 +
#[cfg(feature = "session")]
47 +
pub mod session;
crates/db/src/session.rs (added) +89 −0
1 +
use rusqlite::params;
2 +
3 +
use crate::{Db, DbError};
4 +
5 +
pub const SESSION_SCHEMA: &str = "
6 +
    CREATE TABLE IF NOT EXISTS sessions (
7 +
        id         INTEGER PRIMARY KEY AUTOINCREMENT,
8 +
        token      TEXT NOT NULL UNIQUE,
9 +
        expires_at TEXT NOT NULL
10 +
    );
11 +
";
12 +
13 +
pub fn insert_session(db: &Db, token: &str, expires_at: &str) -> Result<(), DbError> {
14 +
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
15 +
    conn.execute(
16 +
        "INSERT INTO sessions (token, expires_at) VALUES (?1, ?2)",
17 +
        params![token, expires_at],
18 +
    )?;
19 +
    Ok(())
20 +
}
21 +
22 +
pub fn get_session_expiry(db: &Db, token: &str) -> Result<Option<String>, DbError> {
23 +
    use rusqlite::OptionalExtension;
24 +
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
25 +
    let val = conn
26 +
        .query_row(
27 +
            "SELECT expires_at FROM sessions WHERE token = ?1",
28 +
            params![token],
29 +
            |row| row.get(0),
30 +
        )
31 +
        .optional()?;
32 +
    Ok(val)
33 +
}
34 +
35 +
pub fn delete_session(db: &Db, token: &str) -> Result<(), DbError> {
36 +
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
37 +
    conn.execute("DELETE FROM sessions WHERE token = ?1", params![token])?;
38 +
    Ok(())
39 +
}
40 +
41 +
pub fn prune_expired_sessions(db: &Db) -> Result<(), DbError> {
42 +
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
43 +
    conn.execute(
44 +
        "DELETE FROM sessions WHERE expires_at < datetime('now')",
45 +
        [],
46 +
    )?;
47 +
    Ok(())
48 +
}
49 +
50 +
#[cfg(test)]
51 +
mod tests {
52 +
    use super::*;
53 +
    use rusqlite::Connection;
54 +
    use std::sync::{Arc, Mutex};
55 +
56 +
    fn test_db() -> Db {
57 +
        let conn = Connection::open_in_memory().unwrap();
58 +
        conn.execute_batch(SESSION_SCHEMA).unwrap();
59 +
        Arc::new(Mutex::new(conn))
60 +
    }
61 +
62 +
    #[test]
63 +
    fn session_lifecycle() {
64 +
        let db = test_db();
65 +
        insert_session(&db, "tok", "2099-12-31 23:59:59").unwrap();
66 +
        assert_eq!(
67 +
            get_session_expiry(&db, "tok").unwrap(),
68 +
            Some("2099-12-31 23:59:59".to_string())
69 +
        );
70 +
        delete_session(&db, "tok").unwrap();
71 +
        assert!(get_session_expiry(&db, "tok").unwrap().is_none());
72 +
    }
73 +
74 +
    #[test]
75 +
    fn prune_expired_sessions_works() {
76 +
        let db = test_db();
77 +
        insert_session(&db, "old", "2000-01-01 00:00:00").unwrap();
78 +
        insert_session(&db, "new", "2099-01-01 00:00:00").unwrap();
79 +
        prune_expired_sessions(&db).unwrap();
80 +
        assert!(get_session_expiry(&db, "old").unwrap().is_none());
81 +
        assert!(get_session_expiry(&db, "new").unwrap().is_some());
82 +
    }
83 +
84 +
    #[test]
85 +
    fn missing_token_returns_none() {
86 +
        let db = test_db();
87 +
        assert!(get_session_expiry(&db, "nonexistent").unwrap().is_none());
88 +
    }
89 +
}