From 766e9029bf1f1cca79b6ab5fed51cbbd16a6331e Mon Sep 17 00:00:00 2001 From: jakubmanczak Date: Sat, 7 Mar 2026 16:08:29 +0100 Subject: [PATCH] quotes create&getbyid, migration renew, misc --- src/api/mod.rs | 16 +- src/api/quotes.rs | 66 +++++++ ...{2026-02-20--01.sql => 2026-03-07--01.sql} | 20 +-- src/database/mod.rs | 2 +- src/quotes/lines.rs | 10 -- src/quotes/mod.rs | 165 +++++++++++++++++- 6 files changed, 247 insertions(+), 32 deletions(-) create mode 100644 src/api/quotes.rs rename src/database/migrations/{2026-02-20--01.sql => 2026-03-07--01.sql} (98%) delete mode 100644 src/quotes/lines.rs diff --git a/src/api/mod.rs b/src/api/mod.rs index 4243e4d..234f5fe 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -7,12 +7,14 @@ use axum::{ use crate::{ database::DatabaseError, persons::PersonError, + quotes::QuoteError, tags::TagError, users::{UserError, auth::AuthError, sessions::SessionError}, }; mod auth; mod persons; +mod quotes; mod sessions; mod tags; mod users; @@ -20,10 +22,10 @@ mod users; pub fn api_router() -> Router { Router::new() .route("/api/live", get(async || "Mnemosyne lives")) - // + // auth .route("/api/auth/login", post(auth::login)) .route("/api/auth/logout", post(auth::logout)) - // + // users .route("/api/users", get(users::get_all)) .route("/api/users", post(users::create)) .route("/api/users/me", get(users::get_me)) @@ -31,17 +33,17 @@ pub fn api_router() -> Router { .route("/api/users/@{handle}", get(users::get_by_handle)) .route("/api/users/{id}/setpassw", post(users::change_password)) .route("/api/users/{id}/sethandle", post(users::change_handle)) - // + // sessions .route("/api/sessions/{id}", get(sessions::get_by_id)) .route("/api/sessions/{id}/revoke", post(sessions::revoke_by_id)) - // + // tags .route("/api/tags", get(tags::get_all)) .route("/api/tags", post(tags::create)) .route("/api/tags/{id}", get(tags::get_by_id)) .route("/api/tags/{id}", patch(tags::rename)) .route("/api/tags/{id}", delete(tags::delete)) .route("/api/tags/#{name}", get(tags::get_by_name)) - // + // persons & names .route("/api/persons", get(persons::get_all)) .route("/api/persons", post(persons::create)) .route("/api/persons/{id}", get(persons::get_by_id)) @@ -49,6 +51,9 @@ pub fn api_router() -> Router { .route("/api/persons/{id}/addname", post(persons::add_name)) .route("/api/names/{id}", get(persons::n_by_id)) .route("/api/names/{id}/setprimary", post(persons::n_setprimary)) + // quotes + .route("/api/quotes", post(quotes::create)) + .route("/api/quotes/{id}", get(quotes::get_by_id)) } pub struct CompositeError(Response); @@ -75,5 +80,6 @@ composite_from!( SessionError, TagError, PersonError, + QuoteError, DatabaseError ); diff --git a/src/api/quotes.rs b/src/api/quotes.rs new file mode 100644 index 0000000..7fc6c7c --- /dev/null +++ b/src/api/quotes.rs @@ -0,0 +1,66 @@ +use axum::{ + Json, + extract::Path, + http::{HeaderMap, StatusCode}, + response::{IntoResponse, Response}, +}; +use chrono::{DateTime, FixedOffset}; +use serde::Deserialize; +use uuid::Uuid; + +use crate::{ + api::CompositeError, + persons::Name, + quotes::Quote, + users::{ + User, + auth::{UserAuthRequired, UserAuthenticate}, + }, +}; + +pub async fn get_by_id( + Path(id): Path, + headers: HeaderMap, +) -> Result { + User::authenticate(&headers)?.required()?; + Ok(Json(Quote::get_by_id(id)?).into_response()) +} + +#[derive(Deserialize)] +pub struct QuoteLineForm { + pub content: String, + pub name_id: Uuid, +} + +#[derive(Deserialize)] +pub struct QuoteCreateForm { + pub lines: Vec, + pub timestamp: DateTime, + pub context: Option, + pub location: Option, + pub public: bool, +} + +pub async fn create( + headers: HeaderMap, + Json(form): Json, +) -> Result { + let u = User::authenticate(&headers)?.required()?; + + let lines = form + .lines + .into_iter() + .map(|l| Ok((l.content, Name::get_by_id(l.name_id)?))) + .collect::, CompositeError>>()?; + + let q = Quote::create( + lines, + form.timestamp, + form.context, + form.location, + u.id, + form.public, + )?; + + Ok((StatusCode::CREATED, Json(q)).into_response()) +} diff --git a/src/database/migrations/2026-02-20--01.sql b/src/database/migrations/2026-03-07--01.sql similarity index 98% rename from src/database/migrations/2026-02-20--01.sql rename to src/database/migrations/2026-03-07--01.sql index b6ad257..5b87c0e 100644 --- a/src/database/migrations/2026-02-20--01.sql +++ b/src/database/migrations/2026-03-07--01.sql @@ -20,18 +20,9 @@ CREATE TABLE sessions ( ); CREATE INDEX sessions_by_userid ON sessions(user_id); --- CREATE TABLE logs ( --- id BLOB NOT NULL UNIQUE PRIMARY KEY, -- UUIDv7 as bytes --- actor BLOB NOT NULL REFERENCES users(id), -- UUIDv7 as bytes --- -- (userID with special cases: UUID::nil if system, UUID::max if infradmin) --- -- ((infradmin & system shall both be users)) --- target BLOB, -- Option --- change TEXT NOT NULL --- ); - CREATE TABLE quotes ( id BLOB NOT NULL UNIQUE PRIMARY KEY, -- UUIDv7 as bytes - timestamp TEXT NOT NULL, -- RFC3339 into DateTime + timestamp TEXT NOT NULL, -- RFC3339 into DateTime location TEXT, context TEXT, created_by BLOB NOT NULL REFERENCES users(id), -- UUIDv7 as bytes @@ -85,6 +76,15 @@ CREATE TABLE quote_tags ( ) WITHOUT ROWID; CREATE INDEX quote_tags_reverse_index ON quote_tags(tag_id, quote_id); +-- CREATE TABLE logs ( +-- id BLOB NOT NULL UNIQUE PRIMARY KEY, -- UUIDv7 as bytes +-- actor BLOB NOT NULL REFERENCES users(id), -- UUIDv7 as bytes +-- -- (userID with special cases: UUID::nil if system, UUID::max if infradmin) +-- -- ((infradmin & system shall both be users)) +-- target BLOB, -- Option +-- change TEXT NOT NULL +-- ); + -- all this to be followed by: -- - a better access scoping mechanism (role-based like discord) -- - photos just like quotes diff --git a/src/database/mod.rs b/src/database/mod.rs index 3429747..295541c 100644 --- a/src/database/mod.rs +++ b/src/database/mod.rs @@ -8,7 +8,7 @@ macro_rules! migration { ($name, include_str!(concat!("./migrations/", $name, ".sql"))) }; } -const MIGRATIONS: &[(&str, &str)] = &[migration!("2026-02-20--01")]; +const MIGRATIONS: &[(&str, &str)] = &[migration!("2026-03-07--01")]; pub static DB_URL: LazyLock = LazyLock::new(|| env::var("DATABASE_URL").expect("DATABASE_URL is not set")); diff --git a/src/quotes/lines.rs b/src/quotes/lines.rs deleted file mode 100644 index b062320..0000000 --- a/src/quotes/lines.rs +++ /dev/null @@ -1,10 +0,0 @@ -use uuid::Uuid; - -use crate::persons::{Name, Person}; - -#[allow(unused)] -pub struct QuoteLine { - pub id: Uuid, - pub attribution: (Name, Person), - pub content: String, -} diff --git a/src/quotes/mod.rs b/src/quotes/mod.rs index ff5561a..5f8c526 100644 --- a/src/quotes/mod.rs +++ b/src/quotes/mod.rs @@ -1,17 +1,170 @@ -use chrono::{DateTime, Utc}; +use axum::{http::StatusCode, response::IntoResponse}; +use chrono::{DateTime, FixedOffset}; +use rusqlite::OptionalExtension; +use serde::Serialize; use uuid::Uuid; -use crate::quotes::lines::QuoteLine; +use crate::{ + database::{self, DatabaseError}, + persons::Name, +}; -pub mod lines; - -#[allow(unused)] +#[derive(Serialize)] pub struct Quote { pub id: Uuid, pub lines: Vec, - pub timestamp: DateTime, + pub timestamp: DateTime, pub location: Option, pub context: Option, pub created_by: Uuid, pub public: bool, } + +#[derive(Serialize)] +pub struct QuoteLine { + pub id: Uuid, + pub attribution: Name, + pub content: String, +} + +#[derive(Debug, thiserror::Error)] +pub enum QuoteError { + #[error("No quote with ID {0}")] + NoQuoteWithId(Uuid), + #[error("A quote must have at least one line")] + EmptyQuote, + #[error("{0}")] + DatabaseError(#[from] DatabaseError), +} + +impl Quote { + pub fn get_by_id(id: Uuid) -> Result { + let conn = database::conn()?; + + let quotemain = conn + .prepare( + "SELECT timestamp, location, context, created_by, public FROM quotes WHERE id = ?1", + )? + .query_row((id,), |r| { + Ok(( + r.get::<_, DateTime>(0)?, + r.get::<_, Option>(1)?, + r.get::<_, Option>(2)?, + r.get::<_, Uuid>(3)?, + r.get::<_, bool>(4)?, + )) + }) + .optional()?; + + let (timestamp, location, context, created_by, public) = match quotemain { + Some(data) => data, + None => return Err(QuoteError::NoQuoteWithId(id)), + }; + + let lines = conn + .prepare( + r#" + SELECT l.id, l.content, n.id, n.is_primary, n.person_id, n.created_by, n.name + FROM lines AS l JOIN names AS n ON l.name_id = n.id + WHERE l.quote_id = ?1 ORDER BY l.ordering + "#, + )? + .query_map((id,), |r| { + Ok(QuoteLine { + id: r.get(0)?, + content: r.get(1)?, + attribution: Name { + id: r.get(2)?, + is_primary: r.get(3)?, + person_id: r.get(4)?, + created_by: r.get(5)?, + name: r.get(6)?, + }, + }) + })? + .collect::, _>>()?; + + Ok(Quote { + id, + lines, + timestamp, + location, + context, + created_by, + public, + }) + } + pub fn create( + lines: Vec<(String, Name)>, + timestamp: DateTime, + context: Option, + location: Option, + created_by: Uuid, + public: bool, + ) -> Result { + if lines.is_empty() { + return Err(QuoteError::EmptyQuote); + } + + let conn = database::conn()?; + let quote_id = Uuid::now_v7(); + let lines: Vec<(Uuid, String, Name)> = lines + .into_iter() + .map(|(c, a)| (Uuid::now_v7(), c, a)) + .collect(); + + conn.execute("BEGIN TRANSACTION", ())?; + + let mut quote_stmt = conn.prepare( + r#" + INSERT INTO quotes (id, timestamp, location, context, created_by, public) + VALUES (?1, ?2, ?3, ?4, ?5, ?6) + "#, + )?; + quote_stmt.execute((quote_id, timestamp, &location, &context, created_by, public))?; + + let mut line_stmt = conn.prepare( + r#" + INSERT INTO lines (id, quote_id, content, name_id, ordering) + VALUES (?1, ?2, ?3, ?4, ?5) + "#, + )?; + for (ordering, (id, content, attr)) in lines.iter().enumerate() { + line_stmt.execute((id, quote_id, content, attr.id, ordering as i64))?; + } + + conn.execute("COMMIT", ())?; + Ok(Quote { + id: quote_id, + lines: lines + .into_iter() + .map(|(id, content, attribution)| QuoteLine { + id, + content, + attribution, + }) + .collect(), + timestamp, + location, + context, + created_by, + public, + }) + } +} + +impl From for QuoteError { + fn from(error: rusqlite::Error) -> Self { + QuoteError::DatabaseError(DatabaseError::from(error)) + } +} + +impl IntoResponse for QuoteError { + fn into_response(self) -> axum::response::Response { + match self { + Self::DatabaseError(e) => e.into_response(), + Self::NoQuoteWithId(_) => (StatusCode::BAD_REQUEST, self.to_string()).into_response(), + Self::EmptyQuote => (StatusCode::BAD_REQUEST, self.to_string()).into_response(), + } + } +}