From 42d0f71eb134fe305452dc52f7e8a0ec0391e071 Mon Sep 17 00:00:00 2001 From: Jon Seager Date: Tue, 25 Nov 2025 18:21:04 +0000 Subject: [PATCH] feat!: use numeric, database-generated IDs throughout --- Cargo.lock | 21 - Cargo.toml | 3 +- flake.nix | 5 +- migrations/0001_init.sql | 11 +- migrations/0002_auth.sql | 6 +- migrations/0003_sessions.sql | 4 +- scripts/bootstrap-db.sh | 5 + src/cli/roasters.rs | 20 +- src/cli/roasts.rs | 24 +- src/cli/tokens.rs | 5 +- src/client/roasters.rs | 7 +- src/client/roasts.rs | 10 +- src/client/tokens.rs | 11 +- src/domain/ids.rs | 64 ++- src/domain/repositories.rs | 39 +- src/domain/roasters.rs | 16 +- src/domain/roasts.rs | 26 +- src/domain/sessions.rs | 30 +- src/domain/timeline.rs | 7 +- src/domain/tokens.rs | 23 +- src/domain/users.rs | 15 +- src/infrastructure/repositories/roasters.rs | 210 ++++----- src/infrastructure/repositories/roasts.rs | 412 +++++++++--------- src/infrastructure/repositories/sessions.rs | 195 ++++----- .../repositories/timeline_events.rs | 7 +- src/infrastructure/repositories/tokens.rs | 75 ++-- src/infrastructure/repositories/users.rs | 32 +- src/presentation/views.rs | 25 +- src/server/auth.rs | 9 +- src/server/routes/auth.rs | 28 +- src/server/routes/roasters.rs | 19 +- src/server/routes/roasts.rs | 31 +- src/server/routes/timeline.rs | 9 +- src/server/routes/tokens.rs | 24 +- src/server/server.rs | 11 +- tests/cli/roasters_cli.rs | 10 +- tests/cli/roasts_cli.rs | 24 +- tests/cli/tokens_cli.rs | 10 +- tests/server/auth_api.rs | 4 +- tests/server/helpers.rs | 23 +- tests/server/roasters_api.rs | 6 +- tests/server/roasts_api.rs | 35 +- tests/server/timeline.rs | 11 +- 43 files changed, 817 insertions(+), 745 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b099bbf..bb24c5f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -314,17 +314,6 @@ dependencies = [ "generic-array", ] -[[package]] -name = "block-id" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28af93b9e274f9109b06714ef4391d9e159ad849cba25fbd184ef7e281650263" -dependencies = [ - "rand 0.8.5", - "rand_core 0.6.4", - "rand_pcg", -] - [[package]] name = "brewlog" version = "0.1.0" @@ -335,7 +324,6 @@ dependencies = [ "async-trait", "axum", "base64 0.22.1", - "block-id", "chrono", "clap", "once_cell", @@ -1864,15 +1852,6 @@ dependencies = [ "getrandom 0.3.4", ] -[[package]] -name = "rand_pcg" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59cad018caf63deb318e5a4586d99a24424a364f40f1e5778c29aca23f4fc73e" -dependencies = [ - "rand_core 0.6.4", -] - [[package]] name = "redox_syscall" version = "0.5.18" diff --git a/Cargo.toml b/Cargo.toml index 3e4cada..a5c89d9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,14 +15,12 @@ async-trait = "0.1" axum = { version = "0.7", features = ["macros"] } askama = "0.12" base64 = "0.22" -block-id = "0.2.1" chrono = { version = "0.4", features = ["serde", "clock"] } clap = { version = "4.5", features = ["derive", "env"] } reqwest = { version = "0.12", features = ["json", "rustls-tls"] } rpassword = "7.3" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" -once_cell = "1.19" rand = "0.8" sha2 = "0.10" sqlx = { version = "0.7", default-features = false, features = [ @@ -44,6 +42,7 @@ portpicker = "0.1" reqwest = { version = "0.12", features = ["blocking", "cookies"] } tempfile = "3.8" wiremock = "0.6" +once_cell = "1.19" [[test]] name = "cli" diff --git a/flake.nix b/flake.nix index ad9ccbb..0257400 100644 --- a/flake.nix +++ b/flake.nix @@ -86,7 +86,6 @@ RUST_SRC_PATH = "${rust}/lib/rustlib/src/rust/library"; LD_LIBRARY_PATH = with pkgs; lib.makeLibraryPath [ openssl ]; - inputsFrom = [ self.packages.${system}.brewlog ]; buildInputs = with pkgs; [ @@ -95,8 +94,10 @@ lld nil nixfmt-rfc-style - sqlx-cli + openssl + pkg-config sqlite + sqlx-cli ] ++ [ rust diff --git a/migrations/0001_init.sql b/migrations/0001_init.sql index 9c33ca7..5005e70 100644 --- a/migrations/0001_init.sql +++ b/migrations/0001_init.sql @@ -1,8 +1,7 @@ --- migrate:up PRAGMA foreign_keys = ON; CREATE TABLE roasters ( - id TEXT PRIMARY KEY, + id INTEGER PRIMARY KEY, name TEXT NOT NULL, country TEXT NOT NULL, city TEXT, @@ -12,8 +11,8 @@ CREATE TABLE roasters ( ); CREATE TABLE roasts ( - id TEXT PRIMARY KEY, - roaster_id TEXT NOT NULL REFERENCES roasters(id) ON DELETE CASCADE, + id INTEGER PRIMARY KEY, + roaster_id INTEGER NOT NULL REFERENCES roasters(id) ON DELETE CASCADE, name TEXT NOT NULL, origin TEXT, region TEXT, @@ -26,9 +25,9 @@ CREATE TABLE roasts ( CREATE INDEX idx_roasts_roaster_id ON roasts(roaster_id); CREATE TABLE timeline_events ( - id TEXT PRIMARY KEY, + id INTEGER PRIMARY KEY, entity_type TEXT NOT NULL CHECK (entity_type IN ('roaster', 'roast')), - entity_id TEXT NOT NULL, + entity_id INTEGER NOT NULL, occurred_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), title TEXT NOT NULL, details_json TEXT, diff --git a/migrations/0002_auth.sql b/migrations/0002_auth.sql index df765ad..b309ed6 100644 --- a/migrations/0002_auth.sql +++ b/migrations/0002_auth.sql @@ -1,14 +1,14 @@ -- migrate:up CREATE TABLE users ( - id TEXT PRIMARY KEY, + id INTEGER PRIMARY KEY, username TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) ); CREATE TABLE tokens ( - id TEXT PRIMARY KEY, - user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + id INTEGER PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, token_hash TEXT NOT NULL UNIQUE, name TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), diff --git a/migrations/0003_sessions.sql b/migrations/0003_sessions.sql index 82dff26..4903a54 100644 --- a/migrations/0003_sessions.sql +++ b/migrations/0003_sessions.sql @@ -1,7 +1,7 @@ -- Add sessions table for web authentication CREATE TABLE IF NOT EXISTS sessions ( - id TEXT PRIMARY KEY NOT NULL, - user_id TEXT NOT NULL, + id INTEGER PRIMARY KEY NOT NULL, + user_id INTEGER NOT NULL, session_token_hash TEXT NOT NULL, created_at TEXT NOT NULL, expires_at TEXT NOT NULL, diff --git a/scripts/bootstrap-db.sh b/scripts/bootstrap-db.sh index 0174f2e..73ed1eb 100755 --- a/scripts/bootstrap-db.sh +++ b/scripts/bootstrap-db.sh @@ -2,6 +2,11 @@ cargo build +if [[ -z "$BREWLOG_TOKEN" ]]; then + echo "Error: BREWLOG_TOKEN environment variable is not set." + exit 1 +fi + # Tim Wendelboe (Norway) ./target/debug/brewlog add-roaster \ --name "Tim Wendelboe" \ diff --git a/src/cli/roasters.rs b/src/cli/roasters.rs index 63397b5..12eef50 100644 --- a/src/cli/roasters.rs +++ b/src/cli/roasters.rs @@ -4,6 +4,7 @@ use serde_json::json; use super::print_json; use crate::client::BrewlogClient; +use crate::domain::ids::RoasterId; use crate::domain::roasters::{NewRoaster, UpdateRoaster}; #[derive(Debug, Args)] @@ -41,18 +42,18 @@ pub async fn list_roasters(client: &BrewlogClient) -> Result<()> { #[derive(Debug, Args)] pub struct GetRoasterCommand { #[arg(long)] - pub id: String, + pub id: i64, } pub async fn get_roaster(client: &BrewlogClient, command: GetRoasterCommand) -> Result<()> { - let roaster = client.roasters().get(&command.id).await?; + let roaster = client.roasters().get(RoasterId::new(command.id)).await?; print_json(&roaster) } #[derive(Debug, Args)] pub struct UpdateRoasterCommand { #[arg(long)] - pub id: String, + pub id: i64, #[arg(long)] pub name: Option, #[arg(long)] @@ -74,23 +75,26 @@ pub async fn update_roaster(client: &BrewlogClient, command: UpdateRoasterComman notes: command.notes, }; - let roaster = client.roasters().update(&command.id, &payload).await?; + let roaster = client + .roasters() + .update(RoasterId::new(command.id), &payload) + .await?; print_json(&roaster) } #[derive(Debug, Args)] pub struct DeleteRoasterCommand { #[arg(long)] - pub id: String, + pub id: i64, } pub async fn delete_roaster(client: &BrewlogClient, command: DeleteRoasterCommand) -> Result<()> { - let id = command.id; - client.roasters().delete(&id).await?; + let id = RoasterId::new(command.id); + client.roasters().delete(id).await?; let response = json!({ "status": "deleted", "resource": "roaster", - "id": id, + "id": id.into_inner(), }); print_json(&response) } diff --git a/src/cli/roasts.rs b/src/cli/roasts.rs index 160dd12..03beb8e 100644 --- a/src/cli/roasts.rs +++ b/src/cli/roasts.rs @@ -4,12 +4,13 @@ use serde_json::json; use super::print_json; use crate::client::BrewlogClient; +use crate::domain::ids::{RoastId, RoasterId}; use crate::domain::roasts::NewRoast; #[derive(Debug, Args)] pub struct AddRoastCommand { #[arg(long)] - pub roaster_id: String, + pub roaster_id: i64, #[arg(long)] pub name: String, #[arg(long)] @@ -26,7 +27,7 @@ pub struct AddRoastCommand { pub async fn add_roast(client: &BrewlogClient, command: AddRoastCommand) -> Result<()> { let payload = NewRoast { - roaster_id: command.roaster_id, + roaster_id: RoasterId::new(command.roaster_id), name: command.name, origin: command.origin, region: command.region, @@ -42,38 +43,41 @@ pub async fn add_roast(client: &BrewlogClient, command: AddRoastCommand) -> Resu #[derive(Debug, Args)] pub struct ListRoastsCommand { #[arg(long)] - pub roaster_id: Option, + pub roaster_id: Option, } pub async fn list_roasts(client: &BrewlogClient, command: ListRoastsCommand) -> Result<()> { - let roasts = client.roasts().list(command.roaster_id.as_deref()).await?; + let roasts = client + .roasts() + .list(command.roaster_id.map(RoasterId::new)) + .await?; print_json(&roasts) } #[derive(Debug, Args)] pub struct GetRoastCommand { #[arg(long)] - pub id: String, + pub id: i64, } pub async fn get_roast(client: &BrewlogClient, command: GetRoastCommand) -> Result<()> { - let roast = client.roasts().get(&command.id).await?; + let roast = client.roasts().get(RoastId::new(command.id)).await?; print_json(&roast) } #[derive(Debug, Args)] pub struct DeleteRoastCommand { #[arg(long)] - pub id: String, + pub id: i64, } pub async fn delete_roast(client: &BrewlogClient, command: DeleteRoastCommand) -> Result<()> { - let id = command.id; - client.roasts().delete(&id).await?; + let id = RoastId::new(command.id); + client.roasts().delete(id).await?; let response = json!({ "status": "deleted", "resource": "roast", - "id": id, + "id": id.into_inner(), }); print_json(&response) } diff --git a/src/cli/tokens.rs b/src/cli/tokens.rs index 4d41d9c..46f8766 100644 --- a/src/cli/tokens.rs +++ b/src/cli/tokens.rs @@ -4,6 +4,7 @@ use std::io::{self, Write}; use crate::cli::print_json; use crate::client::BrewlogClient; +use crate::domain::ids::TokenId; #[derive(Debug, Args)] pub struct CreateTokenCommand { @@ -16,7 +17,7 @@ pub struct CreateTokenCommand { pub struct RevokeTokenCommand { /// The ID of the token to revoke #[arg(long)] - pub id: String, + pub id: TokenId, } pub async fn create_token(client: &BrewlogClient, cmd: CreateTokenCommand) -> Result<()> { @@ -53,7 +54,7 @@ pub async fn list_tokens(client: &BrewlogClient) -> Result<()> { } pub async fn revoke_token(client: &BrewlogClient, cmd: RevokeTokenCommand) -> Result<()> { - let token = client.tokens().revoke(&cmd.id).await?; + let token = client.tokens().revoke(cmd.id).await?; println!("Token revoked successfully"); print_json(&token) } diff --git a/src/client/roasters.rs b/src/client/roasters.rs index a80579d..cf8a423 100644 --- a/src/client/roasters.rs +++ b/src/client/roasters.rs @@ -1,6 +1,7 @@ use anyhow::{Context, Result}; use reqwest::StatusCode; +use crate::domain::ids::RoasterId; use crate::domain::roasters::{NewRoaster, Roaster, UpdateRoaster}; use super::BrewlogClient; @@ -39,7 +40,7 @@ impl<'a> RoastersClient<'a> { self.inner.handle_response(response).await } - pub async fn get(&self, id: &str) -> Result { + pub async fn get(&self, id: RoasterId) -> Result { let url = self.inner.endpoint(&format!("api/v1/roasters/{id}"))?; let response = self .inner @@ -51,7 +52,7 @@ impl<'a> RoastersClient<'a> { self.inner.handle_response(response).await } - pub async fn update(&self, id: &str, payload: &UpdateRoaster) -> Result { + pub async fn update(&self, id: RoasterId, payload: &UpdateRoaster) -> Result { let url = self.inner.endpoint(&format!("api/v1/roasters/{id}"))?; let response = self .inner @@ -64,7 +65,7 @@ impl<'a> RoastersClient<'a> { self.inner.handle_response(response).await } - pub async fn delete(&self, id: &str) -> Result<()> { + pub async fn delete(&self, id: RoasterId) -> Result<()> { let url = self.inner.endpoint(&format!("api/v1/roasters/{id}"))?; let response = self .inner diff --git a/src/client/roasts.rs b/src/client/roasts.rs index 447f575..37810b6 100644 --- a/src/client/roasts.rs +++ b/src/client/roasts.rs @@ -1,6 +1,7 @@ use anyhow::{Context, Result}; use reqwest::StatusCode; +use crate::domain::ids::{RoastId, RoasterId}; use crate::domain::roasts::{NewRoast, Roast, RoastWithRoaster}; use super::BrewlogClient; @@ -27,10 +28,11 @@ impl<'a> RoastsClient<'a> { self.inner.handle_response(response).await } - pub async fn list(&self, roaster_id: Option<&str>) -> Result> { + pub async fn list(&self, roaster_id: Option) -> Result> { let mut url = self.inner.endpoint("api/v1/roasts")?; if let Some(roaster_id) = roaster_id { - url.query_pairs_mut().append_pair("roaster_id", roaster_id); + url.query_pairs_mut() + .append_pair("roaster_id", &roaster_id.to_string()); } let response = self @@ -43,7 +45,7 @@ impl<'a> RoastsClient<'a> { self.inner.handle_response(response).await } - pub async fn get(&self, id: &str) -> Result { + pub async fn get(&self, id: RoastId) -> Result { let url = self.inner.endpoint(&format!("api/v1/roasts/{id}"))?; let response = self .inner @@ -55,7 +57,7 @@ impl<'a> RoastsClient<'a> { self.inner.handle_response(response).await } - pub async fn delete(&self, id: &str) -> Result<()> { + pub async fn delete(&self, id: RoastId) -> Result<()> { let url = self.inner.endpoint(&format!("api/v1/roasts/{id}"))?; let response = self .inner diff --git a/src/client/tokens.rs b/src/client/tokens.rs index 03d74de..5bfd28e 100644 --- a/src/client/tokens.rs +++ b/src/client/tokens.rs @@ -3,6 +3,7 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use crate::client::BrewlogClient; +use crate::domain::ids::{TokenId, UserId}; pub struct TokensClient<'a> { client: &'a BrewlogClient, @@ -49,10 +50,10 @@ impl<'a> TokensClient<'a> { self.client.handle_response(response).await } - pub async fn revoke(&self, id: &str) -> Result { + pub async fn revoke(&self, id: TokenId) -> Result { let url = self .client - .endpoint(&format!("api/v1/tokens/{}/revoke", id))?; + .endpoint(&format!("api/v1/tokens/{id}/revoke"))?; let response = self .client @@ -73,15 +74,15 @@ struct CreateTokenRequest { #[derive(Debug, Deserialize)] pub struct TokenResponse { - pub id: String, + pub id: TokenId, pub name: String, pub token: String, } #[derive(Debug, Deserialize, Serialize)] pub struct TokenInfo { - pub id: String, - pub user_id: String, + pub id: TokenId, + pub user_id: UserId, pub name: String, pub created_at: DateTime, pub last_used_at: Option>, diff --git a/src/domain/ids.rs b/src/domain/ids.rs index 33e4b30..4cb5076 100644 --- a/src/domain/ids.rs +++ b/src/domain/ids.rs @@ -1,14 +1,56 @@ -use block_id::{Alphabet, BlockId as BlockIdGenerator}; -use once_cell::sync::Lazy; -use rand::RngCore; +use serde::{Deserialize, Serialize}; +use std::fmt; +use std::num::ParseIntError; +use std::str::FromStr; -static ID_GENERATOR: Lazy> = - Lazy::new(|| BlockIdGenerator::new(Alphabet::alphanumeric(), 0x00B1_0C1D_u128, 4)); +macro_rules! define_id { + ($name:ident) => { + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] + #[serde(transparent)] + pub struct $name(pub i64); -pub fn generate_id() -> String { - let mut rng = rand::thread_rng(); - let value = rng.next_u64(); - ID_GENERATOR - .encode_string(value) - .expect("block-id encoding should succeed") + impl $name { + pub const fn new(value: i64) -> Self { + Self(value) + } + + pub const fn into_inner(self) -> i64 { + self.0 + } + } + + impl From for $name { + fn from(value: i64) -> Self { + Self(value) + } + } + + impl From<$name> for i64 { + fn from(value: $name) -> Self { + value.0 + } + } + + impl fmt::Display for $name { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } + } + + impl FromStr for $name { + type Err = ParseIntError; + + fn from_str(s: &str) -> Result { + let value = s.parse::()?; + Ok(Self(value)) + } + } + }; } + +define_id!(RoasterId); +define_id!(RoastId); +define_id!(TimelineEventId); +define_id!(UserId); +define_id!(TokenId); +define_id!(SessionId); diff --git a/src/domain/repositories.rs b/src/domain/repositories.rs index 1389d5f..e84a343 100644 --- a/src/domain/repositories.rs +++ b/src/domain/repositories.rs @@ -1,26 +1,31 @@ use super::RepositoryError; use crate::domain::listing::{ListRequest, Page, SortDirection, SortKey}; +use crate::domain::ids::{RoastId, RoasterId, SessionId, TokenId, UserId}; use crate::domain::roasters::RoasterSortKey; -use crate::domain::roasters::{Roaster, UpdateRoaster}; +use crate::domain::roasters::{NewRoaster, Roaster, UpdateRoaster}; use crate::domain::roasts::RoastSortKey; -use crate::domain::roasts::{Roast, RoastWithRoaster, UpdateRoast}; -use crate::domain::sessions::{Session, SessionId}; +use crate::domain::roasts::{NewRoast, Roast, RoastWithRoaster, UpdateRoast}; +use crate::domain::sessions::{NewSession, Session}; use crate::domain::timeline::{TimelineEvent, TimelineSortKey}; -use crate::domain::tokens::{Token, TokenId}; -use crate::domain::users::{User, UserId}; +use crate::domain::tokens::{NewToken, Token}; +use crate::domain::users::{NewUser, User}; use async_trait::async_trait; #[async_trait] pub trait RoasterRepository: Send + Sync { - async fn insert(&self, roaster: Roaster) -> Result; - async fn get(&self, id: String) -> Result; + async fn insert(&self, roaster: NewRoaster) -> Result; + async fn get(&self, id: RoasterId) -> Result; async fn list( &self, request: &ListRequest, ) -> Result, RepositoryError>; - async fn update(&self, id: String, changes: UpdateRoaster) -> Result; - async fn delete(&self, id: String) -> Result<(), RepositoryError>; + async fn update( + &self, + id: RoasterId, + changes: UpdateRoaster, + ) -> Result; + async fn delete(&self, id: RoasterId) -> Result<(), RepositoryError>; async fn list_all(&self) -> Result, RepositoryError> { let sort_key = ::default(); @@ -43,18 +48,18 @@ pub trait RoasterRepository: Send + Sync { #[async_trait] pub trait RoastRepository: Send + Sync { - async fn insert(&self, roast: Roast) -> Result; - async fn get(&self, id: String) -> Result; + async fn insert(&self, roast: NewRoast) -> Result; + async fn get(&self, id: RoastId) -> Result; async fn list( &self, request: &ListRequest, ) -> Result, RepositoryError>; async fn list_by_roaster( &self, - roaster_id: String, + roaster_id: RoasterId, ) -> Result, RepositoryError>; - async fn update(&self, id: String, changes: UpdateRoast) -> Result; - async fn delete(&self, id: String) -> Result<(), RepositoryError>; + async fn update(&self, id: RoastId, changes: UpdateRoast) -> Result; + async fn delete(&self, id: RoastId) -> Result<(), RepositoryError>; async fn list_all(&self) -> Result, RepositoryError> { let sort_key = ::default(); @@ -82,7 +87,7 @@ pub trait TimelineEventRepository: Send + Sync { #[async_trait] pub trait UserRepository: Send + Sync { - async fn insert(&self, user: User) -> Result; + async fn insert(&self, user: NewUser) -> Result; async fn get(&self, id: UserId) -> Result; async fn get_by_username(&self, username: &str) -> Result; async fn exists(&self) -> Result; @@ -90,7 +95,7 @@ pub trait UserRepository: Send + Sync { #[async_trait] pub trait TokenRepository: Send + Sync { - async fn insert(&self, token: Token) -> Result; + async fn insert(&self, token: NewToken) -> Result; async fn get(&self, id: TokenId) -> Result; async fn get_by_token_hash(&self, token_hash: &str) -> Result; async fn list_by_user(&self, user_id: UserId) -> Result, RepositoryError>; @@ -100,7 +105,7 @@ pub trait TokenRepository: Send + Sync { #[async_trait] pub trait SessionRepository: Send + Sync { - async fn insert(&self, session: Session) -> Result; + async fn insert(&self, session: NewSession) -> Result; async fn get(&self, id: SessionId) -> Result; async fn get_by_token_hash(&self, token_hash: &str) -> Result; async fn delete(&self, id: SessionId) -> Result<(), RepositoryError>; diff --git a/src/domain/roasters.rs b/src/domain/roasters.rs index 1d6579c..131cbf4 100644 --- a/src/domain/roasters.rs +++ b/src/domain/roasters.rs @@ -1,12 +1,12 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; -use crate::domain::ids::generate_id; +use crate::domain::ids::RoasterId; use crate::domain::listing::{SortDirection, SortKey}; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Roaster { - pub id: String, + pub id: RoasterId, pub name: String, pub country: String, pub city: Option, @@ -33,18 +33,6 @@ impl NewRoaster { self.notes = normalize_optional_field(self.notes); self } - - pub fn into_roaster(self) -> Roaster { - Roaster { - id: generate_id(), - name: self.name, - country: self.country, - city: self.city, - homepage: self.homepage, - notes: self.notes, - created_at: Utc::now(), - } - } } fn normalize_optional_field(value: Option) -> Option { diff --git a/src/domain/roasts.rs b/src/domain/roasts.rs index bb4a530..c034547 100644 --- a/src/domain/roasts.rs +++ b/src/domain/roasts.rs @@ -1,13 +1,13 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; -use crate::domain::ids::generate_id; +use crate::domain::ids::{RoastId, RoasterId}; use crate::domain::listing::{SortDirection, SortKey}; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Roast { - pub id: String, - pub roaster_id: String, + pub id: RoastId, + pub roaster_id: RoasterId, pub name: String, pub origin: Option, pub region: Option, @@ -25,7 +25,7 @@ pub struct RoastWithRoaster { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct NewRoast { - pub roaster_id: String, + pub roaster_id: RoasterId, pub name: String, pub origin: String, pub region: String, @@ -34,25 +34,9 @@ pub struct NewRoast { pub process: String, } -impl NewRoast { - pub fn into_roast(self) -> Roast { - Roast { - id: generate_id(), - roaster_id: self.roaster_id, - name: self.name, - origin: Some(self.origin), - region: Some(self.region), - producer: Some(self.producer), - tasting_notes: self.tasting_notes, - process: Some(self.process), - created_at: Utc::now(), - } - } -} - #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct UpdateRoast { - pub roaster_id: Option, + pub roaster_id: Option, pub name: Option, pub origin: Option, pub region: Option, diff --git a/src/domain/sessions.rs b/src/domain/sessions.rs index 5d9041c..27bc71b 100644 --- a/src/domain/sessions.rs +++ b/src/domain/sessions.rs @@ -1,12 +1,12 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; -pub type SessionId = String; +use crate::domain::ids::{SessionId, UserId}; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Session { pub id: SessionId, - pub user_id: String, + pub user_id: UserId, pub session_token_hash: String, pub created_at: DateTime, pub expires_at: DateTime, @@ -15,7 +15,7 @@ pub struct Session { impl Session { pub fn new( id: SessionId, - user_id: String, + user_id: UserId, session_token_hash: String, created_at: DateTime, expires_at: DateTime, @@ -33,3 +33,27 @@ impl Session { Utc::now() > self.expires_at } } + +#[derive(Debug, Clone)] +pub struct NewSession { + pub user_id: UserId, + pub session_token_hash: String, + pub created_at: DateTime, + pub expires_at: DateTime, +} + +impl NewSession { + pub fn new( + user_id: UserId, + session_token_hash: String, + created_at: DateTime, + expires_at: DateTime, + ) -> Self { + Self { + user_id, + session_token_hash, + created_at, + expires_at, + } + } +} diff --git a/src/domain/timeline.rs b/src/domain/timeline.rs index 3e58c93..7e01984 100644 --- a/src/domain/timeline.rs +++ b/src/domain/timeline.rs @@ -1,6 +1,7 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; +use crate::domain::ids::TimelineEventId; use crate::domain::listing::{SortDirection, SortKey}; #[derive(Debug, Clone, Serialize, Deserialize)] @@ -11,9 +12,9 @@ pub struct TimelineEventDetail { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TimelineEvent { - pub id: String, + pub id: TimelineEventId, pub entity_type: String, - pub entity_id: String, + pub entity_id: i64, pub occurred_at: DateTime, pub title: String, pub details: Vec, @@ -23,7 +24,7 @@ pub struct TimelineEvent { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct NewTimelineEvent { pub entity_type: String, - pub entity_id: String, + pub entity_id: i64, pub occurred_at: DateTime, pub title: String, pub details: Vec, diff --git a/src/domain/tokens.rs b/src/domain/tokens.rs index ffa2738..967b6c3 100644 --- a/src/domain/tokens.rs +++ b/src/domain/tokens.rs @@ -1,9 +1,7 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; -use crate::domain::users::UserId; - -pub type TokenId = String; +use crate::domain::ids::{TokenId, UserId}; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Token { @@ -17,9 +15,10 @@ pub struct Token { pub revoked_at: Option>, } -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone)] pub struct NewToken { pub user_id: UserId, + pub token_hash: String, pub name: String, } @@ -30,6 +29,8 @@ impl Token { token_hash: String, name: String, created_at: DateTime, + last_used_at: Option>, + revoked_at: Option>, ) -> Self { Self { id, @@ -37,8 +38,8 @@ impl Token { token_hash, name, created_at, - last_used_at: None, - revoked_at: None, + last_used_at, + revoked_at, } } @@ -50,3 +51,13 @@ impl Token { !self.is_revoked() } } + +impl NewToken { + pub fn new(user_id: UserId, token_hash: String, name: String) -> Self { + Self { + user_id, + token_hash, + name, + } + } +} diff --git a/src/domain/users.rs b/src/domain/users.rs index 3ff0284..4e3f819 100644 --- a/src/domain/users.rs +++ b/src/domain/users.rs @@ -1,7 +1,7 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; -pub type UserId = String; +use crate::domain::ids::UserId; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct User { @@ -12,10 +12,10 @@ pub struct User { pub created_at: DateTime, } -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone)] pub struct NewUser { pub username: String, - pub password: String, + pub password_hash: String, } impl User { @@ -33,3 +33,12 @@ impl User { } } } + +impl NewUser { + pub fn new(username: String, password_hash: String) -> Self { + Self { + username, + password_hash, + } + } +} diff --git a/src/infrastructure/repositories/roasters.rs b/src/infrastructure/repositories/roasters.rs index 5ed5ab3..cfd4d69 100644 --- a/src/infrastructure/repositories/roasters.rs +++ b/src/infrastructure/repositories/roasters.rs @@ -1,17 +1,15 @@ use async_trait::async_trait; use chrono::{DateTime, Utc}; -use sqlx::{QueryBuilder, query_as, query_scalar}; +use sqlx::{QueryBuilder, query, query_as, query_scalar}; use crate::domain::RepositoryError; -use crate::domain::ids::generate_id; +use crate::domain::ids::RoasterId; use crate::domain::listing::{ListRequest, Page, PageSize, SortDirection}; use crate::domain::repositories::RoasterRepository; -use crate::domain::roasters::{Roaster, RoasterSortKey, UpdateRoaster}; +use crate::domain::roasters::{NewRoaster, Roaster, RoasterSortKey, UpdateRoaster}; use crate::domain::timeline::TimelineEventDetail; use crate::infrastructure::database::DatabasePool; -type DbId = String; - #[derive(Clone)] pub struct SqlRoasterRepository { pool: DatabasePool, @@ -22,7 +20,21 @@ impl SqlRoasterRepository { Self { pool } } - fn to_domain(record: RoasterRecord) -> Result { + fn sort_clause(request: &ListRequest) -> String { + let dir_sql = match request.sort_direction() { + SortDirection::Asc => "ASC", + SortDirection::Desc => "DESC", + }; + + match request.sort_key() { + RoasterSortKey::CreatedAt => format!("created_at {dir_sql}, name ASC"), + RoasterSortKey::Name => format!("LOWER(name) {dir_sql}, created_at DESC"), + RoasterSortKey::Country => format!("LOWER(country) {dir_sql}, LOWER(name) ASC"), + RoasterSortKey::City => format!("LOWER(COALESCE(city, '')) {dir_sql}, LOWER(name) ASC"), + } + } + + fn into_domain(record: RoasterRecord) -> Roaster { let RoasterRecord { id, name, @@ -33,57 +45,18 @@ impl SqlRoasterRepository { created_at, } = record; - Ok(Roaster { - id, + Roaster { + id: RoasterId::from(id), name, country, city, homepage, notes, created_at, - }) - } -} - -fn roaster_order_clause(request: &ListRequest) -> String { - let dir_sql = match request.sort_direction() { - SortDirection::Asc => "ASC", - SortDirection::Desc => "DESC", - }; - - match request.sort_key() { - RoasterSortKey::CreatedAt => format!("created_at {dir_sql}, name ASC"), - RoasterSortKey::Name => format!("LOWER(name) {dir_sql}, created_at DESC"), - RoasterSortKey::Country => format!("LOWER(country) {dir_sql}, LOWER(name) ASC"), - RoasterSortKey::City => { - format!("LOWER(COALESCE(city, '')) {dir_sql}, LOWER(name) ASC") } } -} - -#[async_trait] -impl RoasterRepository for SqlRoasterRepository { - async fn insert(&self, roaster: Roaster) -> Result { - let mut tx = self - .pool - .begin() - .await - .map_err(|err| RepositoryError::unexpected(err.to_string()))?; - - let query = "INSERT INTO roasters (id, name, country, city, homepage, notes, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)"; - - sqlx::query(query) - .bind(&roaster.id) - .bind(&roaster.name) - .bind(&roaster.country) - .bind(&roaster.city) - .bind(&roaster.homepage) - .bind(&roaster.notes) - .bind(roaster.created_at) - .execute(&mut *tx) - .await - .map_err(|err| RepositoryError::unexpected(err.to_string()))?; + fn details_for_roaster(roaster: &Roaster) -> Result { let homepage_value = roaster .homepage .as_ref() @@ -111,18 +84,50 @@ impl RoasterRepository for SqlRoasterRepository { }, ]; - let details_json = serde_json::to_string(&details).map_err(|err| { + serde_json::to_string(&details).map_err(|err| { RepositoryError::unexpected(format!("failed to encode timeline event details: {err}")) - })?; + }) + } +} - sqlx::query("INSERT INTO timeline_events (id, entity_type, entity_id, occurred_at, title, details_json, tasting_notes_json) VALUES (?, ?, ?, ?, ?, ?, ?)") - .bind(generate_id()) +#[async_trait] +impl RoasterRepository for SqlRoasterRepository { + async fn insert(&self, new_roaster: NewRoaster) -> Result { + let mut tx = self + .pool + .begin() + .await + .map_err(|err| RepositoryError::unexpected(err.to_string()))?; + + let new_roaster = new_roaster.normalize(); + let created_at = Utc::now(); + + let record = query_as::<_, RoasterRecord>( + "INSERT INTO roasters (name, country, city, homepage, notes, created_at) VALUES (?, ?, ?, ?, ?, ?)\ + RETURNING id, name, country, city, homepage, notes, created_at", + ) + .bind(&new_roaster.name) + .bind(&new_roaster.country) + .bind(new_roaster.city.as_deref()) + .bind(new_roaster.homepage.as_deref()) + .bind(new_roaster.notes.as_deref()) + .bind(created_at) + .fetch_one(&mut *tx) + .await + .map_err(|err| RepositoryError::unexpected(err.to_string()))?; + + let roaster = Self::into_domain(record); + let details_json = Self::details_for_roaster(&roaster)?; + + query( + "INSERT INTO timeline_events (entity_type, entity_id, occurred_at, title, details_json, tasting_notes_json) VALUES (?, ?, ?, ?, ?, ?)", + ) .bind("roaster") - .bind(&roaster.id) + .bind(i64::from(roaster.id)) .bind(roaster.created_at) .bind(&roaster.name) .bind(details_json) - .bind(Option::::None) + .bind::>(None) .execute(&mut *tx) .await .map_err(|err| RepositoryError::unexpected(err.to_string()))?; @@ -134,17 +139,17 @@ impl RoasterRepository for SqlRoasterRepository { Ok(roaster) } - async fn get(&self, id: String) -> Result { + async fn get(&self, id: RoasterId) -> Result { let record = query_as::<_, RoasterRecord>( - "SELECT id, name, country, city, homepage, notes, created_at FROM roasters WHERE id = ?", - ) - .bind(id) - .fetch_optional(&self.pool) - .await - .map_err(|err| RepositoryError::unexpected(err.to_string()))?; + "SELECT id, name, country, city, homepage, notes, created_at FROM roasters WHERE id = ?", + ) + .bind(i64::from(id)) + .fetch_optional(&self.pool) + .await + .map_err(|err| RepositoryError::unexpected(err.to_string()))?; match record { - Some(record) => Self::to_domain(record), + Some(record) => Ok(Self::into_domain(record)), None => Err(RepositoryError::NotFound), } } @@ -153,7 +158,7 @@ impl RoasterRepository for SqlRoasterRepository { &self, request: &ListRequest, ) -> Result, RepositoryError> { - let order_clause = roaster_order_clause(request); + let order_clause = Self::sort_clause(request); match request.page_size() { PageSize::All => { @@ -169,17 +174,17 @@ impl RoasterRepository for SqlRoasterRepository { let items = records .into_iter() - .map(Self::to_domain) - .collect::, _>>()?; - + .map(Self::into_domain) + .collect::>(); let total = items.len() as u64; let page_size = total.min(u64::from(u32::MAX)) as u32; + Ok(Page::new(items, 1, page_size.max(1), total, true)) } PageSize::Limited(page_size) => { - let page_size_i64 = page_size as i64; - let mut page = request.page(); - let offset = ((page - 1) as i64).saturating_mul(page_size_i64); + let limit = page_size as i64; + let mut page_number = request.page(); + let offset = ((page_number - 1) as i64).saturating_mul(limit); let query = format!( "SELECT id, name, country, city, homepage, notes, created_at FROM roasters ORDER BY {} LIMIT ? OFFSET ?", @@ -187,23 +192,24 @@ impl RoasterRepository for SqlRoasterRepository { ); let mut records = query_as::<_, RoasterRecord>(&query) - .bind(page_size_i64) + .bind(limit) .bind(offset) .fetch_all(&self.pool) .await .map_err(|err| RepositoryError::unexpected(err.to_string()))?; - let total: i64 = query_scalar::<_, i64>("SELECT COUNT(*) FROM roasters") + let total: i64 = query_scalar("SELECT COUNT(*) FROM roasters") .fetch_one(&self.pool) .await .map_err(|err| RepositoryError::unexpected(err.to_string()))?; - if page > 1 && records.is_empty() && total > 0 { - let last_page = ((total + page_size_i64 - 1) / page_size_i64) as u32; - page = last_page.max(1); - let offset = ((page - 1) as i64).saturating_mul(page_size_i64); + if page_number > 1 && records.is_empty() && total > 0 { + let last_page = ((total + limit - 1) / limit) as u32; + page_number = last_page.max(1); + let offset = ((page_number - 1) as i64).saturating_mul(limit); + records = query_as::<_, RoasterRecord>(&query) - .bind(page_size_i64) + .bind(limit) .bind(offset) .fetch_all(&self.pool) .await @@ -212,67 +218,77 @@ impl RoasterRepository for SqlRoasterRepository { let items = records .into_iter() - .map(Self::to_domain) - .collect::, _>>()?; + .map(Self::into_domain) + .collect::>(); - Ok(Page::new(items, page, page_size, total as u64, false)) + Ok(Page::new( + items, + page_number, + page_size, + total as u64, + false, + )) } } } - async fn update(&self, id: String, changes: UpdateRoaster) -> Result { + async fn update( + &self, + id: RoasterId, + changes: UpdateRoaster, + ) -> Result { let mut builder = QueryBuilder::new("UPDATE roasters SET "); - let mut first = true; + let mut wrote_field = false; if let Some(name) = changes.name { - if !first { + if wrote_field { builder.push(", "); } - first = false; + wrote_field = true; builder.push("name = "); builder.push_bind(name); } if let Some(country) = changes.country { - if !first { + if wrote_field { builder.push(", "); } - first = false; + wrote_field = true; builder.push("country = "); builder.push_bind(country); } if let Some(city) = changes.city { - if !first { + if wrote_field { builder.push(", "); } - first = false; + wrote_field = true; builder.push("city = "); builder.push_bind(city); } if let Some(homepage) = changes.homepage { - if !first { + if wrote_field { builder.push(", "); } - first = false; + wrote_field = true; builder.push("homepage = "); builder.push_bind(homepage); } if let Some(notes) = changes.notes { - if !first { + if wrote_field { builder.push(", "); } - first = false; + wrote_field = true; builder.push("notes = "); builder.push_bind(notes); } - if first { + if !wrote_field { return Err(RepositoryError::unexpected( "No fields provided for update".to_string(), )); } builder.push(" WHERE id = "); - builder.push_bind(&id); + builder.push_bind(i64::from(id)); let result = builder .build() @@ -287,9 +303,9 @@ impl RoasterRepository for SqlRoasterRepository { self.get(id).await } - async fn delete(&self, id: String) -> Result<(), RepositoryError> { - let result = sqlx::query("DELETE FROM roasters WHERE id = ?") - .bind(id) + async fn delete(&self, id: RoasterId) -> Result<(), RepositoryError> { + let result = query("DELETE FROM roasters WHERE id = ?") + .bind(i64::from(id)) .execute(&self.pool) .await .map_err(|err| RepositoryError::unexpected(err.to_string()))?; @@ -304,7 +320,7 @@ impl RoasterRepository for SqlRoasterRepository { #[derive(Debug, sqlx::FromRow)] struct RoasterRecord { - id: DbId, + id: i64, name: String, country: String, city: Option, diff --git a/src/infrastructure/repositories/roasts.rs b/src/infrastructure/repositories/roasts.rs index c551f41..92b220c 100644 --- a/src/infrastructure/repositories/roasts.rs +++ b/src/infrastructure/repositories/roasts.rs @@ -1,12 +1,13 @@ use async_trait::async_trait; use chrono::{DateTime, Utc}; +use serde_json::{from_str, to_string}; use sqlx::{Error as SqlxError, QueryBuilder, query, query_as, query_scalar}; use crate::domain::RepositoryError; -use crate::domain::ids::generate_id; +use crate::domain::ids::{RoastId, RoasterId}; use crate::domain::listing::{ListRequest, Page, PageSize, SortDirection}; use crate::domain::repositories::RoastRepository; -use crate::domain::roasts::{Roast, RoastSortKey, RoastWithRoaster, UpdateRoast}; +use crate::domain::roasts::{NewRoast, Roast, RoastSortKey, RoastWithRoaster, UpdateRoast}; use crate::domain::timeline::TimelineEventDetail; use crate::infrastructure::database::DatabasePool; @@ -20,152 +21,102 @@ impl SqlRoastRepository { Self { pool } } - fn to_domain(record: RoastRecord) -> Result { - let RoastRecord { - id, - roaster_id, - name, - origin, - region, - producer, - process, - tasting_notes, - created_at, - } = record; - - let tasting_notes = match tasting_notes { - Some(raw) if !raw.is_empty() => serde_json::from_str(&raw).map_err(|err| { - RepositoryError::unexpected(format!("failed to decode tasting notes: {err}")) - })?, - _ => Vec::new(), + fn order_clause(request: &ListRequest) -> String { + let dir_sql = match request.sort_direction() { + SortDirection::Asc => "ASC", + SortDirection::Desc => "DESC", }; - Ok(Roast { - id, - roaster_id, - name, - origin, - region, - producer, - tasting_notes, - process, - created_at, - }) - } - - fn to_with_roaster( - record: RoastWithRoasterRecord, - ) -> Result { - let RoastWithRoasterRecord { - id, - roaster_id, - name, - origin, - region, - producer, - process, - tasting_notes, - created_at, - roaster_name, - } = record; - - let roast = Self::to_domain(RoastRecord { - id, - roaster_id, - name, - origin, - region, - producer, - process, - tasting_notes, - created_at, - })?; - - Ok(RoastWithRoaster { - roast, - roaster_name, - }) - } - - async fn get_record(&self, id: &str) -> Result { - let record = query_as::<_, RoastRecord>( - "SELECT id, roaster_id, name, origin, region, producer, process, tasting_notes, created_at FROM roasts WHERE id = ?", - ) - .bind(id) - .fetch_optional(&self.pool) - .await - .map_err(|err| RepositoryError::unexpected(err.to_string()))?; - - let Some(record) = record else { - return Err(RepositoryError::NotFound); - }; - - Self::to_domain(record) + match request.sort_key() { + RoastSortKey::CreatedAt => format!("r.created_at {dir_sql}, LOWER(r.name) ASC"), + RoastSortKey::Name => format!("LOWER(r.name) {dir_sql}, r.created_at DESC"), + RoastSortKey::Roaster => format!("LOWER(ro.name) {dir_sql}, r.created_at DESC"), + RoastSortKey::Origin => { + format!("LOWER(COALESCE(r.origin, '')) {dir_sql}, r.created_at DESC") + } + RoastSortKey::Producer => { + format!("LOWER(COALESCE(r.producer, '')) {dir_sql}, r.created_at DESC") + } + } } fn encode_notes(notes: &[String]) -> Result, RepositoryError> { if notes.is_empty() { Ok(None) } else { - serde_json::to_string(notes).map(Some).map_err(|err| { + to_string(notes).map(Some).map_err(|err| { RepositoryError::unexpected(format!("failed to encode tasting notes: {err}")) }) } } } -fn roast_order_clause(request: &ListRequest) -> String { - let dir_sql = match request.sort_direction() { - SortDirection::Asc => "ASC", - SortDirection::Desc => "DESC", - }; - - match request.sort_key() { - RoastSortKey::CreatedAt => format!("r.created_at {dir_sql}, LOWER(r.name) ASC"), - RoastSortKey::Name => format!("LOWER(r.name) {dir_sql}, r.created_at DESC"), - RoastSortKey::Roaster => format!("LOWER(ro.name) {dir_sql}, r.created_at DESC"), - RoastSortKey::Origin => { - format!("LOWER(COALESCE(r.origin, '')) {dir_sql}, r.created_at DESC") - } - RoastSortKey::Producer => { - format!("LOWER(COALESCE(r.producer, '')) {dir_sql}, r.created_at DESC") - } - } -} - #[async_trait] impl RoastRepository for SqlRoastRepository { - async fn insert(&self, roast: Roast) -> Result { + async fn insert(&self, new_roast: NewRoast) -> Result { let mut tx = self .pool .begin() .await .map_err(|err| RepositoryError::unexpected(err.to_string()))?; - let notes = Self::encode_notes(&roast.tasting_notes)?; + let NewRoast { + roaster_id, + name, + origin, + region, + producer, + tasting_notes, + process, + } = new_roast; - query( - "INSERT INTO roasts (id, roaster_id, name, origin, region, producer, process, tasting_notes, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", - ) - .bind(&roast.id) - .bind(&roast.roaster_id) - .bind(&roast.name) - .bind(&roast.origin) - .bind(&roast.region) - .bind(&roast.producer) - .bind(&roast.process) - .bind(notes.as_deref()) - .bind(roast.created_at) - .execute(&mut *tx) - .await - .map_err(|err| map_insert_error(err, "unknown roaster reference"))?; + let origin_value = if origin.trim().is_empty() { + None + } else { + Some(origin) + }; + let region_value = if region.trim().is_empty() { + None + } else { + Some(region) + }; + let producer_value = if producer.trim().is_empty() { + None + } else { + Some(producer) + }; + let process_value = if process.trim().is_empty() { + None + } else { + Some(process) + }; - let roaster_name: Option = - sqlx::query_scalar("SELECT name FROM roasters WHERE id = ?") - .bind(&roast.roaster_id) - .fetch_optional(&mut *tx) - .await - .map_err(|err| RepositoryError::unexpected(err.to_string()))?; + let created_at = Utc::now(); + let notes_json = Self::encode_notes(&tasting_notes)?; + + let record = query_as::<_, RoastRecord>( + "INSERT INTO roasts (roaster_id, name, origin, region, producer, process, tasting_notes, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)\ + RETURNING id, roaster_id, name, origin, region, producer, process, tasting_notes, created_at", + ) + .bind(i64::from(roaster_id)) + .bind(&name) + .bind(origin_value.as_deref()) + .bind(region_value.as_deref()) + .bind(producer_value.as_deref()) + .bind(process_value.as_deref()) + .bind(notes_json.as_deref()) + .bind(created_at) + .fetch_one(&mut *tx) + .await + .map_err(|err| map_insert_error(err, "unknown roaster reference"))?; + + let roast = record.into_roast()?; + + let roaster_name: Option = query_scalar("SELECT name FROM roasters WHERE id = ?") + .bind(i64::from(roast.roaster_id)) + .fetch_optional(&mut *tx) + .await + .map_err(|err| RepositoryError::unexpected(err.to_string()))?; let roaster_label = roaster_name.unwrap_or_else(|| "Unknown roaster".to_string()); @@ -192,28 +143,29 @@ impl RoastRepository for SqlRoastRepository { }, ]; - let details_json = serde_json::to_string(&details).map_err(|err| { + let details_json = to_string(&details).map_err(|err| { RepositoryError::unexpected(format!("failed to encode timeline event details: {err}")) })?; let tasting_notes_json = if roast.tasting_notes.is_empty() { None } else { - Some(serde_json::to_string(&roast.tasting_notes).map_err(|err| { + Some(to_string(&roast.tasting_notes).map_err(|err| { RepositoryError::unexpected(format!( "failed to encode timeline event tasting notes: {err}" )) })?) }; - sqlx::query("INSERT INTO timeline_events (id, entity_type, entity_id, occurred_at, title, details_json, tasting_notes_json) VALUES (?, ?, ?, ?, ?, ?, ?)") - .bind(generate_id()) + query( + "INSERT INTO timeline_events (entity_type, entity_id, occurred_at, title, details_json, tasting_notes_json) VALUES (?, ?, ?, ?, ?, ?)", + ) .bind("roast") - .bind(&roast.id) + .bind(i64::from(roast.id)) .bind(roast.created_at) .bind(&roast.name) .bind(details_json) - .bind(tasting_notes_json) + .bind(tasting_notes_json.as_deref()) .execute(&mut *tx) .await .map_err(|err| RepositoryError::unexpected(err.to_string()))?; @@ -225,23 +177,29 @@ impl RoastRepository for SqlRoastRepository { Ok(roast) } - async fn get(&self, id: String) -> Result { - self.get_record(&id).await + async fn get(&self, id: RoastId) -> Result { + query_as::<_, RoastRecord>( + "SELECT id, roaster_id, name, origin, region, producer, process, tasting_notes, created_at FROM roasts WHERE id = ?", + ) + .bind(i64::from(id)) + .fetch_optional(&self.pool) + .await + .map_err(|err| RepositoryError::unexpected(err.to_string()))? + .map(|record| record.into_roast()) + .transpose()? + .ok_or(RepositoryError::NotFound) } async fn list( &self, request: &ListRequest, ) -> Result, RepositoryError> { - let order_clause = roast_order_clause(request); + let order_clause = Self::order_clause(request); match request.page_size() { PageSize::All => { let query = format!( - "SELECT r.id, r.roaster_id, r.name, r.origin, r.region, r.producer, r.process, r.tasting_notes, r.created_at, ro.name AS roaster_name \ - FROM roasts r \ - JOIN roasters ro ON ro.id = r.roaster_id \ - ORDER BY {}", + "SELECT r.id, r.roaster_id, r.name, r.origin, r.region, r.producer, r.process, r.tasting_notes, r.created_at, ro.name AS roaster_name \n FROM roasts r \n JOIN roasters ro ON ro.id = r.roaster_id \n ORDER BY {}", order_clause ); @@ -252,7 +210,7 @@ impl RoastRepository for SqlRoastRepository { let items = records .into_iter() - .map(Self::to_with_roaster) + .map(|record| record.into_with_roaster()) .collect::, _>>()?; let total = items.len() as u64; @@ -260,37 +218,34 @@ impl RoastRepository for SqlRoastRepository { Ok(Page::new(items, 1, page_size.max(1), total, true)) } PageSize::Limited(page_size) => { - let page_size_i64 = page_size as i64; - let mut page = request.page(); - let offset = ((page - 1) as i64).saturating_mul(page_size_i64); + let limit = page_size as i64; + let mut page_number = request.page(); + let offset = ((page_number - 1) as i64).saturating_mul(limit); let query = format!( - "SELECT r.id, r.roaster_id, r.name, r.origin, r.region, r.producer, r.process, r.tasting_notes, r.created_at, ro.name AS roaster_name \ - FROM roasts r \ - JOIN roasters ro ON ro.id = r.roaster_id \ - ORDER BY {} \ - LIMIT ? OFFSET ?", + "SELECT r.id, r.roaster_id, r.name, r.origin, r.region, r.producer, r.process, r.tasting_notes, r.created_at, ro.name AS roaster_name \n FROM roasts r \n JOIN roasters ro ON ro.id = r.roaster_id \n ORDER BY {} \n LIMIT ? OFFSET ?", order_clause ); let mut records = query_as::<_, RoastWithRoasterRecord>(&query) - .bind(page_size_i64) + .bind(limit) .bind(offset) .fetch_all(&self.pool) .await .map_err(|err| RepositoryError::unexpected(err.to_string()))?; - let total: i64 = query_scalar::<_, i64>("SELECT COUNT(*) FROM roasts") + let total: i64 = query_scalar("SELECT COUNT(*) FROM roasts") .fetch_one(&self.pool) .await .map_err(|err| RepositoryError::unexpected(err.to_string()))?; - if page > 1 && records.is_empty() && total > 0 { - let last_page = ((total + page_size_i64 - 1) / page_size_i64) as u32; - page = last_page.max(1); - let offset = ((page - 1) as i64).saturating_mul(page_size_i64); + if page_number > 1 && records.is_empty() && total > 0 { + let last_page = ((total + limit - 1) / limit) as u32; + page_number = last_page.max(1); + let offset = ((page_number - 1) as i64).saturating_mul(limit); + records = query_as::<_, RoastWithRoasterRecord>(&query) - .bind(page_size_i64) + .bind(limit) .bind(offset) .fetch_all(&self.pool) .await @@ -299,34 +254,39 @@ impl RoastRepository for SqlRoastRepository { let items = records .into_iter() - .map(Self::to_with_roaster) + .map(|record| record.into_with_roaster()) .collect::, _>>()?; - Ok(Page::new(items, page, page_size, total as u64, false)) + Ok(Page::new( + items, + page_number, + page_size, + total as u64, + false, + )) } } } async fn list_by_roaster( &self, - roaster_id: String, + roaster_id: RoasterId, ) -> Result, RepositoryError> { let records = query_as::<_, RoastWithRoasterRecord>( - "SELECT r.id, r.roaster_id, r.name, r.origin, r.region, r.producer, r.process, r.tasting_notes, r.created_at, ro.name AS roaster_name \ - FROM roasts r \ - JOIN roasters ro ON ro.id = r.roaster_id \ - WHERE r.roaster_id = ? \ - ORDER BY r.created_at DESC", - ) - .bind(roaster_id) - .fetch_all(&self.pool) - .await - .map_err(|err| RepositoryError::unexpected(err.to_string()))?; + "SELECT r.id, r.roaster_id, r.name, r.origin, r.region, r.producer, r.process, r.tasting_notes, r.created_at, ro.name AS roaster_name \n FROM roasts r \n JOIN roasters ro ON ro.id = r.roaster_id \n WHERE r.roaster_id = ? \n ORDER BY r.created_at DESC", + ) + .bind(i64::from(roaster_id)) + .fetch_all(&self.pool) + .await + .map_err(|err| RepositoryError::unexpected(err.to_string()))?; - records.into_iter().map(Self::to_with_roaster).collect() + records + .into_iter() + .map(|record| record.into_with_roaster()) + .collect() } - async fn update(&self, id: String, changes: UpdateRoast) -> Result { + async fn update(&self, id: RoastId, changes: UpdateRoast) -> Result { let mut tx = self .pool .begin() @@ -344,69 +304,69 @@ impl RoastRepository for SqlRoastRepository { } = changes; let mut builder = QueryBuilder::new("UPDATE roasts SET "); - let mut updated = false; + let mut wrote_field = false; if let Some(roaster_id) = roaster_id { - if updated { + if wrote_field { builder.push(", "); } - updated = true; + wrote_field = true; builder.push("roaster_id = "); - builder.push_bind(roaster_id); + builder.push_bind(i64::from(roaster_id)); } if let Some(name) = name { - if updated { + if wrote_field { builder.push(", "); } - updated = true; + wrote_field = true; builder.push("name = "); builder.push_bind(name); } if let Some(origin) = origin { - if updated { + if wrote_field { builder.push(", "); } - updated = true; + wrote_field = true; builder.push("origin = "); builder.push_bind(origin); } if let Some(region) = region { - if updated { + if wrote_field { builder.push(", "); } - updated = true; + wrote_field = true; builder.push("region = "); builder.push_bind(region); } if let Some(producer) = producer { - if updated { + if wrote_field { builder.push(", "); } - updated = true; + wrote_field = true; builder.push("producer = "); builder.push_bind(producer); } if let Some(process) = process { - if updated { + if wrote_field { builder.push(", "); } - updated = true; + wrote_field = true; builder.push("process = "); builder.push_bind(process); } if let Some(tasting_notes) = tasting_notes { - let notes = Self::encode_notes(&tasting_notes)?; - if updated { + let notes_json = Self::encode_notes(&tasting_notes)?; + if wrote_field { builder.push(", "); } - updated = true; + wrote_field = true; builder.push("tasting_notes = "); - builder.push_bind(notes); + builder.push_bind(notes_json); } - if updated { + if wrote_field { builder.push(" WHERE id = "); - builder.push_bind(&id); + builder.push_bind(i64::from(id)); let result = builder .build() @@ -417,18 +377,22 @@ impl RoastRepository for SqlRoastRepository { if result.rows_affected() == 0 { return Err(RepositoryError::NotFound); } + } else { + return Err(RepositoryError::unexpected( + "No fields provided for update".to_string(), + )); } tx.commit() .await .map_err(|err| RepositoryError::unexpected(err.to_string()))?; - self.get_record(&id).await + self.get(id).await } - async fn delete(&self, id: String) -> Result<(), RepositoryError> { + async fn delete(&self, id: RoastId) -> Result<(), RepositoryError> { let result = query("DELETE FROM roasts WHERE id = ?") - .bind(&id) + .bind(i64::from(id)) .execute(&self.pool) .await .map_err(|err| RepositoryError::unexpected(err.to_string()))?; @@ -453,8 +417,8 @@ fn map_insert_error(err: SqlxError, message: &'static str) -> RepositoryError { #[derive(sqlx::FromRow)] struct RoastRecord { - id: String, - roaster_id: String, + id: i64, + roaster_id: i64, name: String, origin: Option, region: Option, @@ -464,10 +428,45 @@ struct RoastRecord { created_at: DateTime, } +impl RoastRecord { + fn into_roast(self) -> Result { + let RoastRecord { + id, + roaster_id, + name, + origin, + region, + producer, + process, + tasting_notes, + created_at, + } = self; + + let tasting_notes = match tasting_notes { + Some(raw) if !raw.is_empty() => from_str(&raw).map_err(|err| { + RepositoryError::unexpected(format!("failed to decode tasting notes: {err}")) + })?, + _ => Vec::new(), + }; + + Ok(Roast { + id: RoastId::from(id), + roaster_id: RoasterId::from(roaster_id), + name, + origin, + region, + producer, + tasting_notes, + process, + created_at, + }) + } +} + #[derive(sqlx::FromRow)] struct RoastWithRoasterRecord { - id: String, - roaster_id: String, + id: i64, + roaster_id: i64, name: String, origin: Option, region: Option, @@ -477,3 +476,26 @@ struct RoastWithRoasterRecord { created_at: DateTime, roaster_name: String, } + +impl RoastWithRoasterRecord { + fn into_with_roaster(self) -> Result { + let roaster_name = self.roaster_name.clone(); + let roast = RoastRecord { + id: self.id, + roaster_id: self.roaster_id, + name: self.name, + origin: self.origin, + region: self.region, + producer: self.producer, + process: self.process, + tasting_notes: self.tasting_notes, + created_at: self.created_at, + } + .into_roast()?; + + Ok(RoastWithRoaster { + roast, + roaster_name, + }) + } +} diff --git a/src/infrastructure/repositories/sessions.rs b/src/infrastructure/repositories/sessions.rs index d0cb487..b820d58 100644 --- a/src/infrastructure/repositories/sessions.rs +++ b/src/infrastructure/repositories/sessions.rs @@ -1,155 +1,126 @@ use async_trait::async_trait; -use chrono::{DateTime, Utc}; -use sqlx::{Pool, Row, Sqlite}; +use chrono::Utc; +use sqlx::{query, query_as}; -use crate::domain::sessions::{Session, SessionId}; +use crate::domain::ids::{SessionId, UserId}; +use crate::domain::sessions::{NewSession, Session}; use crate::domain::{RepositoryError, repositories::SessionRepository}; +use crate::infrastructure::database::DatabasePool; +#[derive(Clone)] pub struct SqlSessionRepository { - pool: Pool, + pool: DatabasePool, } impl SqlSessionRepository { - pub fn new(pool: Pool) -> Self { + pub fn new(pool: DatabasePool) -> Self { Self { pool } } + + fn to_domain(record: SessionRecord) -> Session { + let SessionRecord { + id, + user_id, + session_token_hash, + created_at, + expires_at, + } = record; + + Session::new( + SessionId::from(id), + UserId::from(user_id), + session_token_hash, + created_at, + expires_at, + ) + } } #[async_trait] impl SessionRepository for SqlSessionRepository { - async fn insert(&self, session: Session) -> Result { - sqlx::query( - r#" - INSERT INTO sessions (id, user_id, session_token_hash, created_at, expires_at) - VALUES (?, ?, ?, ?, ?) - "#, - ) - .bind(&session.id) - .bind(&session.user_id) - .bind(&session.session_token_hash) - .bind(session.created_at.to_rfc3339()) - .bind(session.expires_at.to_rfc3339()) - .execute(&self.pool) - .await - .map_err(|e| RepositoryError::unexpected(format!("failed to insert session: {}", e)))?; + async fn insert(&self, session: NewSession) -> Result { + let query = "INSERT INTO sessions (user_id, session_token_hash, created_at, expires_at) VALUES (?, ?, ?, ?) RETURNING id, user_id, session_token_hash, created_at, expires_at"; - Ok(session) + let NewSession { + user_id, + session_token_hash, + created_at, + expires_at, + } = session; + + let record = query_as::<_, SessionRecord>(query) + .bind(i64::from(user_id)) + .bind(&session_token_hash) + .bind(created_at) + .bind(expires_at) + .fetch_one(&self.pool) + .await + .map_err(|err| { + RepositoryError::unexpected(format!("failed to insert session: {err}")) + })?; + + Ok(Self::to_domain(record)) } async fn get(&self, id: SessionId) -> Result { - let row = sqlx::query( - r#" - SELECT id, user_id, session_token_hash, created_at, expires_at - FROM sessions - WHERE id = ? - "#, - ) - .bind(&id) - .fetch_one(&self.pool) - .await - .map_err(|e| match e { - sqlx::Error::RowNotFound => RepositoryError::NotFound, - _ => RepositoryError::unexpected(format!("failed to get session: {}", e)), - })?; + let query = "SELECT id, user_id, session_token_hash, created_at, expires_at FROM sessions WHERE id = ?"; - let created_at: String = row.try_get("created_at").map_err(|e| { - RepositoryError::unexpected(format!("failed to parse created_at: {}", e)) - })?; - let expires_at: String = row.try_get("expires_at").map_err(|e| { - RepositoryError::unexpected(format!("failed to parse expires_at: {}", e)) - })?; + let record = query_as::<_, SessionRecord>(query) + .bind(i64::from(id)) + .fetch_optional(&self.pool) + .await + .map_err(|err| RepositoryError::unexpected(format!("failed to get session: {err}")))? + .ok_or(RepositoryError::NotFound)?; - Ok(Session { - id: row.try_get("id").map_err(|e| { - RepositoryError::unexpected(format!("failed to parse id: {}", e)) - })?, - user_id: row.try_get("user_id").map_err(|e| { - RepositoryError::unexpected(format!("failed to parse user_id: {}", e)) - })?, - session_token_hash: row.try_get("session_token_hash").map_err(|e| { - RepositoryError::unexpected(format!("failed to parse session_token_hash: {}", e)) - })?, - created_at: DateTime::parse_from_rfc3339(&created_at) - .map_err(|e| { - RepositoryError::unexpected(format!("failed to parse created_at: {}", e)) - })? - .with_timezone(&Utc), - expires_at: DateTime::parse_from_rfc3339(&expires_at) - .map_err(|e| { - RepositoryError::unexpected(format!("failed to parse expires_at: {}", e)) - })? - .with_timezone(&Utc), - }) + Ok(Self::to_domain(record)) } async fn get_by_token_hash(&self, token_hash: &str) -> Result { - let row = sqlx::query( - r#" - SELECT id, user_id, session_token_hash, created_at, expires_at - FROM sessions - WHERE session_token_hash = ? - "#, - ) - .bind(token_hash) - .fetch_one(&self.pool) - .await - .map_err(|e| match e { - sqlx::Error::RowNotFound => RepositoryError::NotFound, - _ => RepositoryError::unexpected(format!("failed to get session by token: {}", e)), - })?; + let query = "SELECT id, user_id, session_token_hash, created_at, expires_at FROM sessions WHERE session_token_hash = ?"; - let created_at: String = row.try_get("created_at").map_err(|e| { - RepositoryError::unexpected(format!("failed to parse created_at: {}", e)) - })?; - let expires_at: String = row.try_get("expires_at").map_err(|e| { - RepositoryError::unexpected(format!("failed to parse expires_at: {}", e)) - })?; + let record = query_as::<_, SessionRecord>(query) + .bind(token_hash) + .fetch_optional(&self.pool) + .await + .map_err(|err| { + RepositoryError::unexpected(format!("failed to get session by token: {err}")) + })? + .ok_or(RepositoryError::NotFound)?; - Ok(Session { - id: row.try_get("id").map_err(|e| { - RepositoryError::unexpected(format!("failed to parse id: {}", e)) - })?, - user_id: row.try_get("user_id").map_err(|e| { - RepositoryError::unexpected(format!("failed to parse user_id: {}", e)) - })?, - session_token_hash: row.try_get("session_token_hash").map_err(|e| { - RepositoryError::unexpected(format!("failed to parse session_token_hash: {}", e)) - })?, - created_at: DateTime::parse_from_rfc3339(&created_at) - .map_err(|e| { - RepositoryError::unexpected(format!("failed to parse created_at: {}", e)) - })? - .with_timezone(&Utc), - expires_at: DateTime::parse_from_rfc3339(&expires_at) - .map_err(|e| { - RepositoryError::unexpected(format!("failed to parse expires_at: {}", e)) - })? - .with_timezone(&Utc), - }) + Ok(Self::to_domain(record)) } async fn delete(&self, id: SessionId) -> Result<(), RepositoryError> { - sqlx::query("DELETE FROM sessions WHERE id = ?") - .bind(&id) + query("DELETE FROM sessions WHERE id = ?") + .bind(i64::from(id)) .execute(&self.pool) .await - .map_err(|e| { - RepositoryError::unexpected(format!("failed to delete session: {}", e)) + .map_err(|err| { + RepositoryError::unexpected(format!("failed to delete session: {err}")) })?; Ok(()) } async fn delete_expired(&self) -> Result<(), RepositoryError> { - let now = Utc::now().to_rfc3339(); - sqlx::query("DELETE FROM sessions WHERE expires_at < ?") - .bind(&now) + let now = Utc::now(); + query("DELETE FROM sessions WHERE expires_at < ?") + .bind(now) .execute(&self.pool) .await - .map_err(|e| { - RepositoryError::unexpected(format!("failed to delete expired sessions: {}", e)) + .map_err(|err| { + RepositoryError::unexpected(format!("failed to delete expired sessions: {err}")) })?; Ok(()) } } + +#[derive(sqlx::FromRow)] +struct SessionRecord { + id: i64, + user_id: i64, + session_token_hash: String, + created_at: chrono::DateTime, + expires_at: chrono::DateTime, +} diff --git a/src/infrastructure/repositories/timeline_events.rs b/src/infrastructure/repositories/timeline_events.rs index 1e61ec8..f4ecb20 100644 --- a/src/infrastructure/repositories/timeline_events.rs +++ b/src/infrastructure/repositories/timeline_events.rs @@ -4,6 +4,7 @@ use serde_json::from_str; use sqlx::{query_as, query_scalar}; use crate::domain::RepositoryError; +use crate::domain::ids::TimelineEventId; use crate::domain::listing::{ListRequest, Page, PageSize, SortDirection}; use crate::domain::repositories::TimelineEventRepository; use crate::domain::timeline::{TimelineEvent, TimelineEventDetail, TimelineSortKey}; @@ -104,9 +105,9 @@ impl TimelineEventRepository for SqlTimelineEventRepository { #[derive(sqlx::FromRow)] struct TimelineEventRecord { - id: String, + id: i64, entity_type: String, - entity_id: String, + entity_id: i64, occurred_at: DateTime, title: String, details_json: Option, @@ -136,7 +137,7 @@ impl TimelineEventRecord { }; Ok(TimelineEvent { - id: self.id, + id: TimelineEventId::from(self.id), entity_type: self.entity_type, entity_id: self.entity_id, occurred_at: self.occurred_at, diff --git a/src/infrastructure/repositories/tokens.rs b/src/infrastructure/repositories/tokens.rs index 5fb20c8..6f69039 100644 --- a/src/infrastructure/repositories/tokens.rs +++ b/src/infrastructure/repositories/tokens.rs @@ -3,9 +3,9 @@ use chrono::{DateTime, Utc}; use sqlx::query_as; use crate::domain::RepositoryError; +use crate::domain::ids::{TokenId, UserId}; use crate::domain::repositories::TokenRepository; -use crate::domain::tokens::{Token, TokenId}; -use crate::domain::users::UserId; +use crate::domain::tokens::{NewToken, Token}; use crate::infrastructure::database::DatabasePool; #[derive(Clone)] @@ -29,50 +29,51 @@ impl SqlTokenRepository { revoked_at, } = record; - Ok(Token { - id, - user_id, + Ok(Token::new( + TokenId::from(id), + UserId::from(user_id), token_hash, name, created_at, last_used_at, revoked_at, - }) + )) } } #[async_trait] impl TokenRepository for SqlTokenRepository { - async fn insert(&self, token: Token) -> Result { - let query = "INSERT INTO tokens (id, user_id, token_hash, name, created_at, last_used_at, revoked_at) VALUES (?, ?, ?, ?, ?, ?, ?)"; + async fn insert(&self, token: NewToken) -> Result { + let query = "INSERT INTO tokens (user_id, token_hash, name) VALUES (?, ?, ?) RETURNING id, user_id, token_hash, name, created_at, last_used_at, revoked_at"; - sqlx::query(query) - .bind(&token.id) - .bind(&token.user_id) - .bind(&token.token_hash) - .bind(&token.name) - .bind(token.created_at) - .bind(token.last_used_at) - .bind(token.revoked_at) - .execute(&self.pool) + let NewToken { + user_id, + token_hash, + name, + } = token; + + let record = query_as::<_, TokenRecord>(query) + .bind(i64::from(user_id)) + .bind(&token_hash) + .bind(&name) + .fetch_one(&self.pool) .await .map_err(|err| { if let sqlx::Error::Database(db_err) = &err - && db_err.is_unique_violation() - { - return RepositoryError::conflict("token already exists"); - } + && db_err.is_unique_violation() { + return RepositoryError::conflict("token already exists"); + } RepositoryError::unexpected(err.to_string()) })?; - Ok(token) + Self::to_domain(record) } async fn get(&self, id: TokenId) -> Result { let query = "SELECT id, user_id, token_hash, name, created_at, last_used_at, revoked_at FROM tokens WHERE id = ?"; let record = query_as::<_, TokenRecord>(query) - .bind(&id) + .bind(i64::from(id)) .fetch_optional(&self.pool) .await .map_err(|err| RepositoryError::unexpected(err.to_string()))? @@ -98,7 +99,7 @@ impl TokenRepository for SqlTokenRepository { let query = "SELECT id, user_id, token_hash, name, created_at, last_used_at, revoked_at FROM tokens WHERE user_id = ? ORDER BY created_at DESC"; let records = query_as::<_, TokenRecord>(query) - .bind(&user_id) + .bind(i64::from(user_id)) .fetch_all(&self.pool) .await .map_err(|err| RepositoryError::unexpected(err.to_string()))?; @@ -107,26 +108,28 @@ impl TokenRepository for SqlTokenRepository { } async fn revoke(&self, id: TokenId) -> Result { - let query = "UPDATE tokens SET revoked_at = ? WHERE id = ?"; + let query = "UPDATE tokens SET revoked_at = ? WHERE id = ? RETURNING id, user_id, token_hash, name, created_at, last_used_at, revoked_at"; let now = Utc::now(); - sqlx::query(query) - .bind(&now) - .bind(&id) - .execute(&self.pool) + let record = query_as::<_, TokenRecord>(query) + .bind(now) + .bind(i64::from(id)) + .fetch_optional(&self.pool) .await .map_err(|err| RepositoryError::unexpected(err.to_string()))?; - self.get(id).await + match record { + Some(record) => Self::to_domain(record), + None => Err(RepositoryError::NotFound), + } } async fn update_last_used(&self, id: TokenId) -> Result<(), RepositoryError> { - let query = "UPDATE tokens SET last_used_at = ? WHERE id = ?"; let now = Utc::now(); - sqlx::query(query) - .bind(&now) - .bind(&id) + sqlx::query("UPDATE tokens SET last_used_at = ? WHERE id = ?") + .bind(now) + .bind(i64::from(id)) .execute(&self.pool) .await .map_err(|err| RepositoryError::unexpected(err.to_string()))?; @@ -137,8 +140,8 @@ impl TokenRepository for SqlTokenRepository { #[derive(sqlx::FromRow)] struct TokenRecord { - id: TokenId, - user_id: UserId, + id: i64, + user_id: i64, token_hash: String, name: String, created_at: DateTime, diff --git a/src/infrastructure/repositories/users.rs b/src/infrastructure/repositories/users.rs index 1fc2170..6d9a411 100644 --- a/src/infrastructure/repositories/users.rs +++ b/src/infrastructure/repositories/users.rs @@ -3,8 +3,9 @@ use chrono::{DateTime, Utc}; use sqlx::query_as; use crate::domain::RepositoryError; +use crate::domain::ids::UserId; use crate::domain::repositories::UserRepository; -use crate::domain::users::{User, UserId}; +use crate::domain::users::{NewUser, User}; use crate::infrastructure::database::DatabasePool; #[derive(Clone)] @@ -25,40 +26,41 @@ impl SqlUserRepository { created_at, } = record; - Ok(User::new(id, username, password_hash, created_at)) + Ok(User::new( + UserId::from(id), + username, + password_hash, + created_at, + )) } } #[async_trait] impl UserRepository for SqlUserRepository { - async fn insert(&self, user: User) -> Result { - let query = - "INSERT INTO users (id, username, password_hash, created_at) VALUES (?, ?, ?, ?)"; + async fn insert(&self, user: NewUser) -> Result { + let query = "INSERT INTO users (username, password_hash) VALUES (?, ?) RETURNING id, username, password_hash, created_at"; - sqlx::query(query) - .bind(&user.id) + let record = sqlx::query_as::<_, UserRecord>(query) .bind(&user.username) .bind(&user.password_hash) - .bind(&user.created_at) - .execute(&self.pool) + .fetch_one(&self.pool) .await .map_err(|err| { - if let sqlx::Error::Database(db_err) = &err { - if db_err.is_unique_violation() { + if let sqlx::Error::Database(db_err) = &err + && db_err.is_unique_violation() { return RepositoryError::conflict("user already exists"); } - } RepositoryError::unexpected(err.to_string()) })?; - Ok(user) + Self::to_domain(record) } async fn get(&self, id: UserId) -> Result { let query = "SELECT id, username, password_hash, created_at FROM users WHERE id = ?"; let record = query_as::<_, UserRecord>(query) - .bind(&id) + .bind(i64::from(id)) .fetch_optional(&self.pool) .await .map_err(|err| RepositoryError::unexpected(err.to_string()))? @@ -94,7 +96,7 @@ impl UserRepository for SqlUserRepository { #[derive(sqlx::FromRow)] struct UserRecord { - id: UserId, + id: i64, username: String, password_hash: String, created_at: DateTime, diff --git a/src/presentation/views.rs b/src/presentation/views.rs index 23581c7..fba8614 100644 --- a/src/presentation/views.rs +++ b/src/presentation/views.rs @@ -24,11 +24,11 @@ impl Paginated { } } - pub fn from_page(page: Page, mut map_item: MapFn) -> Self + pub fn from_page(page: Page, map_item: MapFn) -> Self where MapFn: FnMut(U) -> T, { - let items = page.items.into_iter().map(|item| map_item(item)).collect(); + let items = page.items.into_iter().map(map_item).collect(); Self::new( items, @@ -40,13 +40,11 @@ impl Paginated { } pub fn total_pages(&self) -> u32 { - if self.total == 0 { - 1 - } else if self.showing_all { + if self.total == 0 || self.showing_all { 1 } else { let page_size = self.page_size as u64; - ((self.total + page_size - 1) / page_size) as u32 + self.total.div_ceil(page_size) as u32 } } @@ -260,7 +258,7 @@ pub struct RoasterOptionView { impl From for RoasterOptionView { fn from(roaster: Roaster) -> Self { Self { - id: roaster.id, + id: roaster.id.to_string(), name: roaster.name, } } @@ -269,7 +267,7 @@ impl From for RoasterOptionView { impl From<&Roaster> for RoasterOptionView { fn from(roaster: &Roaster) -> Self { Self { - id: roaster.id.clone(), + id: roaster.id.to_string(), name: roaster.name.clone(), } } @@ -310,7 +308,7 @@ impl From for RoasterView { Self { detail_path, - id, + id: id.to_string(), name, country, city: city.unwrap_or_else(|| "—".to_string()), @@ -354,7 +352,7 @@ impl RoastView { fn from_parts(roast: Roast, roaster_name: &str) -> Self { let Roast { - id: full_id, + id: roast_id, roaster_id: _, name, origin, @@ -365,6 +363,7 @@ impl RoastView { created_at, } = roast; + let full_id = roast_id.to_string(); let id: String = full_id.chars().take(6).collect(); let roaster_label = if roaster_name.trim().is_empty() { "Unknown roaster".to_string() @@ -379,7 +378,7 @@ impl RoastView { let tasting_notes = tasting_notes .into_iter() .flat_map(|note| { - note.split(|ch| ch == ',' || ch == '\n') + note.split([',', '\n']) .map(|segment| segment.trim().to_string()) .filter(|segment| !segment.is_empty()) .collect::>() @@ -479,7 +478,7 @@ impl TimelineEventView { let notes = tasting_notes .into_iter() .flat_map(|note| { - note.split(|ch| ch == ',' || ch == '\n') + note.split([',', '\n']) .map(|segment| segment.trim().to_string()) .filter(|segment| !segment.is_empty()) .collect::>() @@ -491,7 +490,7 @@ impl TimelineEventView { }; Self { - id, + id: id.to_string(), kind_label, badge_class: "bg-amber-200 text-amber-800", accent_class: "bg-amber-600", diff --git a/src/server/auth.rs b/src/server/auth.rs index 1e0995d..a090bbd 100644 --- a/src/server/auth.rs +++ b/src/server/auth.rs @@ -31,11 +31,10 @@ impl FromRequestParts for AuthenticatedUser { } // Try to authenticate via session cookie first - if let Ok(cookies) = Cookies::from_request_parts(parts, state).await { - if let Some(user) = authenticate_via_session(state, &cookies).await { + if let Ok(cookies) = Cookies::from_request_parts(parts, state).await + && let Some(user) = authenticate_via_session(state, &cookies).await { return Ok(AuthenticatedUser(user)); } - } // Fall back to Bearer token authentication let auth_header = parts @@ -67,7 +66,7 @@ impl FromRequestParts for AuthenticatedUser { // Update last used timestamp (fire and forget) let token_repo = state.token_repo.clone(); - let token_id = token_record.id.clone(); + let token_id = token_record.id; tokio::spawn(async move { let _ = token_repo.update_last_used(token_id).await; }); @@ -154,7 +153,7 @@ async fn extract_user_from_request(state: &AppState, request: &Request) -> Optio // Update last used timestamp (fire and forget - don't block on this) let token_repo = state.token_repo.clone(); - let token_id = token_record.id.clone(); + let token_id = token_record.id; tokio::spawn(async move { let _ = token_repo.update_last_used(token_id).await; }); diff --git a/src/server/routes/auth.rs b/src/server/routes/auth.rs index 235e868..137627c 100644 --- a/src/server/routes/auth.rs +++ b/src/server/routes/auth.rs @@ -1,15 +1,14 @@ use askama::Template; +use axum::Form; use axum::extract::State; use axum::http::StatusCode; use axum::response::{IntoResponse, Redirect, Response}; -use axum::Form; use chrono::{Duration, Utc}; use serde::Deserialize; use tower_cookies::{Cookie, Cookies}; use tracing::{error, warn}; -use crate::domain::ids::generate_id; -use crate::domain::sessions::Session; +use crate::domain::sessions::NewSession; use crate::infrastructure::auth::{generate_session_token, hash_token, verify_password}; use crate::server::routes::render_html; use crate::server::server::AppState; @@ -75,15 +74,14 @@ pub(crate) async fn login_submit( let session_token_hash = hash_token(&session_token); // Create session in database (valid for 30 days) - let session = Session::new( - generate_id(), - user.id.clone(), + let new_session = NewSession::new( + user.id, session_token_hash, Utc::now(), Utc::now() + Duration::days(30), ); - if let Err(err) = state.session_repo.insert(session).await { + if let Err(err) = state.session_repo.insert(new_session).await { error!(error = %err, "failed to create session"); return Err(StatusCode::INTERNAL_SERVER_ERROR); } @@ -93,7 +91,7 @@ pub(crate) async fn login_submit( cookie.set_path("/"); cookie.set_http_only(true); cookie.set_same_site(tower_cookies::cookie::SameSite::Strict); - + // Enable secure flag if BREWLOG_SECURE_COOKIES is set to "true" // This should be enabled in production when serving over HTTPS if std::env::var("BREWLOG_SECURE_COOKIES").unwrap_or_default() == "true" { @@ -110,9 +108,13 @@ pub(crate) async fn logout(State(state): State, cookies: Cookies) -> R if let Some(cookie) = cookies.get(SESSION_COOKIE_NAME) { let session_token = cookie.value(); let session_token_hash = hash_token(session_token); - + // Try to find and delete the session - if let Ok(session) = state.session_repo.get_by_token_hash(&session_token_hash).await { + if let Ok(session) = state + .session_repo + .get_by_token_hash(&session_token_hash) + .await + { let _ = state.session_repo.delete(session.id).await; } } @@ -142,7 +144,11 @@ pub async fn is_authenticated(state: &AppState, cookies: &Cookies) -> bool { let session_token_hash = hash_token(session_token); // Check if session exists and is valid - match state.session_repo.get_by_token_hash(&session_token_hash).await { + match state + .session_repo + .get_by_token_hash(&session_token_hash) + .await + { Ok(session) => !session.is_expired(), Err(_) => false, } diff --git a/src/server/routes/roasters.rs b/src/server/routes/roasters.rs index fa169d3..d717aca 100644 --- a/src/server/routes/roasters.rs +++ b/src/server/routes/roasters.rs @@ -3,6 +3,7 @@ use axum::extract::{Path, Query, State}; use axum::http::{HeaderMap, StatusCode}; use axum::response::{Html, IntoResponse, Redirect, Response}; +use crate::domain::ids::RoasterId; use crate::domain::listing::{ListRequest, SortDirection}; use crate::domain::roasters::{NewRoaster, Roaster, RoasterSortKey, UpdateRoaster}; use crate::presentation::templates::{ @@ -49,12 +50,12 @@ pub(crate) async fn roasters_page( if is_datastar_request(&headers) { return render_roaster_list_fragment(state, request) .await - .map_err(|err| map_app_error(err)); + .map_err(map_app_error); } let (roasters, navigator) = load_roaster_page(&state, request) .await - .map_err(|err| map_app_error(err))?; + .map_err(map_app_error)?; let is_authenticated = crate::server::routes::auth::is_authenticated(&state, &cookies).await; @@ -71,11 +72,11 @@ pub(crate) async fn roasters_page( pub(crate) async fn roaster_page( State(state): State, cookies: tower_cookies::Cookies, - Path(id): Path, + Path(id): Path, ) -> Result, StatusCode> { let roaster = state .roaster_repo - .get(id.clone()) + .get(id) .await .map_err(|err| map_app_error(AppError::from(err)))?; let roasts = state @@ -117,10 +118,10 @@ pub(crate) async fn create_roaster( ) -> Result { let request = query.into_request::(); let (new_roaster, source) = payload.into_parts(); - let roaster = new_roaster.normalize().into_roaster(); + let new_roaster = new_roaster.normalize(); let roaster = state .roaster_repo - .insert(roaster) + .insert(new_roaster) .await .map_err(AppError::from)?; @@ -139,7 +140,7 @@ pub(crate) async fn create_roaster( pub(crate) async fn get_roaster( State(state): State, - Path(id): Path, + Path(id): Path, ) -> Result, ApiError> { let roaster = state.roaster_repo.get(id).await.map_err(AppError::from)?; Ok(Json(roaster)) @@ -148,7 +149,7 @@ pub(crate) async fn get_roaster( pub(crate) async fn update_roaster( State(state): State, _auth_user: AuthenticatedUser, - Path(id): Path, + Path(id): Path, Json(payload): Json, ) -> Result, ApiError> { let has_changes = payload.name.is_some() @@ -173,7 +174,7 @@ pub(crate) async fn delete_roaster( State(state): State, _auth_user: AuthenticatedUser, headers: HeaderMap, - Path(id): Path, + Path(id): Path, Query(query): Query, ) -> Result { let request = query.into_request::(); diff --git a/src/server/routes/roasts.rs b/src/server/routes/roasts.rs index 3beb2ca..5b96d75 100644 --- a/src/server/routes/roasts.rs +++ b/src/server/routes/roasts.rs @@ -4,6 +4,7 @@ use axum::http::{HeaderMap, StatusCode}; use axum::response::{Html, IntoResponse, Redirect, Response}; use serde::Deserialize; +use crate::domain::ids::{RoastId, RoasterId}; use crate::domain::listing::{ListRequest, SortDirection}; use crate::domain::roasters::RoasterSortKey; use crate::domain::roasts::{NewRoast, Roast, RoastSortKey, RoastWithRoaster}; @@ -48,7 +49,7 @@ pub(crate) async fn roasts_page( if is_datastar_request(&headers) { return render_roast_list_fragment(state, request) .await - .map_err(|err| map_app_error(err)); + .map_err(map_app_error); } let roasters = state @@ -61,7 +62,7 @@ pub(crate) async fn roasts_page( let (roasts, navigator) = load_roast_page(&state, request) .await - .map_err(|err| map_app_error(err))?; + .map_err(map_app_error)?; let is_authenticated = crate::server::routes::auth::is_authenticated(&state, &cookies).await; @@ -79,16 +80,16 @@ pub(crate) async fn roasts_page( pub(crate) async fn roast_page( State(state): State, cookies: tower_cookies::Cookies, - Path(id): Path, + Path(id): Path, ) -> Result, StatusCode> { let roast = state .roast_repo - .get(id.clone()) + .get(id) .await .map_err(|err| map_app_error(AppError::from(err)))?; let roaster = state .roaster_repo - .get(roast.roaster_id.clone()) + .get(roast.roaster_id) .await .map_err(|err| map_app_error(AppError::from(err)))?; @@ -116,14 +117,13 @@ pub(crate) async fn create_roast( state .roaster_repo - .get(new_roast.roaster_id.clone()) + .get(new_roast.roaster_id) .await .map_err(|err| ApiError::from(AppError::from(err)))?; - let roast = new_roast.into_roast(); let roast = state .roast_repo - .insert(roast) + .insert(new_roast) .await .map_err(AppError::from)?; @@ -156,7 +156,7 @@ pub(crate) async fn list_roasts( pub(crate) async fn get_roast( State(state): State, - Path(id): Path, + Path(id): Path, ) -> Result, ApiError> { let roast = state.roast_repo.get(id).await.map_err(AppError::from)?; Ok(Json(roast)) @@ -166,7 +166,7 @@ pub(crate) async fn delete_roast( State(state): State, _auth_user: AuthenticatedUser, headers: HeaderMap, - Path(id): Path, + Path(id): Path, Query(query): Query, ) -> Result { let request = query.into_request::(); @@ -183,12 +183,12 @@ pub(crate) async fn delete_roast( #[derive(Debug, Deserialize)] pub struct RoastsQuery { - pub roaster_id: Option, + pub roaster_id: Option, } #[derive(Debug, Deserialize)] pub(crate) struct NewRoastSubmission { - roaster_id: String, + roaster_id: RoasterId, name: String, origin: String, region: String, @@ -208,7 +208,10 @@ impl NewRoastSubmission { } } - let roaster_id = require("roaster", self.roaster_id)?; + let roaster_id = self.roaster_id; + if roaster_id.into_inner() <= 0 { + return Err(AppError::validation("invalid roaster id")); + } let name = require("name", self.name)?; let origin = require("origin", self.origin)?; let region = require("region", self.region)?; @@ -249,7 +252,7 @@ impl TastingNotesInput { .filter(|value| !value.is_empty()) .collect(), TastingNotesInput::Text(value) => value - .split(|ch| ch == ',' || ch == '\n') + .split([',', '\n']) .map(|segment| segment.trim().to_string()) .filter(|segment| !segment.is_empty()) .collect(), diff --git a/src/server/routes/timeline.rs b/src/server/routes/timeline.rs index 384280f..e8fd64d 100644 --- a/src/server/routes/timeline.rs +++ b/src/server/routes/timeline.rs @@ -28,12 +28,12 @@ pub(crate) async fn timeline_page( if is_datastar_request(&headers) { return render_timeline_chunk(state, request) .await - .map_err(|err| map_app_error(err)); + .map_err(map_app_error); } let data = load_timeline_page(&state, request) .await - .map_err(|err| map_app_error(err))?; + .map_err(map_app_error)?; let is_authenticated = crate::server::routes::auth::is_authenticated(&state, &cookies).await; @@ -139,12 +139,11 @@ fn build_months(prepared_events: Vec) -> Vec = Vec::new(); for prepared in prepared_events { - if let Some(last) = months.last_mut() { - if last.anchor == prepared.anchor { + if let Some(last) = months.last_mut() + && last.anchor == prepared.anchor { last.events.push(prepared.view); continue; } - } months.push(TimelineMonthView { anchor: prepared.anchor, diff --git a/src/server/routes/tokens.rs b/src/server/routes/tokens.rs index 9d31b9a..b67d732 100644 --- a/src/server/routes/tokens.rs +++ b/src/server/routes/tokens.rs @@ -4,8 +4,8 @@ use axum::http::StatusCode; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; -use crate::domain::ids::generate_id; -use crate::domain::tokens::Token; +use crate::domain::ids::{TokenId, UserId}; +use crate::domain::tokens::{NewToken, Token}; use crate::infrastructure::auth::{generate_token, hash_token, verify_password}; use crate::server::auth::AuthenticatedUser; use crate::server::server::AppState; @@ -19,15 +19,15 @@ pub struct CreateTokenRequest { #[derive(Debug, Serialize)] pub struct CreateTokenResponse { - pub id: String, + pub id: TokenId, pub name: String, pub token: String, } #[derive(Debug, Serialize, Deserialize)] pub struct TokenResponse { - pub id: String, - pub user_id: String, + pub id: TokenId, + pub user_id: UserId, pub name: String, pub created_at: DateTime, pub last_used_at: Option>, @@ -70,18 +70,12 @@ pub async fn create_token( let token_hash = hash_token(&token_value); - let token = Token::new( - generate_id(), - user.id.clone(), - token_hash, - payload.name.clone(), - Utc::now(), - ); + let new_token = NewToken::new(user.id, token_hash, payload.name.clone()); // Store token let stored_token = state .token_repo - .insert(token) + .insert(new_token) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; @@ -110,12 +104,12 @@ pub async fn list_tokens( pub async fn revoke_token( State(state): State, auth_user: AuthenticatedUser, - Path(token_id): Path, + Path(token_id): Path, ) -> Result, StatusCode> { // Get the token to ensure it exists and belongs to the user let token = state .token_repo - .get(token_id.clone()) + .get(token_id) .await .map_err(|_| StatusCode::NOT_FOUND)?; diff --git a/src/server/server.rs b/src/server/server.rs index 255d758..ac090bf 100644 --- a/src/server/server.rs +++ b/src/server/server.rs @@ -3,17 +3,15 @@ use std::sync::Arc; use anyhow::Context; use axum::Router; -use chrono::Utc; use tokio::net::TcpListener; use tokio::signal; use tracing::info; -use crate::domain::ids::generate_id; use crate::domain::repositories::{ RoastRepository, RoasterRepository, SessionRepository, TimelineEventRepository, TokenRepository, UserRepository, }; -use crate::domain::users::User; +use crate::domain::users::NewUser; use crate::infrastructure::auth::hash_password; use crate::infrastructure::database::Database; use crate::infrastructure::repositories::roasters::SqlRoasterRepository; @@ -133,12 +131,7 @@ async fn bootstrap_admin_user( let password_hash = hash_password(&password).context("failed to hash admin password")?; - let admin_user = User::new( - generate_id(), - "admin".to_string(), - password_hash, - Utc::now(), - ); + let admin_user = NewUser::new("admin".to_string(), password_hash); user_repo .insert(admin_user) diff --git a/tests/cli/roasters_cli.rs b/tests/cli/roasters_cli.rs index 167136f..0f26871 100644 --- a/tests/cli/roasters_cli.rs +++ b/tests/cli/roasters_cli.rs @@ -37,7 +37,7 @@ fn test_add_roaster_with_authentication() { assert_eq!(roaster["name"], "Test Roasters"); assert_eq!(roaster["country"], "UK"); - assert!(roaster["id"].is_string(), "Should have an ID"); + assert!(roaster["id"].is_i64(), "Should have an ID"); } #[test] @@ -81,7 +81,9 @@ fn test_list_roasters_shows_added_roaster() { let stdout = String::from_utf8_lossy(&add_output.stdout); let added_roaster: Value = serde_json::from_str(&stdout).expect("Should output valid JSON"); - let roaster_id = added_roaster["id"].as_str().unwrap(); + let roaster_id = added_roaster["id"] + .as_i64() + .expect("roaster id should be numeric"); // List roasters let list_output = run_brewlog(&["list-roasters"], &[]); @@ -96,7 +98,9 @@ fn test_list_roasters_shows_added_roaster() { let roasters_array = roasters.as_array().unwrap(); // Find our roaster in the list - let found = roasters_array.iter().any(|r| r["id"] == roaster_id); + let found = roasters_array + .iter() + .any(|r| r["id"].as_i64() == Some(roaster_id)); assert!(found, "Should find the added roaster in the list"); } diff --git a/tests/cli/roasts_cli.rs b/tests/cli/roasts_cli.rs index f8a5b28..5a97ad1 100644 --- a/tests/cli/roasts_cli.rs +++ b/tests/cli/roasts_cli.rs @@ -44,14 +44,17 @@ fn test_add_roast_with_authentication() { let roaster_stdout = String::from_utf8_lossy(&roaster_output.stdout); let roaster: Value = serde_json::from_str(&roaster_stdout).expect("Should output valid JSON"); - let roaster_id = roaster["id"].as_str().unwrap(); + let roaster_id = roaster["id"] + .as_i64() + .expect("roaster id should be numeric"); + let roaster_id_arg = roaster_id.to_string(); // Now add a roast let output = run_brewlog( &[ "add-roast", "--roaster-id", - roaster_id, + &roaster_id_arg, "--name", "Ethiopian Yirgacheffe", "--origin", @@ -78,8 +81,8 @@ fn test_add_roast_with_authentication() { let roast: Value = serde_json::from_str(&stdout).expect("Should output valid JSON"); assert_eq!(roast["name"], "Ethiopian Yirgacheffe"); - assert_eq!(roast["roaster_id"], roaster_id); - assert!(roast["id"].is_string(), "Should have an ID"); + assert_eq!(roast["roaster_id"].as_i64(), Some(roaster_id)); + assert!(roast["id"].is_i64(), "Should have an ID"); } #[test] @@ -111,14 +114,17 @@ fn test_list_roasts_shows_added_roast() { let roaster_stdout = String::from_utf8_lossy(&roaster_output.stdout); let roaster: Value = serde_json::from_str(&roaster_stdout).unwrap(); - let roaster_id = roaster["id"].as_str().unwrap(); + let roaster_id = roaster["id"] + .as_i64() + .expect("roaster id should be numeric"); + let roaster_id_arg = roaster_id.to_string(); // Add a roast let add_output = run_brewlog( &[ "add-roast", "--roaster-id", - roaster_id, + &roaster_id_arg, "--name", "Colombian Supremo", "--origin", @@ -139,7 +145,9 @@ fn test_list_roasts_shows_added_roast() { let stdout = String::from_utf8_lossy(&add_output.stdout); let added_roast: Value = serde_json::from_str(&stdout).unwrap(); - let roast_id = added_roast["id"].as_str().unwrap(); + let roast_id = added_roast["id"] + .as_i64() + .expect("roast id should be numeric"); // List roasts let list_output = run_brewlog(&["list-roasts"], &[]); @@ -155,7 +163,7 @@ fn test_list_roasts_shows_added_roast() { // Find our roast in the list (note: list returns RoastWithRoaster which has nested structure) let found = roasts_array .iter() - .any(|item| item["roast"]["id"] == roast_id); + .any(|item| item["roast"]["id"].as_i64() == Some(roast_id)); assert!( found, "Should find the added roast in the list. Looking for id={}, found {} roasts", diff --git a/tests/cli/tokens_cli.rs b/tests/cli/tokens_cli.rs index b08c348..40a37e5 100644 --- a/tests/cli/tokens_cli.rs +++ b/tests/cli/tokens_cli.rs @@ -37,7 +37,7 @@ fn test_list_tokens_with_authentication() { fn test_revoke_token_requires_authentication() { let _ = server_info(); - let output = run_brewlog(&["revoke-token", "--id", "some-id"], &[]); + let output = run_brewlog(&["revoke-token", "--id", "1"], &[]); assert!( !output.status.success(), @@ -60,10 +60,10 @@ fn test_revoke_token_with_authentication() { // Find a token to revoke let tokens_array = tokens.as_array().expect("Should be an array"); if let Some(first_token) = tokens_array.first() { - let token_id = first_token["id"].as_str().expect("Token should have ID"); + let token_id = first_token["id"].as_i64().expect("Token should have ID"); let revoke_output = run_brewlog( - &["revoke-token", "--id", token_id], + &["revoke-token", "--id", &token_id.to_string()], &[("BREWLOG_TOKEN", &token)], ); @@ -98,12 +98,12 @@ fn test_revoked_token_cannot_be_used() { .expect("Should find token to revoke"); let token_id = token_to_revoke_entry["id"] - .as_str() + .as_i64() .expect("Token should have ID"); // Revoke the token let revoke_output = run_brewlog( - &["revoke-token", "--id", token_id], + &["revoke-token", "--id", &token_id.to_string()], &[("BREWLOG_TOKEN", &admin_token)], ); assert!( diff --git a/tests/server/auth_api.rs b/tests/server/auth_api.rs index be174d3..eedcf45 100644 --- a/tests/server/auth_api.rs +++ b/tests/server/auth_api.rs @@ -133,7 +133,7 @@ async fn test_revoke_token() { .await .expect("Failed to parse response"); let token = create_body.get("token").unwrap().as_str().unwrap(); - let token_id = create_body.get("id").unwrap().as_str().unwrap(); + let token_id = create_body.get("id").unwrap().as_i64().unwrap(); // Revoke the token let response = client @@ -171,7 +171,7 @@ async fn test_revoked_token_cannot_be_used() { .await .expect("Failed to parse response"); let token = create_body.get("token").unwrap().as_str().unwrap(); - let token_id = create_body.get("id").unwrap().as_str().unwrap(); + let token_id = create_body.get("id").unwrap().as_i64().unwrap(); // Revoke the token client diff --git a/tests/server/helpers.rs b/tests/server/helpers.rs index 1e82104..624cc4c 100644 --- a/tests/server/helpers.rs +++ b/tests/server/helpers.rs @@ -5,7 +5,7 @@ use brewlog::domain::repositories::{ TokenRepository, UserRepository, }; use brewlog::domain::roasters::{NewRoaster, Roaster}; -use brewlog::domain::users::User; +use brewlog::domain::users::NewUser; use brewlog::infrastructure::auth::hash_password; use brewlog::infrastructure::database::Database; use brewlog::infrastructure::repositories::roasters::SqlRoasterRepository; @@ -16,7 +16,6 @@ use brewlog::infrastructure::repositories::tokens::SqlTokenRepository; use brewlog::infrastructure::repositories::users::SqlUserRepository; use brewlog::server::routes::app_router; use brewlog::server::server::AppState; -use chrono::Utc; use reqwest::Client; use tokio::net::TcpListener; @@ -106,14 +105,10 @@ pub async fn spawn_app_with_auth() -> TestApp { // Create admin user with known password let password_hash = hash_password("test_password").expect("Failed to hash password"); - let admin_user = User::new( - "test_admin_id".to_string(), - "admin".to_string(), - password_hash, - Utc::now(), - ); + let admin_user = NewUser::new("admin".to_string(), password_hash); - app.user_repo + let admin_user = app + .user_repo .as_ref() .unwrap() .insert(admin_user) @@ -121,18 +116,12 @@ pub async fn spawn_app_with_auth() -> TestApp { .expect("Failed to create admin user"); // Create a token for testing - use brewlog::domain::tokens::Token; + use brewlog::domain::tokens::NewToken; use brewlog::infrastructure::auth::{generate_token, hash_token}; let token_value = generate_token().expect("Failed to generate token"); let token_hash = hash_token(&token_value); - let token = Token::new( - "test_token_id".to_string(), - "test_admin_id".to_string(), - token_hash, - "test-token".to_string(), - Utc::now(), - ); + let token = NewToken::new(admin_user.id, token_hash, "test-token".to_string()); app.token_repo .as_ref() diff --git a/tests/server/roasters_api.rs b/tests/server/roasters_api.rs index e896b06..060b1ff 100644 --- a/tests/server/roasters_api.rs +++ b/tests/server/roasters_api.rs @@ -122,7 +122,7 @@ async fn getting_a_nonexistent_roaster_returns_a_404() { // Act let response = client - .get(app.api_url("/roasters/nonexistent-id")) + .get(app.api_url("/roasters/999999")) .send() .await .expect("Failed to execute request"); @@ -325,7 +325,7 @@ async fn updating_a_nonexistent_roaster_returns_a_404() { // Act let response = client - .put(app.api_url("/roasters/nonexistent-id")) + .put(app.api_url("/roasters/999999")) .bearer_auth(app.auth_token.as_ref().unwrap()) .json(&update) .send() @@ -392,7 +392,7 @@ async fn deleting_a_nonexistent_roaster_returns_a_404() { // Act let response = client - .delete(app.api_url("/roasters/nonexistent-id")) + .delete(app.api_url("/roasters/999999")) .bearer_auth(app.auth_token.as_ref().unwrap()) .send() .await diff --git a/tests/server/roasts_api.rs b/tests/server/roasts_api.rs index df3fafe..2c28c83 100644 --- a/tests/server/roasts_api.rs +++ b/tests/server/roasts_api.rs @@ -1,4 +1,5 @@ use crate::helpers::{create_default_roaster, create_roaster_with_name, spawn_app_with_auth}; +use brewlog::domain::ids::RoasterId; use brewlog::domain::roasts::{NewRoast, Roast, RoastWithRoaster}; #[tokio::test] @@ -9,7 +10,7 @@ async fn creating_a_roast_returns_a_201_for_valid_data() { let client = reqwest::Client::new(); let new_roast = NewRoast { - roaster_id: roaster_id.clone(), + roaster_id: roaster_id, name: "Ethiopian Yirgacheffe".to_string(), origin: "Ethiopia".to_string(), region: "Yirgacheffe".to_string(), @@ -52,7 +53,7 @@ async fn creating_a_roast_persists_the_data() { let client = reqwest::Client::new(); let new_roast = NewRoast { - roaster_id: roaster_id.clone(), + roaster_id: roaster_id, name: "Colombian Supremo".to_string(), origin: "Colombia".to_string(), region: "Huila".to_string(), @@ -91,7 +92,7 @@ async fn creating_a_roast_with_nonexistent_roaster_returns_a_404() { let client = reqwest::Client::new(); let new_roast = NewRoast { - roaster_id: "nonexistent-roaster-id".to_string(), + roaster_id: RoasterId::new(999999), name: "Orphaned Roast".to_string(), origin: "Unknown".to_string(), region: "Unknown".to_string(), @@ -121,7 +122,7 @@ async fn getting_a_roast_returns_a_200_for_valid_id() { let client = reqwest::Client::new(); let new_roast = NewRoast { - roaster_id: roaster_id.clone(), + roaster_id: roaster_id, name: "Kenyan AA".to_string(), origin: "Kenya".to_string(), region: "Nyeri".to_string(), @@ -166,7 +167,7 @@ async fn getting_a_nonexistent_roast_returns_a_404() { // Act let response = client - .get(app.api_url("/roasts/nonexistent-id")) + .get(app.api_url("/roasts/999999")) .send() .await .expect("Failed to execute request"); @@ -204,7 +205,7 @@ async fn listing_roasts_returns_a_200_with_multiple_roasts() { // Create multiple roasts let roast1 = NewRoast { - roaster_id: roaster_id.clone(), + roaster_id: roaster_id, name: "First Roast".to_string(), origin: "Brazil".to_string(), region: "Santos".to_string(), @@ -214,7 +215,7 @@ async fn listing_roasts_returns_a_200_with_multiple_roasts() { }; let roast2 = NewRoast { - roaster_id: roaster_id.clone(), + roaster_id: roaster_id, name: "Second Roast".to_string(), origin: "Guatemala".to_string(), region: "Antigua".to_string(), @@ -264,7 +265,7 @@ async fn listing_roasts_by_roaster_returns_a_200_with_filtered_list() { // Create roasts for both roasters let roast1 = NewRoast { - roaster_id: roaster1_id.clone(), + roaster_id: roaster1_id, name: "Roaster 1 Roast".to_string(), origin: "Brazil".to_string(), region: "Santos".to_string(), @@ -274,7 +275,7 @@ async fn listing_roasts_by_roaster_returns_a_200_with_filtered_list() { }; let roast2 = NewRoast { - roaster_id: roaster2_id.clone(), + roaster_id: roaster2_id, name: "Roaster 2 Roast".to_string(), origin: "Guatemala".to_string(), region: "Antigua".to_string(), @@ -322,7 +323,7 @@ async fn deleting_a_roast_returns_a_204_for_valid_id() { let client = reqwest::Client::new(); let new_roast = NewRoast { - roaster_id: roaster_id.clone(), + roaster_id: roaster_id, name: "Temporary Roast".to_string(), origin: "Peru".to_string(), region: "Cusco".to_string(), @@ -373,7 +374,7 @@ async fn deleting_a_nonexistent_roast_returns_a_404() { // Act let response = client - .delete(app.api_url("/roasts/nonexistent-id")) + .delete(app.api_url("/roasts/999999")) .bearer_auth(app.auth_token.as_ref().unwrap()) .send() .await @@ -397,7 +398,7 @@ async fn creating_a_roast_with_empty_name_returns_a_400() { .header("content-type", "application/json") .body(format!( r#"{{ - "roaster_id": "{}", + "roaster_id": {}, "name": " ", "origin": "Ethiopia", "region": "Yirgacheffe", @@ -405,7 +406,7 @@ async fn creating_a_roast_with_empty_name_returns_a_400() { "tasting_notes": "Blueberry", "process": "Washed" }}"#, - roaster_id + i64::from(roaster_id) )) .send() .await @@ -429,14 +430,14 @@ async fn creating_a_roast_with_missing_required_fields_returns_a_400() { .header("content-type", "application/json") .body(format!( r#"{{ - "roaster_id": "{}", + "roaster_id": {}, "name": "Test Roast", "region": "Yirgacheffe", "producer": "Co-op", "tasting_notes": "Blueberry", "process": "Washed" }}"#, - roaster_id + i64::from(roaster_id) )) .send() .await @@ -460,7 +461,7 @@ async fn creating_a_roast_with_empty_tasting_notes_returns_a_400() { .header("content-type", "application/json") .body(format!( r#"{{ - "roaster_id": "{}", + "roaster_id": {}, "name": "Test Roast", "origin": "Ethiopia", "region": "Yirgacheffe", @@ -468,7 +469,7 @@ async fn creating_a_roast_with_empty_tasting_notes_returns_a_400() { "tasting_notes": "", "process": "Washed" }}"#, - roaster_id + i64::from(roaster_id) )) .send() .await diff --git a/tests/server/timeline.rs b/tests/server/timeline.rs index 4fc15be..61b1ea0 100644 --- a/tests/server/timeline.rs +++ b/tests/server/timeline.rs @@ -1,13 +1,14 @@ use crate::helpers::{create_roaster_with_payload, spawn_app_with_auth}; +use brewlog::domain::ids::RoasterId; use brewlog::domain::roasters::NewRoaster; use brewlog::domain::roasts::NewRoast; use reqwest::Client; use tokio::time::{Duration, sleep}; -async fn create_roast(app: &crate::helpers::TestApp, roaster_id: &str, name: &str) { +async fn create_roast(app: &crate::helpers::TestApp, roaster_id: RoasterId, name: &str) { let client = Client::new(); let roast = NewRoast { - roaster_id: roaster_id.to_string(), + roaster_id, name: name.to_string(), origin: "Ethiopia".to_string(), region: "Yirgacheffe".to_string(), @@ -50,7 +51,7 @@ async fn seed_timeline_with_roasts( let mut roast_names = Vec::new(); for index in 0..roast_count { let roast_name = format!("Seed Roast {index:02}"); - create_roast(app, &roaster.id, &roast_name).await; + create_roast(app, roaster.id, &roast_name).await; roast_names.push(roast_name); // Space out timestamps to keep ordering deterministic. sleep(Duration::from_millis(2)).await; @@ -96,7 +97,7 @@ async fn creating_a_roaster_surfaces_on_the_timeline() { }, ) .await; - let roaster_id = roaster.id.clone(); + let roaster_id = roaster.id; sleep(Duration::from_millis(10)).await; @@ -143,7 +144,7 @@ async fn creating_a_roast_surfaces_on_the_timeline() { sleep(Duration::from_millis(5)).await; let roast_name = "Timeline Natural"; - create_roast(&app, &roaster_id, roast_name).await; + create_roast(&app, roaster_id, roast_name).await; let response = client .get(format!("{}/timeline", app.address))