feat!: use numeric, database-generated IDs throughout

This commit is contained in:
Jon Seager 2025-11-25 18:21:04 +00:00
parent 6e1053be8f
commit 42d0f71eb1
No known key found for this signature in database
43 changed files with 817 additions and 745 deletions

21
Cargo.lock generated
View file

@ -314,17 +314,6 @@ dependencies = [
"generic-array", "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]] [[package]]
name = "brewlog" name = "brewlog"
version = "0.1.0" version = "0.1.0"
@ -335,7 +324,6 @@ dependencies = [
"async-trait", "async-trait",
"axum", "axum",
"base64 0.22.1", "base64 0.22.1",
"block-id",
"chrono", "chrono",
"clap", "clap",
"once_cell", "once_cell",
@ -1864,15 +1852,6 @@ dependencies = [
"getrandom 0.3.4", "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]] [[package]]
name = "redox_syscall" name = "redox_syscall"
version = "0.5.18" version = "0.5.18"

View file

@ -15,14 +15,12 @@ async-trait = "0.1"
axum = { version = "0.7", features = ["macros"] } axum = { version = "0.7", features = ["macros"] }
askama = "0.12" askama = "0.12"
base64 = "0.22" base64 = "0.22"
block-id = "0.2.1"
chrono = { version = "0.4", features = ["serde", "clock"] } chrono = { version = "0.4", features = ["serde", "clock"] }
clap = { version = "4.5", features = ["derive", "env"] } clap = { version = "4.5", features = ["derive", "env"] }
reqwest = { version = "0.12", features = ["json", "rustls-tls"] } reqwest = { version = "0.12", features = ["json", "rustls-tls"] }
rpassword = "7.3" rpassword = "7.3"
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0" serde_json = "1.0"
once_cell = "1.19"
rand = "0.8" rand = "0.8"
sha2 = "0.10" sha2 = "0.10"
sqlx = { version = "0.7", default-features = false, features = [ sqlx = { version = "0.7", default-features = false, features = [
@ -44,6 +42,7 @@ portpicker = "0.1"
reqwest = { version = "0.12", features = ["blocking", "cookies"] } reqwest = { version = "0.12", features = ["blocking", "cookies"] }
tempfile = "3.8" tempfile = "3.8"
wiremock = "0.6" wiremock = "0.6"
once_cell = "1.19"
[[test]] [[test]]
name = "cli" name = "cli"

View file

@ -86,7 +86,6 @@
RUST_SRC_PATH = "${rust}/lib/rustlib/src/rust/library"; RUST_SRC_PATH = "${rust}/lib/rustlib/src/rust/library";
LD_LIBRARY_PATH = with pkgs; lib.makeLibraryPath [ openssl ]; LD_LIBRARY_PATH = with pkgs; lib.makeLibraryPath [ openssl ];
inputsFrom = [ self.packages.${system}.brewlog ];
buildInputs = buildInputs =
with pkgs; with pkgs;
[ [
@ -95,8 +94,10 @@
lld lld
nil nil
nixfmt-rfc-style nixfmt-rfc-style
sqlx-cli openssl
pkg-config
sqlite sqlite
sqlx-cli
] ]
++ [ ++ [
rust rust

View file

@ -1,8 +1,7 @@
-- migrate:up
PRAGMA foreign_keys = ON; PRAGMA foreign_keys = ON;
CREATE TABLE roasters ( CREATE TABLE roasters (
id TEXT PRIMARY KEY, id INTEGER PRIMARY KEY,
name TEXT NOT NULL, name TEXT NOT NULL,
country TEXT NOT NULL, country TEXT NOT NULL,
city TEXT, city TEXT,
@ -12,8 +11,8 @@ CREATE TABLE roasters (
); );
CREATE TABLE roasts ( CREATE TABLE roasts (
id TEXT PRIMARY KEY, id INTEGER PRIMARY KEY,
roaster_id TEXT NOT NULL REFERENCES roasters(id) ON DELETE CASCADE, roaster_id INTEGER NOT NULL REFERENCES roasters(id) ON DELETE CASCADE,
name TEXT NOT NULL, name TEXT NOT NULL,
origin TEXT, origin TEXT,
region TEXT, region TEXT,
@ -26,9 +25,9 @@ CREATE TABLE roasts (
CREATE INDEX idx_roasts_roaster_id ON roasts(roaster_id); CREATE INDEX idx_roasts_roaster_id ON roasts(roaster_id);
CREATE TABLE timeline_events ( CREATE TABLE timeline_events (
id TEXT PRIMARY KEY, id INTEGER PRIMARY KEY,
entity_type TEXT NOT NULL CHECK (entity_type IN ('roaster', 'roast')), 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')), occurred_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
title TEXT NOT NULL, title TEXT NOT NULL,
details_json TEXT, details_json TEXT,

View file

@ -1,14 +1,14 @@
-- migrate:up -- migrate:up
CREATE TABLE users ( CREATE TABLE users (
id TEXT PRIMARY KEY, id INTEGER PRIMARY KEY,
username TEXT NOT NULL UNIQUE, username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL, password_hash TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
); );
CREATE TABLE tokens ( CREATE TABLE tokens (
id TEXT PRIMARY KEY, id INTEGER PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE, token_hash TEXT NOT NULL UNIQUE,
name TEXT NOT NULL, name TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),

View file

@ -1,7 +1,7 @@
-- Add sessions table for web authentication -- Add sessions table for web authentication
CREATE TABLE IF NOT EXISTS sessions ( CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY NOT NULL, id INTEGER PRIMARY KEY NOT NULL,
user_id TEXT NOT NULL, user_id INTEGER NOT NULL,
session_token_hash TEXT NOT NULL, session_token_hash TEXT NOT NULL,
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
expires_at TEXT NOT NULL, expires_at TEXT NOT NULL,

View file

@ -2,6 +2,11 @@
cargo build cargo build
if [[ -z "$BREWLOG_TOKEN" ]]; then
echo "Error: BREWLOG_TOKEN environment variable is not set."
exit 1
fi
# Tim Wendelboe (Norway) # Tim Wendelboe (Norway)
./target/debug/brewlog add-roaster \ ./target/debug/brewlog add-roaster \
--name "Tim Wendelboe" \ --name "Tim Wendelboe" \

View file

@ -4,6 +4,7 @@ use serde_json::json;
use super::print_json; use super::print_json;
use crate::client::BrewlogClient; use crate::client::BrewlogClient;
use crate::domain::ids::RoasterId;
use crate::domain::roasters::{NewRoaster, UpdateRoaster}; use crate::domain::roasters::{NewRoaster, UpdateRoaster};
#[derive(Debug, Args)] #[derive(Debug, Args)]
@ -41,18 +42,18 @@ pub async fn list_roasters(client: &BrewlogClient) -> Result<()> {
#[derive(Debug, Args)] #[derive(Debug, Args)]
pub struct GetRoasterCommand { pub struct GetRoasterCommand {
#[arg(long)] #[arg(long)]
pub id: String, pub id: i64,
} }
pub async fn get_roaster(client: &BrewlogClient, command: GetRoasterCommand) -> Result<()> { 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) print_json(&roaster)
} }
#[derive(Debug, Args)] #[derive(Debug, Args)]
pub struct UpdateRoasterCommand { pub struct UpdateRoasterCommand {
#[arg(long)] #[arg(long)]
pub id: String, pub id: i64,
#[arg(long)] #[arg(long)]
pub name: Option<String>, pub name: Option<String>,
#[arg(long)] #[arg(long)]
@ -74,23 +75,26 @@ pub async fn update_roaster(client: &BrewlogClient, command: UpdateRoasterComman
notes: command.notes, 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) print_json(&roaster)
} }
#[derive(Debug, Args)] #[derive(Debug, Args)]
pub struct DeleteRoasterCommand { pub struct DeleteRoasterCommand {
#[arg(long)] #[arg(long)]
pub id: String, pub id: i64,
} }
pub async fn delete_roaster(client: &BrewlogClient, command: DeleteRoasterCommand) -> Result<()> { pub async fn delete_roaster(client: &BrewlogClient, command: DeleteRoasterCommand) -> Result<()> {
let id = command.id; let id = RoasterId::new(command.id);
client.roasters().delete(&id).await?; client.roasters().delete(id).await?;
let response = json!({ let response = json!({
"status": "deleted", "status": "deleted",
"resource": "roaster", "resource": "roaster",
"id": id, "id": id.into_inner(),
}); });
print_json(&response) print_json(&response)
} }

View file

@ -4,12 +4,13 @@ use serde_json::json;
use super::print_json; use super::print_json;
use crate::client::BrewlogClient; use crate::client::BrewlogClient;
use crate::domain::ids::{RoastId, RoasterId};
use crate::domain::roasts::NewRoast; use crate::domain::roasts::NewRoast;
#[derive(Debug, Args)] #[derive(Debug, Args)]
pub struct AddRoastCommand { pub struct AddRoastCommand {
#[arg(long)] #[arg(long)]
pub roaster_id: String, pub roaster_id: i64,
#[arg(long)] #[arg(long)]
pub name: String, pub name: String,
#[arg(long)] #[arg(long)]
@ -26,7 +27,7 @@ pub struct AddRoastCommand {
pub async fn add_roast(client: &BrewlogClient, command: AddRoastCommand) -> Result<()> { pub async fn add_roast(client: &BrewlogClient, command: AddRoastCommand) -> Result<()> {
let payload = NewRoast { let payload = NewRoast {
roaster_id: command.roaster_id, roaster_id: RoasterId::new(command.roaster_id),
name: command.name, name: command.name,
origin: command.origin, origin: command.origin,
region: command.region, region: command.region,
@ -42,38 +43,41 @@ pub async fn add_roast(client: &BrewlogClient, command: AddRoastCommand) -> Resu
#[derive(Debug, Args)] #[derive(Debug, Args)]
pub struct ListRoastsCommand { pub struct ListRoastsCommand {
#[arg(long)] #[arg(long)]
pub roaster_id: Option<String>, pub roaster_id: Option<i64>,
} }
pub async fn list_roasts(client: &BrewlogClient, command: ListRoastsCommand) -> Result<()> { 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) print_json(&roasts)
} }
#[derive(Debug, Args)] #[derive(Debug, Args)]
pub struct GetRoastCommand { pub struct GetRoastCommand {
#[arg(long)] #[arg(long)]
pub id: String, pub id: i64,
} }
pub async fn get_roast(client: &BrewlogClient, command: GetRoastCommand) -> Result<()> { 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) print_json(&roast)
} }
#[derive(Debug, Args)] #[derive(Debug, Args)]
pub struct DeleteRoastCommand { pub struct DeleteRoastCommand {
#[arg(long)] #[arg(long)]
pub id: String, pub id: i64,
} }
pub async fn delete_roast(client: &BrewlogClient, command: DeleteRoastCommand) -> Result<()> { pub async fn delete_roast(client: &BrewlogClient, command: DeleteRoastCommand) -> Result<()> {
let id = command.id; let id = RoastId::new(command.id);
client.roasts().delete(&id).await?; client.roasts().delete(id).await?;
let response = json!({ let response = json!({
"status": "deleted", "status": "deleted",
"resource": "roast", "resource": "roast",
"id": id, "id": id.into_inner(),
}); });
print_json(&response) print_json(&response)
} }

View file

@ -4,6 +4,7 @@ use std::io::{self, Write};
use crate::cli::print_json; use crate::cli::print_json;
use crate::client::BrewlogClient; use crate::client::BrewlogClient;
use crate::domain::ids::TokenId;
#[derive(Debug, Args)] #[derive(Debug, Args)]
pub struct CreateTokenCommand { pub struct CreateTokenCommand {
@ -16,7 +17,7 @@ pub struct CreateTokenCommand {
pub struct RevokeTokenCommand { pub struct RevokeTokenCommand {
/// The ID of the token to revoke /// The ID of the token to revoke
#[arg(long)] #[arg(long)]
pub id: String, pub id: TokenId,
} }
pub async fn create_token(client: &BrewlogClient, cmd: CreateTokenCommand) -> Result<()> { 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<()> { 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"); println!("Token revoked successfully");
print_json(&token) print_json(&token)
} }

View file

@ -1,6 +1,7 @@
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use reqwest::StatusCode; use reqwest::StatusCode;
use crate::domain::ids::RoasterId;
use crate::domain::roasters::{NewRoaster, Roaster, UpdateRoaster}; use crate::domain::roasters::{NewRoaster, Roaster, UpdateRoaster};
use super::BrewlogClient; use super::BrewlogClient;
@ -39,7 +40,7 @@ impl<'a> RoastersClient<'a> {
self.inner.handle_response(response).await self.inner.handle_response(response).await
} }
pub async fn get(&self, id: &str) -> Result<Roaster> { pub async fn get(&self, id: RoasterId) -> Result<Roaster> {
let url = self.inner.endpoint(&format!("api/v1/roasters/{id}"))?; let url = self.inner.endpoint(&format!("api/v1/roasters/{id}"))?;
let response = self let response = self
.inner .inner
@ -51,7 +52,7 @@ impl<'a> RoastersClient<'a> {
self.inner.handle_response(response).await self.inner.handle_response(response).await
} }
pub async fn update(&self, id: &str, payload: &UpdateRoaster) -> Result<Roaster> { pub async fn update(&self, id: RoasterId, payload: &UpdateRoaster) -> Result<Roaster> {
let url = self.inner.endpoint(&format!("api/v1/roasters/{id}"))?; let url = self.inner.endpoint(&format!("api/v1/roasters/{id}"))?;
let response = self let response = self
.inner .inner
@ -64,7 +65,7 @@ impl<'a> RoastersClient<'a> {
self.inner.handle_response(response).await 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 url = self.inner.endpoint(&format!("api/v1/roasters/{id}"))?;
let response = self let response = self
.inner .inner

View file

@ -1,6 +1,7 @@
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use reqwest::StatusCode; use reqwest::StatusCode;
use crate::domain::ids::{RoastId, RoasterId};
use crate::domain::roasts::{NewRoast, Roast, RoastWithRoaster}; use crate::domain::roasts::{NewRoast, Roast, RoastWithRoaster};
use super::BrewlogClient; use super::BrewlogClient;
@ -27,10 +28,11 @@ impl<'a> RoastsClient<'a> {
self.inner.handle_response(response).await self.inner.handle_response(response).await
} }
pub async fn list(&self, roaster_id: Option<&str>) -> Result<Vec<RoastWithRoaster>> { pub async fn list(&self, roaster_id: Option<RoasterId>) -> Result<Vec<RoastWithRoaster>> {
let mut url = self.inner.endpoint("api/v1/roasts")?; let mut url = self.inner.endpoint("api/v1/roasts")?;
if let Some(roaster_id) = roaster_id { 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 let response = self
@ -43,7 +45,7 @@ impl<'a> RoastsClient<'a> {
self.inner.handle_response(response).await self.inner.handle_response(response).await
} }
pub async fn get(&self, id: &str) -> Result<Roast> { pub async fn get(&self, id: RoastId) -> Result<Roast> {
let url = self.inner.endpoint(&format!("api/v1/roasts/{id}"))?; let url = self.inner.endpoint(&format!("api/v1/roasts/{id}"))?;
let response = self let response = self
.inner .inner
@ -55,7 +57,7 @@ impl<'a> RoastsClient<'a> {
self.inner.handle_response(response).await 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 url = self.inner.endpoint(&format!("api/v1/roasts/{id}"))?;
let response = self let response = self
.inner .inner

View file

@ -3,6 +3,7 @@ use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::client::BrewlogClient; use crate::client::BrewlogClient;
use crate::domain::ids::{TokenId, UserId};
pub struct TokensClient<'a> { pub struct TokensClient<'a> {
client: &'a BrewlogClient, client: &'a BrewlogClient,
@ -49,10 +50,10 @@ impl<'a> TokensClient<'a> {
self.client.handle_response(response).await self.client.handle_response(response).await
} }
pub async fn revoke(&self, id: &str) -> Result<TokenInfo> { pub async fn revoke(&self, id: TokenId) -> Result<TokenInfo> {
let url = self let url = self
.client .client
.endpoint(&format!("api/v1/tokens/{}/revoke", id))?; .endpoint(&format!("api/v1/tokens/{id}/revoke"))?;
let response = self let response = self
.client .client
@ -73,15 +74,15 @@ struct CreateTokenRequest {
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct TokenResponse { pub struct TokenResponse {
pub id: String, pub id: TokenId,
pub name: String, pub name: String,
pub token: String, pub token: String,
} }
#[derive(Debug, Deserialize, Serialize)] #[derive(Debug, Deserialize, Serialize)]
pub struct TokenInfo { pub struct TokenInfo {
pub id: String, pub id: TokenId,
pub user_id: String, pub user_id: UserId,
pub name: String, pub name: String,
pub created_at: DateTime<Utc>, pub created_at: DateTime<Utc>,
pub last_used_at: Option<DateTime<Utc>>, pub last_used_at: Option<DateTime<Utc>>,

View file

@ -1,14 +1,56 @@
use block_id::{Alphabet, BlockId as BlockIdGenerator}; use serde::{Deserialize, Serialize};
use once_cell::sync::Lazy; use std::fmt;
use rand::RngCore; use std::num::ParseIntError;
use std::str::FromStr;
static ID_GENERATOR: Lazy<BlockIdGenerator<char>> = macro_rules! define_id {
Lazy::new(|| BlockIdGenerator::new(Alphabet::alphanumeric(), 0x00B1_0C1D_u128, 4)); ($name:ident) => {
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct $name(pub i64);
pub fn generate_id() -> String { impl $name {
let mut rng = rand::thread_rng(); pub const fn new(value: i64) -> Self {
let value = rng.next_u64(); Self(value)
ID_GENERATOR }
.encode_string(value)
.expect("block-id encoding should succeed") pub const fn into_inner(self) -> i64 {
self.0
}
}
impl From<i64> 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<Self, Self::Err> {
let value = s.parse::<i64>()?;
Ok(Self(value))
}
}
};
} }
define_id!(RoasterId);
define_id!(RoastId);
define_id!(TimelineEventId);
define_id!(UserId);
define_id!(TokenId);
define_id!(SessionId);

View file

@ -1,26 +1,31 @@
use super::RepositoryError; use super::RepositoryError;
use crate::domain::listing::{ListRequest, Page, SortDirection, SortKey}; 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::RoasterSortKey;
use crate::domain::roasters::{Roaster, UpdateRoaster}; use crate::domain::roasters::{NewRoaster, Roaster, UpdateRoaster};
use crate::domain::roasts::RoastSortKey; use crate::domain::roasts::RoastSortKey;
use crate::domain::roasts::{Roast, RoastWithRoaster, UpdateRoast}; use crate::domain::roasts::{NewRoast, Roast, RoastWithRoaster, UpdateRoast};
use crate::domain::sessions::{Session, SessionId}; use crate::domain::sessions::{NewSession, Session};
use crate::domain::timeline::{TimelineEvent, TimelineSortKey}; use crate::domain::timeline::{TimelineEvent, TimelineSortKey};
use crate::domain::tokens::{Token, TokenId}; use crate::domain::tokens::{NewToken, Token};
use crate::domain::users::{User, UserId}; use crate::domain::users::{NewUser, User};
use async_trait::async_trait; use async_trait::async_trait;
#[async_trait] #[async_trait]
pub trait RoasterRepository: Send + Sync { pub trait RoasterRepository: Send + Sync {
async fn insert(&self, roaster: Roaster) -> Result<Roaster, RepositoryError>; async fn insert(&self, roaster: NewRoaster) -> Result<Roaster, RepositoryError>;
async fn get(&self, id: String) -> Result<Roaster, RepositoryError>; async fn get(&self, id: RoasterId) -> Result<Roaster, RepositoryError>;
async fn list( async fn list(
&self, &self,
request: &ListRequest<RoasterSortKey>, request: &ListRequest<RoasterSortKey>,
) -> Result<Page<Roaster>, RepositoryError>; ) -> Result<Page<Roaster>, RepositoryError>;
async fn update(&self, id: String, changes: UpdateRoaster) -> Result<Roaster, RepositoryError>; async fn update(
async fn delete(&self, id: String) -> Result<(), RepositoryError>; &self,
id: RoasterId,
changes: UpdateRoaster,
) -> Result<Roaster, RepositoryError>;
async fn delete(&self, id: RoasterId) -> Result<(), RepositoryError>;
async fn list_all(&self) -> Result<Vec<Roaster>, RepositoryError> { async fn list_all(&self) -> Result<Vec<Roaster>, RepositoryError> {
let sort_key = <RoasterSortKey as SortKey>::default(); let sort_key = <RoasterSortKey as SortKey>::default();
@ -43,18 +48,18 @@ pub trait RoasterRepository: Send + Sync {
#[async_trait] #[async_trait]
pub trait RoastRepository: Send + Sync { pub trait RoastRepository: Send + Sync {
async fn insert(&self, roast: Roast) -> Result<Roast, RepositoryError>; async fn insert(&self, roast: NewRoast) -> Result<Roast, RepositoryError>;
async fn get(&self, id: String) -> Result<Roast, RepositoryError>; async fn get(&self, id: RoastId) -> Result<Roast, RepositoryError>;
async fn list( async fn list(
&self, &self,
request: &ListRequest<RoastSortKey>, request: &ListRequest<RoastSortKey>,
) -> Result<Page<RoastWithRoaster>, RepositoryError>; ) -> Result<Page<RoastWithRoaster>, RepositoryError>;
async fn list_by_roaster( async fn list_by_roaster(
&self, &self,
roaster_id: String, roaster_id: RoasterId,
) -> Result<Vec<RoastWithRoaster>, RepositoryError>; ) -> Result<Vec<RoastWithRoaster>, RepositoryError>;
async fn update(&self, id: String, changes: UpdateRoast) -> Result<Roast, RepositoryError>; async fn update(&self, id: RoastId, changes: UpdateRoast) -> Result<Roast, RepositoryError>;
async fn delete(&self, id: String) -> Result<(), RepositoryError>; async fn delete(&self, id: RoastId) -> Result<(), RepositoryError>;
async fn list_all(&self) -> Result<Vec<RoastWithRoaster>, RepositoryError> { async fn list_all(&self) -> Result<Vec<RoastWithRoaster>, RepositoryError> {
let sort_key = <RoastSortKey as SortKey>::default(); let sort_key = <RoastSortKey as SortKey>::default();
@ -82,7 +87,7 @@ pub trait TimelineEventRepository: Send + Sync {
#[async_trait] #[async_trait]
pub trait UserRepository: Send + Sync { pub trait UserRepository: Send + Sync {
async fn insert(&self, user: User) -> Result<User, RepositoryError>; async fn insert(&self, user: NewUser) -> Result<User, RepositoryError>;
async fn get(&self, id: UserId) -> Result<User, RepositoryError>; async fn get(&self, id: UserId) -> Result<User, RepositoryError>;
async fn get_by_username(&self, username: &str) -> Result<User, RepositoryError>; async fn get_by_username(&self, username: &str) -> Result<User, RepositoryError>;
async fn exists(&self) -> Result<bool, RepositoryError>; async fn exists(&self) -> Result<bool, RepositoryError>;
@ -90,7 +95,7 @@ pub trait UserRepository: Send + Sync {
#[async_trait] #[async_trait]
pub trait TokenRepository: Send + Sync { pub trait TokenRepository: Send + Sync {
async fn insert(&self, token: Token) -> Result<Token, RepositoryError>; async fn insert(&self, token: NewToken) -> Result<Token, RepositoryError>;
async fn get(&self, id: TokenId) -> Result<Token, RepositoryError>; async fn get(&self, id: TokenId) -> Result<Token, RepositoryError>;
async fn get_by_token_hash(&self, token_hash: &str) -> Result<Token, RepositoryError>; async fn get_by_token_hash(&self, token_hash: &str) -> Result<Token, RepositoryError>;
async fn list_by_user(&self, user_id: UserId) -> Result<Vec<Token>, RepositoryError>; async fn list_by_user(&self, user_id: UserId) -> Result<Vec<Token>, RepositoryError>;
@ -100,7 +105,7 @@ pub trait TokenRepository: Send + Sync {
#[async_trait] #[async_trait]
pub trait SessionRepository: Send + Sync { pub trait SessionRepository: Send + Sync {
async fn insert(&self, session: Session) -> Result<Session, RepositoryError>; async fn insert(&self, session: NewSession) -> Result<Session, RepositoryError>;
async fn get(&self, id: SessionId) -> Result<Session, RepositoryError>; async fn get(&self, id: SessionId) -> Result<Session, RepositoryError>;
async fn get_by_token_hash(&self, token_hash: &str) -> Result<Session, RepositoryError>; async fn get_by_token_hash(&self, token_hash: &str) -> Result<Session, RepositoryError>;
async fn delete(&self, id: SessionId) -> Result<(), RepositoryError>; async fn delete(&self, id: SessionId) -> Result<(), RepositoryError>;

View file

@ -1,12 +1,12 @@
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::domain::ids::generate_id; use crate::domain::ids::RoasterId;
use crate::domain::listing::{SortDirection, SortKey}; use crate::domain::listing::{SortDirection, SortKey};
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Roaster { pub struct Roaster {
pub id: String, pub id: RoasterId,
pub name: String, pub name: String,
pub country: String, pub country: String,
pub city: Option<String>, pub city: Option<String>,
@ -33,18 +33,6 @@ impl NewRoaster {
self.notes = normalize_optional_field(self.notes); self.notes = normalize_optional_field(self.notes);
self 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<String>) -> Option<String> { fn normalize_optional_field(value: Option<String>) -> Option<String> {

View file

@ -1,13 +1,13 @@
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::domain::ids::generate_id; use crate::domain::ids::{RoastId, RoasterId};
use crate::domain::listing::{SortDirection, SortKey}; use crate::domain::listing::{SortDirection, SortKey};
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Roast { pub struct Roast {
pub id: String, pub id: RoastId,
pub roaster_id: String, pub roaster_id: RoasterId,
pub name: String, pub name: String,
pub origin: Option<String>, pub origin: Option<String>,
pub region: Option<String>, pub region: Option<String>,
@ -25,7 +25,7 @@ pub struct RoastWithRoaster {
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NewRoast { pub struct NewRoast {
pub roaster_id: String, pub roaster_id: RoasterId,
pub name: String, pub name: String,
pub origin: String, pub origin: String,
pub region: String, pub region: String,
@ -34,25 +34,9 @@ pub struct NewRoast {
pub process: String, 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)] #[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct UpdateRoast { pub struct UpdateRoast {
pub roaster_id: Option<String>, pub roaster_id: Option<RoasterId>,
pub name: Option<String>, pub name: Option<String>,
pub origin: Option<String>, pub origin: Option<String>,
pub region: Option<String>, pub region: Option<String>,

View file

@ -1,12 +1,12 @@
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
pub type SessionId = String; use crate::domain::ids::{SessionId, UserId};
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Session { pub struct Session {
pub id: SessionId, pub id: SessionId,
pub user_id: String, pub user_id: UserId,
pub session_token_hash: String, pub session_token_hash: String,
pub created_at: DateTime<Utc>, pub created_at: DateTime<Utc>,
pub expires_at: DateTime<Utc>, pub expires_at: DateTime<Utc>,
@ -15,7 +15,7 @@ pub struct Session {
impl Session { impl Session {
pub fn new( pub fn new(
id: SessionId, id: SessionId,
user_id: String, user_id: UserId,
session_token_hash: String, session_token_hash: String,
created_at: DateTime<Utc>, created_at: DateTime<Utc>,
expires_at: DateTime<Utc>, expires_at: DateTime<Utc>,
@ -33,3 +33,27 @@ impl Session {
Utc::now() > self.expires_at Utc::now() > self.expires_at
} }
} }
#[derive(Debug, Clone)]
pub struct NewSession {
pub user_id: UserId,
pub session_token_hash: String,
pub created_at: DateTime<Utc>,
pub expires_at: DateTime<Utc>,
}
impl NewSession {
pub fn new(
user_id: UserId,
session_token_hash: String,
created_at: DateTime<Utc>,
expires_at: DateTime<Utc>,
) -> Self {
Self {
user_id,
session_token_hash,
created_at,
expires_at,
}
}
}

View file

@ -1,6 +1,7 @@
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::domain::ids::TimelineEventId;
use crate::domain::listing::{SortDirection, SortKey}; use crate::domain::listing::{SortDirection, SortKey};
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@ -11,9 +12,9 @@ pub struct TimelineEventDetail {
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TimelineEvent { pub struct TimelineEvent {
pub id: String, pub id: TimelineEventId,
pub entity_type: String, pub entity_type: String,
pub entity_id: String, pub entity_id: i64,
pub occurred_at: DateTime<Utc>, pub occurred_at: DateTime<Utc>,
pub title: String, pub title: String,
pub details: Vec<TimelineEventDetail>, pub details: Vec<TimelineEventDetail>,
@ -23,7 +24,7 @@ pub struct TimelineEvent {
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NewTimelineEvent { pub struct NewTimelineEvent {
pub entity_type: String, pub entity_type: String,
pub entity_id: String, pub entity_id: i64,
pub occurred_at: DateTime<Utc>, pub occurred_at: DateTime<Utc>,
pub title: String, pub title: String,
pub details: Vec<TimelineEventDetail>, pub details: Vec<TimelineEventDetail>,

View file

@ -1,9 +1,7 @@
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::domain::users::UserId; use crate::domain::ids::{TokenId, UserId};
pub type TokenId = String;
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Token { pub struct Token {
@ -17,9 +15,10 @@ pub struct Token {
pub revoked_at: Option<DateTime<Utc>>, pub revoked_at: Option<DateTime<Utc>>,
} }
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone)]
pub struct NewToken { pub struct NewToken {
pub user_id: UserId, pub user_id: UserId,
pub token_hash: String,
pub name: String, pub name: String,
} }
@ -30,6 +29,8 @@ impl Token {
token_hash: String, token_hash: String,
name: String, name: String,
created_at: DateTime<Utc>, created_at: DateTime<Utc>,
last_used_at: Option<DateTime<Utc>>,
revoked_at: Option<DateTime<Utc>>,
) -> Self { ) -> Self {
Self { Self {
id, id,
@ -37,8 +38,8 @@ impl Token {
token_hash, token_hash,
name, name,
created_at, created_at,
last_used_at: None, last_used_at,
revoked_at: None, revoked_at,
} }
} }
@ -50,3 +51,13 @@ impl Token {
!self.is_revoked() !self.is_revoked()
} }
} }
impl NewToken {
pub fn new(user_id: UserId, token_hash: String, name: String) -> Self {
Self {
user_id,
token_hash,
name,
}
}
}

View file

@ -1,7 +1,7 @@
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
pub type UserId = String; use crate::domain::ids::UserId;
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct User { pub struct User {
@ -12,10 +12,10 @@ pub struct User {
pub created_at: DateTime<Utc>, pub created_at: DateTime<Utc>,
} }
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone)]
pub struct NewUser { pub struct NewUser {
pub username: String, pub username: String,
pub password: String, pub password_hash: String,
} }
impl User { impl User {
@ -33,3 +33,12 @@ impl User {
} }
} }
} }
impl NewUser {
pub fn new(username: String, password_hash: String) -> Self {
Self {
username,
password_hash,
}
}
}

View file

@ -1,17 +1,15 @@
use async_trait::async_trait; use async_trait::async_trait;
use chrono::{DateTime, Utc}; 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::RepositoryError;
use crate::domain::ids::generate_id; use crate::domain::ids::RoasterId;
use crate::domain::listing::{ListRequest, Page, PageSize, SortDirection}; use crate::domain::listing::{ListRequest, Page, PageSize, SortDirection};
use crate::domain::repositories::RoasterRepository; 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::domain::timeline::TimelineEventDetail;
use crate::infrastructure::database::DatabasePool; use crate::infrastructure::database::DatabasePool;
type DbId = String;
#[derive(Clone)] #[derive(Clone)]
pub struct SqlRoasterRepository { pub struct SqlRoasterRepository {
pool: DatabasePool, pool: DatabasePool,
@ -22,7 +20,21 @@ impl SqlRoasterRepository {
Self { pool } Self { pool }
} }
fn to_domain(record: RoasterRecord) -> Result<Roaster, RepositoryError> { fn sort_clause(request: &ListRequest<RoasterSortKey>) -> 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 { let RoasterRecord {
id, id,
name, name,
@ -33,57 +45,18 @@ impl SqlRoasterRepository {
created_at, created_at,
} = record; } = record;
Ok(Roaster { Roaster {
id, id: RoasterId::from(id),
name, name,
country, country,
city, city,
homepage, homepage,
notes, notes,
created_at, created_at,
})
}
}
fn roaster_order_clause(request: &ListRequest<RoasterSortKey>) -> 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<Roaster, RepositoryError> {
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<String, RepositoryError> {
let homepage_value = roaster let homepage_value = roaster
.homepage .homepage
.as_ref() .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}")) 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 (?, ?, ?, ?, ?, ?, ?)") #[async_trait]
.bind(generate_id()) impl RoasterRepository for SqlRoasterRepository {
async fn insert(&self, new_roaster: NewRoaster) -> Result<Roaster, RepositoryError> {
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")
.bind(&roaster.id) .bind(i64::from(roaster.id))
.bind(roaster.created_at) .bind(roaster.created_at)
.bind(&roaster.name) .bind(&roaster.name)
.bind(details_json) .bind(details_json)
.bind(Option::<String>::None) .bind::<Option<&str>>(None)
.execute(&mut *tx) .execute(&mut *tx)
.await .await
.map_err(|err| RepositoryError::unexpected(err.to_string()))?; .map_err(|err| RepositoryError::unexpected(err.to_string()))?;
@ -134,17 +139,17 @@ impl RoasterRepository for SqlRoasterRepository {
Ok(roaster) Ok(roaster)
} }
async fn get(&self, id: String) -> Result<Roaster, RepositoryError> { async fn get(&self, id: RoasterId) -> Result<Roaster, RepositoryError> {
let record = query_as::<_, RoasterRecord>( let record = query_as::<_, RoasterRecord>(
"SELECT id, name, country, city, homepage, notes, created_at FROM roasters WHERE id = ?", "SELECT id, name, country, city, homepage, notes, created_at FROM roasters WHERE id = ?",
) )
.bind(id) .bind(i64::from(id))
.fetch_optional(&self.pool) .fetch_optional(&self.pool)
.await .await
.map_err(|err| RepositoryError::unexpected(err.to_string()))?; .map_err(|err| RepositoryError::unexpected(err.to_string()))?;
match record { match record {
Some(record) => Self::to_domain(record), Some(record) => Ok(Self::into_domain(record)),
None => Err(RepositoryError::NotFound), None => Err(RepositoryError::NotFound),
} }
} }
@ -153,7 +158,7 @@ impl RoasterRepository for SqlRoasterRepository {
&self, &self,
request: &ListRequest<RoasterSortKey>, request: &ListRequest<RoasterSortKey>,
) -> Result<Page<Roaster>, RepositoryError> { ) -> Result<Page<Roaster>, RepositoryError> {
let order_clause = roaster_order_clause(request); let order_clause = Self::sort_clause(request);
match request.page_size() { match request.page_size() {
PageSize::All => { PageSize::All => {
@ -169,17 +174,17 @@ impl RoasterRepository for SqlRoasterRepository {
let items = records let items = records
.into_iter() .into_iter()
.map(Self::to_domain) .map(Self::into_domain)
.collect::<Result<Vec<_>, _>>()?; .collect::<Vec<_>>();
let total = items.len() as u64; let total = items.len() as u64;
let page_size = total.min(u64::from(u32::MAX)) as u32; let page_size = total.min(u64::from(u32::MAX)) as u32;
Ok(Page::new(items, 1, page_size.max(1), total, true)) Ok(Page::new(items, 1, page_size.max(1), total, true))
} }
PageSize::Limited(page_size) => { PageSize::Limited(page_size) => {
let page_size_i64 = page_size as i64; let limit = page_size as i64;
let mut page = request.page(); let mut page_number = request.page();
let offset = ((page - 1) as i64).saturating_mul(page_size_i64); let offset = ((page_number - 1) as i64).saturating_mul(limit);
let query = format!( let query = format!(
"SELECT id, name, country, city, homepage, notes, created_at FROM roasters ORDER BY {} LIMIT ? OFFSET ?", "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) let mut records = query_as::<_, RoasterRecord>(&query)
.bind(page_size_i64) .bind(limit)
.bind(offset) .bind(offset)
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await .await
.map_err(|err| RepositoryError::unexpected(err.to_string()))?; .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) .fetch_one(&self.pool)
.await .await
.map_err(|err| RepositoryError::unexpected(err.to_string()))?; .map_err(|err| RepositoryError::unexpected(err.to_string()))?;
if page > 1 && records.is_empty() && total > 0 { if page_number > 1 && records.is_empty() && total > 0 {
let last_page = ((total + page_size_i64 - 1) / page_size_i64) as u32; let last_page = ((total + limit - 1) / limit) as u32;
page = last_page.max(1); page_number = last_page.max(1);
let offset = ((page - 1) as i64).saturating_mul(page_size_i64); let offset = ((page_number - 1) as i64).saturating_mul(limit);
records = query_as::<_, RoasterRecord>(&query) records = query_as::<_, RoasterRecord>(&query)
.bind(page_size_i64) .bind(limit)
.bind(offset) .bind(offset)
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await .await
@ -212,67 +218,77 @@ impl RoasterRepository for SqlRoasterRepository {
let items = records let items = records
.into_iter() .into_iter()
.map(Self::to_domain) .map(Self::into_domain)
.collect::<Result<Vec<_>, _>>()?; .collect::<Vec<_>>();
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<Roaster, RepositoryError> { async fn update(
&self,
id: RoasterId,
changes: UpdateRoaster,
) -> Result<Roaster, RepositoryError> {
let mut builder = QueryBuilder::new("UPDATE roasters SET "); let mut builder = QueryBuilder::new("UPDATE roasters SET ");
let mut first = true; let mut wrote_field = false;
if let Some(name) = changes.name { if let Some(name) = changes.name {
if !first { if wrote_field {
builder.push(", "); builder.push(", ");
} }
first = false; wrote_field = true;
builder.push("name = "); builder.push("name = ");
builder.push_bind(name); builder.push_bind(name);
} }
if let Some(country) = changes.country { if let Some(country) = changes.country {
if !first { if wrote_field {
builder.push(", "); builder.push(", ");
} }
first = false; wrote_field = true;
builder.push("country = "); builder.push("country = ");
builder.push_bind(country); builder.push_bind(country);
} }
if let Some(city) = changes.city { if let Some(city) = changes.city {
if !first { if wrote_field {
builder.push(", "); builder.push(", ");
} }
first = false; wrote_field = true;
builder.push("city = "); builder.push("city = ");
builder.push_bind(city); builder.push_bind(city);
} }
if let Some(homepage) = changes.homepage { if let Some(homepage) = changes.homepage {
if !first { if wrote_field {
builder.push(", "); builder.push(", ");
} }
first = false; wrote_field = true;
builder.push("homepage = "); builder.push("homepage = ");
builder.push_bind(homepage); builder.push_bind(homepage);
} }
if let Some(notes) = changes.notes { if let Some(notes) = changes.notes {
if !first { if wrote_field {
builder.push(", "); builder.push(", ");
} }
first = false; wrote_field = true;
builder.push("notes = "); builder.push("notes = ");
builder.push_bind(notes); builder.push_bind(notes);
} }
if first { if !wrote_field {
return Err(RepositoryError::unexpected( return Err(RepositoryError::unexpected(
"No fields provided for update".to_string(), "No fields provided for update".to_string(),
)); ));
} }
builder.push(" WHERE id = "); builder.push(" WHERE id = ");
builder.push_bind(&id); builder.push_bind(i64::from(id));
let result = builder let result = builder
.build() .build()
@ -287,9 +303,9 @@ impl RoasterRepository for SqlRoasterRepository {
self.get(id).await self.get(id).await
} }
async fn delete(&self, id: String) -> Result<(), RepositoryError> { async fn delete(&self, id: RoasterId) -> Result<(), RepositoryError> {
let result = sqlx::query("DELETE FROM roasters WHERE id = ?") let result = query("DELETE FROM roasters WHERE id = ?")
.bind(id) .bind(i64::from(id))
.execute(&self.pool) .execute(&self.pool)
.await .await
.map_err(|err| RepositoryError::unexpected(err.to_string()))?; .map_err(|err| RepositoryError::unexpected(err.to_string()))?;
@ -304,7 +320,7 @@ impl RoasterRepository for SqlRoasterRepository {
#[derive(Debug, sqlx::FromRow)] #[derive(Debug, sqlx::FromRow)]
struct RoasterRecord { struct RoasterRecord {
id: DbId, id: i64,
name: String, name: String,
country: String, country: String,
city: Option<String>, city: Option<String>,

View file

@ -1,12 +1,13 @@
use async_trait::async_trait; use async_trait::async_trait;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use serde_json::{from_str, to_string};
use sqlx::{Error as SqlxError, QueryBuilder, query, query_as, query_scalar}; use sqlx::{Error as SqlxError, QueryBuilder, query, query_as, query_scalar};
use crate::domain::RepositoryError; 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::listing::{ListRequest, Page, PageSize, SortDirection};
use crate::domain::repositories::RoastRepository; 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::domain::timeline::TimelineEventDetail;
use crate::infrastructure::database::DatabasePool; use crate::infrastructure::database::DatabasePool;
@ -20,101 +21,7 @@ impl SqlRoastRepository {
Self { pool } Self { pool }
} }
fn to_domain(record: RoastRecord) -> Result<Roast, RepositoryError> { fn order_clause(request: &ListRequest<RoastSortKey>) -> String {
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(),
};
Ok(Roast {
id,
roaster_id,
name,
origin,
region,
producer,
tasting_notes,
process,
created_at,
})
}
fn to_with_roaster(
record: RoastWithRoasterRecord,
) -> Result<RoastWithRoaster, RepositoryError> {
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<Roast, RepositoryError> {
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)
}
fn encode_notes(notes: &[String]) -> Result<Option<String>, RepositoryError> {
if notes.is_empty() {
Ok(None)
} else {
serde_json::to_string(notes).map(Some).map_err(|err| {
RepositoryError::unexpected(format!("failed to encode tasting notes: {err}"))
})
}
}
}
fn roast_order_clause(request: &ListRequest<RoastSortKey>) -> String {
let dir_sql = match request.sort_direction() { let dir_sql = match request.sort_direction() {
SortDirection::Asc => "ASC", SortDirection::Asc => "ASC",
SortDirection::Desc => "DESC", SortDirection::Desc => "DESC",
@ -131,38 +38,82 @@ fn roast_order_clause(request: &ListRequest<RoastSortKey>) -> String {
format!("LOWER(COALESCE(r.producer, '')) {dir_sql}, r.created_at DESC") format!("LOWER(COALESCE(r.producer, '')) {dir_sql}, r.created_at DESC")
} }
} }
}
fn encode_notes(notes: &[String]) -> Result<Option<String>, RepositoryError> {
if notes.is_empty() {
Ok(None)
} else {
to_string(notes).map(Some).map_err(|err| {
RepositoryError::unexpected(format!("failed to encode tasting notes: {err}"))
})
}
}
} }
#[async_trait] #[async_trait]
impl RoastRepository for SqlRoastRepository { impl RoastRepository for SqlRoastRepository {
async fn insert(&self, roast: Roast) -> Result<Roast, RepositoryError> { async fn insert(&self, new_roast: NewRoast) -> Result<Roast, RepositoryError> {
let mut tx = self let mut tx = self
.pool .pool
.begin() .begin()
.await .await
.map_err(|err| RepositoryError::unexpected(err.to_string()))?; .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( let origin_value = if origin.trim().is_empty() {
"INSERT INTO roasts (id, roaster_id, name, origin, region, producer, process, tasting_notes, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", 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 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(&roast.id) .bind(i64::from(roaster_id))
.bind(&roast.roaster_id) .bind(&name)
.bind(&roast.name) .bind(origin_value.as_deref())
.bind(&roast.origin) .bind(region_value.as_deref())
.bind(&roast.region) .bind(producer_value.as_deref())
.bind(&roast.producer) .bind(process_value.as_deref())
.bind(&roast.process) .bind(notes_json.as_deref())
.bind(notes.as_deref()) .bind(created_at)
.bind(roast.created_at) .fetch_one(&mut *tx)
.execute(&mut *tx)
.await .await
.map_err(|err| map_insert_error(err, "unknown roaster reference"))?; .map_err(|err| map_insert_error(err, "unknown roaster reference"))?;
let roaster_name: Option<String> = let roast = record.into_roast()?;
sqlx::query_scalar("SELECT name FROM roasters WHERE id = ?")
.bind(&roast.roaster_id) let roaster_name: Option<String> = query_scalar("SELECT name FROM roasters WHERE id = ?")
.bind(i64::from(roast.roaster_id))
.fetch_optional(&mut *tx) .fetch_optional(&mut *tx)
.await .await
.map_err(|err| RepositoryError::unexpected(err.to_string()))?; .map_err(|err| RepositoryError::unexpected(err.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}")) RepositoryError::unexpected(format!("failed to encode timeline event details: {err}"))
})?; })?;
let tasting_notes_json = if roast.tasting_notes.is_empty() { let tasting_notes_json = if roast.tasting_notes.is_empty() {
None None
} else { } else {
Some(serde_json::to_string(&roast.tasting_notes).map_err(|err| { Some(to_string(&roast.tasting_notes).map_err(|err| {
RepositoryError::unexpected(format!( RepositoryError::unexpected(format!(
"failed to encode timeline event tasting notes: {err}" "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 (?, ?, ?, ?, ?, ?, ?)") query(
.bind(generate_id()) "INSERT INTO timeline_events (entity_type, entity_id, occurred_at, title, details_json, tasting_notes_json) VALUES (?, ?, ?, ?, ?, ?)",
)
.bind("roast") .bind("roast")
.bind(&roast.id) .bind(i64::from(roast.id))
.bind(roast.created_at) .bind(roast.created_at)
.bind(&roast.name) .bind(&roast.name)
.bind(details_json) .bind(details_json)
.bind(tasting_notes_json) .bind(tasting_notes_json.as_deref())
.execute(&mut *tx) .execute(&mut *tx)
.await .await
.map_err(|err| RepositoryError::unexpected(err.to_string()))?; .map_err(|err| RepositoryError::unexpected(err.to_string()))?;
@ -225,23 +177,29 @@ impl RoastRepository for SqlRoastRepository {
Ok(roast) Ok(roast)
} }
async fn get(&self, id: String) -> Result<Roast, RepositoryError> { async fn get(&self, id: RoastId) -> Result<Roast, RepositoryError> {
self.get_record(&id).await 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( async fn list(
&self, &self,
request: &ListRequest<RoastSortKey>, request: &ListRequest<RoastSortKey>,
) -> Result<Page<RoastWithRoaster>, RepositoryError> { ) -> Result<Page<RoastWithRoaster>, RepositoryError> {
let order_clause = roast_order_clause(request); let order_clause = Self::order_clause(request);
match request.page_size() { match request.page_size() {
PageSize::All => { PageSize::All => {
let query = format!( 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 \ "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 {}",
FROM roasts r \
JOIN roasters ro ON ro.id = r.roaster_id \
ORDER BY {}",
order_clause order_clause
); );
@ -252,7 +210,7 @@ impl RoastRepository for SqlRoastRepository {
let items = records let items = records
.into_iter() .into_iter()
.map(Self::to_with_roaster) .map(|record| record.into_with_roaster())
.collect::<Result<Vec<_>, _>>()?; .collect::<Result<Vec<_>, _>>()?;
let total = items.len() as u64; 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)) Ok(Page::new(items, 1, page_size.max(1), total, true))
} }
PageSize::Limited(page_size) => { PageSize::Limited(page_size) => {
let page_size_i64 = page_size as i64; let limit = page_size as i64;
let mut page = request.page(); let mut page_number = request.page();
let offset = ((page - 1) as i64).saturating_mul(page_size_i64); let offset = ((page_number - 1) as i64).saturating_mul(limit);
let query = format!( 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 \ "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 ?",
FROM roasts r \
JOIN roasters ro ON ro.id = r.roaster_id \
ORDER BY {} \
LIMIT ? OFFSET ?",
order_clause order_clause
); );
let mut records = query_as::<_, RoastWithRoasterRecord>(&query) let mut records = query_as::<_, RoastWithRoasterRecord>(&query)
.bind(page_size_i64) .bind(limit)
.bind(offset) .bind(offset)
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await .await
.map_err(|err| RepositoryError::unexpected(err.to_string()))?; .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) .fetch_one(&self.pool)
.await .await
.map_err(|err| RepositoryError::unexpected(err.to_string()))?; .map_err(|err| RepositoryError::unexpected(err.to_string()))?;
if page > 1 && records.is_empty() && total > 0 { if page_number > 1 && records.is_empty() && total > 0 {
let last_page = ((total + page_size_i64 - 1) / page_size_i64) as u32; let last_page = ((total + limit - 1) / limit) as u32;
page = last_page.max(1); page_number = last_page.max(1);
let offset = ((page - 1) as i64).saturating_mul(page_size_i64); let offset = ((page_number - 1) as i64).saturating_mul(limit);
records = query_as::<_, RoastWithRoasterRecord>(&query) records = query_as::<_, RoastWithRoasterRecord>(&query)
.bind(page_size_i64) .bind(limit)
.bind(offset) .bind(offset)
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await .await
@ -299,34 +254,39 @@ impl RoastRepository for SqlRoastRepository {
let items = records let items = records
.into_iter() .into_iter()
.map(Self::to_with_roaster) .map(|record| record.into_with_roaster())
.collect::<Result<Vec<_>, _>>()?; .collect::<Result<Vec<_>, _>>()?;
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( async fn list_by_roaster(
&self, &self,
roaster_id: String, roaster_id: RoasterId,
) -> Result<Vec<RoastWithRoaster>, RepositoryError> { ) -> Result<Vec<RoastWithRoaster>, RepositoryError> {
let records = query_as::<_, RoastWithRoasterRecord>( 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 \ "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",
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) .bind(i64::from(roaster_id))
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await .await
.map_err(|err| RepositoryError::unexpected(err.to_string()))?; .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<Roast, RepositoryError> { async fn update(&self, id: RoastId, changes: UpdateRoast) -> Result<Roast, RepositoryError> {
let mut tx = self let mut tx = self
.pool .pool
.begin() .begin()
@ -344,69 +304,69 @@ impl RoastRepository for SqlRoastRepository {
} = changes; } = changes;
let mut builder = QueryBuilder::new("UPDATE roasts SET "); 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 let Some(roaster_id) = roaster_id {
if updated { if wrote_field {
builder.push(", "); builder.push(", ");
} }
updated = true; wrote_field = true;
builder.push("roaster_id = "); builder.push("roaster_id = ");
builder.push_bind(roaster_id); builder.push_bind(i64::from(roaster_id));
} }
if let Some(name) = name { if let Some(name) = name {
if updated { if wrote_field {
builder.push(", "); builder.push(", ");
} }
updated = true; wrote_field = true;
builder.push("name = "); builder.push("name = ");
builder.push_bind(name); builder.push_bind(name);
} }
if let Some(origin) = origin { if let Some(origin) = origin {
if updated { if wrote_field {
builder.push(", "); builder.push(", ");
} }
updated = true; wrote_field = true;
builder.push("origin = "); builder.push("origin = ");
builder.push_bind(origin); builder.push_bind(origin);
} }
if let Some(region) = region { if let Some(region) = region {
if updated { if wrote_field {
builder.push(", "); builder.push(", ");
} }
updated = true; wrote_field = true;
builder.push("region = "); builder.push("region = ");
builder.push_bind(region); builder.push_bind(region);
} }
if let Some(producer) = producer { if let Some(producer) = producer {
if updated { if wrote_field {
builder.push(", "); builder.push(", ");
} }
updated = true; wrote_field = true;
builder.push("producer = "); builder.push("producer = ");
builder.push_bind(producer); builder.push_bind(producer);
} }
if let Some(process) = process { if let Some(process) = process {
if updated { if wrote_field {
builder.push(", "); builder.push(", ");
} }
updated = true; wrote_field = true;
builder.push("process = "); builder.push("process = ");
builder.push_bind(process); builder.push_bind(process);
} }
if let Some(tasting_notes) = tasting_notes { if let Some(tasting_notes) = tasting_notes {
let notes = Self::encode_notes(&tasting_notes)?; let notes_json = Self::encode_notes(&tasting_notes)?;
if updated { if wrote_field {
builder.push(", "); builder.push(", ");
} }
updated = true; wrote_field = true;
builder.push("tasting_notes = "); builder.push("tasting_notes = ");
builder.push_bind(notes); builder.push_bind(notes_json);
} }
if updated { if wrote_field {
builder.push(" WHERE id = "); builder.push(" WHERE id = ");
builder.push_bind(&id); builder.push_bind(i64::from(id));
let result = builder let result = builder
.build() .build()
@ -417,18 +377,22 @@ impl RoastRepository for SqlRoastRepository {
if result.rows_affected() == 0 { if result.rows_affected() == 0 {
return Err(RepositoryError::NotFound); return Err(RepositoryError::NotFound);
} }
} else {
return Err(RepositoryError::unexpected(
"No fields provided for update".to_string(),
));
} }
tx.commit() tx.commit()
.await .await
.map_err(|err| RepositoryError::unexpected(err.to_string()))?; .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 = ?") let result = query("DELETE FROM roasts WHERE id = ?")
.bind(&id) .bind(i64::from(id))
.execute(&self.pool) .execute(&self.pool)
.await .await
.map_err(|err| RepositoryError::unexpected(err.to_string()))?; .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)] #[derive(sqlx::FromRow)]
struct RoastRecord { struct RoastRecord {
id: String, id: i64,
roaster_id: String, roaster_id: i64,
name: String, name: String,
origin: Option<String>, origin: Option<String>,
region: Option<String>, region: Option<String>,
@ -464,10 +428,45 @@ struct RoastRecord {
created_at: DateTime<Utc>, created_at: DateTime<Utc>,
} }
impl RoastRecord {
fn into_roast(self) -> Result<Roast, RepositoryError> {
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)] #[derive(sqlx::FromRow)]
struct RoastWithRoasterRecord { struct RoastWithRoasterRecord {
id: String, id: i64,
roaster_id: String, roaster_id: i64,
name: String, name: String,
origin: Option<String>, origin: Option<String>,
region: Option<String>, region: Option<String>,
@ -477,3 +476,26 @@ struct RoastWithRoasterRecord {
created_at: DateTime<Utc>, created_at: DateTime<Utc>,
roaster_name: String, roaster_name: String,
} }
impl RoastWithRoasterRecord {
fn into_with_roaster(self) -> Result<RoastWithRoaster, RepositoryError> {
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,
})
}
}

View file

@ -1,155 +1,126 @@
use async_trait::async_trait; use async_trait::async_trait;
use chrono::{DateTime, Utc}; use chrono::Utc;
use sqlx::{Pool, Row, Sqlite}; 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::domain::{RepositoryError, repositories::SessionRepository};
use crate::infrastructure::database::DatabasePool;
#[derive(Clone)]
pub struct SqlSessionRepository { pub struct SqlSessionRepository {
pool: Pool<Sqlite>, pool: DatabasePool,
} }
impl SqlSessionRepository { impl SqlSessionRepository {
pub fn new(pool: Pool<Sqlite>) -> Self { pub fn new(pool: DatabasePool) -> Self {
Self { pool } 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] #[async_trait]
impl SessionRepository for SqlSessionRepository { impl SessionRepository for SqlSessionRepository {
async fn insert(&self, session: Session) -> Result<Session, RepositoryError> { async fn insert(&self, session: NewSession) -> Result<Session, RepositoryError> {
sqlx::query( 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";
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)))?;
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<Session, RepositoryError> { async fn get(&self, id: SessionId) -> Result<Session, RepositoryError> {
let row = sqlx::query( let query = "SELECT id, user_id, session_token_hash, created_at, expires_at FROM sessions WHERE id = ?";
r#"
SELECT id, user_id, session_token_hash, created_at, expires_at let record = query_as::<_, SessionRecord>(query)
FROM sessions .bind(i64::from(id))
WHERE id = ? .fetch_optional(&self.pool)
"#,
)
.bind(&id)
.fetch_one(&self.pool)
.await .await
.map_err(|e| match e { .map_err(|err| RepositoryError::unexpected(format!("failed to get session: {err}")))?
sqlx::Error::RowNotFound => RepositoryError::NotFound, .ok_or(RepositoryError::NotFound)?;
_ => RepositoryError::unexpected(format!("failed to get session: {}", e)),
})?;
let created_at: String = row.try_get("created_at").map_err(|e| { Ok(Self::to_domain(record))
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))
})?;
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),
})
} }
async fn get_by_token_hash(&self, token_hash: &str) -> Result<Session, RepositoryError> { async fn get_by_token_hash(&self, token_hash: &str) -> Result<Session, RepositoryError> {
let row = sqlx::query( let query = "SELECT id, user_id, session_token_hash, created_at, expires_at FROM sessions WHERE session_token_hash = ?";
r#"
SELECT id, user_id, session_token_hash, created_at, expires_at let record = query_as::<_, SessionRecord>(query)
FROM sessions
WHERE session_token_hash = ?
"#,
)
.bind(token_hash) .bind(token_hash)
.fetch_one(&self.pool) .fetch_optional(&self.pool)
.await .await
.map_err(|e| match e { .map_err(|err| {
sqlx::Error::RowNotFound => RepositoryError::NotFound, RepositoryError::unexpected(format!("failed to get session by token: {err}"))
_ => RepositoryError::unexpected(format!("failed to get session by token: {}", e)),
})?;
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))
})?;
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), .ok_or(RepositoryError::NotFound)?;
expires_at: DateTime::parse_from_rfc3339(&expires_at)
.map_err(|e| { Ok(Self::to_domain(record))
RepositoryError::unexpected(format!("failed to parse expires_at: {}", e))
})?
.with_timezone(&Utc),
})
} }
async fn delete(&self, id: SessionId) -> Result<(), RepositoryError> { async fn delete(&self, id: SessionId) -> Result<(), RepositoryError> {
sqlx::query("DELETE FROM sessions WHERE id = ?") query("DELETE FROM sessions WHERE id = ?")
.bind(&id) .bind(i64::from(id))
.execute(&self.pool) .execute(&self.pool)
.await .await
.map_err(|e| { .map_err(|err| {
RepositoryError::unexpected(format!("failed to delete session: {}", e)) RepositoryError::unexpected(format!("failed to delete session: {err}"))
})?; })?;
Ok(()) Ok(())
} }
async fn delete_expired(&self) -> Result<(), RepositoryError> { async fn delete_expired(&self) -> Result<(), RepositoryError> {
let now = Utc::now().to_rfc3339(); let now = Utc::now();
sqlx::query("DELETE FROM sessions WHERE expires_at < ?") query("DELETE FROM sessions WHERE expires_at < ?")
.bind(&now) .bind(now)
.execute(&self.pool) .execute(&self.pool)
.await .await
.map_err(|e| { .map_err(|err| {
RepositoryError::unexpected(format!("failed to delete expired sessions: {}", e)) RepositoryError::unexpected(format!("failed to delete expired sessions: {err}"))
})?; })?;
Ok(()) Ok(())
} }
} }
#[derive(sqlx::FromRow)]
struct SessionRecord {
id: i64,
user_id: i64,
session_token_hash: String,
created_at: chrono::DateTime<Utc>,
expires_at: chrono::DateTime<Utc>,
}

View file

@ -4,6 +4,7 @@ use serde_json::from_str;
use sqlx::{query_as, query_scalar}; use sqlx::{query_as, query_scalar};
use crate::domain::RepositoryError; use crate::domain::RepositoryError;
use crate::domain::ids::TimelineEventId;
use crate::domain::listing::{ListRequest, Page, PageSize, SortDirection}; use crate::domain::listing::{ListRequest, Page, PageSize, SortDirection};
use crate::domain::repositories::TimelineEventRepository; use crate::domain::repositories::TimelineEventRepository;
use crate::domain::timeline::{TimelineEvent, TimelineEventDetail, TimelineSortKey}; use crate::domain::timeline::{TimelineEvent, TimelineEventDetail, TimelineSortKey};
@ -104,9 +105,9 @@ impl TimelineEventRepository for SqlTimelineEventRepository {
#[derive(sqlx::FromRow)] #[derive(sqlx::FromRow)]
struct TimelineEventRecord { struct TimelineEventRecord {
id: String, id: i64,
entity_type: String, entity_type: String,
entity_id: String, entity_id: i64,
occurred_at: DateTime<Utc>, occurred_at: DateTime<Utc>,
title: String, title: String,
details_json: Option<String>, details_json: Option<String>,
@ -136,7 +137,7 @@ impl TimelineEventRecord {
}; };
Ok(TimelineEvent { Ok(TimelineEvent {
id: self.id, id: TimelineEventId::from(self.id),
entity_type: self.entity_type, entity_type: self.entity_type,
entity_id: self.entity_id, entity_id: self.entity_id,
occurred_at: self.occurred_at, occurred_at: self.occurred_at,

View file

@ -3,9 +3,9 @@ use chrono::{DateTime, Utc};
use sqlx::query_as; use sqlx::query_as;
use crate::domain::RepositoryError; use crate::domain::RepositoryError;
use crate::domain::ids::{TokenId, UserId};
use crate::domain::repositories::TokenRepository; use crate::domain::repositories::TokenRepository;
use crate::domain::tokens::{Token, TokenId}; use crate::domain::tokens::{NewToken, Token};
use crate::domain::users::UserId;
use crate::infrastructure::database::DatabasePool; use crate::infrastructure::database::DatabasePool;
#[derive(Clone)] #[derive(Clone)]
@ -29,50 +29,51 @@ impl SqlTokenRepository {
revoked_at, revoked_at,
} = record; } = record;
Ok(Token { Ok(Token::new(
id, TokenId::from(id),
user_id, UserId::from(user_id),
token_hash, token_hash,
name, name,
created_at, created_at,
last_used_at, last_used_at,
revoked_at, revoked_at,
}) ))
} }
} }
#[async_trait] #[async_trait]
impl TokenRepository for SqlTokenRepository { impl TokenRepository for SqlTokenRepository {
async fn insert(&self, token: Token) -> Result<Token, RepositoryError> { async fn insert(&self, token: NewToken) -> Result<Token, RepositoryError> {
let query = "INSERT INTO tokens (id, user_id, token_hash, name, created_at, last_used_at, revoked_at) VALUES (?, ?, ?, ?, ?, ?, ?)"; 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) let NewToken {
.bind(&token.id) user_id,
.bind(&token.user_id) token_hash,
.bind(&token.token_hash) name,
.bind(&token.name) } = token;
.bind(token.created_at)
.bind(token.last_used_at) let record = query_as::<_, TokenRecord>(query)
.bind(token.revoked_at) .bind(i64::from(user_id))
.execute(&self.pool) .bind(&token_hash)
.bind(&name)
.fetch_one(&self.pool)
.await .await
.map_err(|err| { .map_err(|err| {
if let sqlx::Error::Database(db_err) = &err if let sqlx::Error::Database(db_err) = &err
&& db_err.is_unique_violation() && db_err.is_unique_violation() {
{
return RepositoryError::conflict("token already exists"); return RepositoryError::conflict("token already exists");
} }
RepositoryError::unexpected(err.to_string()) RepositoryError::unexpected(err.to_string())
})?; })?;
Ok(token) Self::to_domain(record)
} }
async fn get(&self, id: TokenId) -> Result<Token, RepositoryError> { async fn get(&self, id: TokenId) -> Result<Token, RepositoryError> {
let query = "SELECT id, user_id, token_hash, name, created_at, last_used_at, revoked_at FROM tokens WHERE id = ?"; 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) let record = query_as::<_, TokenRecord>(query)
.bind(&id) .bind(i64::from(id))
.fetch_optional(&self.pool) .fetch_optional(&self.pool)
.await .await
.map_err(|err| RepositoryError::unexpected(err.to_string()))? .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 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) let records = query_as::<_, TokenRecord>(query)
.bind(&user_id) .bind(i64::from(user_id))
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await .await
.map_err(|err| RepositoryError::unexpected(err.to_string()))?; .map_err(|err| RepositoryError::unexpected(err.to_string()))?;
@ -107,26 +108,28 @@ impl TokenRepository for SqlTokenRepository {
} }
async fn revoke(&self, id: TokenId) -> Result<Token, RepositoryError> { async fn revoke(&self, id: TokenId) -> Result<Token, RepositoryError> {
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(); let now = Utc::now();
sqlx::query(query) let record = query_as::<_, TokenRecord>(query)
.bind(&now) .bind(now)
.bind(&id) .bind(i64::from(id))
.execute(&self.pool) .fetch_optional(&self.pool)
.await .await
.map_err(|err| RepositoryError::unexpected(err.to_string()))?; .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> { async fn update_last_used(&self, id: TokenId) -> Result<(), RepositoryError> {
let query = "UPDATE tokens SET last_used_at = ? WHERE id = ?";
let now = Utc::now(); let now = Utc::now();
sqlx::query(query) sqlx::query("UPDATE tokens SET last_used_at = ? WHERE id = ?")
.bind(&now) .bind(now)
.bind(&id) .bind(i64::from(id))
.execute(&self.pool) .execute(&self.pool)
.await .await
.map_err(|err| RepositoryError::unexpected(err.to_string()))?; .map_err(|err| RepositoryError::unexpected(err.to_string()))?;
@ -137,8 +140,8 @@ impl TokenRepository for SqlTokenRepository {
#[derive(sqlx::FromRow)] #[derive(sqlx::FromRow)]
struct TokenRecord { struct TokenRecord {
id: TokenId, id: i64,
user_id: UserId, user_id: i64,
token_hash: String, token_hash: String,
name: String, name: String,
created_at: DateTime<Utc>, created_at: DateTime<Utc>,

View file

@ -3,8 +3,9 @@ use chrono::{DateTime, Utc};
use sqlx::query_as; use sqlx::query_as;
use crate::domain::RepositoryError; use crate::domain::RepositoryError;
use crate::domain::ids::UserId;
use crate::domain::repositories::UserRepository; use crate::domain::repositories::UserRepository;
use crate::domain::users::{User, UserId}; use crate::domain::users::{NewUser, User};
use crate::infrastructure::database::DatabasePool; use crate::infrastructure::database::DatabasePool;
#[derive(Clone)] #[derive(Clone)]
@ -25,40 +26,41 @@ impl SqlUserRepository {
created_at, created_at,
} = record; } = record;
Ok(User::new(id, username, password_hash, created_at)) Ok(User::new(
UserId::from(id),
username,
password_hash,
created_at,
))
} }
} }
#[async_trait] #[async_trait]
impl UserRepository for SqlUserRepository { impl UserRepository for SqlUserRepository {
async fn insert(&self, user: User) -> Result<User, RepositoryError> { async fn insert(&self, user: NewUser) -> Result<User, RepositoryError> {
let query = let query = "INSERT INTO users (username, password_hash) VALUES (?, ?) RETURNING id, username, password_hash, created_at";
"INSERT INTO users (id, username, password_hash, created_at) VALUES (?, ?, ?, ?)";
sqlx::query(query) let record = sqlx::query_as::<_, UserRecord>(query)
.bind(&user.id)
.bind(&user.username) .bind(&user.username)
.bind(&user.password_hash) .bind(&user.password_hash)
.bind(&user.created_at) .fetch_one(&self.pool)
.execute(&self.pool)
.await .await
.map_err(|err| { .map_err(|err| {
if let sqlx::Error::Database(db_err) = &err { if let sqlx::Error::Database(db_err) = &err
if db_err.is_unique_violation() { && db_err.is_unique_violation() {
return RepositoryError::conflict("user already exists"); return RepositoryError::conflict("user already exists");
} }
}
RepositoryError::unexpected(err.to_string()) RepositoryError::unexpected(err.to_string())
})?; })?;
Ok(user) Self::to_domain(record)
} }
async fn get(&self, id: UserId) -> Result<User, RepositoryError> { async fn get(&self, id: UserId) -> Result<User, RepositoryError> {
let query = "SELECT id, username, password_hash, created_at FROM users WHERE id = ?"; let query = "SELECT id, username, password_hash, created_at FROM users WHERE id = ?";
let record = query_as::<_, UserRecord>(query) let record = query_as::<_, UserRecord>(query)
.bind(&id) .bind(i64::from(id))
.fetch_optional(&self.pool) .fetch_optional(&self.pool)
.await .await
.map_err(|err| RepositoryError::unexpected(err.to_string()))? .map_err(|err| RepositoryError::unexpected(err.to_string()))?
@ -94,7 +96,7 @@ impl UserRepository for SqlUserRepository {
#[derive(sqlx::FromRow)] #[derive(sqlx::FromRow)]
struct UserRecord { struct UserRecord {
id: UserId, id: i64,
username: String, username: String,
password_hash: String, password_hash: String,
created_at: DateTime<Utc>, created_at: DateTime<Utc>,

View file

@ -24,11 +24,11 @@ impl<T> Paginated<T> {
} }
} }
pub fn from_page<U, MapFn>(page: Page<U>, mut map_item: MapFn) -> Self pub fn from_page<U, MapFn>(page: Page<U>, map_item: MapFn) -> Self
where where
MapFn: FnMut(U) -> T, 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( Self::new(
items, items,
@ -40,13 +40,11 @@ impl<T> Paginated<T> {
} }
pub fn total_pages(&self) -> u32 { pub fn total_pages(&self) -> u32 {
if self.total == 0 { if self.total == 0 || self.showing_all {
1
} else if self.showing_all {
1 1
} else { } else {
let page_size = self.page_size as u64; 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<Roaster> for RoasterOptionView { impl From<Roaster> for RoasterOptionView {
fn from(roaster: Roaster) -> Self { fn from(roaster: Roaster) -> Self {
Self { Self {
id: roaster.id, id: roaster.id.to_string(),
name: roaster.name, name: roaster.name,
} }
} }
@ -269,7 +267,7 @@ impl From<Roaster> for RoasterOptionView {
impl From<&Roaster> for RoasterOptionView { impl From<&Roaster> for RoasterOptionView {
fn from(roaster: &Roaster) -> Self { fn from(roaster: &Roaster) -> Self {
Self { Self {
id: roaster.id.clone(), id: roaster.id.to_string(),
name: roaster.name.clone(), name: roaster.name.clone(),
} }
} }
@ -310,7 +308,7 @@ impl From<Roaster> for RoasterView {
Self { Self {
detail_path, detail_path,
id, id: id.to_string(),
name, name,
country, country,
city: city.unwrap_or_else(|| "".to_string()), city: city.unwrap_or_else(|| "".to_string()),
@ -354,7 +352,7 @@ impl RoastView {
fn from_parts(roast: Roast, roaster_name: &str) -> Self { fn from_parts(roast: Roast, roaster_name: &str) -> Self {
let Roast { let Roast {
id: full_id, id: roast_id,
roaster_id: _, roaster_id: _,
name, name,
origin, origin,
@ -365,6 +363,7 @@ impl RoastView {
created_at, created_at,
} = roast; } = roast;
let full_id = roast_id.to_string();
let id: String = full_id.chars().take(6).collect(); let id: String = full_id.chars().take(6).collect();
let roaster_label = if roaster_name.trim().is_empty() { let roaster_label = if roaster_name.trim().is_empty() {
"Unknown roaster".to_string() "Unknown roaster".to_string()
@ -379,7 +378,7 @@ impl RoastView {
let tasting_notes = tasting_notes let tasting_notes = tasting_notes
.into_iter() .into_iter()
.flat_map(|note| { .flat_map(|note| {
note.split(|ch| ch == ',' || ch == '\n') note.split([',', '\n'])
.map(|segment| segment.trim().to_string()) .map(|segment| segment.trim().to_string())
.filter(|segment| !segment.is_empty()) .filter(|segment| !segment.is_empty())
.collect::<Vec<_>>() .collect::<Vec<_>>()
@ -479,7 +478,7 @@ impl TimelineEventView {
let notes = tasting_notes let notes = tasting_notes
.into_iter() .into_iter()
.flat_map(|note| { .flat_map(|note| {
note.split(|ch| ch == ',' || ch == '\n') note.split([',', '\n'])
.map(|segment| segment.trim().to_string()) .map(|segment| segment.trim().to_string())
.filter(|segment| !segment.is_empty()) .filter(|segment| !segment.is_empty())
.collect::<Vec<_>>() .collect::<Vec<_>>()
@ -491,7 +490,7 @@ impl TimelineEventView {
}; };
Self { Self {
id, id: id.to_string(),
kind_label, kind_label,
badge_class: "bg-amber-200 text-amber-800", badge_class: "bg-amber-200 text-amber-800",
accent_class: "bg-amber-600", accent_class: "bg-amber-600",

View file

@ -31,11 +31,10 @@ impl FromRequestParts<AppState> for AuthenticatedUser {
} }
// Try to authenticate via session cookie first // Try to authenticate via session cookie first
if let Ok(cookies) = Cookies::from_request_parts(parts, state).await { if let Ok(cookies) = Cookies::from_request_parts(parts, state).await
if let Some(user) = authenticate_via_session(state, &cookies).await { && let Some(user) = authenticate_via_session(state, &cookies).await {
return Ok(AuthenticatedUser(user)); return Ok(AuthenticatedUser(user));
} }
}
// Fall back to Bearer token authentication // Fall back to Bearer token authentication
let auth_header = parts let auth_header = parts
@ -67,7 +66,7 @@ impl FromRequestParts<AppState> for AuthenticatedUser {
// Update last used timestamp (fire and forget) // Update last used timestamp (fire and forget)
let token_repo = state.token_repo.clone(); let token_repo = state.token_repo.clone();
let token_id = token_record.id.clone(); let token_id = token_record.id;
tokio::spawn(async move { tokio::spawn(async move {
let _ = token_repo.update_last_used(token_id).await; 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) // Update last used timestamp (fire and forget - don't block on this)
let token_repo = state.token_repo.clone(); let token_repo = state.token_repo.clone();
let token_id = token_record.id.clone(); let token_id = token_record.id;
tokio::spawn(async move { tokio::spawn(async move {
let _ = token_repo.update_last_used(token_id).await; let _ = token_repo.update_last_used(token_id).await;
}); });

View file

@ -1,15 +1,14 @@
use askama::Template; use askama::Template;
use axum::Form;
use axum::extract::State; use axum::extract::State;
use axum::http::StatusCode; use axum::http::StatusCode;
use axum::response::{IntoResponse, Redirect, Response}; use axum::response::{IntoResponse, Redirect, Response};
use axum::Form;
use chrono::{Duration, Utc}; use chrono::{Duration, Utc};
use serde::Deserialize; use serde::Deserialize;
use tower_cookies::{Cookie, Cookies}; use tower_cookies::{Cookie, Cookies};
use tracing::{error, warn}; use tracing::{error, warn};
use crate::domain::ids::generate_id; use crate::domain::sessions::NewSession;
use crate::domain::sessions::Session;
use crate::infrastructure::auth::{generate_session_token, hash_token, verify_password}; use crate::infrastructure::auth::{generate_session_token, hash_token, verify_password};
use crate::server::routes::render_html; use crate::server::routes::render_html;
use crate::server::server::AppState; use crate::server::server::AppState;
@ -75,15 +74,14 @@ pub(crate) async fn login_submit(
let session_token_hash = hash_token(&session_token); let session_token_hash = hash_token(&session_token);
// Create session in database (valid for 30 days) // Create session in database (valid for 30 days)
let session = Session::new( let new_session = NewSession::new(
generate_id(), user.id,
user.id.clone(),
session_token_hash, session_token_hash,
Utc::now(), Utc::now(),
Utc::now() + Duration::days(30), 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"); error!(error = %err, "failed to create session");
return Err(StatusCode::INTERNAL_SERVER_ERROR); return Err(StatusCode::INTERNAL_SERVER_ERROR);
} }
@ -112,7 +110,11 @@ pub(crate) async fn logout(State(state): State<AppState>, cookies: Cookies) -> R
let session_token_hash = hash_token(session_token); let session_token_hash = hash_token(session_token);
// Try to find and delete the session // 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; 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); let session_token_hash = hash_token(session_token);
// Check if session exists and is valid // 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(), Ok(session) => !session.is_expired(),
Err(_) => false, Err(_) => false,
} }

View file

@ -3,6 +3,7 @@ use axum::extract::{Path, Query, State};
use axum::http::{HeaderMap, StatusCode}; use axum::http::{HeaderMap, StatusCode};
use axum::response::{Html, IntoResponse, Redirect, Response}; use axum::response::{Html, IntoResponse, Redirect, Response};
use crate::domain::ids::RoasterId;
use crate::domain::listing::{ListRequest, SortDirection}; use crate::domain::listing::{ListRequest, SortDirection};
use crate::domain::roasters::{NewRoaster, Roaster, RoasterSortKey, UpdateRoaster}; use crate::domain::roasters::{NewRoaster, Roaster, RoasterSortKey, UpdateRoaster};
use crate::presentation::templates::{ use crate::presentation::templates::{
@ -49,12 +50,12 @@ pub(crate) async fn roasters_page(
if is_datastar_request(&headers) { if is_datastar_request(&headers) {
return render_roaster_list_fragment(state, request) return render_roaster_list_fragment(state, request)
.await .await
.map_err(|err| map_app_error(err)); .map_err(map_app_error);
} }
let (roasters, navigator) = load_roaster_page(&state, request) let (roasters, navigator) = load_roaster_page(&state, request)
.await .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; 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( pub(crate) async fn roaster_page(
State(state): State<AppState>, State(state): State<AppState>,
cookies: tower_cookies::Cookies, cookies: tower_cookies::Cookies,
Path(id): Path<String>, Path(id): Path<RoasterId>,
) -> Result<Html<String>, StatusCode> { ) -> Result<Html<String>, StatusCode> {
let roaster = state let roaster = state
.roaster_repo .roaster_repo
.get(id.clone()) .get(id)
.await .await
.map_err(|err| map_app_error(AppError::from(err)))?; .map_err(|err| map_app_error(AppError::from(err)))?;
let roasts = state let roasts = state
@ -117,10 +118,10 @@ pub(crate) async fn create_roaster(
) -> Result<Response, ApiError> { ) -> Result<Response, ApiError> {
let request = query.into_request::<RoasterSortKey>(); let request = query.into_request::<RoasterSortKey>();
let (new_roaster, source) = payload.into_parts(); let (new_roaster, source) = payload.into_parts();
let roaster = new_roaster.normalize().into_roaster(); let new_roaster = new_roaster.normalize();
let roaster = state let roaster = state
.roaster_repo .roaster_repo
.insert(roaster) .insert(new_roaster)
.await .await
.map_err(AppError::from)?; .map_err(AppError::from)?;
@ -139,7 +140,7 @@ pub(crate) async fn create_roaster(
pub(crate) async fn get_roaster( pub(crate) async fn get_roaster(
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<String>, Path(id): Path<RoasterId>,
) -> Result<Json<Roaster>, ApiError> { ) -> Result<Json<Roaster>, ApiError> {
let roaster = state.roaster_repo.get(id).await.map_err(AppError::from)?; let roaster = state.roaster_repo.get(id).await.map_err(AppError::from)?;
Ok(Json(roaster)) Ok(Json(roaster))
@ -148,7 +149,7 @@ pub(crate) async fn get_roaster(
pub(crate) async fn update_roaster( pub(crate) async fn update_roaster(
State(state): State<AppState>, State(state): State<AppState>,
_auth_user: AuthenticatedUser, _auth_user: AuthenticatedUser,
Path(id): Path<String>, Path(id): Path<RoasterId>,
Json(payload): Json<UpdateRoaster>, Json(payload): Json<UpdateRoaster>,
) -> Result<Json<Roaster>, ApiError> { ) -> Result<Json<Roaster>, ApiError> {
let has_changes = payload.name.is_some() let has_changes = payload.name.is_some()
@ -173,7 +174,7 @@ pub(crate) async fn delete_roaster(
State(state): State<AppState>, State(state): State<AppState>,
_auth_user: AuthenticatedUser, _auth_user: AuthenticatedUser,
headers: HeaderMap, headers: HeaderMap,
Path(id): Path<String>, Path(id): Path<RoasterId>,
Query(query): Query<ListQuery>, Query(query): Query<ListQuery>,
) -> Result<Response, ApiError> { ) -> Result<Response, ApiError> {
let request = query.into_request::<RoasterSortKey>(); let request = query.into_request::<RoasterSortKey>();

View file

@ -4,6 +4,7 @@ use axum::http::{HeaderMap, StatusCode};
use axum::response::{Html, IntoResponse, Redirect, Response}; use axum::response::{Html, IntoResponse, Redirect, Response};
use serde::Deserialize; use serde::Deserialize;
use crate::domain::ids::{RoastId, RoasterId};
use crate::domain::listing::{ListRequest, SortDirection}; use crate::domain::listing::{ListRequest, SortDirection};
use crate::domain::roasters::RoasterSortKey; use crate::domain::roasters::RoasterSortKey;
use crate::domain::roasts::{NewRoast, Roast, RoastSortKey, RoastWithRoaster}; use crate::domain::roasts::{NewRoast, Roast, RoastSortKey, RoastWithRoaster};
@ -48,7 +49,7 @@ pub(crate) async fn roasts_page(
if is_datastar_request(&headers) { if is_datastar_request(&headers) {
return render_roast_list_fragment(state, request) return render_roast_list_fragment(state, request)
.await .await
.map_err(|err| map_app_error(err)); .map_err(map_app_error);
} }
let roasters = state let roasters = state
@ -61,7 +62,7 @@ pub(crate) async fn roasts_page(
let (roasts, navigator) = load_roast_page(&state, request) let (roasts, navigator) = load_roast_page(&state, request)
.await .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; 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( pub(crate) async fn roast_page(
State(state): State<AppState>, State(state): State<AppState>,
cookies: tower_cookies::Cookies, cookies: tower_cookies::Cookies,
Path(id): Path<String>, Path(id): Path<RoastId>,
) -> Result<Html<String>, StatusCode> { ) -> Result<Html<String>, StatusCode> {
let roast = state let roast = state
.roast_repo .roast_repo
.get(id.clone()) .get(id)
.await .await
.map_err(|err| map_app_error(AppError::from(err)))?; .map_err(|err| map_app_error(AppError::from(err)))?;
let roaster = state let roaster = state
.roaster_repo .roaster_repo
.get(roast.roaster_id.clone()) .get(roast.roaster_id)
.await .await
.map_err(|err| map_app_error(AppError::from(err)))?; .map_err(|err| map_app_error(AppError::from(err)))?;
@ -116,14 +117,13 @@ pub(crate) async fn create_roast(
state state
.roaster_repo .roaster_repo
.get(new_roast.roaster_id.clone()) .get(new_roast.roaster_id)
.await .await
.map_err(|err| ApiError::from(AppError::from(err)))?; .map_err(|err| ApiError::from(AppError::from(err)))?;
let roast = new_roast.into_roast();
let roast = state let roast = state
.roast_repo .roast_repo
.insert(roast) .insert(new_roast)
.await .await
.map_err(AppError::from)?; .map_err(AppError::from)?;
@ -156,7 +156,7 @@ pub(crate) async fn list_roasts(
pub(crate) async fn get_roast( pub(crate) async fn get_roast(
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<String>, Path(id): Path<RoastId>,
) -> Result<Json<Roast>, ApiError> { ) -> Result<Json<Roast>, ApiError> {
let roast = state.roast_repo.get(id).await.map_err(AppError::from)?; let roast = state.roast_repo.get(id).await.map_err(AppError::from)?;
Ok(Json(roast)) Ok(Json(roast))
@ -166,7 +166,7 @@ pub(crate) async fn delete_roast(
State(state): State<AppState>, State(state): State<AppState>,
_auth_user: AuthenticatedUser, _auth_user: AuthenticatedUser,
headers: HeaderMap, headers: HeaderMap,
Path(id): Path<String>, Path(id): Path<RoastId>,
Query(query): Query<ListQuery>, Query(query): Query<ListQuery>,
) -> Result<Response, ApiError> { ) -> Result<Response, ApiError> {
let request = query.into_request::<RoastSortKey>(); let request = query.into_request::<RoastSortKey>();
@ -183,12 +183,12 @@ pub(crate) async fn delete_roast(
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct RoastsQuery { pub struct RoastsQuery {
pub roaster_id: Option<String>, pub roaster_id: Option<RoasterId>,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub(crate) struct NewRoastSubmission { pub(crate) struct NewRoastSubmission {
roaster_id: String, roaster_id: RoasterId,
name: String, name: String,
origin: String, origin: String,
region: 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 name = require("name", self.name)?;
let origin = require("origin", self.origin)?; let origin = require("origin", self.origin)?;
let region = require("region", self.region)?; let region = require("region", self.region)?;
@ -249,7 +252,7 @@ impl TastingNotesInput {
.filter(|value| !value.is_empty()) .filter(|value| !value.is_empty())
.collect(), .collect(),
TastingNotesInput::Text(value) => value TastingNotesInput::Text(value) => value
.split(|ch| ch == ',' || ch == '\n') .split([',', '\n'])
.map(|segment| segment.trim().to_string()) .map(|segment| segment.trim().to_string())
.filter(|segment| !segment.is_empty()) .filter(|segment| !segment.is_empty())
.collect(), .collect(),

View file

@ -28,12 +28,12 @@ pub(crate) async fn timeline_page(
if is_datastar_request(&headers) { if is_datastar_request(&headers) {
return render_timeline_chunk(state, request) return render_timeline_chunk(state, request)
.await .await
.map_err(|err| map_app_error(err)); .map_err(map_app_error);
} }
let data = load_timeline_page(&state, request) let data = load_timeline_page(&state, request)
.await .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; let is_authenticated = crate::server::routes::auth::is_authenticated(&state, &cookies).await;
@ -139,12 +139,11 @@ fn build_months(prepared_events: Vec<TimelinePreparedEvent>) -> Vec<TimelineMont
let mut months: Vec<TimelineMonthView> = Vec::new(); let mut months: Vec<TimelineMonthView> = Vec::new();
for prepared in prepared_events { for prepared in prepared_events {
if let Some(last) = months.last_mut() { if let Some(last) = months.last_mut()
if last.anchor == prepared.anchor { && last.anchor == prepared.anchor {
last.events.push(prepared.view); last.events.push(prepared.view);
continue; continue;
} }
}
months.push(TimelineMonthView { months.push(TimelineMonthView {
anchor: prepared.anchor, anchor: prepared.anchor,

View file

@ -4,8 +4,8 @@ use axum::http::StatusCode;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::domain::ids::generate_id; use crate::domain::ids::{TokenId, UserId};
use crate::domain::tokens::Token; use crate::domain::tokens::{NewToken, Token};
use crate::infrastructure::auth::{generate_token, hash_token, verify_password}; use crate::infrastructure::auth::{generate_token, hash_token, verify_password};
use crate::server::auth::AuthenticatedUser; use crate::server::auth::AuthenticatedUser;
use crate::server::server::AppState; use crate::server::server::AppState;
@ -19,15 +19,15 @@ pub struct CreateTokenRequest {
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
pub struct CreateTokenResponse { pub struct CreateTokenResponse {
pub id: String, pub id: TokenId,
pub name: String, pub name: String,
pub token: String, pub token: String,
} }
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
pub struct TokenResponse { pub struct TokenResponse {
pub id: String, pub id: TokenId,
pub user_id: String, pub user_id: UserId,
pub name: String, pub name: String,
pub created_at: DateTime<Utc>, pub created_at: DateTime<Utc>,
pub last_used_at: Option<DateTime<Utc>>, pub last_used_at: Option<DateTime<Utc>>,
@ -70,18 +70,12 @@ pub async fn create_token(
let token_hash = hash_token(&token_value); let token_hash = hash_token(&token_value);
let token = Token::new( let new_token = NewToken::new(user.id, token_hash, payload.name.clone());
generate_id(),
user.id.clone(),
token_hash,
payload.name.clone(),
Utc::now(),
);
// Store token // Store token
let stored_token = state let stored_token = state
.token_repo .token_repo
.insert(token) .insert(new_token)
.await .await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
@ -110,12 +104,12 @@ pub async fn list_tokens(
pub async fn revoke_token( pub async fn revoke_token(
State(state): State<AppState>, State(state): State<AppState>,
auth_user: AuthenticatedUser, auth_user: AuthenticatedUser,
Path(token_id): Path<String>, Path(token_id): Path<TokenId>,
) -> Result<Json<TokenResponse>, StatusCode> { ) -> Result<Json<TokenResponse>, StatusCode> {
// Get the token to ensure it exists and belongs to the user // Get the token to ensure it exists and belongs to the user
let token = state let token = state
.token_repo .token_repo
.get(token_id.clone()) .get(token_id)
.await .await
.map_err(|_| StatusCode::NOT_FOUND)?; .map_err(|_| StatusCode::NOT_FOUND)?;

View file

@ -3,17 +3,15 @@ use std::sync::Arc;
use anyhow::Context; use anyhow::Context;
use axum::Router; use axum::Router;
use chrono::Utc;
use tokio::net::TcpListener; use tokio::net::TcpListener;
use tokio::signal; use tokio::signal;
use tracing::info; use tracing::info;
use crate::domain::ids::generate_id;
use crate::domain::repositories::{ use crate::domain::repositories::{
RoastRepository, RoasterRepository, SessionRepository, TimelineEventRepository, RoastRepository, RoasterRepository, SessionRepository, TimelineEventRepository,
TokenRepository, UserRepository, TokenRepository, UserRepository,
}; };
use crate::domain::users::User; use crate::domain::users::NewUser;
use crate::infrastructure::auth::hash_password; use crate::infrastructure::auth::hash_password;
use crate::infrastructure::database::Database; use crate::infrastructure::database::Database;
use crate::infrastructure::repositories::roasters::SqlRoasterRepository; 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 password_hash = hash_password(&password).context("failed to hash admin password")?;
let admin_user = User::new( let admin_user = NewUser::new("admin".to_string(), password_hash);
generate_id(),
"admin".to_string(),
password_hash,
Utc::now(),
);
user_repo user_repo
.insert(admin_user) .insert(admin_user)

View file

@ -37,7 +37,7 @@ fn test_add_roaster_with_authentication() {
assert_eq!(roaster["name"], "Test Roasters"); assert_eq!(roaster["name"], "Test Roasters");
assert_eq!(roaster["country"], "UK"); assert_eq!(roaster["country"], "UK");
assert!(roaster["id"].is_string(), "Should have an ID"); assert!(roaster["id"].is_i64(), "Should have an ID");
} }
#[test] #[test]
@ -81,7 +81,9 @@ fn test_list_roasters_shows_added_roaster() {
let stdout = String::from_utf8_lossy(&add_output.stdout); let stdout = String::from_utf8_lossy(&add_output.stdout);
let added_roaster: Value = serde_json::from_str(&stdout).expect("Should output valid JSON"); 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 // List roasters
let list_output = run_brewlog(&["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(); let roasters_array = roasters.as_array().unwrap();
// Find our roaster in the list // 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"); assert!(found, "Should find the added roaster in the list");
} }

View file

@ -44,14 +44,17 @@ fn test_add_roast_with_authentication() {
let roaster_stdout = String::from_utf8_lossy(&roaster_output.stdout); 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: 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 // Now add a roast
let output = run_brewlog( let output = run_brewlog(
&[ &[
"add-roast", "add-roast",
"--roaster-id", "--roaster-id",
roaster_id, &roaster_id_arg,
"--name", "--name",
"Ethiopian Yirgacheffe", "Ethiopian Yirgacheffe",
"--origin", "--origin",
@ -78,8 +81,8 @@ fn test_add_roast_with_authentication() {
let roast: Value = serde_json::from_str(&stdout).expect("Should output valid JSON"); let roast: Value = serde_json::from_str(&stdout).expect("Should output valid JSON");
assert_eq!(roast["name"], "Ethiopian Yirgacheffe"); assert_eq!(roast["name"], "Ethiopian Yirgacheffe");
assert_eq!(roast["roaster_id"], roaster_id); assert_eq!(roast["roaster_id"].as_i64(), Some(roaster_id));
assert!(roast["id"].is_string(), "Should have an ID"); assert!(roast["id"].is_i64(), "Should have an ID");
} }
#[test] #[test]
@ -111,14 +114,17 @@ fn test_list_roasts_shows_added_roast() {
let roaster_stdout = String::from_utf8_lossy(&roaster_output.stdout); let roaster_stdout = String::from_utf8_lossy(&roaster_output.stdout);
let roaster: Value = serde_json::from_str(&roaster_stdout).unwrap(); 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 // Add a roast
let add_output = run_brewlog( let add_output = run_brewlog(
&[ &[
"add-roast", "add-roast",
"--roaster-id", "--roaster-id",
roaster_id, &roaster_id_arg,
"--name", "--name",
"Colombian Supremo", "Colombian Supremo",
"--origin", "--origin",
@ -139,7 +145,9 @@ fn test_list_roasts_shows_added_roast() {
let stdout = String::from_utf8_lossy(&add_output.stdout); let stdout = String::from_utf8_lossy(&add_output.stdout);
let added_roast: Value = serde_json::from_str(&stdout).unwrap(); 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 // List roasts
let list_output = run_brewlog(&["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) // Find our roast in the list (note: list returns RoastWithRoaster which has nested structure)
let found = roasts_array let found = roasts_array
.iter() .iter()
.any(|item| item["roast"]["id"] == roast_id); .any(|item| item["roast"]["id"].as_i64() == Some(roast_id));
assert!( assert!(
found, found,
"Should find the added roast in the list. Looking for id={}, found {} roasts", "Should find the added roast in the list. Looking for id={}, found {} roasts",

View file

@ -37,7 +37,7 @@ fn test_list_tokens_with_authentication() {
fn test_revoke_token_requires_authentication() { fn test_revoke_token_requires_authentication() {
let _ = server_info(); let _ = server_info();
let output = run_brewlog(&["revoke-token", "--id", "some-id"], &[]); let output = run_brewlog(&["revoke-token", "--id", "1"], &[]);
assert!( assert!(
!output.status.success(), !output.status.success(),
@ -60,10 +60,10 @@ fn test_revoke_token_with_authentication() {
// Find a token to revoke // Find a token to revoke
let tokens_array = tokens.as_array().expect("Should be an array"); let tokens_array = tokens.as_array().expect("Should be an array");
if let Some(first_token) = tokens_array.first() { 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( let revoke_output = run_brewlog(
&["revoke-token", "--id", token_id], &["revoke-token", "--id", &token_id.to_string()],
&[("BREWLOG_TOKEN", &token)], &[("BREWLOG_TOKEN", &token)],
); );
@ -98,12 +98,12 @@ fn test_revoked_token_cannot_be_used() {
.expect("Should find token to revoke"); .expect("Should find token to revoke");
let token_id = token_to_revoke_entry["id"] let token_id = token_to_revoke_entry["id"]
.as_str() .as_i64()
.expect("Token should have ID"); .expect("Token should have ID");
// Revoke the token // Revoke the token
let revoke_output = run_brewlog( let revoke_output = run_brewlog(
&["revoke-token", "--id", token_id], &["revoke-token", "--id", &token_id.to_string()],
&[("BREWLOG_TOKEN", &admin_token)], &[("BREWLOG_TOKEN", &admin_token)],
); );
assert!( assert!(

View file

@ -133,7 +133,7 @@ async fn test_revoke_token() {
.await .await
.expect("Failed to parse response"); .expect("Failed to parse response");
let token = create_body.get("token").unwrap().as_str().unwrap(); 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 // Revoke the token
let response = client let response = client
@ -171,7 +171,7 @@ async fn test_revoked_token_cannot_be_used() {
.await .await
.expect("Failed to parse response"); .expect("Failed to parse response");
let token = create_body.get("token").unwrap().as_str().unwrap(); 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 // Revoke the token
client client

View file

@ -5,7 +5,7 @@ use brewlog::domain::repositories::{
TokenRepository, UserRepository, TokenRepository, UserRepository,
}; };
use brewlog::domain::roasters::{NewRoaster, Roaster}; 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::auth::hash_password;
use brewlog::infrastructure::database::Database; use brewlog::infrastructure::database::Database;
use brewlog::infrastructure::repositories::roasters::SqlRoasterRepository; use brewlog::infrastructure::repositories::roasters::SqlRoasterRepository;
@ -16,7 +16,6 @@ use brewlog::infrastructure::repositories::tokens::SqlTokenRepository;
use brewlog::infrastructure::repositories::users::SqlUserRepository; use brewlog::infrastructure::repositories::users::SqlUserRepository;
use brewlog::server::routes::app_router; use brewlog::server::routes::app_router;
use brewlog::server::server::AppState; use brewlog::server::server::AppState;
use chrono::Utc;
use reqwest::Client; use reqwest::Client;
use tokio::net::TcpListener; use tokio::net::TcpListener;
@ -106,14 +105,10 @@ pub async fn spawn_app_with_auth() -> TestApp {
// Create admin user with known password // Create admin user with known password
let password_hash = hash_password("test_password").expect("Failed to hash password"); let password_hash = hash_password("test_password").expect("Failed to hash password");
let admin_user = User::new( let admin_user = NewUser::new("admin".to_string(), password_hash);
"test_admin_id".to_string(),
"admin".to_string(),
password_hash,
Utc::now(),
);
app.user_repo let admin_user = app
.user_repo
.as_ref() .as_ref()
.unwrap() .unwrap()
.insert(admin_user) .insert(admin_user)
@ -121,18 +116,12 @@ pub async fn spawn_app_with_auth() -> TestApp {
.expect("Failed to create admin user"); .expect("Failed to create admin user");
// Create a token for testing // Create a token for testing
use brewlog::domain::tokens::Token; use brewlog::domain::tokens::NewToken;
use brewlog::infrastructure::auth::{generate_token, hash_token}; use brewlog::infrastructure::auth::{generate_token, hash_token};
let token_value = generate_token().expect("Failed to generate token"); let token_value = generate_token().expect("Failed to generate token");
let token_hash = hash_token(&token_value); let token_hash = hash_token(&token_value);
let token = Token::new( let token = NewToken::new(admin_user.id, token_hash, "test-token".to_string());
"test_token_id".to_string(),
"test_admin_id".to_string(),
token_hash,
"test-token".to_string(),
Utc::now(),
);
app.token_repo app.token_repo
.as_ref() .as_ref()

View file

@ -122,7 +122,7 @@ async fn getting_a_nonexistent_roaster_returns_a_404() {
// Act // Act
let response = client let response = client
.get(app.api_url("/roasters/nonexistent-id")) .get(app.api_url("/roasters/999999"))
.send() .send()
.await .await
.expect("Failed to execute request"); .expect("Failed to execute request");
@ -325,7 +325,7 @@ async fn updating_a_nonexistent_roaster_returns_a_404() {
// Act // Act
let response = client let response = client
.put(app.api_url("/roasters/nonexistent-id")) .put(app.api_url("/roasters/999999"))
.bearer_auth(app.auth_token.as_ref().unwrap()) .bearer_auth(app.auth_token.as_ref().unwrap())
.json(&update) .json(&update)
.send() .send()
@ -392,7 +392,7 @@ async fn deleting_a_nonexistent_roaster_returns_a_404() {
// Act // Act
let response = client let response = client
.delete(app.api_url("/roasters/nonexistent-id")) .delete(app.api_url("/roasters/999999"))
.bearer_auth(app.auth_token.as_ref().unwrap()) .bearer_auth(app.auth_token.as_ref().unwrap())
.send() .send()
.await .await

View file

@ -1,4 +1,5 @@
use crate::helpers::{create_default_roaster, create_roaster_with_name, spawn_app_with_auth}; 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}; use brewlog::domain::roasts::{NewRoast, Roast, RoastWithRoaster};
#[tokio::test] #[tokio::test]
@ -9,7 +10,7 @@ async fn creating_a_roast_returns_a_201_for_valid_data() {
let client = reqwest::Client::new(); let client = reqwest::Client::new();
let new_roast = NewRoast { let new_roast = NewRoast {
roaster_id: roaster_id.clone(), roaster_id: roaster_id,
name: "Ethiopian Yirgacheffe".to_string(), name: "Ethiopian Yirgacheffe".to_string(),
origin: "Ethiopia".to_string(), origin: "Ethiopia".to_string(),
region: "Yirgacheffe".to_string(), region: "Yirgacheffe".to_string(),
@ -52,7 +53,7 @@ async fn creating_a_roast_persists_the_data() {
let client = reqwest::Client::new(); let client = reqwest::Client::new();
let new_roast = NewRoast { let new_roast = NewRoast {
roaster_id: roaster_id.clone(), roaster_id: roaster_id,
name: "Colombian Supremo".to_string(), name: "Colombian Supremo".to_string(),
origin: "Colombia".to_string(), origin: "Colombia".to_string(),
region: "Huila".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 client = reqwest::Client::new();
let new_roast = NewRoast { let new_roast = NewRoast {
roaster_id: "nonexistent-roaster-id".to_string(), roaster_id: RoasterId::new(999999),
name: "Orphaned Roast".to_string(), name: "Orphaned Roast".to_string(),
origin: "Unknown".to_string(), origin: "Unknown".to_string(),
region: "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 client = reqwest::Client::new();
let new_roast = NewRoast { let new_roast = NewRoast {
roaster_id: roaster_id.clone(), roaster_id: roaster_id,
name: "Kenyan AA".to_string(), name: "Kenyan AA".to_string(),
origin: "Kenya".to_string(), origin: "Kenya".to_string(),
region: "Nyeri".to_string(), region: "Nyeri".to_string(),
@ -166,7 +167,7 @@ async fn getting_a_nonexistent_roast_returns_a_404() {
// Act // Act
let response = client let response = client
.get(app.api_url("/roasts/nonexistent-id")) .get(app.api_url("/roasts/999999"))
.send() .send()
.await .await
.expect("Failed to execute request"); .expect("Failed to execute request");
@ -204,7 +205,7 @@ async fn listing_roasts_returns_a_200_with_multiple_roasts() {
// Create multiple roasts // Create multiple roasts
let roast1 = NewRoast { let roast1 = NewRoast {
roaster_id: roaster_id.clone(), roaster_id: roaster_id,
name: "First Roast".to_string(), name: "First Roast".to_string(),
origin: "Brazil".to_string(), origin: "Brazil".to_string(),
region: "Santos".to_string(), region: "Santos".to_string(),
@ -214,7 +215,7 @@ async fn listing_roasts_returns_a_200_with_multiple_roasts() {
}; };
let roast2 = NewRoast { let roast2 = NewRoast {
roaster_id: roaster_id.clone(), roaster_id: roaster_id,
name: "Second Roast".to_string(), name: "Second Roast".to_string(),
origin: "Guatemala".to_string(), origin: "Guatemala".to_string(),
region: "Antigua".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 // Create roasts for both roasters
let roast1 = NewRoast { let roast1 = NewRoast {
roaster_id: roaster1_id.clone(), roaster_id: roaster1_id,
name: "Roaster 1 Roast".to_string(), name: "Roaster 1 Roast".to_string(),
origin: "Brazil".to_string(), origin: "Brazil".to_string(),
region: "Santos".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 { let roast2 = NewRoast {
roaster_id: roaster2_id.clone(), roaster_id: roaster2_id,
name: "Roaster 2 Roast".to_string(), name: "Roaster 2 Roast".to_string(),
origin: "Guatemala".to_string(), origin: "Guatemala".to_string(),
region: "Antigua".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 client = reqwest::Client::new();
let new_roast = NewRoast { let new_roast = NewRoast {
roaster_id: roaster_id.clone(), roaster_id: roaster_id,
name: "Temporary Roast".to_string(), name: "Temporary Roast".to_string(),
origin: "Peru".to_string(), origin: "Peru".to_string(),
region: "Cusco".to_string(), region: "Cusco".to_string(),
@ -373,7 +374,7 @@ async fn deleting_a_nonexistent_roast_returns_a_404() {
// Act // Act
let response = client let response = client
.delete(app.api_url("/roasts/nonexistent-id")) .delete(app.api_url("/roasts/999999"))
.bearer_auth(app.auth_token.as_ref().unwrap()) .bearer_auth(app.auth_token.as_ref().unwrap())
.send() .send()
.await .await
@ -397,7 +398,7 @@ async fn creating_a_roast_with_empty_name_returns_a_400() {
.header("content-type", "application/json") .header("content-type", "application/json")
.body(format!( .body(format!(
r#"{{ r#"{{
"roaster_id": "{}", "roaster_id": {},
"name": " ", "name": " ",
"origin": "Ethiopia", "origin": "Ethiopia",
"region": "Yirgacheffe", "region": "Yirgacheffe",
@ -405,7 +406,7 @@ async fn creating_a_roast_with_empty_name_returns_a_400() {
"tasting_notes": "Blueberry", "tasting_notes": "Blueberry",
"process": "Washed" "process": "Washed"
}}"#, }}"#,
roaster_id i64::from(roaster_id)
)) ))
.send() .send()
.await .await
@ -429,14 +430,14 @@ async fn creating_a_roast_with_missing_required_fields_returns_a_400() {
.header("content-type", "application/json") .header("content-type", "application/json")
.body(format!( .body(format!(
r#"{{ r#"{{
"roaster_id": "{}", "roaster_id": {},
"name": "Test Roast", "name": "Test Roast",
"region": "Yirgacheffe", "region": "Yirgacheffe",
"producer": "Co-op", "producer": "Co-op",
"tasting_notes": "Blueberry", "tasting_notes": "Blueberry",
"process": "Washed" "process": "Washed"
}}"#, }}"#,
roaster_id i64::from(roaster_id)
)) ))
.send() .send()
.await .await
@ -460,7 +461,7 @@ async fn creating_a_roast_with_empty_tasting_notes_returns_a_400() {
.header("content-type", "application/json") .header("content-type", "application/json")
.body(format!( .body(format!(
r#"{{ r#"{{
"roaster_id": "{}", "roaster_id": {},
"name": "Test Roast", "name": "Test Roast",
"origin": "Ethiopia", "origin": "Ethiopia",
"region": "Yirgacheffe", "region": "Yirgacheffe",
@ -468,7 +469,7 @@ async fn creating_a_roast_with_empty_tasting_notes_returns_a_400() {
"tasting_notes": "", "tasting_notes": "",
"process": "Washed" "process": "Washed"
}}"#, }}"#,
roaster_id i64::from(roaster_id)
)) ))
.send() .send()
.await .await

View file

@ -1,13 +1,14 @@
use crate::helpers::{create_roaster_with_payload, spawn_app_with_auth}; use crate::helpers::{create_roaster_with_payload, spawn_app_with_auth};
use brewlog::domain::ids::RoasterId;
use brewlog::domain::roasters::NewRoaster; use brewlog::domain::roasters::NewRoaster;
use brewlog::domain::roasts::NewRoast; use brewlog::domain::roasts::NewRoast;
use reqwest::Client; use reqwest::Client;
use tokio::time::{Duration, sleep}; 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 client = Client::new();
let roast = NewRoast { let roast = NewRoast {
roaster_id: roaster_id.to_string(), roaster_id,
name: name.to_string(), name: name.to_string(),
origin: "Ethiopia".to_string(), origin: "Ethiopia".to_string(),
region: "Yirgacheffe".to_string(), region: "Yirgacheffe".to_string(),
@ -50,7 +51,7 @@ async fn seed_timeline_with_roasts(
let mut roast_names = Vec::new(); let mut roast_names = Vec::new();
for index in 0..roast_count { for index in 0..roast_count {
let roast_name = format!("Seed Roast {index:02}"); 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); roast_names.push(roast_name);
// Space out timestamps to keep ordering deterministic. // Space out timestamps to keep ordering deterministic.
sleep(Duration::from_millis(2)).await; sleep(Duration::from_millis(2)).await;
@ -96,7 +97,7 @@ async fn creating_a_roaster_surfaces_on_the_timeline() {
}, },
) )
.await; .await;
let roaster_id = roaster.id.clone(); let roaster_id = roaster.id;
sleep(Duration::from_millis(10)).await; 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; sleep(Duration::from_millis(5)).await;
let roast_name = "Timeline Natural"; let roast_name = "Timeline Natural";
create_roast(&app, &roaster_id, roast_name).await; create_roast(&app, roaster_id, roast_name).await;
let response = client let response = client
.get(format!("{}/timeline", app.address)) .get(format!("{}/timeline", app.address))