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