feat: bootstrap brewlog platform

This commit is contained in:
Jon Seager 2025-11-24 11:22:17 +00:00
commit 3241f3c961
No known key found for this signature in database
51 changed files with 8720 additions and 0 deletions

4
.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
target/
brewlog.db
TODO.md
SPEC.md

3253
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

34
Cargo.toml Normal file
View file

@ -0,0 +1,34 @@
[package]
name = "brewlog"
version = "0.1.0"
edition = "2024"
[features]
default = ["sqlite"]
sqlite = ["sqlx/sqlite"]
postgres = ["sqlx/postgres"]
[dependencies]
anyhow = "1.0"
async-trait = "0.1"
axum = { version = "0.7", features = ["macros"] }
askama = "0.12"
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"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
once_cell = "1.19"
rand = "0.8"
sqlx = { version = "0.7", default-features = false, features = [
"runtime-tokio",
"macros",
"chrono",
"any",
"migrate",
] }
thiserror = "1.0"
tokio = { version = "1.38", features = ["rt-multi-thread", "macros", "signal"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }

62
flake.lock Normal file
View file

@ -0,0 +1,62 @@
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1763283776,
"narHash": "sha256-Y7TDFPK4GlqrKrivOcsHG8xSGqQx3A6c+i7novT85Uk=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "50a96edd8d0db6cc8db57dab6bb6d6ee1f3dc49a",
"type": "github"
},
"original": {
"owner": "nixos",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs_2": {
"locked": {
"lastModified": 1744536153,
"narHash": "sha256-awS2zRgF4uTwrOKwwiJcByDzDOdo3Q1rPZbiHQg/N38=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "18dd725c29603f582cf1900e0d25f9f1063dbf11",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs",
"rust-overlay": "rust-overlay"
}
},
"rust-overlay": {
"inputs": {
"nixpkgs": "nixpkgs_2"
},
"locked": {
"lastModified": 1763433504,
"narHash": "sha256-cVid5UNpk88sPYHkLAA5aZEHOFQXSB/2L1vl18Aq7IM=",
"owner": "oxalica",
"repo": "rust-overlay",
"rev": "42ce16c6d8318a654d53f047c9400b7d902d6e61",
"type": "github"
},
"original": {
"owner": "oxalica",
"repo": "rust-overlay",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

108
flake.nix Normal file
View file

@ -0,0 +1,108 @@
{
description = "brewlog";
inputs = {
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
rust-overlay.url = "github:oxalica/rust-overlay";
};
outputs =
{
self,
nixpkgs,
rust-overlay,
}:
let
supportedSystems = [
"x86_64-linux"
"aarch64-linux"
];
forAllSystems = nixpkgs.lib.genAttrs supportedSystems;
pkgsForSystem =
system:
(import nixpkgs {
inherit system;
overlays = [ (import rust-overlay) ];
});
in
{
packages = forAllSystems (
system:
let
inherit (pkgsForSystem system)
lib
rustPlatform
pkg-config
openssl
;
cargoToml = lib.trivial.importTOML ./Cargo.toml;
version = cargoToml.package.version;
in
rec {
default = brewlog;
brewlog = rustPlatform.buildRustPackage {
pname = "brewlog";
version = version;
src = lib.cleanSource ./.;
cargoLock.lockFile = ./Cargo.lock;
nativeBuildInputs = [ pkg-config ];
buildInputs = [ openssl ];
meta = {
description = "Log your favourite roasters, roasts, brews and cafes!";
homepage = "https://github.com/jnsgruk/brewlog";
license = lib.licenses.asl20;
mainProgram = "brewlog";
platforms = lib.platforms.unix;
maintainers = with lib.maintainers; [ jnsgruk ];
};
};
}
);
devShells = forAllSystems (
system:
let
pkgs = pkgsForSystem system;
rust = pkgs.rust-bin.stable.latest.default.override {
extensions = [
"rust-src"
"clippy"
"rust-analyzer"
"rustfmt"
];
};
in
{
default = pkgs.mkShell {
name = "brewlog";
NIX_CONFIG = "experimental-features = nix-command flakes";
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;
[
cargo-watch
clang
lld
nil
nixfmt-rfc-style
sqlx-cli
sqlite
]
++ [
rust
];
};
}
);
};
}

93
migrations/0001_init.sql Normal file
View file

@ -0,0 +1,93 @@
-- migrate:up
PRAGMA foreign_keys = ON;
CREATE TABLE roasters (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
country TEXT NOT NULL,
city TEXT,
homepage TEXT,
notes TEXT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
CREATE TABLE roasts (
id TEXT PRIMARY KEY,
roaster_id TEXT NOT NULL REFERENCES roasters(id) ON DELETE CASCADE,
name TEXT NOT NULL,
origin TEXT,
region TEXT,
producer TEXT,
process TEXT,
tasting_notes TEXT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
CREATE TABLE cafes (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
latitude REAL,
longitude REAL,
website TEXT,
address TEXT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
CREATE TABLE gear (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
gear_type TEXT NOT NULL CHECK (gear_type IN ('grinder','brewer','kettle','filter_paper')),
manufacturer TEXT,
model TEXT,
notes TEXT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
CREATE TABLE brews (
id TEXT PRIMARY KEY,
roast_id TEXT NOT NULL REFERENCES roasts(id) ON DELETE RESTRICT,
method TEXT NOT NULL,
dose_grams REAL,
water_grams REAL,
brew_temperature_c REAL,
grind_setting TEXT,
brewed_at TEXT NOT NULL,
notes TEXT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
CREATE TABLE brew_gear (
brew_id TEXT NOT NULL REFERENCES brews(id) ON DELETE CASCADE,
gear_id TEXT NOT NULL REFERENCES gear(id) ON DELETE RESTRICT,
PRIMARY KEY (brew_id, gear_id)
);
CREATE TABLE cups (
id TEXT PRIMARY KEY,
cafe_id TEXT NOT NULL REFERENCES cafes(id) ON DELETE RESTRICT,
roast_id TEXT REFERENCES roasts(id) ON DELETE SET NULL,
price_cents INTEGER,
consumed_at TEXT NOT NULL,
notes TEXT,
rating INTEGER CHECK (rating BETWEEN 0 AND 255),
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
CREATE INDEX idx_roasts_roaster_id ON roasts(roaster_id);
CREATE INDEX idx_brews_roast_id ON brews(roast_id);
CREATE INDEX idx_brew_gear_gear_id ON brew_gear(gear_id);
CREATE INDEX idx_cups_cafe_id ON cups(cafe_id);
CREATE INDEX idx_cups_roast_id ON cups(roast_id);
CREATE TABLE timeline_events (
id TEXT PRIMARY KEY,
entity_type TEXT NOT NULL CHECK (entity_type IN ('roaster', 'roast')),
entity_id TEXT NOT NULL,
occurred_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
title TEXT NOT NULL,
details_json TEXT,
tasting_notes_json TEXT
);
CREATE INDEX idx_timeline_events_entity ON timeline_events(entity_type, entity_id);
CREATE INDEX idx_timeline_events_occurred_at ON timeline_events(occurred_at);

407
scripts/bootstrap-db.sh Executable file
View file

@ -0,0 +1,407 @@
#!/usr/bin/env bash
cargo build
# Tim Wendelboe (Norway)
./target/debug/brewlog add-roaster \
--name "Tim Wendelboe" \
--country "Norway" \
--city "Oslo" \
--homepage "https://timwendelboe.no" \
--notes "World-renowned Nordic micro-roastery dedicated to clarity and sustainability."
./target/debug/brewlog add-roast \
--roaster-id "$(./target/debug/brewlog list-roasters | jq -r '.[] | select(.name=="Tim Wendelboe") | .id')" \
--name "Ben Saïd Natural" \
--origin "Ethiopia" \
--region "Sidamo" \
--producer "Ben Saïd" \
--process "Natural" \
--tasting-notes "Bergamot, Apricot, Floral"
./target/debug/brewlog add-roast \
--roaster-id "$(./target/debug/brewlog list-roasters | jq -r '.[] | select(.name=="Tim Wendelboe") | .id')" \
--name "Finca Tamana Washed" \
--origin "Colombia" \
--region "El Pital, Huila" \
--producer "Elias Roa" \
--process "Washed" \
--tasting-notes "Red Apple, Vanilla, Caramel"
# Coffee Collective (Denmark)
./target/debug/brewlog add-roaster \
--name "Coffee Collective" \
--country "Denmark" \
--city "Copenhagen" \
--homepage "https://coffeecollective.dk" \
--notes "Pioneers of transparency and sustainability; multi-time Nordic roaster award winners."
./target/debug/brewlog add-roast \
--roaster-id "$(./target/debug/brewlog list-roasters | jq -r '.[] | select(.name=="Coffee Collective") | .id')" \
--name "Daterra Sweet Collection" \
--origin "Brazil" \
--region "Cerrado" \
--producer "Daterra" \
--process "Pulped Natural" \
--tasting-notes "Hazelnut, Milk Chocolate, Yellow Fruit"
./target/debug/brewlog add-roast \
--roaster-id "$(./target/debug/brewlog list-roasters | jq -r '.[] | select(.name=="Coffee Collective") | .id')" \
--name "Kieni" \
--origin "Kenya" \
--region "Nyeri" \
--producer "Kieni Factory" \
--process "Washed" \
--tasting-notes "Currant, Black Tea, Grape"
# Drop Coffee (Sweden)
./target/debug/brewlog add-roaster \
--name "Drop Coffee" \
--country "Sweden" \
--city "Stockholm" \
--homepage "https://dropcoffee.com" \
--notes "Award-winning Swedish roastery prized for its elegance and clean Scandinavian style."
./target/debug/brewlog add-roast \
--roaster-id "$(./target/debug/brewlog list-roasters | jq -r '.[] | select(.name=="Drop Coffee") | .id')" \
--name "La Linda" \
--origin "Bolivia" \
--region "Caranavi" \
--producer "Pedro Rodriguez" \
--process "Washed" \
--tasting-notes "Red Apple, Caramel, Floral"
./target/debug/brewlog add-roast \
--roaster-id "$(./target/debug/brewlog list-roasters | jq -r '.[] | select(.name=="Drop Coffee") | .id')" \
--name "El Sunzita" \
--origin "El Salvador" \
--region "Ahuachapan" \
--producer "Jorge Raul Rivera" \
--process "Natural" \
--tasting-notes "Strawberry, Mango, Dark Chocolate"
# La Cabra (Denmark)
./target/debug/brewlog add-roaster \
--name "La Cabra" \
--country "Denmark" \
--city "Aarhus" \
--homepage "https://www.lacabra.dk" \
--notes "Scandinavian minimalist roastery known for clarity and innovative sourcing."
./target/debug/brewlog add-roast \
--roaster-id "$(./target/debug/brewlog list-roasters | jq -r '.[] | select(.name=="La Cabra") | .id')" \
--name "Halo Beriti" \
--origin "Ethiopia" \
--region "Yirgacheffe" \
--producer "Halo Beriti Cooperative" \
--process "Washed" \
--tasting-notes "Jasmine, Lemon, Stone Fruit"
./target/debug/brewlog add-roast \
--roaster-id "$(./target/debug/brewlog list-roasters | jq -r '.[] | select(.name=="La Cabra") | .id')" \
--name "Cerro Azul" \
--origin "Colombia" \
--region "Valle del Cauca" \
--producer "Granja La Esperanza" \
--process "Washed" \
--tasting-notes "Blueberry, Plum, Grapefruit"
# April Coffee (Denmark)
./target/debug/brewlog add-roaster \
--name "April Coffee" \
--country "Denmark" \
--city "Copenhagen" \
--homepage "https://aprilcoffeeroasters.com" \
--notes "Modern approach to Nordic coffee, emphasizing transparency and traceability."
./target/debug/brewlog add-roast \
--roaster-id "$(./target/debug/brewlog list-roasters | jq -r '.[] | select(.name=="April Coffee") | .id')" \
--name "El Salvador Pacamara" \
--origin "El Salvador" \
--region "Santa Ana" \
--producer "Ernesto Menendez" \
--process "Honey" \
--tasting-notes "Grapefruit, Sugar Cane, Plum"
./target/debug/brewlog add-roast \
--roaster-id "$(./target/debug/brewlog list-roasters | jq -r '.[] | select(.name=="April Coffee") | .id')" \
--name "Guji Highland" \
--origin "Ethiopia" \
--region "Guji" \
--producer "Andualem Abebe" \
--process "Natural" \
--tasting-notes "Peach, Strawberry, Cream"
# Assembly Coffee (UK)
./target/debug/brewlog add-roaster \
--name "Assembly Coffee" \
--country "UK" \
--city "London" \
--homepage "https://assemblycoffee.co.uk" \
--notes "Based in Brixton, Assembly focuses on collaborative sourcing and education."
./target/debug/brewlog add-roast \
--roaster-id "$(./target/debug/brewlog list-roasters | jq -r '.[] | select(.name=="Assembly Coffee") | .id')" \
--name "Kochere" \
--origin "Ethiopia" \
--region "Yirgacheffe" \
--producer "Kochere Region Growers" \
--process "Washed" \
--tasting-notes "Peach, Lemon, Jasmine"
./target/debug/brewlog add-roast \
--roaster-id "$(./target/debug/brewlog list-roasters | jq -r '.[] | select(.name=="Assembly Coffee") | .id')" \
--name "La Laja" \
--origin "Mexico" \
--region "Veracruz" \
--producer "La Laja Estate" \
--process "Natural" \
--tasting-notes "Cherry, Milk Chocolate, Praline"
# Square Mile (UK)
./target/debug/brewlog add-roaster \
--name "Square Mile Coffee" \
--country "UK" \
--city "London" \
--homepage "https://squaremilecoffee.com" \
--notes "One of London's pioneers; delivers balanced and clear, fruit-forward coffees."
./target/debug/brewlog add-roast \
--roaster-id "$(./target/debug/brewlog list-roasters | jq -r '.[] | select(.name=="Square Mile Coffee") | .id')" \
--name "Red Brick Espresso" \
--origin "Blend" \
--region "Multiple Origins" \
--producer "Various" \
--process "Washed, Natural" \
--tasting-notes "Berry, Chocolate, Citrus"
./target/debug/brewlog add-roast \
--roaster-id "$(./target/debug/brewlog list-roasters | jq -r '.[] | select(.name=="Square Mile Coffee") | .id')" \
--name "Kamwangi" \
--origin "Kenya" \
--region "Kirinyaga" \
--producer "Kamwangi Factory" \
--process "Washed" \
--tasting-notes "Blackcurrant, Rhubarb, Blood Orange"
# Dak Coffee Roasters (Netherlands)
./target/debug/brewlog add-roaster \
--name "Dak Coffee Roasters" \
--country "Netherlands" \
--city "Amsterdam" \
--homepage "https://www.dakcoffeeroasters.com" \
--notes "Highly experimental Dutch roastery; celebrates vibrant acidity and alternative processing."
./target/debug/brewlog add-roast \
--roaster-id "$(./target/debug/brewlog list-roasters | jq -r '.[] | select(.name=="Dak Coffee Roasters") | .id')" \
--name "El Paraiso 92 Anaerobic" \
--origin "Colombia" \
--region "Cauca" \
--producer "Diego Bermudez" \
--process "Thermal Shock Anaerobic" \
--tasting-notes "Passionfruit, Raspberry, Yogurt"
./target/debug/brewlog add-roast \
--roaster-id "$(./target/debug/brewlog list-roasters | jq -r '.[] | select(.name=="Dak Coffee Roasters") | .id')" \
--name "Oreti SL28" \
--origin "Kenya" \
--region "Kirinyaga" \
--producer "Oreti Estate" \
--process "Washed" \
--tasting-notes "Grapefruit, Blackcurrant, Plum"
# Bonanza Coffee (Germany)
./target/debug/brewlog add-roaster \
--name "Bonanza Coffee" \
--country "Germany" \
--city "Berlin" \
--homepage "https://www.bonanzacoffee.de" \
--notes "Pioneering Berlin roastery focused on brightness, balance, and freshness."
./target/debug/brewlog add-roast \
--roaster-id "$(./target/debug/brewlog list-roasters | jq -r '.[] | select(.name=="Bonanza Coffee") | .id')" \
--name "Gatomboya" \
--origin "Kenya" \
--region "Nyeri" \
--producer "Gatomboya Cooperative" \
--process "Washed" \
--tasting-notes "Blackcurrant, Lime, Tomato"
./target/debug/brewlog add-roast \
--roaster-id "$(./target/debug/brewlog list-roasters | jq -r '.[] | select(.name=="Bonanza Coffee") | .id')" \
--name "Los Pirineos" \
--origin "El Salvador" \
--region "Usulután" \
--producer "Gilberto Baraona" \
--process "Honey" \
--tasting-notes "Maple, Fudge, Green Apple"
# Friedhats (Netherlands)
./target/debug/brewlog add-roaster \
--name "Friedhats" \
--country "Netherlands" \
--city "Amsterdam" \
--homepage "https://friedhats.com" \
--notes "Quirky branding meets serious, awarded, fruit-forward coffees from Amsterdam."
./target/debug/brewlog add-roast \
--roaster-id "$(./target/debug/brewlog list-roasters | jq -r '.[] | select(.name=="Friedhats") | .id')" \
--name "Sidamo Guji" \
--origin "Ethiopia" \
--region "Guji" \
--producer "Smallholders" \
--process "Natural" \
--tasting-notes "Peach, Raspberry, Rosehip"
./target/debug/brewlog add-roast \
--roaster-id "$(./target/debug/brewlog list-roasters | jq -r '.[] | select(.name=="Friedhats") | .id')" \
--name "La Esmeralda Geisha" \
--origin "Panama" \
--region "Boquete" \
--producer "Hacienda La Esmeralda" \
--process "Washed" \
--tasting-notes "Jasmine, Bergamot, Papaya"
# Origin Coffee (UK)
./target/debug/brewlog add-roaster \
--name "Origin Coffee" \
--country "UK" \
--city "Porthleven" \
--homepage "https://origincoffee.co.uk" \
--notes "Specialty roaster with close partnerships at origin; leading UK scene with cutting-edge lots."
./target/debug/brewlog add-roast \
--roaster-id "$(./target/debug/brewlog list-roasters | jq -r '.[] | select(.name=="Origin Coffee") | .id')" \
--name "San Fermin" \
--origin "Colombia" \
--region "Tolima" \
--producer "San Fermin Smallholders" \
--process "Washed" \
--tasting-notes "Red Grape, Caramel, Blood Orange"
./target/debug/brewlog add-roast \
--roaster-id "$(./target/debug/brewlog list-roasters | jq -r '.[] | select(.name=="Origin Coffee") | .id')" \
--name "Aricha" \
--origin "Ethiopia" \
--region "Yirgacheffe" \
--producer "Aricha Washing Station" \
--process "Washed" \
--tasting-notes "Honey, Peach, Black Tea"
# Dark Arts Coffee (UK)
./target/debug/brewlog add-roaster \
--name "Dark Arts Coffee" \
--country "UK" \
--city "London" \
--homepage "https://www.darkartscoffee.co.uk" \
--notes "Playful, disruptive roaster with a cult following and flavor-forward offerings."
./target/debug/brewlog add-roast \
--roaster-id "$(./target/debug/brewlog list-roasters | jq -r '.[] | select(.name=="Dark Arts Coffee") | .id')" \
--name "Death to Decaf" \
--origin "Brazil" \
--region "Minas Gerais" \
--producer "Carmo de Minas" \
--process "Swiss Water Decaf" \
--tasting-notes "Cocoa, Cherry, Almond"
./target/debug/brewlog add-roast \
--roaster-id "$(./target/debug/brewlog list-roasters | jq -r '.[] | select(.name=="Dark Arts Coffee") | .id')" \
--name "Snoop" \
--origin "Guatemala" \
--region "Huehuetenango" \
--producer "Various Smallholders" \
--process "Washed" \
--tasting-notes "Toffee, Green Apple, Plum"
# KAWA Coffee (France)
./target/debug/brewlog add-roaster \
--name "KAWA Coffee" \
--country "France" \
--city "Paris" \
--homepage "https://www.kawa.coffee" \
--notes "One of Paris most exciting specialty roasteries, known for unusual and competition-level lots."
./target/debug/brewlog add-roast \
--roaster-id "$(./target/debug/brewlog list-roasters | jq -r '.[] | select(.name=="KAWA Coffee") | .id')" \
--name "Sudan Rume" \
--origin "Colombia" \
--region "Cauca" \
--producer "Granja La Esperanza" \
--process "Natural" \
--tasting-notes "Strawberry, Cinnamon, Grape"
./target/debug/brewlog add-roast \
--roaster-id "$(./target/debug/brewlog list-roasters | jq -r '.[] | select(.name=="KAWA Coffee") | .id')" \
--name "Arbegona" \
--origin "Ethiopia" \
--region "Sidama" \
--producer "Arbegona Washing Station" \
--process "Washed" \
--tasting-notes "Violet, Apricot, Lemon"
# Stow Coffee (Slovenia)
./target/debug/brewlog add-roaster \
--name "Stow Coffee" \
--country "Slovenia" \
--city "Ljubljana" \
--homepage "https://www.stowcoffee.com" \
--notes "Slovenias specialty leader, awarded for pure, brightly acidic profiles and innovation."
./target/debug/brewlog add-roast \
--roaster-id "$(./target/debug/brewlog list-roasters | jq -r '.[] | select(.name=="Stow Coffee") | .id')" \
--name "Santa Barbara" \
--origin "Honduras" \
--region "Santa Barbara" \
--producer "Benjamin Paz" \
--process "Honey" \
--tasting-notes "Red Currant, Honeydew, Cocoa"
./target/debug/brewlog add-roast \
--roaster-id "$(./target/debug/brewlog list-roasters | jq -r '.[] | select(.name=="Stow Coffee") | .id')" \
--name "Suke Quto" \
--origin "Ethiopia" \
--region "Guji" \
--producer "Tesfaye Bekele" \
--process "Natural" \
--tasting-notes "Blackberry, Vanilla, Jasmine"
# Bows Coffee (Canada)
./target/debug/brewlog add-roaster \
--name "Bows Coffee" \
--country "Canada" \
--city "Victoria" \
--homepage "https://bowscoffee.com" \
--notes "Canadian micro-roaster with focus on clarity, complexity, and ethical sourcing."
./target/debug/brewlog add-roast \
--roaster-id "$(./target/debug/brewlog list-roasters | jq -r '.[] | select(.name=="Bows Coffee") | .id')" \
--name "La Chumeca" \
--origin "Costa Rica" \
--region "Tarrazú" \
--producer "Doña Olga Jiménez" \
--process "White Honey" \
--tasting-notes "Mandarin, Honeycomb, Almond"
./target/debug/brewlog add-roast \
--roaster-id "$(./target/debug/brewlog list-roasters | jq -r '.[] | select(.name=="Bows Coffee") | .id')" \
--name "Simbi" \
--origin "Rwanda" \
--region "Huye" \
--producer "Simbi Co-op" \
--process "Washed" \
--tasting-notes "Black Tea, Orange, Cane Sugar"

73
src/cli/mod.rs Normal file
View file

@ -0,0 +1,73 @@
pub mod roasters;
pub mod roasts;
use std::net::SocketAddr;
use clap::{Args, Parser, Subcommand};
use roasters::{AddRoasterCommand, DeleteRoasterCommand, GetRoasterCommand, UpdateRoasterCommand};
use roasts::{AddRoastCommand, DeleteRoastCommand, GetRoastCommand, ListRoastsCommand};
#[derive(Debug, Parser)]
#[command(author, version, about = "Track coffee roasts, brews, and cups", long_about = None)]
pub struct Cli {
#[arg(
long,
global = true,
env = "BREWLOG_URL",
default_value = "http://127.0.0.1:3000"
)]
pub api_url: String,
#[command(subcommand)]
pub command: Commands,
}
#[derive(Debug, Subcommand)]
pub enum Commands {
#[command(name = "serve")]
Serve(ServeCommand),
// Roasters
#[command(name = "add-roaster")]
AddRoaster(AddRoasterCommand),
#[command(name = "list-roasters")]
ListRoasters,
#[command(name = "get-roaster")]
GetRoaster(GetRoasterCommand),
#[command(name = "update-roaster")]
UpdateRoaster(UpdateRoasterCommand),
#[command(name = "delete-roaster")]
DeleteRoaster(DeleteRoasterCommand),
// Roasts
#[command(name = "add-roast")]
AddRoast(AddRoastCommand),
#[command(name = "list-roasts")]
ListRoasts(ListRoastsCommand),
#[command(name = "get-roast")]
GetRoast(GetRoastCommand),
#[command(name = "delete-roast")]
DeleteRoast(DeleteRoastCommand),
}
#[derive(Debug, Args)]
pub struct ServeCommand {
#[arg(
long,
env = "BREWLOG_DATABASE_URL",
default_value = "sqlite://brewlog.db"
)]
pub database_url: String,
#[arg(long, env = "BREWLOG_BIND_ADDRESS", default_value = "127.0.0.1:3000")]
pub bind_address: SocketAddr,
}
pub(crate) fn print_json<T>(value: &T) -> anyhow::Result<()>
where
T: serde::Serialize,
{
let rendered = serde_json::to_string_pretty(value)?;
println!("{rendered}");
Ok(())
}

96
src/cli/roasters.rs Normal file
View file

@ -0,0 +1,96 @@
use anyhow::Result;
use clap::Args;
use serde_json::json;
use super::print_json;
use crate::client::BrewlogClient;
use crate::domain::roasters::{NewRoaster, UpdateRoaster};
#[derive(Debug, Args)]
pub struct AddRoasterCommand {
#[arg(long)]
pub name: String,
#[arg(long)]
pub country: String,
#[arg(long)]
pub city: Option<String>,
#[arg(long)]
pub homepage: Option<String>,
#[arg(long)]
pub notes: Option<String>,
}
pub async fn add_roaster(client: &BrewlogClient, command: AddRoasterCommand) -> Result<()> {
let payload = NewRoaster {
name: command.name,
country: command.country,
city: command.city,
homepage: command.homepage,
notes: command.notes,
};
let roaster = client.roasters().create(&payload).await?;
print_json(&roaster)
}
pub async fn list_roasters(client: &BrewlogClient) -> Result<()> {
let roasters = client.roasters().list().await?;
print_json(&roasters)
}
#[derive(Debug, Args)]
pub struct GetRoasterCommand {
#[arg(long)]
pub id: String,
}
pub async fn get_roaster(client: &BrewlogClient, command: GetRoasterCommand) -> Result<()> {
let roaster = client.roasters().get(&command.id).await?;
print_json(&roaster)
}
#[derive(Debug, Args)]
pub struct UpdateRoasterCommand {
#[arg(long)]
pub id: String,
#[arg(long)]
pub name: Option<String>,
#[arg(long)]
pub country: Option<String>,
#[arg(long)]
pub city: Option<String>,
#[arg(long)]
pub homepage: Option<String>,
#[arg(long)]
pub notes: Option<String>,
}
pub async fn update_roaster(client: &BrewlogClient, command: UpdateRoasterCommand) -> Result<()> {
let payload = UpdateRoaster {
name: command.name,
country: command.country,
city: command.city,
homepage: command.homepage,
notes: command.notes,
};
let roaster = client.roasters().update(&command.id, &payload).await?;
print_json(&roaster)
}
#[derive(Debug, Args)]
pub struct DeleteRoasterCommand {
#[arg(long)]
pub id: String,
}
pub async fn delete_roaster(client: &BrewlogClient, command: DeleteRoasterCommand) -> Result<()> {
let id = command.id;
client.roasters().delete(&id).await?;
let response = json!({
"status": "deleted",
"resource": "roaster",
"id": id,
});
print_json(&response)
}

79
src/cli/roasts.rs Normal file
View file

@ -0,0 +1,79 @@
use anyhow::Result;
use clap::Args;
use serde_json::json;
use super::print_json;
use crate::client::BrewlogClient;
use crate::domain::roasts::NewRoast;
#[derive(Debug, Args)]
pub struct AddRoastCommand {
#[arg(long)]
pub roaster_id: String,
#[arg(long)]
pub name: String,
#[arg(long)]
pub origin: Option<String>,
#[arg(long)]
pub region: Option<String>,
#[arg(long)]
pub producer: Option<String>,
#[arg(long)]
pub process: Option<String>,
#[arg(long = "tasting-notes")]
pub tasting_notes: Vec<String>,
}
pub async fn add_roast(client: &BrewlogClient, command: AddRoastCommand) -> Result<()> {
let payload = NewRoast {
roaster_id: command.roaster_id,
name: command.name,
origin: command.origin,
region: command.region,
producer: command.producer,
tasting_notes: command.tasting_notes,
process: command.process,
};
let roast = client.roasts().create(&payload).await?;
print_json(&roast)
}
#[derive(Debug, Args)]
pub struct ListRoastsCommand {
#[arg(long)]
pub roaster_id: Option<String>,
}
pub async fn list_roasts(client: &BrewlogClient, command: ListRoastsCommand) -> Result<()> {
let roasts = client.roasts().list(command.roaster_id.as_deref()).await?;
print_json(&roasts)
}
#[derive(Debug, Args)]
pub struct GetRoastCommand {
#[arg(long)]
pub id: String,
}
pub async fn get_roast(client: &BrewlogClient, command: GetRoastCommand) -> Result<()> {
let roast = client.roasts().get(&command.id).await?;
print_json(&roast)
}
#[derive(Debug, Args)]
pub struct DeleteRoastCommand {
#[arg(long)]
pub id: String,
}
pub async fn delete_roast(client: &BrewlogClient, command: DeleteRoastCommand) -> Result<()> {
let id = command.id;
client.roasts().delete(&id).await?;
let response = json!({
"status": "deleted",
"resource": "roast",
"id": id,
});
print_json(&response)
}

80
src/client/mod.rs Normal file
View file

@ -0,0 +1,80 @@
pub mod roasters;
pub mod roasts;
use anyhow::{Context, Result, anyhow};
use reqwest::{Client, Url};
use crate::server::errors::ErrorResponse;
pub struct BrewlogClient {
base_url: Url,
http: Client,
}
impl BrewlogClient {
pub fn new(base_url: Url) -> Result<Self> {
let mut normalized = base_url;
if !normalized.path().ends_with('/') {
normalized.set_path(&format!("{}/", normalized.path().trim_end_matches('/')));
}
let http = Client::builder()
.user_agent("brewlog-cli/0.1")
.build()
.context("failed to configure HTTP client")?;
Ok(Self {
base_url: normalized,
http,
})
}
pub fn from_base_url(base_url: &str) -> Result<Self> {
let url = Url::parse(base_url).with_context(|| format!("invalid API url: {base_url}"))?;
Self::new(url)
}
pub fn roasters(&self) -> roasters::RoastersClient<'_> {
roasters::RoastersClient::new(self)
}
pub fn roasts(&self) -> roasts::RoastsClient<'_> {
roasts::RoastsClient::new(self)
}
pub(crate) fn endpoint(&self, path: &str) -> Result<Url> {
self.base_url
.join(path)
.with_context(|| format!("invalid API path: {path}"))
}
pub(crate) fn http_client(&self) -> &Client {
&self.http
}
pub(crate) async fn handle_response<T>(&self, response: reqwest::Response) -> Result<T>
where
T: serde::de::DeserializeOwned,
{
if response.status().is_success() {
response
.json::<T>()
.await
.context("failed to deserialize response body")
} else {
Err(self.response_error(response).await)
}
}
pub(crate) async fn response_error(&self, response: reqwest::Response) -> anyhow::Error {
let status = response.status();
let bytes = response.bytes().await.unwrap_or_default();
if let Ok(err) = serde_json::from_slice::<ErrorResponse>(&bytes) {
return anyhow!("request failed ({status}): {}", err.message);
}
let message = String::from_utf8_lossy(&bytes);
anyhow!("request failed ({status}): {message}")
}
}

86
src/client/roasters.rs Normal file
View file

@ -0,0 +1,86 @@
use anyhow::{Context, Result};
use reqwest::StatusCode;
use crate::domain::roasters::{NewRoaster, Roaster, UpdateRoaster};
use super::BrewlogClient;
pub struct RoastersClient<'a> {
inner: &'a BrewlogClient,
}
impl<'a> RoastersClient<'a> {
pub(crate) fn new(inner: &'a BrewlogClient) -> Self {
Self { inner }
}
pub async fn create(&self, payload: &NewRoaster) -> Result<Roaster> {
let url = self.inner.endpoint("api/v1/roasters")?;
let response = self
.inner
.http_client()
.post(url)
.json(payload)
.send()
.await
.context("failed to issue create roaster request")?;
self.inner.handle_response(response).await
}
pub async fn list(&self) -> Result<Vec<Roaster>> {
let url = self.inner.endpoint("api/v1/roasters")?;
let response = self
.inner
.http_client()
.get(url)
.send()
.await
.context("failed to issue list roasters request")?;
self.inner.handle_response(response).await
}
pub async fn get(&self, id: &str) -> Result<Roaster> {
let url = self.inner.endpoint(&format!("api/v1/roasters/{id}"))?;
let response = self
.inner
.http_client()
.get(url)
.send()
.await
.context("failed to issue get roaster request")?;
self.inner.handle_response(response).await
}
pub async fn update(&self, id: &str, payload: &UpdateRoaster) -> Result<Roaster> {
let url = self.inner.endpoint(&format!("api/v1/roasters/{id}"))?;
let response = self
.inner
.http_client()
.put(url)
.json(payload)
.send()
.await
.context("failed to issue update roaster request")?;
self.inner.handle_response(response).await
}
pub async fn delete(&self, id: &str) -> Result<()> {
let url = self.inner.endpoint(&format!("api/v1/roasters/{id}"))?;
let response = self
.inner
.http_client()
.delete(url)
.send()
.await
.context("failed to issue delete roaster request")?;
match response.status() {
StatusCode::NO_CONTENT => Ok(()),
_ => Err(self.inner.response_error(response).await),
}
}
}

76
src/client/roasts.rs Normal file
View file

@ -0,0 +1,76 @@
use anyhow::{Context, Result};
use reqwest::StatusCode;
use crate::domain::roasts::{NewRoast, Roast, RoastWithRoaster};
use super::BrewlogClient;
pub struct RoastsClient<'a> {
inner: &'a BrewlogClient,
}
impl<'a> RoastsClient<'a> {
pub(crate) fn new(inner: &'a BrewlogClient) -> Self {
Self { inner }
}
pub async fn create(&self, payload: &NewRoast) -> Result<Roast> {
let url = self.inner.endpoint("api/v1/roasts")?;
let response = self
.inner
.http_client()
.post(url)
.json(payload)
.send()
.await
.context("failed to issue create roast request")?;
self.inner.handle_response(response).await
}
pub async fn list(&self, roaster_id: Option<&str>) -> 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);
}
let response = self
.inner
.http_client()
.get(url)
.send()
.await
.context("failed to issue list roasts request")?;
self.inner.handle_response(response).await
}
pub async fn get(&self, id: &str) -> Result<Roast> {
let url = self.inner.endpoint(&format!("api/v1/roasts/{id}"))?;
let response = self
.inner
.http_client()
.get(url)
.send()
.await
.context("failed to issue get roast request")?;
self.inner.handle_response(response).await
}
pub async fn delete(&self, id: &str) -> Result<()> {
let url = self.inner.endpoint(&format!("api/v1/roasts/{id}"))?;
let response = self
.inner
.http_client()
.delete(url)
.send()
.await
.context("failed to issue delete roast request")?;
match response.status() {
StatusCode::NO_CONTENT => Ok(()),
_ => Err(self.inner.response_error(response).await),
}
}
}

14
src/domain/ids.rs Normal file
View file

@ -0,0 +1,14 @@
use block_id::{Alphabet, BlockId as BlockIdGenerator};
use once_cell::sync::Lazy;
use rand::RngCore;
static ID_GENERATOR: Lazy<BlockIdGenerator<char>> =
Lazy::new(|| BlockIdGenerator::new(Alphabet::alphanumeric(), 0xB10C_1D_u128, 4));
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")
}

230
src/domain/listing.rs Normal file
View file

@ -0,0 +1,230 @@
use std::cmp;
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
pub enum SortDirection {
Asc,
Desc,
}
impl SortDirection {
pub const fn as_str(self) -> &'static str {
match self {
SortDirection::Asc => "asc",
SortDirection::Desc => "desc",
}
}
pub const fn opposite(self) -> Self {
match self {
SortDirection::Asc => SortDirection::Desc,
SortDirection::Desc => SortDirection::Asc,
}
}
}
pub trait SortKey: Copy + Eq {
fn default() -> Self;
fn from_query(value: &str) -> Option<Self>;
fn query_value(self) -> &'static str;
fn default_direction(self) -> SortDirection;
}
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
pub enum PageSize {
Limited(u32),
All,
}
impl PageSize {
pub fn limited(size: u32) -> Self {
if size == 0 {
PageSize::All
} else {
PageSize::Limited(size)
}
}
pub const fn is_all(self) -> bool {
matches!(self, PageSize::All)
}
pub const fn as_option(self) -> Option<u32> {
match self {
PageSize::Limited(value) => Some(value),
PageSize::All => None,
}
}
pub fn to_query_value(self) -> String {
match self {
PageSize::All => "all".to_string(),
PageSize::Limited(value) => value.to_string(),
}
}
}
pub const DEFAULT_PAGE_SIZE: u32 = 10;
pub const MAX_PAGE_SIZE: u32 = 50;
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
pub struct ListRequest<K: SortKey> {
page: u32,
page_size: PageSize,
sort_key: K,
sort_direction: SortDirection,
}
impl<K: SortKey> ListRequest<K> {
pub fn new(page: u32, page_size: PageSize, sort_key: K, sort_direction: SortDirection) -> Self {
let page = page.max(1);
let page_size = match page_size {
PageSize::Limited(size) => {
if size == 0 {
PageSize::All
} else {
let clamped = cmp::min(size, MAX_PAGE_SIZE).max(1);
PageSize::Limited(clamped)
}
}
PageSize::All => PageSize::All,
};
Self {
page,
page_size,
sort_key,
sort_direction,
}
}
pub fn default() -> Self {
let key = K::default();
Self::new(
1,
PageSize::Limited(DEFAULT_PAGE_SIZE),
key,
key.default_direction(),
)
}
pub fn show_all(sort_key: K, sort_direction: SortDirection) -> Self {
Self::new(1, PageSize::All, sort_key, sort_direction)
}
pub const fn page(&self) -> u32 {
self.page
}
pub const fn page_size(&self) -> PageSize {
self.page_size
}
pub const fn sort_key(&self) -> K {
self.sort_key
}
pub const fn sort_direction(&self) -> SortDirection {
self.sort_direction
}
pub fn with_page(self, page: u32) -> Self {
Self {
page: page.max(1),
..self
}
}
pub fn with_page_size(self, page_size: PageSize) -> Self {
Self::new(self.page, page_size, self.sort_key, self.sort_direction)
}
pub fn with_sort(self, key: K) -> Self {
let direction = if key == self.sort_key {
self.sort_direction.opposite()
} else {
key.default_direction()
};
Self::new(self.page, self.page_size, key, direction)
}
pub fn with_sort_and_direction(self, key: K, direction: SortDirection) -> Self {
Self::new(self.page, self.page_size, key, direction)
}
pub fn ensure_page_within(self, total: u64) -> Self {
if matches!(self.page_size, PageSize::All) {
return Self::new(1, PageSize::All, self.sort_key, self.sort_direction);
}
let Some(limit) = self.page_size.as_option() else {
return self;
};
if total == 0 {
return Self::new(1, self.page_size, self.sort_key, self.sort_direction);
}
let last_page = ((total + limit as u64 - 1) / limit as u64) as u32;
let adjusted_page = self.page.min(last_page.max(1));
Self::new(
adjusted_page,
self.page_size,
self.sort_key,
self.sort_direction,
)
}
}
#[derive(Debug, Clone)]
pub struct Page<T> {
pub items: Vec<T>,
pub page: u32,
pub page_size: u32,
pub total: u64,
pub showing_all: bool,
}
impl<T> Page<T> {
pub fn new(items: Vec<T>, page: u32, page_size: u32, total: u64, showing_all: bool) -> Self {
Self {
items,
page: page.max(1),
page_size: page_size.max(1),
total,
showing_all,
}
}
pub fn total_pages(&self) -> u32 {
if self.total == 0 || self.showing_all {
1
} else {
let size = self.page_size as u64;
((self.total + size - 1) / size) as u32
}
}
pub fn has_previous(&self) -> bool {
!self.showing_all && self.page > 1
}
pub fn has_next(&self) -> bool {
!self.showing_all && self.page < self.total_pages()
}
pub fn start_index(&self) -> u64 {
if self.total == 0 {
0
} else {
((self.page - 1) as u64) * self.page_size as u64 + 1
}
}
pub fn end_index(&self) -> u64 {
if self.total == 0 {
0
} else {
self.start_index() + self.items.len() as u64 - 1
}
}
}

32
src/domain/mod.rs Normal file
View file

@ -0,0 +1,32 @@
pub mod ids;
pub mod listing;
pub mod origins;
pub mod repositories;
pub mod roasters;
pub mod roasts;
pub mod timeline;
use std::fmt::Display;
use thiserror::Error;
// TODO: This should probably be in a file named errors.rs
// so that it's domain::errors::RepositoryError.
#[derive(Debug, Error)]
pub enum RepositoryError {
#[error("entity not found")]
NotFound,
#[error("conflict: {0}")]
Conflict(String),
#[error("unexpected data store error: {0}")]
Unexpected(String),
}
impl RepositoryError {
pub fn conflict<T: Display>(message: T) -> Self {
Self::Conflict(message.to_string())
}
pub fn unexpected<T: Display>(message: T) -> Self {
Self::Unexpected(message.to_string())
}
}

13
src/domain/origins.rs Normal file
View file

@ -0,0 +1,13 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Origin {
pub id: String,
pub name: String,
pub country: String,
pub region: Option<String>,
pub elevation_masl: Option<u16>,
pub notes: Option<String>,
pub created_at: DateTime<Utc>,
}

View file

@ -0,0 +1,67 @@
use super::RepositoryError;
use crate::domain::listing::{ListRequest, Page, SortDirection, SortKey};
use crate::domain::roasters::RoasterSortKey;
use crate::domain::roasters::{Roaster, UpdateRoaster};
use crate::domain::roasts::RoastSortKey;
use crate::domain::roasts::{Roast, RoastWithRoaster, UpdateRoast};
use crate::domain::timeline::TimelineEvent;
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 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 list_all(&self) -> Result<Vec<Roaster>, RepositoryError> {
let sort_key = <RoasterSortKey as SortKey>::default();
let request =
ListRequest::<RoasterSortKey>::show_all(sort_key, sort_key.default_direction());
let page = self.list(&request).await?;
Ok(page.items)
}
async fn list_all_sorted(
&self,
sort_key: RoasterSortKey,
direction: SortDirection,
) -> Result<Vec<Roaster>, RepositoryError> {
let request = ListRequest::show_all(sort_key, direction);
let page = self.list(&request).await?;
Ok(page.items)
}
}
#[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 list(
&self,
request: &ListRequest<RoastSortKey>,
) -> Result<Page<RoastWithRoaster>, RepositoryError>;
async fn list_by_roaster(
&self,
roaster_id: String,
) -> 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 list_all(&self) -> Result<Vec<RoastWithRoaster>, RepositoryError> {
let sort_key = <RoastSortKey as SortKey>::default();
let request = ListRequest::<RoastSortKey>::show_all(sort_key, sort_key.default_direction());
let page = self.list(&request).await?;
Ok(page.items)
}
}
#[async_trait]
pub trait TimelineEventRepository: Send + Sync {
async fn list_all(&self) -> Result<Vec<TimelineEvent>, RepositoryError>;
}

108
src/domain/roasters.rs Normal file
View file

@ -0,0 +1,108 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::domain::ids::generate_id;
use crate::domain::listing::{SortDirection, SortKey};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Roaster {
pub id: String,
pub name: String,
pub country: String,
pub city: Option<String>,
pub homepage: Option<String>,
pub notes: Option<String>,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NewRoaster {
pub name: String,
pub country: String,
pub city: Option<String>,
pub homepage: Option<String>,
pub notes: Option<String>,
}
impl NewRoaster {
pub fn normalize(mut self) -> Self {
self.name = self.name.trim().to_string();
self.country = self.country.trim().to_string();
self.city = normalize_optional_field(self.city);
self.homepage = normalize_optional_field(self.homepage);
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> {
value.and_then(|raw| {
let trimmed = raw.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
})
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct UpdateRoaster {
pub name: Option<String>,
pub country: Option<String>,
pub city: Option<String>,
pub homepage: Option<String>,
pub notes: Option<String>,
}
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
pub enum RoasterSortKey {
CreatedAt,
Name,
Country,
City,
}
impl SortKey for RoasterSortKey {
fn default() -> Self {
RoasterSortKey::CreatedAt
}
fn from_query(value: &str) -> Option<Self> {
match value {
"created-at" => Some(RoasterSortKey::CreatedAt),
"name" => Some(RoasterSortKey::Name),
"country" => Some(RoasterSortKey::Country),
"city" => Some(RoasterSortKey::City),
_ => None,
}
}
fn query_value(self) -> &'static str {
match self {
RoasterSortKey::CreatedAt => "created-at",
RoasterSortKey::Name => "name",
RoasterSortKey::Country => "country",
RoasterSortKey::City => "city",
}
}
fn default_direction(self) -> SortDirection {
match self {
RoasterSortKey::CreatedAt => SortDirection::Desc,
_ => SortDirection::Asc,
}
}
}

105
src/domain/roasts.rs Normal file
View file

@ -0,0 +1,105 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::domain::ids::generate_id;
use crate::domain::listing::{SortDirection, SortKey};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Roast {
pub id: String,
pub roaster_id: String,
pub name: String,
pub origin: Option<String>,
pub region: Option<String>,
pub producer: Option<String>,
pub tasting_notes: Vec<String>,
pub process: Option<String>,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RoastWithRoaster {
pub roast: Roast,
pub roaster_name: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NewRoast {
pub roaster_id: String,
pub name: String,
pub origin: Option<String>,
pub region: Option<String>,
pub producer: Option<String>,
pub tasting_notes: Vec<String>,
pub process: Option<String>,
}
impl NewRoast {
pub fn into_roast(self) -> Roast {
Roast {
id: generate_id(),
roaster_id: self.roaster_id,
name: self.name,
origin: self.origin,
region: self.region,
producer: self.producer,
tasting_notes: self.tasting_notes,
process: self.process,
created_at: Utc::now(),
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct UpdateRoast {
pub roaster_id: Option<String>,
pub name: Option<String>,
pub origin: Option<String>,
pub region: Option<String>,
pub producer: Option<String>,
pub tasting_notes: Option<Vec<String>>,
pub process: Option<String>,
}
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
pub enum RoastSortKey {
CreatedAt,
Name,
Roaster,
Origin,
Producer,
}
impl SortKey for RoastSortKey {
fn default() -> Self {
RoastSortKey::CreatedAt
}
fn from_query(value: &str) -> Option<Self> {
match value {
"created-at" => Some(RoastSortKey::CreatedAt),
"name" => Some(RoastSortKey::Name),
"roaster" => Some(RoastSortKey::Roaster),
"origin" => Some(RoastSortKey::Origin),
"producer" => Some(RoastSortKey::Producer),
_ => None,
}
}
fn query_value(self) -> &'static str {
match self {
RoastSortKey::CreatedAt => "created-at",
RoastSortKey::Name => "name",
RoastSortKey::Roaster => "roaster",
RoastSortKey::Origin => "origin",
RoastSortKey::Producer => "producer",
}
}
fn default_direction(self) -> SortDirection {
match self {
RoastSortKey::CreatedAt => SortDirection::Desc,
_ => SortDirection::Asc,
}
}
}

29
src/domain/timeline.rs Normal file
View file

@ -0,0 +1,29 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TimelineEventDetail {
pub label: String,
pub value: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TimelineEvent {
pub id: String,
pub entity_type: String,
pub entity_id: String,
pub occurred_at: DateTime<Utc>,
pub title: String,
pub details: Vec<TimelineEventDetail>,
pub tasting_notes: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NewTimelineEvent {
pub entity_type: String,
pub entity_id: String,
pub occurred_at: DateTime<Utc>,
pub title: String,
pub details: Vec<TimelineEventDetail>,
pub tasting_notes: Vec<String>,
}

View file

@ -0,0 +1,81 @@
use anyhow::Context;
use sqlx::migrate::Migrator;
#[cfg(all(feature = "sqlite", feature = "postgres"))]
compile_error!("features `sqlite` and `postgres` cannot both be enabled");
#[cfg(not(any(feature = "sqlite", feature = "postgres")))]
compile_error!("enable either the `sqlite` or `postgres` feature");
#[cfg(feature = "sqlite")]
pub type DatabasePool = sqlx::SqlitePool;
#[cfg(feature = "sqlite")]
type PoolOptions = sqlx::sqlite::SqlitePoolOptions;
#[cfg(feature = "sqlite")]
pub type DatabaseTransaction<'a> = sqlx::Transaction<'a, sqlx::Sqlite>;
#[cfg(feature = "postgres")]
pub type DatabasePool = sqlx::PgPool;
#[cfg(feature = "postgres")]
type PoolOptions = sqlx::postgres::PgPoolOptions;
#[cfg(feature = "postgres")]
pub type DatabaseTransaction<'a> = sqlx::Transaction<'a, sqlx::Postgres>;
pub struct Database {
pool: DatabasePool,
}
impl Database {
pub async fn connect(database_url: &str) -> anyhow::Result<Self> {
#[cfg(feature = "sqlite")]
let pool = {
use sqlx::sqlite::SqliteConnectOptions;
use std::str::FromStr;
let options = SqliteConnectOptions::from_str(database_url)
.with_context(|| format!("invalid database url: {database_url}"))?
.create_if_missing(true);
PoolOptions::new()
.max_connections(5)
.connect_with(options)
.await
.with_context(|| format!("failed to connect to database: {database_url}"))?
};
#[cfg(feature = "postgres")]
let pool = PoolOptions::new()
.max_connections(5)
.connect(database_url)
.await
.with_context(|| format!("failed to connect to database: {database_url}"))?;
#[cfg(feature = "sqlite")]
{
sqlx::query("PRAGMA foreign_keys = ON;")
.execute(&pool)
.await
.context("failed to enable foreign keys for sqlite")?;
}
let db = Self { pool };
db.migrate().await?;
Ok(db)
}
pub fn pool(&self) -> &DatabasePool {
&self.pool
}
pub fn clone_pool(&self) -> DatabasePool {
self.pool.clone()
}
pub async fn migrate(&self) -> anyhow::Result<()> {
static MIGRATOR: Migrator = sqlx::migrate!("./migrations");
MIGRATOR
.run(&self.pool)
.await
.context("database migration failed")
}
}

View file

@ -0,0 +1,2 @@
pub mod database;
pub mod repositories;

View file

@ -0,0 +1,3 @@
pub mod roasters;
pub mod roasts;
pub mod timeline_events;

View file

@ -0,0 +1,314 @@
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use sqlx::{QueryBuilder, query_as, query_scalar};
use crate::domain::RepositoryError;
use crate::domain::ids::generate_id;
use crate::domain::listing::{ListRequest, Page, PageSize, SortDirection};
use crate::domain::repositories::RoasterRepository;
use crate::domain::roasters::{Roaster, RoasterSortKey, UpdateRoaster};
use crate::domain::timeline::TimelineEventDetail;
use crate::infrastructure::database::DatabasePool;
type DbId = String;
#[derive(Clone)]
pub struct SqlRoasterRepository {
pool: DatabasePool,
}
impl SqlRoasterRepository {
pub fn new(pool: DatabasePool) -> Self {
Self { pool }
}
fn to_domain(record: RoasterRecord) -> Result<Roaster, RepositoryError> {
let RoasterRecord {
id,
name,
country,
city,
homepage,
notes,
created_at,
} = record;
Ok(Roaster {
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()))?;
let homepage_value = roaster
.homepage
.as_ref()
.filter(|value| !value.is_empty())
.cloned()
.unwrap_or_else(|| "".to_string());
let details = vec![
TimelineEventDetail {
label: "Country".to_string(),
value: roaster.country.clone(),
},
TimelineEventDetail {
label: "City".to_string(),
value: roaster
.city
.as_ref()
.filter(|value| !value.is_empty())
.cloned()
.unwrap_or_else(|| "".to_string()),
},
TimelineEventDetail {
label: "Homepage".to_string(),
value: homepage_value,
},
];
let details_json = 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())
.bind("roaster")
.bind(&roaster.id)
.bind(roaster.created_at)
.bind(&roaster.name)
.bind(details_json)
.bind(Option::<String>::None)
.execute(&mut *tx)
.await
.map_err(|err| RepositoryError::unexpected(err.to_string()))?;
tx.commit()
.await
.map_err(|err| RepositoryError::unexpected(err.to_string()))?;
Ok(roaster)
}
async fn get(&self, id: String) -> Result<Roaster, RepositoryError> {
let record = query_as::<_, RoasterRecord>(
"SELECT id, name, country, city, homepage, notes, created_at FROM roasters WHERE id = ?",
)
.bind(id)
.fetch_optional(&self.pool)
.await
.map_err(|err| RepositoryError::unexpected(err.to_string()))?;
match record {
Some(record) => Self::to_domain(record),
None => Err(RepositoryError::NotFound),
}
}
async fn list(
&self,
request: &ListRequest<RoasterSortKey>,
) -> Result<Page<Roaster>, RepositoryError> {
let order_clause = roaster_order_clause(request);
match request.page_size() {
PageSize::All => {
let query = format!(
"SELECT id, name, country, city, homepage, notes, created_at FROM roasters ORDER BY {}",
order_clause
);
let records = query_as::<_, RoasterRecord>(&query)
.fetch_all(&self.pool)
.await
.map_err(|err| RepositoryError::unexpected(err.to_string()))?;
let items = records
.into_iter()
.map(Self::to_domain)
.collect::<Result<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 query = format!(
"SELECT id, name, country, city, homepage, notes, created_at FROM roasters ORDER BY {} LIMIT ? OFFSET ?",
order_clause
);
let mut records = query_as::<_, RoasterRecord>(&query)
.bind(page_size_i64)
.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")
.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);
records = query_as::<_, RoasterRecord>(&query)
.bind(page_size_i64)
.bind(offset)
.fetch_all(&self.pool)
.await
.map_err(|err| RepositoryError::unexpected(err.to_string()))?;
}
let items = records
.into_iter()
.map(Self::to_domain)
.collect::<Result<Vec<_>, _>>()?;
Ok(Page::new(items, page, page_size, total as u64, false))
}
}
}
async fn update(&self, id: String, changes: UpdateRoaster) -> Result<Roaster, RepositoryError> {
let mut builder = QueryBuilder::new("UPDATE roasters SET ");
let mut first = true;
if let Some(name) = changes.name {
if !first {
builder.push(", ");
}
first = false;
builder.push("name = ");
builder.push_bind(name);
}
if let Some(country) = changes.country {
if !first {
builder.push(", ");
}
first = false;
builder.push("country = ");
builder.push_bind(country);
}
if let Some(city) = changes.city {
if !first {
builder.push(", ");
}
first = false;
builder.push("city = ");
builder.push_bind(city);
}
if let Some(homepage) = changes.homepage {
if !first {
builder.push(", ");
}
first = false;
builder.push("homepage = ");
builder.push_bind(homepage);
}
if let Some(notes) = changes.notes {
if !first {
builder.push(", ");
}
first = false;
builder.push("notes = ");
builder.push_bind(notes);
}
if first {
return Err(RepositoryError::unexpected(
"No fields provided for update".to_string(),
));
}
builder.push(" WHERE id = ");
builder.push_bind(&id);
let result = builder
.build()
.execute(&self.pool)
.await
.map_err(|err| RepositoryError::unexpected(err.to_string()))?;
if result.rows_affected() == 0 {
return Err(RepositoryError::NotFound);
}
self.get(id).await
}
async fn delete(&self, id: String) -> Result<(), RepositoryError> {
let result = sqlx::query("DELETE FROM roasters WHERE id = ?")
.bind(id)
.execute(&self.pool)
.await
.map_err(|err| RepositoryError::unexpected(err.to_string()))?;
if result.rows_affected() == 0 {
return Err(RepositoryError::NotFound);
}
Ok(())
}
}
#[derive(Debug, sqlx::FromRow)]
struct RoasterRecord {
id: DbId,
name: String,
country: String,
city: Option<String>,
homepage: Option<String>,
notes: Option<String>,
created_at: DateTime<Utc>,
}

View file

@ -0,0 +1,479 @@
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use sqlx::{Error as SqlxError, QueryBuilder, query, query_as, query_scalar};
use crate::domain::RepositoryError;
use crate::domain::ids::generate_id;
use crate::domain::listing::{ListRequest, Page, PageSize, SortDirection};
use crate::domain::repositories::RoastRepository;
use crate::domain::roasts::{Roast, RoastSortKey, RoastWithRoaster, UpdateRoast};
use crate::domain::timeline::TimelineEventDetail;
use crate::infrastructure::database::DatabasePool;
#[derive(Clone)]
pub struct SqlRoastRepository {
pool: DatabasePool,
}
impl SqlRoastRepository {
pub fn new(pool: DatabasePool) -> Self {
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 {
let dir_sql = match request.sort_direction() {
SortDirection::Asc => "ASC",
SortDirection::Desc => "DESC",
};
match request.sort_key() {
RoastSortKey::CreatedAt => format!("r.created_at {dir_sql}, LOWER(r.name) ASC"),
RoastSortKey::Name => format!("LOWER(r.name) {dir_sql}, r.created_at DESC"),
RoastSortKey::Roaster => format!("LOWER(ro.name) {dir_sql}, r.created_at DESC"),
RoastSortKey::Origin => {
format!("LOWER(COALESCE(r.origin, '')) {dir_sql}, r.created_at DESC")
}
RoastSortKey::Producer => {
format!("LOWER(COALESCE(r.producer, '')) {dir_sql}, r.created_at DESC")
}
}
}
#[async_trait]
impl RoastRepository for SqlRoastRepository {
async fn insert(&self, roast: Roast) -> Result<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)?;
query(
"INSERT INTO roasts (id, roaster_id, name, origin, region, producer, process, tasting_notes, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(&roast.id)
.bind(&roast.roaster_id)
.bind(&roast.name)
.bind(&roast.origin)
.bind(&roast.region)
.bind(&roast.producer)
.bind(&roast.process)
.bind(notes.as_deref())
.bind(roast.created_at)
.execute(&mut *tx)
.await
.map_err(|err| map_insert_error(err, "unknown roaster reference"))?;
let roaster_name: Option<String> =
sqlx::query_scalar("SELECT name FROM roasters WHERE id = ?")
.bind(&roast.roaster_id)
.fetch_optional(&mut *tx)
.await
.map_err(|err| RepositoryError::unexpected(err.to_string()))?;
let roaster_label = roaster_name.unwrap_or_else(|| "Unknown roaster".to_string());
let details = vec![
TimelineEventDetail {
label: "Roaster".to_string(),
value: roaster_label.clone(),
},
TimelineEventDetail {
label: "Origin".to_string(),
value: roast.origin.clone().unwrap_or_else(|| "".to_string()),
},
TimelineEventDetail {
label: "Region".to_string(),
value: roast.region.clone().unwrap_or_else(|| "".to_string()),
},
TimelineEventDetail {
label: "Producer".to_string(),
value: roast.producer.clone().unwrap_or_else(|| "".to_string()),
},
TimelineEventDetail {
label: "Process".to_string(),
value: roast.process.clone().unwrap_or_else(|| "".to_string()),
},
];
let details_json = serde_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| {
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())
.bind("roast")
.bind(&roast.id)
.bind(roast.created_at)
.bind(&roast.name)
.bind(details_json)
.bind(tasting_notes_json)
.execute(&mut *tx)
.await
.map_err(|err| RepositoryError::unexpected(err.to_string()))?;
tx.commit()
.await
.map_err(|err| RepositoryError::unexpected(err.to_string()))?;
Ok(roast)
}
async fn get(&self, id: String) -> Result<Roast, RepositoryError> {
self.get_record(&id).await
}
async fn list(
&self,
request: &ListRequest<RoastSortKey>,
) -> Result<Page<RoastWithRoaster>, RepositoryError> {
let order_clause = roast_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 {}",
order_clause
);
let records = query_as::<_, RoastWithRoasterRecord>(&query)
.fetch_all(&self.pool)
.await
.map_err(|err| RepositoryError::unexpected(err.to_string()))?;
let items = records
.into_iter()
.map(Self::to_with_roaster)
.collect::<Result<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 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 ?",
order_clause
);
let mut records = query_as::<_, RoastWithRoasterRecord>(&query)
.bind(page_size_i64)
.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")
.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);
records = query_as::<_, RoastWithRoasterRecord>(&query)
.bind(page_size_i64)
.bind(offset)
.fetch_all(&self.pool)
.await
.map_err(|err| RepositoryError::unexpected(err.to_string()))?;
}
let items = records
.into_iter()
.map(Self::to_with_roaster)
.collect::<Result<Vec<_>, _>>()?;
Ok(Page::new(items, page, page_size, total as u64, false))
}
}
}
async fn list_by_roaster(
&self,
roaster_id: String,
) -> 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",
)
.bind(roaster_id)
.fetch_all(&self.pool)
.await
.map_err(|err| RepositoryError::unexpected(err.to_string()))?;
records.into_iter().map(Self::to_with_roaster).collect()
}
async fn update(&self, id: String, changes: UpdateRoast) -> Result<Roast, RepositoryError> {
let mut tx = self
.pool
.begin()
.await
.map_err(|err| RepositoryError::unexpected(err.to_string()))?;
let UpdateRoast {
roaster_id,
name,
origin,
region,
producer,
tasting_notes,
process,
} = changes;
let mut builder = QueryBuilder::new("UPDATE roasts SET ");
let mut updated = false;
if let Some(roaster_id) = roaster_id {
if updated {
builder.push(", ");
}
updated = true;
builder.push("roaster_id = ");
builder.push_bind(roaster_id);
}
if let Some(name) = name {
if updated {
builder.push(", ");
}
updated = true;
builder.push("name = ");
builder.push_bind(name);
}
if let Some(origin) = origin {
if updated {
builder.push(", ");
}
updated = true;
builder.push("origin = ");
builder.push_bind(origin);
}
if let Some(region) = region {
if updated {
builder.push(", ");
}
updated = true;
builder.push("region = ");
builder.push_bind(region);
}
if let Some(producer) = producer {
if updated {
builder.push(", ");
}
updated = true;
builder.push("producer = ");
builder.push_bind(producer);
}
if let Some(process) = process {
if updated {
builder.push(", ");
}
updated = true;
builder.push("process = ");
builder.push_bind(process);
}
if let Some(tasting_notes) = tasting_notes {
let notes = Self::encode_notes(&tasting_notes)?;
if updated {
builder.push(", ");
}
updated = true;
builder.push("tasting_notes = ");
builder.push_bind(notes);
}
if updated {
builder.push(" WHERE id = ");
builder.push_bind(&id);
let result = builder
.build()
.execute(&mut *tx)
.await
.map_err(|err| RepositoryError::unexpected(err.to_string()))?;
if result.rows_affected() == 0 {
return Err(RepositoryError::NotFound);
}
}
tx.commit()
.await
.map_err(|err| RepositoryError::unexpected(err.to_string()))?;
self.get_record(&id).await
}
async fn delete(&self, id: String) -> Result<(), RepositoryError> {
let result = query("DELETE FROM roasts WHERE id = ?")
.bind(&id)
.execute(&self.pool)
.await
.map_err(|err| RepositoryError::unexpected(err.to_string()))?;
if result.rows_affected() == 0 {
return Err(RepositoryError::NotFound);
}
Ok(())
}
}
fn map_insert_error(err: SqlxError, message: &'static str) -> RepositoryError {
if let SqlxError::Database(db_err) = &err {
if db_err.code().as_deref() == Some("787") {
return RepositoryError::conflict(message);
}
}
RepositoryError::unexpected(err.to_string())
}
#[derive(sqlx::FromRow)]
struct RoastRecord {
id: String,
roaster_id: String,
name: String,
origin: Option<String>,
region: Option<String>,
producer: Option<String>,
process: Option<String>,
tasting_notes: Option<String>,
created_at: DateTime<Utc>,
}
#[derive(sqlx::FromRow)]
struct RoastWithRoasterRecord {
id: String,
roaster_id: String,
name: String,
origin: Option<String>,
region: Option<String>,
producer: Option<String>,
process: Option<String>,
tasting_notes: Option<String>,
created_at: DateTime<Utc>,
roaster_name: String,
}

View file

@ -0,0 +1,84 @@
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use serde_json::from_str;
use sqlx::query_as;
use crate::domain::RepositoryError;
use crate::domain::repositories::TimelineEventRepository;
use crate::domain::timeline::{TimelineEvent, TimelineEventDetail};
use crate::infrastructure::database::DatabasePool;
#[derive(Clone)]
pub struct SqlTimelineEventRepository {
pool: DatabasePool,
}
impl SqlTimelineEventRepository {
pub fn new(pool: DatabasePool) -> Self {
Self { pool }
}
}
#[async_trait]
impl TimelineEventRepository for SqlTimelineEventRepository {
async fn list_all(&self) -> Result<Vec<TimelineEvent>, RepositoryError> {
let records = query_as::<_, TimelineEventRecord>(
"SELECT id, entity_type, entity_id, occurred_at, title, details_json, tasting_notes_json FROM timeline_events ORDER BY occurred_at DESC",
)
.fetch_all(&self.pool)
.await
.map_err(|err| RepositoryError::unexpected(err.to_string()))?;
let mut events = Vec::with_capacity(records.len());
for record in records {
events.push(record.into_domain()?);
}
Ok(events)
}
}
#[derive(sqlx::FromRow)]
struct TimelineEventRecord {
id: String,
entity_type: String,
entity_id: String,
occurred_at: DateTime<Utc>,
title: String,
details_json: Option<String>,
tasting_notes_json: Option<String>,
}
impl TimelineEventRecord {
fn into_domain(self) -> Result<TimelineEvent, RepositoryError> {
let details = match self.details_json {
Some(raw) if !raw.is_empty() => {
from_str::<Vec<TimelineEventDetail>>(&raw).map_err(|err| {
RepositoryError::unexpected(format!(
"failed to decode timeline event details: {err}"
))
})?
}
_ => Vec::new(),
};
let tasting_notes = match self.tasting_notes_json {
Some(raw) if !raw.is_empty() => from_str::<Vec<String>>(&raw).map_err(|err| {
RepositoryError::unexpected(format!(
"failed to decode timeline event tasting notes: {err}"
))
})?,
_ => Vec::new(),
};
Ok(TimelineEvent {
id: self.id,
entity_type: self.entity_type,
entity_id: self.entity_id,
occurred_at: self.occurred_at,
title: self.title,
details,
tasting_notes,
})
}
}

6
src/lib.rs Normal file
View file

@ -0,0 +1,6 @@
pub mod cli;
pub mod client;
pub mod domain;
pub mod infrastructure;
pub mod presentation;
pub mod server;

56
src/main.rs Normal file
View file

@ -0,0 +1,56 @@
use anyhow::Result;
use brewlog::cli::{Cli, Commands, ServeCommand, roasters, roasts};
use brewlog::client::BrewlogClient;
use brewlog::server::{ServerConfig, serve};
use clap::Parser;
use tracing_subscriber::{EnvFilter, fmt, prelude::*};
#[tokio::main]
async fn main() -> Result<()> {
if let Err(err) = init_tracing() {
eprintln!("failed to initialize tracing: {err}");
}
let cli = Cli::parse();
match cli.command {
Commands::Serve(cmd) => run_server(cmd).await,
command => {
let client = BrewlogClient::from_base_url(&cli.api_url)?;
match command {
// Roasters
Commands::AddRoaster(cmd) => roasters::add_roaster(&client, cmd).await,
Commands::ListRoasters => roasters::list_roasters(&client).await,
Commands::GetRoaster(cmd) => roasters::get_roaster(&client, cmd).await,
Commands::UpdateRoaster(cmd) => roasters::update_roaster(&client, cmd).await,
Commands::DeleteRoaster(cmd) => roasters::delete_roaster(&client, cmd).await,
// Roasts
Commands::AddRoast(cmd) => roasts::add_roast(&client, cmd).await,
Commands::ListRoasts(cmd) => roasts::list_roasts(&client, cmd).await,
Commands::GetRoast(cmd) => roasts::get_roast(&client, cmd).await,
Commands::DeleteRoast(cmd) => roasts::delete_roast(&client, cmd).await,
Commands::Serve(_) => unreachable!("serve command handled earlier"),
}
}
}
}
async fn run_server(command: ServeCommand) -> Result<()> {
let config = ServerConfig {
bind_address: command.bind_address,
database_url: command.database_url,
};
serve(config).await
}
fn init_tracing() -> anyhow::Result<()> {
let env_filter = EnvFilter::try_from_default_env().or_else(|_| EnvFilter::try_new("info"))?;
tracing_subscriber::registry()
.with(env_filter)
.with(fmt::layer())
.try_init()
.map_err(|err| anyhow::anyhow!("failed to initialize tracing: {err}"))
}

5
src/presentation/mod.rs Normal file
View file

@ -0,0 +1,5 @@
pub mod templates;
pub mod views;
pub use templates::*;
pub use views::*;

View file

@ -0,0 +1,64 @@
use askama::Template;
use super::views::{
ListNavigator, Paginated, RoastView, RoasterOptionView, RoasterView, TimelineMonthView,
};
use crate::domain::roasters::RoasterSortKey;
use crate::domain::roasts::RoastSortKey;
#[derive(Template)]
#[template(path = "roasters.html")]
pub struct RoastersTemplate {
pub nav_active: &'static str,
pub roasters: Paginated<RoasterView>,
pub navigator: ListNavigator<RoasterSortKey>,
}
#[derive(Template)]
#[template(path = "partials/roaster_list.html")]
pub struct RoasterListTemplate {
pub roasters: Paginated<RoasterView>,
pub navigator: ListNavigator<RoasterSortKey>,
}
#[derive(Template)]
#[template(path = "roaster_detail.html")]
pub struct RoasterDetailTemplate {
pub nav_active: &'static str,
pub roaster: RoasterView,
pub roasts: Vec<RoastView>,
}
#[derive(Template)]
#[template(path = "roasts.html")]
pub struct RoastsTemplate {
pub nav_active: &'static str,
pub roasts: Paginated<RoastView>,
pub roaster_options: Vec<RoasterOptionView>,
pub navigator: ListNavigator<RoastSortKey>,
}
#[derive(Template)]
#[template(path = "roast_detail.html")]
pub struct RoastDetailTemplate {
pub nav_active: &'static str,
pub roast: RoastView,
}
#[derive(Template)]
#[template(path = "partials/roast_list.html")]
pub struct RoastListTemplate {
pub roasts: Paginated<RoastView>,
pub navigator: ListNavigator<RoastSortKey>,
}
#[derive(Template)]
#[template(path = "timeline.html")]
pub struct TimelineTemplate {
pub nav_active: &'static str,
pub months: Vec<TimelineMonthView>,
}
pub fn render_template<T: Template>(template: T) -> Result<String, askama::Error> {
template.render()
}

433
src/presentation/views.rs Normal file
View file

@ -0,0 +1,433 @@
use crate::domain::listing::{DEFAULT_PAGE_SIZE, ListRequest, Page, PageSize, SortKey};
use crate::domain::roasters::Roaster;
use crate::domain::roasts::{Roast, RoastWithRoaster};
pub struct Paginated<T> {
pub items: Vec<T>,
pub page: u32,
pub page_size: u32,
pub total: u64,
pub showing_all: bool,
}
impl<T> Paginated<T> {
pub fn new(items: Vec<T>, page: u32, page_size: u32, total: u64, showing_all: bool) -> Self {
let page = page.max(1);
let page_size = page_size.max(1);
Self {
items,
page,
page_size,
total,
showing_all,
}
}
pub fn from_page<U, MapFn>(page: Page<U>, mut map_item: MapFn) -> Self
where
MapFn: FnMut(U) -> T,
{
let items = page.items.into_iter().map(|item| map_item(item)).collect();
Self::new(
items,
page.page,
page.page_size,
page.total,
page.showing_all,
)
}
pub fn total_pages(&self) -> u32 {
if self.total == 0 {
1
} else if self.showing_all {
1
} else {
let page_size = self.page_size as u64;
((self.total + page_size - 1) / page_size) as u32
}
}
pub fn has_previous(&self) -> bool {
!self.showing_all && self.page > 1
}
pub fn has_next(&self) -> bool {
!self.showing_all && self.page < self.total_pages()
}
pub fn previous_page(&self) -> Option<u32> {
if self.has_previous() {
Some(self.page - 1)
} else {
None
}
}
pub fn next_page(&self) -> Option<u32> {
if self.has_next() {
Some(self.page + 1)
} else {
None
}
}
pub fn start_index(&self) -> u64 {
if self.total == 0 {
0
} else {
((self.page - 1) as u64) * self.page_size as u64 + 1
}
}
pub fn end_index(&self) -> u64 {
if self.total == 0 {
0
} else {
self.start_index() + self.items.len() as u64 - 1
}
}
pub fn is_showing_all(&self) -> bool {
self.showing_all
}
pub fn is_page_size(&self, value: u32) -> bool {
!self.showing_all && self.page_size == value
}
pub fn page_size_query_value(&self) -> String {
if self.showing_all {
"all".to_string()
} else {
self.page_size.to_string()
}
}
}
#[derive(Clone, Copy, Debug)]
pub struct ListNavigator<K: SortKey> {
base_path: &'static str,
fragment_path: &'static str,
request: ListRequest<K>,
}
impl<K: SortKey> ListNavigator<K> {
pub fn new(
base_path: &'static str,
fragment_path: &'static str,
request: ListRequest<K>,
) -> Self {
Self {
base_path,
fragment_path,
request,
}
}
pub const fn request(&self) -> ListRequest<K> {
self.request
}
pub fn sort_key(&self) -> &'static str {
self.request.sort_key().query_value()
}
pub fn sort_direction(&self) -> &'static str {
self.request.sort_direction().as_str()
}
pub fn page(&self) -> u32 {
self.request.page()
}
pub fn page_size_value(&self) -> String {
self.request.page_size().to_query_value()
}
pub fn is_showing_all(&self) -> bool {
self.request.page_size().is_all()
}
pub fn page_href(&self, page: u32) -> String {
self.build_href(self.base_path, self.request.with_page(page))
}
pub fn fragment_page_href(&self, page: u32) -> String {
self.build_href(self.fragment_path, self.request.with_page(page))
}
pub fn rows_href(&self, value: &str) -> String {
self.build_href(self.base_path, Self::request_for_rows(self.request, value))
}
pub fn fragment_rows_href(&self, value: &str) -> String {
self.build_href(
self.fragment_path,
Self::request_for_rows(self.request, value),
)
}
pub fn sort_href(&self, key: &str) -> String {
self.build_href(self.base_path, Self::request_for_sort(self.request, key))
}
pub fn fragment_sort_href(&self, key: &str) -> String {
self.build_href(
self.fragment_path,
Self::request_for_sort(self.request, key),
)
}
pub fn is_sorted_by(&self, key: &str) -> bool {
K::from_query(key)
.map(|candidate| candidate == self.request.sort_key())
.unwrap_or(false)
}
pub fn next_sort_dir(&self, key: &str) -> &'static str {
let sort_key = K::from_query(key).unwrap_or_else(K::default);
let direction = if sort_key == self.request.sort_key() {
self.request.sort_direction().opposite()
} else {
sort_key.default_direction()
};
direction.as_str()
}
pub fn query(&self) -> String {
Self::query_string(self.request)
}
pub fn query_for_page(&self, page: u32) -> String {
Self::query_string(self.request.with_page(page))
}
pub fn query_for_rows(&self, value: &str) -> String {
Self::query_string(Self::request_for_rows(self.request, value))
}
pub fn query_for_sort(&self, key: &str) -> String {
Self::query_string(Self::request_for_sort(self.request, key))
}
fn build_href(&self, path: &str, request: ListRequest<K>) -> String {
if let Some((base, fragment)) = path.split_once('#') {
format!("{}?{}#{}", base, Self::query_string(request), fragment)
} else {
format!("{}?{}", path, Self::query_string(request))
}
}
fn request_for_rows(request: ListRequest<K>, value: &str) -> ListRequest<K> {
let page_size = page_size_from_text(value);
request.with_page(1).with_page_size(page_size)
}
fn request_for_sort(request: ListRequest<K>, key: &str) -> ListRequest<K> {
let sort_key = K::from_query(key).unwrap_or_else(K::default);
request.with_page(1).with_sort(sort_key)
}
fn query_string(request: ListRequest<K>) -> String {
format!(
"page={}&page_size={}&sort={}&dir={}",
request.page(),
request.page_size().to_query_value(),
request.sort_key().query_value(),
request.sort_direction().as_str()
)
}
}
fn page_size_from_text(value: &str) -> PageSize {
if value.eq_ignore_ascii_case("all") {
PageSize::All
} else if let Ok(parsed) = value.parse::<u32>() {
PageSize::limited(parsed)
} else {
PageSize::limited(DEFAULT_PAGE_SIZE)
}
}
pub struct RoasterOptionView {
pub id: String,
pub name: String,
}
impl From<Roaster> for RoasterOptionView {
fn from(roaster: Roaster) -> Self {
Self {
id: roaster.id,
name: roaster.name,
}
}
}
impl From<&Roaster> for RoasterOptionView {
fn from(roaster: &Roaster) -> Self {
Self {
id: roaster.id.clone(),
name: roaster.name.clone(),
}
}
}
pub struct RoasterView {
pub id: String,
pub detail_path: String,
pub name: String,
pub country: String,
pub city: String,
pub has_homepage: bool,
pub homepage_url: String,
pub homepage_label: String,
pub notes: String,
pub created_at: String,
pub created_at_sort_key: i64,
}
impl From<Roaster> for RoasterView {
fn from(roaster: Roaster) -> Self {
let Roaster {
id,
name,
country,
city,
homepage,
notes,
created_at,
} = roaster;
let homepage = homepage.unwrap_or_default();
let has_homepage = !homepage.is_empty();
let detail_path = format!("/roasters/{id}");
let created_at_sort_key = created_at.timestamp();
let created_at_label = created_at.format("%Y-%m-%d").to_string();
Self {
detail_path,
id,
name,
country,
city: city.unwrap_or_else(|| "".to_string()),
has_homepage,
homepage_url: homepage.clone(),
homepage_label: homepage,
notes: notes.unwrap_or_else(|| "This roaster has no notes yet.".to_string()),
created_at: created_at_label,
created_at_sort_key,
}
}
}
pub struct RoastView {
pub id: String,
pub full_id: String,
pub detail_path: String,
pub name: String,
pub roaster_label: String,
pub origin: String,
pub region: String,
pub producer: String,
pub process: String,
pub created_at: String,
pub created_at_sort_key: i64,
pub tasting_notes: Vec<String>,
}
impl RoastView {
pub fn from_domain(roast: Roast, roaster_name: &str) -> Self {
Self::from_parts(roast, roaster_name)
}
pub fn from_list_item(item: RoastWithRoaster) -> Self {
let RoastWithRoaster {
roast,
roaster_name,
} = item;
Self::from_parts(roast, &roaster_name)
}
fn from_parts(roast: Roast, roaster_name: &str) -> Self {
let Roast {
id: full_id,
roaster_id: _,
name,
origin,
region,
producer,
tasting_notes,
process,
created_at,
} = roast;
let id: String = full_id.chars().take(6).collect();
let roaster_label = if roaster_name.trim().is_empty() {
"Unknown roaster".to_string()
} else {
roaster_name.to_string()
};
let origin = origin.unwrap_or_else(|| "".to_string());
let region = region.unwrap_or_else(|| "".to_string());
let producer = producer.unwrap_or_else(|| "".to_string());
let process = process.unwrap_or_else(|| "".to_string());
let created_at_sort_key = created_at.timestamp();
let tasting_notes = tasting_notes
.into_iter()
.flat_map(|note| {
note.split(|ch| ch == ',' || ch == '\n')
.map(|segment| segment.trim().to_string())
.filter(|segment| !segment.is_empty())
.collect::<Vec<_>>()
})
.collect();
let created_at = created_at.format("%Y-%m-%d").to_string();
let detail_path = format!("/roasts/{full_id}");
Self {
id,
full_id,
detail_path,
name,
roaster_label,
origin,
region,
producer,
process,
created_at,
created_at_sort_key,
tasting_notes,
}
}
}
pub struct TimelineEventDetailView {
pub label: String,
pub value: String,
}
pub struct TimelineEventView {
pub id: String,
pub kind_label: &'static str,
pub badge_class: &'static str,
pub accent_class: &'static str,
pub card_border_class: &'static str,
pub title_class: &'static str,
pub date_label: String,
pub time_label: Option<String>,
pub iso_timestamp: String,
pub title: String,
pub link: String,
pub external_link: Option<String>,
pub details: Vec<TimelineEventDetailView>,
pub tasting_notes: Option<Vec<String>>,
}
pub struct TimelineMonthView {
pub anchor: String,
pub heading: String,
pub events: Vec<TimelineEventView>,
}

86
src/server/errors.rs Normal file
View file

@ -0,0 +1,86 @@
use axum::Json;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use serde::{Deserialize, Serialize};
use thiserror::Error;
use tracing::error;
use crate::domain::RepositoryError;
#[derive(Debug, Serialize, Deserialize)]
pub struct ErrorResponse {
pub message: String,
}
impl ErrorResponse {
pub fn new<T: ToString>(message: T) -> Self {
Self {
message: message.to_string(),
}
}
}
pub struct ApiError(AppError);
impl From<AppError> for ApiError {
fn from(value: AppError) -> Self {
Self(value)
}
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let (status, message) = match self.0 {
AppError::Validation(message) => (StatusCode::BAD_REQUEST, message),
AppError::NotFound => (StatusCode::NOT_FOUND, "entity not found".to_string()),
AppError::Unexpected(message) => {
error!(error = %message, "unexpected application error");
(
StatusCode::INTERNAL_SERVER_ERROR,
"unexpected error".to_string(),
)
}
};
(status, Json(ErrorResponse::new(message))).into_response()
}
}
pub fn map_app_error(err: AppError) -> StatusCode {
match err {
AppError::Validation(_) => StatusCode::BAD_REQUEST,
AppError::NotFound => StatusCode::NOT_FOUND,
AppError::Unexpected(_) => StatusCode::INTERNAL_SERVER_ERROR,
}
}
pub type AppResult<T> = Result<T, AppError>;
#[derive(Debug, Error)]
pub enum AppError {
#[error("validation failed: {0}")]
Validation(String),
#[error("entity not found")]
NotFound,
#[error("unexpected error: {0}")]
Unexpected(String),
}
impl AppError {
pub fn validation<T: ToString>(msg: T) -> Self {
Self::Validation(msg.to_string())
}
pub fn unexpected<T: ToString>(msg: T) -> Self {
Self::Unexpected(msg.to_string())
}
}
impl From<RepositoryError> for AppError {
fn from(value: RepositoryError) -> Self {
match value {
RepositoryError::NotFound => Self::NotFound,
RepositoryError::Conflict(msg) => Self::Validation(msg),
RepositoryError::Unexpected(msg) => Self::Unexpected(msg),
}
}
}

6
src/server/mod.rs Normal file
View file

@ -0,0 +1,6 @@
pub mod errors;
pub mod routes;
pub mod server;
pub use routes::app_router;
pub use server::{ServerConfig, serve};

73
src/server/routes/mod.rs Normal file
View file

@ -0,0 +1,73 @@
pub mod roasters;
pub mod roasts;
pub mod support;
pub mod timeline;
use askama::Template;
use axum::http::StatusCode;
use axum::response::{Html, IntoResponse, Redirect};
use axum::routing::{get, post};
use tracing::error;
use crate::server::server::AppState;
use crate::presentation::templates::render_template;
pub fn app_router(state: AppState) -> axum::Router {
let api_routes = axum::Router::new()
.route(
"/roasters",
post(roasters::create_roaster).get(roasters::list_roasters),
)
.route(
"/roasters/:id",
get(roasters::get_roaster)
.put(roasters::update_roaster)
.delete(roasters::delete_roaster),
)
.route(
"/roasts",
post(roasts::create_roast).get(roasts::list_roasts),
)
.route(
"/roasts/:id",
get(roasts::get_roast).delete(roasts::delete_roast),
);
axum::Router::new()
.route("/", get(root_redirect))
.route("/roasters", get(roasters::roasters_page))
.route("/roasters/:id", get(roasters::roaster_page))
.route("/roasts", get(roasts::roasts_page))
.route("/roasts/:id", get(roasts::roast_page))
.route("/timeline", get(timeline::timeline_page))
.route("/styles.css", get(styles))
.route("/favicon.ico", get(favicon))
.nest("/api/v1", api_routes)
.with_state(state)
}
async fn root_redirect() -> Redirect {
Redirect::temporary("/timeline")
}
async fn styles() -> impl IntoResponse {
(
[("content-type", "text/css; charset=utf-8")],
include_str!("../../../templates/styles.css"),
)
}
async fn favicon() -> impl IntoResponse {
(
[("content-type", "image/x-icon")],
include_bytes!("../../../templates/favicon.ico").as_ref(),
)
}
pub(crate) fn render_html<T: Template>(template: T) -> Result<Html<String>, StatusCode> {
render_template(template).map(Html).map_err(|err| {
error!(error = %err, "failed to render template");
StatusCode::INTERNAL_SERVER_ERROR
})
}

View file

@ -0,0 +1,221 @@
use axum::Json;
use axum::extract::{Path, Query, State};
use axum::http::{HeaderMap, StatusCode};
use axum::response::{Html, IntoResponse, Redirect, Response};
use crate::domain::listing::{ListRequest, Page, PageSize, SortDirection};
use crate::domain::roasters::{NewRoaster, Roaster, RoasterSortKey, UpdateRoaster};
use crate::presentation::templates::{
RoasterDetailTemplate, RoasterListTemplate, RoastersTemplate,
};
use crate::presentation::views::{ListNavigator, Paginated, RoastView, RoasterView};
use crate::server::errors::{ApiError, AppError, map_app_error};
use crate::server::routes::render_html;
use crate::server::routes::support::{
FlexiblePayload, ListQuery, PayloadSource, is_datastar_request, set_datastar_patch_headers,
};
use crate::server::server::AppState;
const ROASTER_PAGE_PATH: &str = "/roasters";
const ROASTER_FRAGMENT_PATH: &str = "/roasters#roaster-list";
fn normalize_request(
request: ListRequest<RoasterSortKey>,
page: &Page<Roaster>,
) -> ListRequest<RoasterSortKey> {
let page_size = if page.showing_all {
PageSize::All
} else {
PageSize::limited(page.page_size)
};
ListRequest::new(
page.page,
page_size,
request.sort_key(),
request.sort_direction(),
)
}
fn roaster_navigator(request: ListRequest<RoasterSortKey>) -> ListNavigator<RoasterSortKey> {
ListNavigator::new(ROASTER_PAGE_PATH, ROASTER_FRAGMENT_PATH, request)
}
async fn load_roaster_page(
state: &AppState,
request: ListRequest<RoasterSortKey>,
) -> Result<(Paginated<RoasterView>, ListNavigator<RoasterSortKey>), AppError> {
let page = state
.roaster_repo
.list(&request)
.await
.map_err(AppError::from)?;
let normalized_request = normalize_request(request, &page);
let roasters = Paginated::from_page(page, RoasterView::from);
let navigator = roaster_navigator(normalized_request);
Ok((roasters, navigator))
}
pub(crate) async fn roasters_page(
State(state): State<AppState>,
headers: HeaderMap,
Query(query): Query<ListQuery>,
) -> Result<Response, StatusCode> {
let request = query.into_request::<RoasterSortKey>();
if is_datastar_request(&headers) {
return render_roaster_list_fragment(state, request)
.await
.map_err(|err| map_app_error(err));
}
let (roasters, navigator) = load_roaster_page(&state, request)
.await
.map_err(|err| map_app_error(err))?;
let template = RoastersTemplate {
nav_active: "roasters",
roasters,
navigator,
};
render_html(template).map(IntoResponse::into_response)
}
pub(crate) async fn roaster_page(
State(state): State<AppState>,
Path(id): Path<String>,
) -> Result<Html<String>, StatusCode> {
let roaster = state
.roaster_repo
.get(id.clone())
.await
.map_err(|err| map_app_error(AppError::from(err)))?;
let roasts = state
.roast_repo
.list_by_roaster(id)
.await
.map_err(|err| map_app_error(AppError::from(err)))?;
let roaster_view = RoasterView::from(roaster);
let template = RoasterDetailTemplate {
nav_active: "roasters",
roaster: roaster_view,
roasts: roasts.into_iter().map(RoastView::from_list_item).collect(),
};
render_html(template)
}
pub(crate) async fn list_roasters(
State(state): State<AppState>,
) -> Result<Json<Vec<Roaster>>, ApiError> {
let roasters = state
.roaster_repo
.list_all_sorted(RoasterSortKey::Name, SortDirection::Asc)
.await
.map_err(AppError::from)?;
Ok(Json(roasters))
}
pub(crate) async fn create_roaster(
State(state): State<AppState>,
headers: HeaderMap,
Query(query): Query<ListQuery>,
payload: FlexiblePayload<NewRoaster>,
) -> Result<Response, ApiError> {
let request = query.into_request::<RoasterSortKey>();
let (new_roaster, source) = payload.into_parts();
let roaster = new_roaster.normalize().into_roaster();
let roaster = state
.roaster_repo
.insert(roaster)
.await
.map_err(AppError::from)?;
if is_datastar_request(&headers) {
render_roaster_list_fragment(state, request)
.await
.map_err(ApiError::from)
} else if matches!(source, PayloadSource::Form) {
let target = roaster_navigator(request).page_href(1);
Ok(Redirect::to(&target).into_response())
} else {
Ok((StatusCode::CREATED, Json(roaster)).into_response())
}
}
pub(crate) async fn get_roaster(
State(state): State<AppState>,
Path(id): Path<String>,
) -> Result<Json<Roaster>, ApiError> {
let roaster = state.roaster_repo.get(id).await.map_err(AppError::from)?;
Ok(Json(roaster))
}
pub(crate) async fn update_roaster(
State(state): State<AppState>,
Path(id): Path<String>,
Json(payload): Json<UpdateRoaster>,
) -> Result<Json<Roaster>, ApiError> {
let has_changes = payload.name.is_some()
|| payload.country.is_some()
|| payload.city.is_some()
|| payload.homepage.is_some()
|| payload.notes.is_some();
if !has_changes {
return Err(AppError::validation("no changes provided").into());
}
let roaster = state
.roaster_repo
.update(id, payload)
.await
.map_err(AppError::from)?;
Ok(Json(roaster))
}
pub(crate) async fn delete_roaster(
State(state): State<AppState>,
headers: HeaderMap,
Path(id): Path<String>,
Query(query): Query<ListQuery>,
) -> Result<Response, ApiError> {
let request = query.into_request::<RoasterSortKey>();
state
.roaster_repo
.delete(id)
.await
.map_err(AppError::from)?;
if is_datastar_request(&headers) {
render_roaster_list_fragment(state, request)
.await
.map_err(ApiError::from)
} else {
Ok(StatusCode::NO_CONTENT.into_response())
}
}
async fn render_roaster_list_fragment(
state: AppState,
request: ListRequest<RoasterSortKey>,
) -> Result<Response, AppError> {
let (roasters, navigator) = load_roaster_page(&state, request).await?;
let template = RoasterListTemplate {
roasters,
navigator,
};
let html = crate::presentation::templates::render_template(template)
.map_err(|err| AppError::unexpected(format!("failed to render roaster list: {err}")))?;
let mut response = Html(html).into_response();
set_datastar_patch_headers(response.headers_mut(), "#roaster-list");
Ok(response)
}

343
src/server/routes/roasts.rs Normal file
View file

@ -0,0 +1,343 @@
use axum::Json;
use axum::extract::{Path, Query, State};
use axum::http::{HeaderMap, StatusCode};
use axum::response::{Html, IntoResponse, Redirect, Response};
use serde::Deserialize;
use serde::de::{self, Deserializer, SeqAccess, Visitor};
use std::fmt;
use crate::domain::listing::{ListRequest, Page, PageSize, SortDirection};
use crate::domain::roasters::RoasterSortKey;
use crate::domain::roasts::{NewRoast, Roast, RoastSortKey, RoastWithRoaster};
use crate::presentation::templates::{RoastDetailTemplate, RoastListTemplate, RoastsTemplate};
use crate::presentation::views::{ListNavigator, Paginated, RoastView, RoasterOptionView};
use crate::server::errors::{ApiError, AppError, map_app_error};
use crate::server::routes::render_html;
use crate::server::routes::support::{
FlexiblePayload, ListQuery, PayloadSource, is_datastar_request, set_datastar_patch_headers,
};
use crate::server::server::AppState;
const ROAST_PAGE_PATH: &str = "/roasts";
const ROAST_FRAGMENT_PATH: &str = "/roasts#roast-list";
fn normalize_request(
request: ListRequest<RoastSortKey>,
page: &Page<RoastWithRoaster>,
) -> ListRequest<RoastSortKey> {
let page_size = if page.showing_all {
PageSize::All
} else {
PageSize::limited(page.page_size)
};
ListRequest::new(
page.page,
page_size,
request.sort_key(),
request.sort_direction(),
)
}
fn roast_navigator(request: ListRequest<RoastSortKey>) -> ListNavigator<RoastSortKey> {
ListNavigator::new(ROAST_PAGE_PATH, ROAST_FRAGMENT_PATH, request)
}
async fn load_roast_page(
state: &AppState,
request: ListRequest<RoastSortKey>,
) -> Result<(Paginated<RoastView>, ListNavigator<RoastSortKey>), AppError> {
let page = state
.roast_repo
.list(&request)
.await
.map_err(AppError::from)?;
let normalized_request = normalize_request(request, &page);
let roasts = Paginated::from_page(page, RoastView::from_list_item);
let navigator = roast_navigator(normalized_request);
Ok((roasts, navigator))
}
pub(crate) async fn roasts_page(
State(state): State<AppState>,
headers: HeaderMap,
Query(query): Query<ListQuery>,
) -> Result<Response, StatusCode> {
let request = query.into_request::<RoastSortKey>();
if is_datastar_request(&headers) {
return render_roast_list_fragment(state, request)
.await
.map_err(|err| map_app_error(err));
}
let roasters = state
.roaster_repo
.list_all_sorted(RoasterSortKey::Name, SortDirection::Asc)
.await
.map_err(|err| map_app_error(AppError::from(err)))?;
let roaster_options = roasters.into_iter().map(RoasterOptionView::from).collect();
let (roasts, navigator) = load_roast_page(&state, request)
.await
.map_err(|err| map_app_error(err))?;
let template = RoastsTemplate {
nav_active: "roasts",
roasts,
roaster_options,
navigator,
};
render_html(template).map(IntoResponse::into_response)
}
pub(crate) async fn roast_page(
State(state): State<AppState>,
Path(id): Path<String>,
) -> Result<Html<String>, StatusCode> {
let roast = state
.roast_repo
.get(id.clone())
.await
.map_err(|err| map_app_error(AppError::from(err)))?;
let roaster = state
.roaster_repo
.get(roast.roaster_id.clone())
.await
.map_err(|err| map_app_error(AppError::from(err)))?;
let template = RoastDetailTemplate {
nav_active: "roasts",
roast: RoastView::from_domain(roast, &roaster.name),
};
render_html(template)
}
pub(crate) async fn create_roast(
State(state): State<AppState>,
headers: HeaderMap,
Query(query): Query<ListQuery>,
payload: FlexiblePayload<NewRoastSubmission>,
) -> Result<Response, ApiError> {
let request = query.into_request::<RoastSortKey>();
let (submission, source) = payload.into_parts();
let new_roast = submission.into_new_roast().map_err(ApiError::from)?;
state
.roaster_repo
.get(new_roast.roaster_id.clone())
.await
.map_err(|err| ApiError::from(AppError::from(err)))?;
let roast = new_roast.into_roast();
let roast = state
.roast_repo
.insert(roast)
.await
.map_err(AppError::from)?;
if is_datastar_request(&headers) {
render_roast_list_fragment(state, request)
.await
.map_err(ApiError::from)
} else if matches!(source, PayloadSource::Form) {
let target = roast_navigator(request).page_href(1);
Ok(Redirect::to(&target).into_response())
} else {
Ok((StatusCode::CREATED, Json(roast)).into_response())
}
}
pub(crate) async fn list_roasts(
State(state): State<AppState>,
Query(params): Query<RoastsQuery>,
) -> Result<Json<Vec<RoastWithRoaster>>, ApiError> {
let roasts = match params.roaster_id {
Some(roaster_id) => state
.roast_repo
.list_by_roaster(roaster_id)
.await
.map_err(AppError::from)?,
None => state.roast_repo.list_all().await.map_err(AppError::from)?,
};
Ok(Json(roasts))
}
pub(crate) async fn get_roast(
State(state): State<AppState>,
Path(id): Path<String>,
) -> Result<Json<Roast>, ApiError> {
let roast = state.roast_repo.get(id).await.map_err(AppError::from)?;
Ok(Json(roast))
}
pub(crate) async fn delete_roast(
State(state): State<AppState>,
headers: HeaderMap,
Path(id): Path<String>,
Query(query): Query<ListQuery>,
) -> Result<Response, ApiError> {
let request = query.into_request::<RoastSortKey>();
state.roast_repo.delete(id).await.map_err(AppError::from)?;
if is_datastar_request(&headers) {
render_roast_list_fragment(state, request)
.await
.map_err(ApiError::from)
} else {
Ok(StatusCode::NO_CONTENT.into_response())
}
}
#[derive(Debug, Deserialize)]
pub struct RoastsQuery {
pub roaster_id: Option<String>,
}
#[derive(Debug, Deserialize)]
pub(crate) struct NewRoastSubmission {
roaster_id: String,
name: String,
#[serde(default)]
origin: Option<String>,
#[serde(default)]
region: Option<String>,
#[serde(default)]
producer: Option<String>,
#[serde(default, deserialize_with = "string_or_vec")]
tasting_notes: Vec<String>,
#[serde(default)]
process: Option<String>,
}
impl NewRoastSubmission {
fn into_new_roast(self) -> Result<NewRoast, AppError> {
let roaster_id = self.roaster_id.trim().to_string();
if roaster_id.is_empty() {
return Err(AppError::validation("roaster is required"));
}
let name = self.name.trim().to_string();
if name.is_empty() {
return Err(AppError::validation("name is required"));
}
Ok(NewRoast {
roaster_id,
name,
origin: trim_optional(self.origin),
region: trim_optional(self.region),
producer: trim_optional(self.producer),
tasting_notes: self
.tasting_notes
.into_iter()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.collect(),
process: trim_optional(self.process),
})
}
}
fn trim_optional(value: Option<String>) -> Option<String> {
value.and_then(|raw| {
let trimmed = raw.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
})
}
fn string_or_vec<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
where
D: Deserializer<'de>,
{
struct StringOrVecVisitor;
impl<'de> Visitor<'de> for StringOrVecVisitor {
type Value = Vec<String>;
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
formatter.write_str("a string or sequence of strings")
}
fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
where
E: de::Error,
{
let split = value
.split(|ch| ch == ',' || ch == '\n')
.map(|segment| segment.trim().to_string())
.filter(|segment| !segment.is_empty())
.collect();
Ok(split)
}
fn visit_string<E>(self, value: String) -> Result<Self::Value, E>
where
E: de::Error,
{
self.visit_str(&value)
}
fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error>
where
A: SeqAccess<'de>,
{
let mut values = Vec::new();
while let Some(value) = seq.next_element::<String>()? {
let trimmed = value.trim();
if !trimmed.is_empty() {
values.push(trimmed.to_string());
}
}
Ok(values)
}
fn visit_unit<E>(self) -> Result<Self::Value, E>
where
E: de::Error,
{
Ok(Vec::new())
}
fn visit_none<E>(self) -> Result<Self::Value, E>
where
E: de::Error,
{
Ok(Vec::new())
}
fn visit_some<D2>(self, deserializer: D2) -> Result<Self::Value, D2::Error>
where
D2: Deserializer<'de>,
{
string_or_vec(deserializer)
}
}
deserializer.deserialize_any(StringOrVecVisitor)
}
async fn render_roast_list_fragment(
state: AppState,
request: ListRequest<RoastSortKey>,
) -> Result<Response, AppError> {
let (roasts, navigator) = load_roast_page(&state, request).await?;
let template = RoastListTemplate { roasts, navigator };
let html = crate::presentation::templates::render_template(template)
.map_err(|err| AppError::unexpected(format!("failed to render roast list: {err}")))?;
let mut response = Html(html).into_response();
set_datastar_patch_headers(response.headers_mut(), "#roast-list");
Ok(response)
}

View file

@ -0,0 +1,143 @@
use axum::async_trait;
use axum::extract::{Form, FromRequest, Json as JsonPayload, Request};
use axum::http::{HeaderMap, HeaderValue, header::CONTENT_TYPE};
use serde::Deserialize;
use crate::domain::listing::{DEFAULT_PAGE_SIZE, ListRequest, PageSize, SortDirection, SortKey};
use crate::server::errors::{ApiError, AppError};
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum PayloadSource {
Json,
Form,
}
pub struct FlexiblePayload<T> {
inner: T,
source: PayloadSource,
}
impl<T> FlexiblePayload<T> {
pub fn into_parts(self) -> (T, PayloadSource) {
(self.inner, self.source)
}
}
#[derive(Debug, Default, Deserialize)]
pub(crate) struct ListQuery {
page: Option<u32>,
#[serde(default)]
page_size: Option<PageSizeParam>,
#[serde(default, rename = "sort")]
sort_key: Option<String>,
#[serde(default, rename = "dir")]
sort_dir: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum PageSizeParam {
Number(u32),
Text(String),
}
impl ListQuery {
pub fn into_request<K: SortKey>(self) -> ListRequest<K> {
let page = self.page.unwrap_or(1);
let page_size = match self.page_size {
Some(PageSizeParam::Number(value)) => PageSize::limited(value),
Some(PageSizeParam::Text(text)) => page_size_from_text(&text),
None => PageSize::limited(DEFAULT_PAGE_SIZE),
};
let sort_key = self
.sort_key
.as_deref()
.and_then(K::from_query)
.unwrap_or_else(K::default);
let sort_direction = self
.sort_dir
.as_deref()
.and_then(parse_direction)
.unwrap_or_else(|| sort_key.default_direction());
ListRequest::new(page, page_size, sort_key, sort_direction)
}
}
fn page_size_from_text(value: &str) -> PageSize {
if value.eq_ignore_ascii_case("all") {
PageSize::All
} else if let Ok(parsed) = value.parse::<u32>() {
PageSize::limited(parsed)
} else {
PageSize::limited(DEFAULT_PAGE_SIZE)
}
}
fn parse_direction(value: &str) -> Option<SortDirection> {
match value.to_ascii_lowercase().as_str() {
"asc" => Some(SortDirection::Asc),
"desc" => Some(SortDirection::Desc),
_ => None,
}
}
#[async_trait]
impl<S, T> FromRequest<S> for FlexiblePayload<T>
where
S: Send + Sync,
T: Send + 'static,
JsonPayload<T>: FromRequest<S>,
Form<T>: FromRequest<S>,
{
type Rejection = ApiError;
async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
let content_type = req
.headers()
.get(CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.unwrap_or("")
.to_ascii_lowercase();
if content_type.starts_with("application/json") {
let JsonPayload(payload) = JsonPayload::<T>::from_request(req, state)
.await
.map_err(|_| ApiError::from(AppError::validation("invalid JSON payload")))?;
return Ok(Self {
inner: payload,
source: PayloadSource::Json,
});
}
if content_type.is_empty() || content_type.starts_with("application/x-www-form-urlencoded")
{
let Form(payload) = Form::<T>::from_request(req, state)
.await
.map_err(|_| ApiError::from(AppError::validation("invalid form payload")))?;
return Ok(Self {
inner: payload,
source: PayloadSource::Form,
});
}
Err(AppError::validation("unsupported content type").into())
}
}
pub fn is_datastar_request(headers: &HeaderMap) -> bool {
headers
.get("datastar-request")
.and_then(|value| value.to_str().ok())
.map(|value| value.eq_ignore_ascii_case("true"))
.unwrap_or(false)
}
pub fn set_datastar_patch_headers(headers: &mut HeaderMap, selector: &'static str) {
let _ = headers.insert("datastar-selector", HeaderValue::from_static(selector));
let _ = headers.insert("datastar-mode", HeaderValue::from_static("replace"));
}

View file

@ -0,0 +1,118 @@
use axum::extract::State;
use axum::http::StatusCode;
use axum::response::Html;
use crate::presentation::templates::TimelineTemplate;
use crate::presentation::views::{TimelineEventDetailView, TimelineEventView, TimelineMonthView};
use crate::server::errors::{AppError, map_app_error};
use crate::server::routes::render_html;
use crate::server::server::AppState;
pub(crate) async fn timeline_page(
State(state): State<AppState>,
) -> Result<Html<String>, StatusCode> {
let events = state
.timeline_repo
.list_all()
.await
.map_err(|err| map_app_error(AppError::from(err)))?;
let mut months: Vec<TimelineMonthView> = Vec::new();
for event in events {
let occurred_at = event.occurred_at;
let anchor = occurred_at.format("%Y-%m").to_string();
let heading = occurred_at.format("%B %Y").to_string();
let entity_id = event.entity_id.clone();
let kind_label = match event.entity_type.as_str() {
"roaster" => "Roaster Added",
"roast" => "Roast Added",
_ => "Event",
};
let link = match event.entity_type.as_str() {
"roaster" => format!("/roasters/{entity_id}"),
"roast" => format!("/roasts/{entity_id}"),
_ => String::from("#"),
};
let mut details: Vec<TimelineEventDetailView> = event
.details
.into_iter()
.map(|detail| TimelineEventDetailView {
label: detail.label,
value: detail.value,
})
.collect();
let external_link = if let Some(index) = details
.iter()
.position(|detail| detail.label.eq_ignore_ascii_case("homepage"))
{
let value = details.remove(index).value;
let trimmed = value.trim();
if trimmed.is_empty() || trimmed == "" {
None
} else {
Some(trimmed.to_string())
}
} else {
None
};
let tasting_notes = if event.entity_type == "roast" {
let notes = event
.tasting_notes
.clone()
.into_iter()
.flat_map(|note| {
note.split(|ch| ch == ',' || ch == '\n')
.map(|segment| segment.trim())
.filter(|segment| !segment.is_empty())
.map(|segment| segment.to_string())
.collect::<Vec<_>>()
})
.collect::<Vec<_>>();
Some(notes)
} else {
None
};
let view = TimelineEventView {
id: event.id,
kind_label,
badge_class: "bg-amber-200 text-amber-800",
accent_class: "bg-amber-600",
card_border_class: "border-amber-200 bg-amber-50/80",
title_class: "text-amber-800",
date_label: occurred_at.format("%B %d, %Y").to_string(),
time_label: Some(occurred_at.format("%H:%M UTC").to_string()),
iso_timestamp: occurred_at.to_rfc3339(),
title: event.title,
link,
external_link,
details,
tasting_notes,
};
if let Some(last) = months.last_mut() {
if last.anchor == anchor {
last.events.push(view);
continue;
}
}
months.push(TimelineMonthView {
anchor,
heading,
events: vec![view],
});
}
let template = TimelineTemplate {
nav_active: "timeline",
months,
};
render_html(template)
}

90
src/server/server.rs Normal file
View file

@ -0,0 +1,90 @@
use std::net::SocketAddr;
use std::sync::Arc;
use anyhow::Context;
use axum::Router;
use tokio::net::TcpListener;
use tokio::signal;
use tracing::info;
use crate::domain::repositories::{RoastRepository, RoasterRepository, TimelineEventRepository};
use crate::infrastructure::database::Database;
use crate::infrastructure::repositories::roasters::SqlRoasterRepository;
use crate::infrastructure::repositories::roasts::SqlRoastRepository;
use crate::infrastructure::repositories::timeline_events::SqlTimelineEventRepository;
use crate::server::routes::app_router;
pub struct ServerConfig {
pub bind_address: SocketAddr,
pub database_url: String,
}
#[derive(Clone)]
pub struct AppState {
pub roaster_repo: Arc<dyn RoasterRepository>,
pub roast_repo: Arc<dyn RoastRepository>,
pub timeline_repo: Arc<dyn TimelineEventRepository>,
}
impl AppState {
pub fn new(
roaster_repo: Arc<dyn RoasterRepository>,
roast_repo: Arc<dyn RoastRepository>,
timeline_repo: Arc<dyn TimelineEventRepository>,
) -> Self {
Self {
roaster_repo,
roast_repo,
timeline_repo,
}
}
}
pub async fn serve(config: ServerConfig) -> anyhow::Result<()> {
let database = Database::connect(&config.database_url)
.await
.context("failed to connect to database")?;
database.migrate().await?;
let roaster_repo = Arc::new(SqlRoasterRepository::new(database.clone_pool()));
let roast_repo = Arc::new(SqlRoastRepository::new(database.clone_pool()));
let timeline_repo = Arc::new(SqlTimelineEventRepository::new(database.clone_pool()));
let state = AppState::new(roaster_repo, roast_repo, timeline_repo);
let listener = TcpListener::bind(config.bind_address)
.await
.with_context(|| format!("failed to bind to {}", config.bind_address))?;
let app: Router = app_router(state);
info!(address = %config.bind_address, "starting HTTP server");
axum::serve(listener, app)
.with_graceful_shutdown(shutdown_signal())
.await
.context("server terminated unexpectedly")?;
info!("server shutdown complete");
Ok(())
}
async fn shutdown_signal() {
let ctrl_c = async {
signal::ctrl_c()
.await
.expect("failed to install Ctrl+C handler");
};
let terminate = async {
signal::unix::signal(signal::unix::SignalKind::terminate())
.expect("failed to install SIGTERM handler")
.recv()
.await;
};
tokio::select! {
_ = ctrl_c => {},
_ = terminate => {},
}
}

23
templates/base.html Normal file
View file

@ -0,0 +1,23 @@
<!DOCTYPE html>
<html lang="en" data-star-root>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{% block title %}Brewlog{% endblock %}</title>
<link rel="stylesheet" href="/styles.css" />
<script src="https://cdn.tailwindcss.com"></script>
<script
type="module"
src="https://cdn.jsdelivr.net/gh/starfederation/datastar@1.0.0-RC.6/bundles/datastar.js"
></script>
{% block head %}{% endblock %}
</head>
<body class="min-h-screen bg-amber-50 text-stone-900">
<main class="mx-auto flex w-full max-w-5xl flex-col gap-6 px-6 py-10">
{% include "nav.html" %} {% block content %}{% endblock %}
<footer class="mt-8 text-xs text-stone-500">
<p>Built with Axum, Tailwind CSS, and Datastar.</p>
</footer>
</main>
</body>
</html>

BIN
templates/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

8
templates/nav.html Normal file
View file

@ -0,0 +1,8 @@
<nav class="flex items-center justify-between gap-4 rounded-lg border border-amber-300 bg-amber-100/80 p-3 text-sm text-stone-600">
<div class="font-semibold uppercase tracking-[0.25em] text-amber-700"><a href="/timeline">Brewlog</a></div>
<div class="flex items-center gap-3">
<a class="border-b-2 pb-1 transition {% if nav_active == "roasters" %}text-amber-700 border-amber-500{% else %}text-stone-500 border-transparent hover:text-amber-600 hover:border-amber-400{% endif %}" href="/roasters">Roasters</a>
<a class="border-b-2 pb-1 transition {% if nav_active == "roasts" %}text-amber-700 border-amber-500{% else %}text-stone-500 border-transparent hover:text-amber-600 hover:border-amber-400{% endif %}" href="/roasts">Roasts</a>
<a class="border-b-2 pb-1 transition {% if nav_active == "timeline" %}text-amber-700 border-amber-500{% else %}text-stone-500 border-transparent hover:text-amber-600 hover:border-amber-400{% endif %}" href="/timeline">Timeline</a>
</div>
</nav>

View file

@ -0,0 +1,255 @@
<div id="roast-list" class="mt-6" data-star-scope="roasts">
{% if roasts.items.is_empty() %}
<div
class="rounded-lg border border-dashed border-amber-300 bg-amber-100/40 px-4 py-6 text-sm text-stone-600"
>
<p class="text-center">No roasts recorded yet. Use the form above to add your first roast.</p>
</div>
{% else %}
<section class="rounded-lg border border-amber-300 bg-amber-100/80 shadow-sm">
<div
class="flex flex-col gap-3 border-b border-amber-200/70 px-4 py-3 text-xs text-stone-600 sm:flex-row sm:items-center sm:justify-between"
>
<p>
Showing {{ roasts.start_index() }}&ndash;{{ roasts.end_index() }} of {{ roasts.total }}
roasts
</p>
<div class="flex flex-wrap items-center gap-3">
<div class="flex items-center gap-2">
<button
type="button"
class="inline-flex items-center gap-1 rounded-full border border-amber-400 px-3 py-1 font-semibold transition hover:border-amber-500 hover:text-amber-800 disabled:cursor-not-allowed disabled:border-amber-200 disabled:text-amber-300"
{% if roasts.has_previous() %}
data-on:click="@get('{{ navigator.fragment_page_href(roasts.previous_page().unwrap()) }}', {responseOverrides: {selector: '#roast-list', mode: 'replace'}})"
{% else %}
disabled
{% endif %}
>
<span aria-hidden="true"></span>
<span>Previous</span>
</button>
{% if roasts.is_showing_all() %}
<span class="font-semibold text-amber-800">Showing all results</span>
{% else %}
<span class="font-semibold text-amber-800"
>Page {{ roasts.page }} of {{ roasts.total_pages() }}</span
>
{% endif %}
<button
type="button"
class="inline-flex items-center gap-1 rounded-full border border-amber-400 px-3 py-1 font-semibold transition hover:border-amber-500 hover:text-amber-800 disabled:cursor-not-allowed disabled:border-amber-200 disabled:text-amber-300"
{% if roasts.has_next() %}
data-on:click="@get('{{ navigator.fragment_page_href(roasts.next_page().unwrap()) }}', {responseOverrides: {selector: '#roast-list', mode: 'replace'}})"
{% else %}
disabled
{% endif %}
>
<span>Next</span>
<span aria-hidden="true"></span>
</button>
</div>
<label class="flex items-center gap-2 text-xs font-semibold text-stone-600">
<span>Rows</span>
<select
class="rounded-md border border-amber-300 bg-white px-2 py-1 text-xs font-semibold text-amber-800 transition hover:border-amber-400"
data-on:change="@get(evt.target.selectedOptions[0].dataset.href, {responseOverrides: {selector: '#roast-list', mode: 'replace'}})"
>
<option value="5" data-href="{{ navigator.fragment_rows_href("5") }}" {% if roasts.is_page_size(5) %}selected{% endif %}>5</option>
<option value="10" data-href="{{ navigator.fragment_rows_href("10") }}" {% if roasts.is_page_size(10) %}selected{% endif %}>10</option>
<option value="20" data-href="{{ navigator.fragment_rows_href("20") }}" {% if roasts.is_page_size(20) %}selected{% endif %}>20</option>
<option value="50" data-href="{{ navigator.fragment_rows_href("50") }}" {% if roasts.is_page_size(50) %}selected{% endif %}>50</option>
<option value="all" data-href="{{ navigator.fragment_rows_href("all") }}" {% if roasts.is_showing_all() %}selected{% endif %}>All</option>
</select>
</label>
</div>
</div>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-amber-200 text-left text-sm text-stone-700">
<thead class="bg-amber-200/60 text-xs font-semibold tracking-wide text-amber-900">
<tr>
{% set created_sorted = navigator.is_sorted_by("created-at") %}
<th
scope="col"
class="px-4 py-3"
aria-sort="{% if created_sorted %}{% if navigator.sort_direction() == "asc" %}ascending{% else %}descending{% endif %}{% else %}none{% endif %}"
>
<button
type="button"
class="flex items-center gap-1 text-amber-900 hover:text-amber-700"
data-on:click="@get('{{ navigator.fragment_sort_href("created-at") }}', {responseOverrides: {selector: '#roast-list', mode: 'replace'}})"
>
<span>Added</span>
{% if created_sorted %}
{% if navigator.sort_direction() == "asc" %}
<span aria-hidden="true"></span>
{% else %}
<span aria-hidden="true"></span>
{% endif %}
{% else %}
<span aria-hidden="true"></span>
{% endif %}
</button>
</th>
{% set name_sorted = navigator.is_sorted_by("name") %}
<th
scope="col"
class="px-4 py-3"
aria-sort="{% if name_sorted %}{% if navigator.sort_direction() == "asc" %}ascending{% else %}descending{% endif %}{% else %}none{% endif %}"
>
<button
type="button"
class="flex items-center gap-1 text-amber-900 hover:text-amber-700"
data-on:click="@get('{{ navigator.fragment_sort_href("name") }}', {responseOverrides: {selector: '#roast-list', mode: 'replace'}})"
>
<span>Roast</span>
{% if name_sorted %}
{% if navigator.sort_direction() == "asc" %}
<span aria-hidden="true"></span>
{% else %}
<span aria-hidden="true"></span>
{% endif %}
{% else %}
<span aria-hidden="true"></span>
{% endif %}
</button>
</th>
{% set roaster_sorted = navigator.is_sorted_by("roaster") %}
<th
scope="col"
class="px-4 py-3"
aria-sort="{% if roaster_sorted %}{% if navigator.sort_direction() == "asc" %}ascending{% else %}descending{% endif %}{% else %}none{% endif %}"
>
<button
type="button"
class="flex items-center gap-1 text-amber-900 hover:text-amber-700"
data-on:click="@get('{{ navigator.fragment_sort_href("roaster") }}', {responseOverrides: {selector: '#roast-list', mode: 'replace'}})"
>
<span>Roaster</span>
{% if roaster_sorted %}
{% if navigator.sort_direction() == "asc" %}
<span aria-hidden="true"></span>
{% else %}
<span aria-hidden="true"></span>
{% endif %}
{% else %}
<span aria-hidden="true"></span>
{% endif %}
</button>
</th>
{% set origin_sorted = navigator.is_sorted_by("origin") %}
<th
scope="col"
class="px-4 py-3"
aria-sort="{% if origin_sorted %}{% if navigator.sort_direction() == "asc" %}ascending{% else %}descending{% endif %}{% else %}none{% endif %}"
>
<button
type="button"
class="flex items-center gap-1 text-amber-900 hover:text-amber-700"
data-on:click="@get('{{ navigator.fragment_sort_href("origin") }}', {responseOverrides: {selector: '#roast-list', mode: 'replace'}})"
>
<span>Origin</span>
{% if origin_sorted %}
{% if navigator.sort_direction() == "asc" %}
<span aria-hidden="true"></span>
{% else %}
<span aria-hidden="true"></span>
{% endif %}
{% else %}
<span aria-hidden="true"></span>
{% endif %}
</button>
</th>
{% set producer_sorted = navigator.is_sorted_by("producer") %}
<th
scope="col"
class="px-4 py-3"
aria-sort="{% if producer_sorted %}{% if navigator.sort_direction() == "asc" %}ascending{% else %}descending{% endif %}{% else %}none{% endif %}"
>
<button
type="button"
class="flex items-center gap-1 text-amber-900 hover:text-amber-700"
data-on:click="@get('{{ navigator.fragment_sort_href("producer") }}', {responseOverrides: {selector: '#roast-list', mode: 'replace'}})"
>
<span>Producer</span>
{% if producer_sorted %}
{% if navigator.sort_direction() == "asc" %}
<span aria-hidden="true"></span>
{% else %}
<span aria-hidden="true"></span>
{% endif %}
{% else %}
<span aria-hidden="true"></span>
{% endif %}
</button>
</th>
<th scope="col" class="px-4 py-3">Notes</th>
<th scope="col" class="px-4 py-3 text-right">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-amber-200/70">
{% for roast in roasts.items %}
<tr
data-star-key="{{ roast.full_id }}"
data-sort-created-at="{{ roast.created_at_sort_key }}"
data-sort-name="{{ roast.name }}"
data-sort-roaster="{{ roast.roaster_label }}"
data-sort-origin="{{ roast.origin }}"
data-sort-producer="{{ roast.producer }}"
class="bg-amber-50/40 transition hover:bg-amber-50"
>
<td class="whitespace-nowrap px-4 py-3 text-xs font-medium text-stone-600">
{{ roast.created_at }}
</td>
<td class="px-4 py-3">
<div class="flex flex-col">
<a
href="{{ roast.detail_path }}"
class="font-semibold text-amber-800 hover:text-amber-600"
>{{ roast.name }}</a
>
</div>
</td>
<td class="px-4 py-3 whitespace-nowrap">{{ roast.roaster_label }}</td>
<td class="px-4 py-3 whitespace-nowrap">{{ roast.origin }}</td>
<td class="px-4 py-3 whitespace-nowrap">{{ roast.producer }}</td>
<td class="px-4 py-3">
{% if roast.tasting_notes.is_empty() %}
<span class="text-xs text-stone-500">No tasting notes yet.</span>
{% else %}
<ul class="flex flex-wrap gap-2">
{% for note in roast.tasting_notes %}
<li>
<span
class="inline-flex items-center rounded-full border border-amber-500/60 bg-amber-500/10 px-3 py-1 text-xs font-semibold text-amber-700"
>{{ note }}</span
>
</li>
{% endfor %}
</ul>
{% endif %}
</td>
<td class="px-4 py-3 text-right">
<button
type="button"
class="inline-flex h-8 w-8 items-center justify-center rounded-md text-stone-500 transition hover:text-red-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-500"
title="Delete roast"
data-on:click="confirm('Delete this roast?') && @delete('/api/v1/roasts/{{ roast.full_id }}?{{ navigator.query() }}', {responseOverrides: {selector: '#roast-list', mode: 'replace'}})"
>
<span class="sr-only">Delete</span>
<svg class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path
fill-rule="evenodd"
d="M7.5 3a1 1 0 0 1 1-1h3a1 1 0 0 1 1 1H15a1 1 0 1 1 0 2h-.4l-.74 10.36A2 2 0 0 1 11.87 17H8.13a2 2 0 0 1-1.99-1.64L5.4 5H5a1 1 0 1 1 0-2h2.5Zm.9 4.5a.75.75 0 0 1 .75.75v6a.75.75 0 1 1-1.5 0v-6a.75.75 0 0 1 .75-.75Zm3.4 0a.75.75 0 0 1 .75.75v6a.75.75 0 1 1-1.5 0v-6a.75.75 0 0 1 .75-.75Z"
clip-rule="evenodd"
/>
</svg>
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</section>
{% endif %}
</div>

View file

@ -0,0 +1,240 @@
<div id="roaster-list" class="mt-6" data-star-scope="roasters">
{% if roasters.items.is_empty() %}
<div
class="rounded-lg border border-dashed border-amber-300 bg-amber-100/40 px-4 py-6 text-sm text-stone-600"
>
<p class="text-center">
No roasters recorded yet. Use the form above to add your first roaster.
</p>
</div>
{% else %}
<section class="rounded-lg border border-amber-300 bg-amber-100/80 shadow-sm">
<div
class="flex flex-col gap-3 border-b border-amber-200/70 px-4 py-3 text-xs text-stone-600 sm:flex-row sm:items-center sm:justify-between"
>
<p>
Showing {{ roasters.start_index() }}&ndash;{{ roasters.end_index() }} of {{ roasters.total
}} roasters
</p>
<div class="flex flex-wrap items-center gap-3">
<div class="flex items-center gap-2">
<button
type="button"
class="inline-flex items-center gap-1 rounded-full border border-amber-400 px-3 py-1 font-semibold transition hover:border-amber-500 hover:text-amber-800 disabled:cursor-not-allowed disabled:border-amber-200 disabled:text-amber-300"
{% if roasters.has_previous() %}
data-on:click="@get('{{ navigator.fragment_page_href(roasters.previous_page().unwrap()) }}', {responseOverrides: {selector: '#roaster-list', mode: 'replace'}})"
{% else %}
disabled
{% endif %}
>
<span aria-hidden="true"></span>
<span>Previous</span>
</button>
{% if roasters.is_showing_all() %}
<span class="font-semibold text-amber-800">Showing all results</span>
{% else %}
<span class="font-semibold text-amber-800"
>Page {{ roasters.page }} of {{ roasters.total_pages() }}</span
>
{% endif %}
<button
type="button"
class="inline-flex items-center gap-1 rounded-full border border-amber-400 px-3 py-1 font-semibold transition hover:border-amber-500 hover:text-amber-800 disabled:cursor-not-allowed disabled:border-amber-200 disabled:text-amber-300"
{% if roasters.has_next() %}
data-on:click="@get('{{ navigator.fragment_page_href(roasters.next_page().unwrap()) }}', {responseOverrides: {selector: '#roaster-list', mode: 'replace'}})"
{% else %}
disabled
{% endif %}
>
<span>Next</span>
<span aria-hidden="true"></span>
</button>
</div>
<label class="flex items-center gap-2 text-xs font-semibold text-stone-600">
<span>Rows</span>
<select
class="rounded-md border border-amber-300 bg-white px-2 py-1 text-xs font-semibold text-amber-800 transition hover:border-amber-400"
data-on:change="@get(evt.target.selectedOptions[0].dataset.href, {responseOverrides: {selector: '#roaster-list', mode: 'replace'}})"
>
<option value="5" data-href="{{ navigator.fragment_rows_href("5") }}" {% if roasters.is_page_size(5) %}selected{% endif %}>5</option>
<option value="10" data-href="{{ navigator.fragment_rows_href("10") }}" {% if roasters.is_page_size(10) %}selected{% endif %}>10</option>
<option value="20" data-href="{{ navigator.fragment_rows_href("20") }}" {% if roasters.is_page_size(20) %}selected{% endif %}>20</option>
<option value="50" data-href="{{ navigator.fragment_rows_href("50") }}" {% if roasters.is_page_size(50) %}selected{% endif %}>50</option>
<option value="all" data-href="{{ navigator.fragment_rows_href("all") }}" {% if roasters.is_showing_all() %}selected{% endif %}>All</option>
</select>
</label>
</div>
</div>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-amber-200 text-left text-sm text-stone-700">
<thead class="bg-amber-200/60 text-xs font-semibold tracking-wide text-amber-900">
<tr>
{% set created_sorted = navigator.is_sorted_by("created-at") %}
<th
scope="col"
class="px-4 py-3"
aria-sort="{% if created_sorted %}{% if navigator.sort_direction() == "asc" %}ascending{% else %}descending{% endif %}{% else %}none{% endif %}"
>
<button
type="button"
class="flex items-center gap-1 text-amber-900 hover:text-amber-700"
data-on:click="@get('{{ navigator.fragment_sort_href("created-at") }}', {responseOverrides: {selector: '#roaster-list', mode: 'replace'}})"
>
<span>Added</span>
{% if created_sorted %}
{% if navigator.sort_direction() == "asc" %}
<span aria-hidden="true"></span>
{% else %}
<span aria-hidden="true"></span>
{% endif %}
{% else %}
<span aria-hidden="true"></span>
{% endif %}
</button>
</th>
{% set name_sorted = navigator.is_sorted_by("name") %}
<th
scope="col"
class="px-4 py-3"
aria-sort="{% if name_sorted %}{% if navigator.sort_direction() == "asc" %}ascending{% else %}descending{% endif %}{% else %}none{% endif %}"
>
<button
type="button"
class="flex items-center gap-1 text-amber-900 hover:text-amber-700"
data-on:click="@get('{{ navigator.fragment_sort_href("name") }}', {responseOverrides: {selector: '#roaster-list', mode: 'replace'}})"
>
<span>Name</span>
{% if name_sorted %}
{% if navigator.sort_direction() == "asc" %}
<span aria-hidden="true"></span>
{% else %}
<span aria-hidden="true"></span>
{% endif %}
{% else %}
<span aria-hidden="true"></span>
{% endif %}
</button>
</th>
{% set country_sorted = navigator.is_sorted_by("country") %}
<th
scope="col"
class="px-4 py-3"
aria-sort="{% if country_sorted %}{% if navigator.sort_direction() == "asc" %}ascending{% else %}descending{% endif %}{% else %}none{% endif %}"
>
<button
type="button"
class="flex items-center gap-1 text-amber-900 hover:text-amber-700"
data-on:click="@get('{{ navigator.fragment_sort_href("country") }}', {responseOverrides: {selector: '#roaster-list', mode: 'replace'}})"
>
<span>Country</span>
{% if country_sorted %}
{% if navigator.sort_direction() == "asc" %}
<span aria-hidden="true"></span>
{% else %}
<span aria-hidden="true"></span>
{% endif %}
{% else %}
<span aria-hidden="true"></span>
{% endif %}
</button>
</th>
{% set city_sorted = navigator.is_sorted_by("city") %}
<th
scope="col"
class="px-4 py-3"
aria-sort="{% if city_sorted %}{% if navigator.sort_direction() == "asc" %}ascending{% else %}descending{% endif %}{% else %}none{% endif %}"
>
<button
type="button"
class="flex items-center gap-1 text-amber-900 hover:text-amber-700"
data-on:click="@get('{{ navigator.fragment_sort_href("city") }}', {responseOverrides: {selector: '#roaster-list', mode: 'replace'}})"
>
<span>City</span>
{% if city_sorted %}
{% if navigator.sort_direction() == "asc" %}
<span aria-hidden="true"></span>
{% else %}
<span aria-hidden="true"></span>
{% endif %}
{% else %}
<span aria-hidden="true"></span>
{% endif %}
</button>
</th>
<th scope="col" class="px-4 py-3">Homepage</th>
<th scope="col" class="px-4 py-3">Notes</th>
<th scope="col" class="px-4 py-3 text-right">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-amber-200/70">
{% for roaster in roasters.items %}
<tr
data-star-key="{{ roaster.id }}"
data-sort-created-at="{{ roaster.created_at_sort_key }}"
data-sort-name="{{ roaster.name }}"
data-sort-country="{{ roaster.country }}"
data-sort-city="{{ roaster.city }}"
class="bg-amber-50/40 transition hover:bg-amber-50"
>
<td class="whitespace-nowrap px-4 py-3 text-xs font-medium text-stone-600">
{{ roaster.created_at }}
</td>
<td class="px-4 py-3">
<a
href="{{ roaster.detail_path }}"
class="font-semibold text-amber-800 hover:text-amber-600"
>{{ roaster.name }}</a
>
</td>
<td class="px-4 py-3 whitespace-nowrap">{{ roaster.country }}</td>
<td class="px-4 py-3 whitespace-nowrap">{{ roaster.city }}</td>
<td class="px-4 py-3">
{% if roaster.has_homepage %}
<a
class="inline-flex items-center justify-center rounded-md border border-amber-400 bg-amber-200/50 p-2 text-amber-800 transition hover:border-amber-500 hover:text-amber-900"
href="{{ roaster.homepage_url }}"
target="_blank"
rel="noreferrer noopener"
aria-label="Visit {{ roaster.name }} homepage"
>
<svg class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path
fill-rule="evenodd"
d="M11.3 2a.7.7 0 0 0 0 1.4h3.3l-8.1 8.1a.7.7 0 1 0 1 1l8.1-8.1v3.3a.7.7 0 1 0 1.4 0V2.7A.7.7 0 0 0 16.3 2h-5Z"
clip-rule="evenodd"
/>
<path
d="M4.7 5.2a1.5 1.5 0 0 1 1.5-1.5h2.1a.7.7 0 1 0 0-1.4H6.2a2.9 2.9 0 0 0-2.9 2.9v7.6a2.9 2.9 0 0 0 2.9 2.9h7.6a2.9 2.9 0 0 0 2.9-2.9v-2.1a.7.7 0 1 0-1.4 0v2.1a1.5 1.5 0 0 1-1.5 1.5H6.1a1.5 1.5 0 0 1-1.5-1.5V5.2Z"
/>
</svg>
</a>
{% else %}
<span class="text-stone-400">&mdash;</span>
{% endif %}
</td>
<td class="px-4 py-3 text-sm text-stone-600">{{ roaster.notes }}</td>
<td class="px-4 py-3 text-right">
<button
type="button"
class="inline-flex h-8 w-8 items-center justify-center rounded-md text-stone-500 transition hover:text-red-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-500"
title="Delete roaster"
data-on:click="confirm('Delete this roaster?') && @delete('/api/v1/roasters/{{ roaster.id }}?{{ navigator.query() }}', {responseOverrides: {selector: '#roaster-list', mode: 'replace'}})"
>
<span class="sr-only">Delete</span>
<svg class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path
fill-rule="evenodd"
d="M7.5 3a1 1 0 0 1 1-1h3a1 1 0 0 1 1 1H15a1 1 0 1 1 0 2h-.4l-.74 10.36A2 2 0 0 1 11.87 17H8.13a2 2 0 0 1-1.99-1.64L5.4 5H5a1 1 0 1 1 0-2h2.5Zm.9 4.5a.75.75 0 0 1 .75.75v6a.75.75 0 1 1-1.5 0v-6a.75.75 0 0 1 .75-.75Zm3.4 0a.75.75 0 0 1 .75.75v6a.75.75 0 1 1-1.5 0v-6a.75.75 0 0 1 .75-.75Z"
clip-rule="evenodd"
/>
</svg>
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</section>
{% endif %}
</div>

View file

@ -0,0 +1,50 @@
{% extends "base.html" %} {% block title %}Brewlog · Roast · {{ roast.name }}{% endblock %} {%
block content %}
<header class="flex flex-col gap-2">
<h1 class="text-3xl font-semibold">{{ roast.name }}</h1>
<p class="max-w-2xl text-sm text-stone-600">Detailed view for this roast.</p>
</header>
<section class="grid gap-4 rounded-lg border border-amber-300 bg-amber-100/80 p-5 shadow-sm">
<dl class="grid gap-2 text-sm text-stone-700">
<div class="flex justify-between gap-2">
<dt class="font-medium text-stone-500">Roaster</dt>
<dd class="text-right">{{ roast.roaster_label }}</dd>
</div>
<div class="flex justify-between gap-2">
<dt class="font-medium text-stone-500">Origin</dt>
<dd class="text-right">{{ roast.origin }}</dd>
</div>
<div class="flex justify-between gap-2">
<dt class="font-medium text-stone-500">Region</dt>
<dd class="text-right">{{ roast.region }}</dd>
</div>
<div class="flex justify-between gap-2">
<dt class="font-medium text-stone-500">Producer</dt>
<dd class="text-right">{{ roast.producer }}</dd>
</div>
<div class="flex justify-between gap-2">
<dt class="font-medium text-stone-500">Process</dt>
<dd class="text-right">{{ roast.process }}</dd>
</div>
<div class="flex justify-between gap-2">
<dt class="font-medium text-stone-500">Created</dt>
<dd class="text-right">{{ roast.created_at }}</dd>
</div>
</dl>
{% if roast.tasting_notes.is_empty() %}
<p class="text-sm text-stone-600">No tasting notes yet.</p>
{% else %}
<ul class="flex flex-wrap gap-2 text-sm">
{% for note in roast.tasting_notes %}
<li>
<span
class="inline-flex items-center rounded-full border border-amber-500/60 bg-amber-500/10 px-3 py-1 text-xs font-semibold text-amber-700"
>{{ note }}</span
>
</li>
{% endfor %}
</ul>
{% endif %}
</section>
{% endblock %}

View file

@ -0,0 +1,116 @@
{% extends "base.html" %} {% block title %}Brewlog · Roaster · {{ roaster.name }}{% endblock %} {%
block content %}
<header class="flex flex-col gap-2">
<h1 class="text-3xl font-semibold">{{ roaster.name }}</h1>
<p class="max-w-2xl text-sm text-stone-600">Detailed view for {{ roaster.name }}.</p>
</header>
<section class="grid gap-4 rounded-lg border border-amber-300 bg-amber-100/80 p-5 shadow-sm">
<dl class="grid gap-2 text-sm text-stone-700">
<div class="flex justify-between gap-2">
<dt class="font-medium text-stone-500">Country</dt>
<dd class="text-right">{{ roaster.country }}</dd>
</div>
<div class="flex justify-between gap-2">
<dt class="font-medium text-stone-500">City</dt>
<dd class="text-right">{{ roaster.city }}</dd>
</div>
<div class="flex items-center justify-between gap-2">
<dt class="font-medium text-stone-500">Homepage</dt>
<dd class="flex justify-end">
{% if roaster.has_homepage %}
<a
class="inline-flex items-center gap-2 rounded-md border border-amber-400 bg-amber-200/60 px-3 py-1 text-xs font-semibold text-amber-800 transition hover:border-amber-500 hover:bg-amber-200 hover:text-amber-900"
href="{{ roaster.homepage_url }}"
target="_blank"
rel="noreferrer noopener"
aria-label="Visit {{ roaster.name }} homepage"
>
<svg class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path
fill-rule="evenodd"
d="M11.3 2a.7.7 0 0 0 0 1.4h3.3l-8.1 8.1a.7.7 0 1 0 1 1l8.1-8.1v3.3a.7.7 0 1 0 1.4 0V2.7A.7.7 0 0 0 16.3 2h-5Z"
clip-rule="evenodd"
/>
<path
d="M4.7 5.2a1.5 1.5 0 0 1 1.5-1.5h2.1a.7.7 0 1 0 0-1.4H6.2a2.9 2.9 0 0 0-2.9 2.9v7.6a2.9 2.9 0 0 0 2.9 2.9h7.6a2.9 2.9 0 0 0 2.9-2.9v-2.1a.7.7 0 1 0-1.4 0v2.1a1.5 1.5 0 0 1-1.5 1.5H6.1a1.5 1.5 0 0 1-1.5-1.5V5.2Z"
/>
</svg>
<span>Visit</span>
</a>
{% else %}
<span>&mdash;</span>
{% endif %}
</dd>
</div>
<div class="flex justify-between gap-2">
<dt class="font-medium text-stone-500">Created</dt>
<dd class="text-right">{{ roaster.created_at }}</dd>
</div>
</dl>
<p class="text-sm text-stone-600">{{ roaster.notes }}</p>
</section>
<section class="flex flex-col gap-4">
<div class="flex items-center justify-between">
<h2 class="text-2xl font-semibold text-amber-800">Roasts</h2>
</div>
{% if roasts.is_empty() %}
<p class="text-sm text-stone-600">This roaster has no roasts yet. Use the CLI to add one.</p>
{% else %}
<div class="overflow-x-auto rounded-lg border border-amber-300 bg-amber-100/80 shadow-sm">
<table class="min-w-full divide-y divide-amber-200 text-left text-sm text-stone-700">
<thead class="bg-amber-200/60 text-xs font-semibold tracking-wide text-amber-900">
<tr>
<th scope="col" class="px-4 py-3">Added</th>
<th scope="col" class="px-4 py-3">Roast</th>
<th scope="col" class="px-4 py-3">Origin</th>
<th scope="col" class="px-4 py-3">Region</th>
<th scope="col" class="px-4 py-3">Producer</th>
<th scope="col" class="px-4 py-3">Process</th>
<th scope="col" class="px-4 py-3">Tasting Notes</th>
</tr>
</thead>
<tbody class="divide-y divide-amber-200/70">
{% for roast in roasts %}
<tr
data-star-key="{{ roast.full_id }}"
class="bg-amber-50/40 transition hover:bg-amber-50"
>
<td class="whitespace-nowrap px-4 py-3 text-xs font-medium text-stone-600">
{{ roast.created_at }}
</td>
<td class="px-4 py-3">
<a
href="{{ roast.detail_path }}"
class="font-semibold text-amber-800 hover:text-amber-600"
>{{ roast.name }}</a
>
</td>
<td class="px-4 py-3 whitespace-nowrap">{{ roast.origin }}</td>
<td class="px-4 py-3 whitespace-nowrap">{{ roast.region }}</td>
<td class="px-4 py-3 whitespace-nowrap">{{ roast.producer }}</td>
<td class="px-4 py-3 whitespace-nowrap">{{ roast.process }}</td>
<td class="px-4 py-3">
{% if roast.tasting_notes.is_empty() %}
<span class="text-xs text-stone-500">No tasting notes yet.</span>
{% else %}
<ul class="flex flex-wrap gap-2">
{% for note in roast.tasting_notes %}
<li>
<span
class="inline-flex items-center rounded-full border border-amber-500/60 bg-amber-500/10 px-3 py-1 text-xs font-semibold text-amber-700"
>{{ note }}</span
>
</li>
{% endfor %}
</ul>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
</section>
{% endblock %}

101
templates/roasters.html Normal file
View file

@ -0,0 +1,101 @@
{% extends "base.html" %} {% block title %}Brewlog · Roasters{% endblock %} {% block content %}
<section data-signals:show-form="false">
<header class="flex flex-wrap items-start justify-between gap-4">
<div class="flex flex-col gap-2">
<h1 class="text-3xl font-semibold">Roasters</h1>
<p class="max-w-2xl text-sm text-stone-600">Browse the coffee roasters known to Brewlog.</p>
</div>
<button
type="button"
class="flex h-10 w-10 items-center justify-center rounded-full border border-amber-500 text-2xl font-semibold text-amber-700 transition hover:border-amber-400 hover:text-amber-600"
data-class:hidden="$showForm"
data-on:click="$showForm = true"
aria-label="Add new roaster"
>
<span aria-hidden="true">+</span>
</button>
</header>
<div
class="mt-6 rounded-lg border border-amber-300 bg-amber-100/80 p-5 shadow-sm"
data-show="$showForm"
style="display: none"
>
<div>
<h2 class="text-lg font-semibold text-amber-700">New Roaster</h2>
<p class="mt-1 text-sm text-stone-600">
Provide the core details and Brewlog will keep track of everything for you.
</p>
</div>
<form
method="post"
action="/api/v1/roasters"
class="mt-4 flex flex-col gap-4"
data-on:submit="@post('/api/v1/roasters?{{ navigator.query_for_page(1) }}', {contentType: 'form', responseOverrides: {selector: '#roaster-list', mode: 'replace'}})"
data-ref="form"
data-on:datastar-fetch="evt.detail.type === 'finished' && ($showForm = false, $form && $form.reset())"
>
<div class="grid gap-4 sm:grid-cols-2">
<label class="flex flex-col gap-1 text-sm">
<span class="text-stone-700">Name *</span>
<input
type="text"
name="name"
required
class="input-field"
placeholder="Example Coffee Roasters"
/>
</label>
<label class="flex flex-col gap-1 text-sm">
<span class="text-stone-700">Country *</span>
<input
type="text"
name="country"
required
class="input-field"
placeholder="United States"
/>
</label>
<label class="flex flex-col gap-1 text-sm">
<span class="text-stone-700">City</span>
<input type="text" name="city" class="input-field" placeholder="Portland" />
</label>
<label class="flex flex-col gap-1 text-sm">
<span class="text-stone-700">Homepage</span>
<input
type="url"
name="homepage"
class="input-field"
placeholder="https://example.coffee"
/>
</label>
<label class="sm:col-span-2 flex flex-col gap-1 text-sm">
<span class="text-stone-700">Notes</span>
<textarea
name="notes"
rows="3"
class="input-field"
placeholder="Short description, sourcing approach, or contact details"
></textarea>
</label>
</div>
<div class="flex items-center justify-end gap-2">
<button
type="button"
class="rounded-md border border-amber-300 px-4 py-2 text-sm font-semibold text-stone-700 transition hover:border-amber-400 hover:text-stone-800"
data-on:click="($showForm = false, $form && $form.reset())"
>
Cancel
</button>
<button
type="submit"
class="rounded-md bg-amber-600 px-4 py-2 text-sm font-semibold text-amber-50 transition hover:bg-amber-500"
>
Save Roaster
</button>
</div>
</form>
</div>
</section>
{% include "partials/roaster_list.html" %} {% endblock %}

125
templates/roasts.html Normal file
View file

@ -0,0 +1,125 @@
{% extends "base.html" %} {% block title %}Brewlog · Roasts{% endblock %} {% block content %}
<section data-signals:show-form="false">
<header class="flex flex-wrap items-start justify-between gap-4">
<div class="flex flex-col gap-2">
<h1 class="text-3xl font-semibold">Roasts</h1>
<p class="max-w-2xl text-sm text-stone-600">Explore the latest roasts logged in Brewlog.</p>
</div>
{% if !roaster_options.is_empty() %}
<button
type="button"
class="flex h-10 w-10 items-center justify-center rounded-full border border-amber-500 text-2xl font-semibold text-amber-700 transition hover:border-amber-400 hover:text-amber-600"
data-class:hidden="$showForm"
data-on:click="$showForm = true"
aria-label="Add new roast"
>
<span aria-hidden="true">+</span>
</button>
{% endif %}
</header>
{% if roaster_options.is_empty() %}
<div
class="mt-6 rounded-lg border border-dashed border-amber-300 bg-amber-100/60 p-5 text-sm text-stone-600"
>
<h2 class="text-lg font-semibold text-amber-700">Add a roaster first</h2>
<p class="mt-2">
Roasts need a roaster.
<a class="text-amber-700 hover:text-amber-600 underline" href="/roasters"
>Create a roaster</a
>
to enable this form.
</p>
</div>
{% else %}
<div
class="mt-6 rounded-lg border border-amber-300 bg-amber-100/80 p-5 shadow-sm"
data-show="$showForm"
style="display: none"
>
<div>
<h2 class="text-lg font-semibold text-amber-700">New Roast</h2>
<p class="mt-1 text-sm text-stone-600">
Select a roaster, describe the roast, and Brewlog will take care of the rest.
</p>
</div>
<form
method="post"
action="/api/v1/roasts"
class="mt-4 flex flex-col gap-4"
data-on:submit="@post('/api/v1/roasts?{{ navigator.query_for_page(1) }}', {contentType: 'form', responseOverrides: {selector: '#roast-list', mode: 'replace'}})"
data-ref="form"
data-on:datastar-fetch="evt.detail.type === 'finished' && ($showForm = false, $form && $form.reset())"
>
<div class="grid gap-4 sm:grid-cols-2">
<label class="flex flex-col gap-1 text-sm">
<span class="text-stone-700">Roaster *</span>
<select name="roaster_id" required class="input-field">
<option value="">Select a roaster</option>
{% for roaster in roaster_options %}
<option value="{{ roaster.id }}">{{ roaster.name }}</option>
{% endfor %}
</select>
</label>
<label class="flex flex-col gap-1 text-sm">
<span class="text-stone-700">Roast Name *</span>
<input
type="text"
name="name"
required
class="input-field"
placeholder="Ethiopia Yirgacheffe"
/>
</label>
<label class="flex flex-col gap-1 text-sm">
<span class="text-stone-700">Origin</span>
<input type="text" name="origin" class="input-field" placeholder="Ethiopia" />
</label>
<label class="flex flex-col gap-1 text-sm">
<span class="text-stone-700">Region</span>
<input type="text" name="region" class="input-field" placeholder="Guji" />
</label>
<label class="flex flex-col gap-1 text-sm">
<span class="text-stone-700">Producer</span>
<input
type="text"
name="producer"
class="input-field"
placeholder="Chelbesa Cooperative"
/>
</label>
<label class="flex flex-col gap-1 text-sm">
<span class="text-stone-700">Process</span>
<input type="text" name="process" class="input-field" placeholder="Washed" />
</label>
<label class="sm:col-span-2 flex flex-col gap-1 text-sm">
<span class="text-stone-700">Tasting Notes (comma or newline separated)</span>
<textarea
name="tasting_notes"
rows="2"
class="input-field"
placeholder="Blueberry, Jasmine"
></textarea>
</label>
</div>
<div class="flex items-center justify-end gap-2">
<button
type="button"
class="rounded-md border border-amber-300 px-4 py-2 text-sm font-semibold text-stone-700 transition hover:border-amber-400 hover:text-stone-800"
data-on:click="($showForm = false, $form && $form.reset())"
>
Cancel
</button>
<button
type="submit"
class="rounded-md bg-amber-600 px-4 py-2 text-sm font-semibold text-amber-50 transition hover:bg-amber-500"
>
Save Roast
</button>
</div>
</form>
</div>
{% endif %}
</section>
{% include "partials/roast_list.html" %} {% endblock %}

26
templates/styles.css Normal file
View file

@ -0,0 +1,26 @@
:root {
color-scheme: light;
}
body {
font-family: "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
a {
text-decoration: none;
}
.input-field {
border-radius: 0.5rem;
border: 1px solid rgba(120, 53, 15, 0.3);
background-color: rgba(254, 243, 199, 0.85);
padding: 0.6rem 0.75rem;
color: #4a2f0b;
transition: border-color 150ms ease, box-shadow 150ms ease;
}
.input-field:focus {
outline: none;
border-color: rgba(180, 83, 9, 0.6);
box-shadow: 0 0 0 2px rgba(180, 83, 9, 0.2);
}

120
templates/timeline.html Normal file
View file

@ -0,0 +1,120 @@
{% extends "base.html" %} {% block title %}Brewlog · Timeline{% endblock %} {% block content %}
<header class="flex flex-col gap-2">
<h1 class="text-3xl font-semibold">Timeline</h1>
<p class="max-w-2xl text-sm text-stone-600">
Follow the history of roasters and roasts in Brewlog.
</p>
</header>
<div class="mt-8 grid gap-12 lg:grid-cols-[minmax(0,1fr),14rem]">
<section class="space-y-12">
{% if months.is_empty() %}
<p
class="rounded-lg border border-dashed border-amber-300 bg-amber-100/60 p-6 text-sm text-stone-600"
>
No events yet. Create roasters or roasts to populate the timeline.
</p>
{% else %} {% for month in months %}
<div id="{{ month.anchor }}" class="scroll-mt-24">
<h2 class="text-2xl font-semibold text-amber-800">{{ month.heading }}</h2>
<ol class="mt-6 space-y-8 border-l-2 border-amber-200 pl-4 sm:pl-8">
{% for event in month.events %}
<li class="relative pl-6 sm:pl-10">
<span
class="absolute left-[-10px] top-2 h-5 w-5 rounded-full border-[6px] border-amber-50 {{ event.accent_class }} sm:left-[-14px]"
></span>
<div class="rounded-lg border {{ event.card_border_class }} p-5 shadow-sm">
<div class="flex flex-wrap items-center justify-between gap-2">
<span
class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold {{ event.badge_class }}"
>{{ event.kind_label }}</span
>
<time
datetime="{{ event.iso_timestamp }}"
class="text-xs uppercase tracking-wide text-stone-500"
>
{{ event.date_label }}{% if let Some(label) = event.time_label %} · {{ label }}{%
endif %}
</time>
</div>
<h3 class="mt-3 flex items-center gap-2 text-lg font-semibold {{ event.title_class }}">
<a href="{{ event.link }}" class="hover:text-amber-600">{{ event.title }}</a>
{% if let Some(url) = event.external_link %}
<a
href="{{ url }}"
class="inline-flex h-7 w-7 items-center justify-center text-amber-700 transition hover:text-amber-500"
target="_blank"
rel="noreferrer noopener"
aria-label="Open external link"
>
<svg
class="h-3.5 w-3.5"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fill-rule="evenodd"
d="M11.3 2a.7.7 0 0 0 0 1.4h3.3l-8.1 8.1a.7.7 0 1 0 1 1l8.1-8.1v3.3a.7.7 0 1 0 1.4 0V2.7A.7.7 0 0 0 16.3 2h-5Z"
clip-rule="evenodd"
/>
<path
d="M4.7 5.2a1.5 1.5 0 0 1 1.5-1.5h2.1a.7.7 0 1 0 0-1.4H6.2a2.9 2.9 0 0 0-2.9 2.9v7.6a2.9 2.9 0 0 0 2.9 2.9h7.6a2.9 2.9 0 0 0 2.9-2.9v-2.1a.7.7 0 1 0-1.4 0v2.1a1.5 1.5 0 0 1-1.5 1.5H6.1a1.5 1.5 0 0 1-1.5-1.5V5.2Z"
/>
</svg>
<span class="sr-only">Open external link</span>
</a>
{% endif %}
</h3>
{% if event.details.len() > 0 %}
<dl class="mt-4 flex flex-col gap-2 text-sm text-stone-600">
{% for detail in event.details %}
<div class="flex justify-between gap-2">
<dt class="font-medium text-stone-500">{{ detail.label }}</dt>
<dd class="text-right">{{ detail.value }}</dd>
</div>
{% endfor %}
</dl>
{% endif %} {% if let Some(notes) = event.tasting_notes %} {% if notes.is_empty() %}
<p class="mt-4 text-sm text-stone-600">No tasting notes yet.</p>
{% else %}
<ul class="mt-4 flex flex-wrap gap-2">
{% for note in notes %}
<li>
<span
class="inline-flex items-center rounded-full border border-amber-500/60 bg-amber-500/10 px-3 py-1 text-xs font-semibold text-amber-700"
>{{ note }}</span
>
</li>
{% endfor %}
</ul>
{% endif %} {% endif %}
</div>
</li>
{% endfor %}
</ol>
</div>
{% endfor %} {% endif %}
</section>
<aside class="sticky top-24 self-start lg:top-28">
<div class="rounded-lg border border-amber-200 bg-amber-50/80 p-4 shadow-sm">
<h2 class="text-sm font-semibold uppercase tracking-wide text-stone-500">Jump to</h2>
{% if months.is_empty() %}
<p class="mt-3 text-sm text-stone-600">
Timeline navigation will appear once events are available.
</p>
{% else %}
<nav class="mt-3 flex flex-col gap-2 text-sm">
{% for month in months %}
<a
href="#{{ month.anchor }}"
class="rounded-md border border-transparent px-3 py-2 transition hover:border-amber-400 hover:bg-amber-100/70 hover:text-amber-700"
>{{ month.heading }}</a
>
{% endfor %}
</nav>
{% endif %}
</div>
</aside>
</div>
{% endblock %}