Compare commits

..

No commits in common. "8c5697ccc2b247e79771bfe1a0d4424cc58f8eb4" and "1d2b071fe9706de4fc2b4706fd9f07c76a263e31" have entirely different histories.

26 changed files with 399 additions and 1154 deletions

View file

@ -1,5 +1,5 @@
[tools] [tools]
rust = { version = "1.94", components = "rustfmt,clippy,rust-analyzer,rust-src" } rust = { version = "1.93", components = "rustfmt,clippy,rust-analyzer,rust-src" }
node = "latest" node = "latest"
uv = "latest" uv = "latest"
prek = "latest" prek = "latest"

927
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -7,7 +7,7 @@ edition = "2024"
anyhow = "1.0" anyhow = "1.0"
async-trait = "0.1" async-trait = "0.1"
axum = { version = "0.8", features = ["macros"] } axum = { version = "0.8", features = ["macros"] }
askama = "0.16" askama = "0.15"
base64 = "0.22" base64 = "0.22"
chrono = { version = "0.4", features = ["serde", "clock"] } chrono = { version = "0.4", features = ["serde", "clock"] }
clap = { version = "4.6", features = ["derive", "env"] } clap = { version = "4.6", features = ["derive", "env"] }
@ -20,7 +20,7 @@ serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0" serde_json = "1.0"
rand = "0.10" rand = "0.10"
sha2 = "0.11" sha2 = "0.11"
sqlx = { version = "0.9", default-features = false, features = [ sqlx = { version = "0.8", default-features = false, features = [
"runtime-tokio", "runtime-tokio",
"tls-rustls", "tls-rustls",
"macros", "macros",
@ -53,7 +53,7 @@ once_cell = "1.21"
webauthn-authenticator-rs = { version = "0.5", features = ["softpasskey"] } webauthn-authenticator-rs = { version = "0.5", features = ["softpasskey"] }
wiremock = "0.6" wiremock = "0.6"
paste = "1.0.15" paste = "1.0.15"
thirtyfour = "0.37" thirtyfour = "0.36"
[[test]] [[test]]
name = "cli" name = "cli"

View file

@ -4,23 +4,16 @@
# Uses chisel to create a minimal Ubuntu rootfs for the runtime image. # Uses chisel to create a minimal Ubuntu rootfs for the runtime image.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Builder — Ubuntu with Rust toolchain installed # Builder — Ubuntu with Rust toolchain pre-installed
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
FROM ubuntu:26.04 AS builder FROM ubuntu/rust:1.93-26.04_edge AS builder
ENV RUSTUP_HOME=/usr/local/rustup \
CARGO_HOME=/usr/local/cargo \
PATH=/usr/local/cargo/bin:${PATH}
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
build-essential \
pkg-config \ pkg-config \
libssl-dev \ libssl-dev \
mold \ mold \
binutils \ binutils \
curl \ curl \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
RUN curl -sSf https://sh.rustup.rs \
| sh -s -- -y --profile minimal --default-toolchain 1.94.1
# Install tailwindcss standalone (needed by build.rs) # Install tailwindcss standalone (needed by build.rs)
RUN mkdir -p /usr/local/bin \ RUN mkdir -p /usr/local/bin \
@ -50,22 +43,17 @@ RUN curl -sL "https://github.com/canonical/chisel/releases/download/v1.4.1/chise
RUN mkdir /rootfs && chisel cut --root /rootfs \ RUN mkdir /rootfs && chisel cut --root /rootfs \
base-files_base \ base-files_base \
base-files_release-info \ base-files_release-info \
base-passwd_data \
ca-certificates_data \ ca-certificates_data \
libgcc-s1_libs \ libgcc-s1_libs \
libc6_libs \ libc6_libs \
libssl3t64_libs \ libssl3t64_libs \
openssl_bins openssl_bins
RUN useradd --root /rootfs -u 1000 -U -M -s /bin/false brewlog \
&& mkdir -p /rootfs/home/brewlog /rootfs/data \
&& chown 1000:1000 /rootfs/home/brewlog /rootfs/data
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Runtime — scratch with chisel rootfs # Runtime — scratch with chisel rootfs
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
FROM scratch FROM scratch
COPY --from=chisel /rootfs / COPY --from=chisel /rootfs /
COPY --from=builder /out/brewlog /usr/local/bin/brewlog COPY --from=builder /out/brewlog /usr/local/bin/brewlog
USER 1000:1000 USER 65534:65534
ENTRYPOINT ["brewlog", "serve", "--database-url", "sqlite:///data/brewlog.db"] ENTRYPOINT ["brewlog", "serve", "--database-url", "sqlite:///data/brewlog.db"]

View file

@ -59,10 +59,6 @@ This link expires in 1 hour.
Open that URL, choose a display name, and register a passkey. This creates an account and Open that URL, choose a display name, and register a passkey. This creates an account and
signs in automatically. signs in automatically.
To onboard additional users later, generate a fresh invite link from the **Admin** page
("Invite" → "New Invite") or from the CLI (`brewlog invite create`). Each link is valid for
7 days and can be used once.
### Install from Git ### Install from Git
To build and install from source, you'll need a working Rust toolchain: To build and install from source, you'll need a working Rust toolchain:
@ -93,13 +89,6 @@ export BREWLOG_TOKEN="<token from above>"
brewlog roaster add --name "Radical Roasters" --country "United Kingdom" brewlog roaster add --name "Radical Roasters" --country "United Kingdom"
``` ```
To invite another person, generate a single-use registration link (valid for 7 days):
```bash
brewlog invite create
# Prints a https://.../register/<token> link to share with the new user
```
Run `brewlog --help` for the full command reference. Run `brewlog --help` for the full command reference.
## Configuration ## Configuration

View file

@ -1,75 +0,0 @@
use axum::Json;
use axum::extract::State;
use axum::http::HeaderMap;
use axum::response::{IntoResponse, Response};
use chrono::{DateTime, Duration, Utc};
use serde::Serialize;
use tracing::{error, info};
use crate::application::auth::AuthenticatedUser;
use crate::application::errors::{ApiError, AppError};
use crate::application::routes::support::{is_datastar_request, render_signals_json};
use crate::application::state::AppState;
use crate::domain::registration_tokens::NewRegistrationToken;
use crate::infrastructure::auth::{generate_session_token, hash_token};
/// How long a generated invite link stays valid. Clamped by
/// `NewRegistrationToken::new` to `MAX_TOKEN_DURATION` (7 days).
const INVITE_VALIDITY: Duration = Duration::days(7);
#[derive(Debug, Serialize)]
pub struct CreateInviteResponse {
pub url: String,
pub token: String,
pub expires_at: DateTime<Utc>,
}
/// Generate an additional registration link so an authenticated user can
/// onboard another person. Mirrors the first-user bootstrap in `server.rs`,
/// but requires authentication and works after users already exist.
#[tracing::instrument(skip(state, auth_user, headers))]
pub async fn create_invite(
State(state): State<AppState>,
auth_user: AuthenticatedUser,
headers: HeaderMap,
) -> Result<Response, ApiError> {
let token = generate_session_token();
let token_hash = hash_token(&token);
let now = Utc::now();
let expires_at = now.checked_add_signed(INVITE_VALIDITY).ok_or_else(|| {
error!("timestamp overflow computing invite expiry");
ApiError::from(AppError::unexpected("failed to create invite"))
})?;
let new_token = NewRegistrationToken::new(token_hash, now, expires_at);
let stored = state
.registration_token_repo
.insert(new_token)
.await
.map_err(|err| {
error!(error = %err, "failed to store invite token");
ApiError::from(AppError::unexpected("failed to create invite"))
})?;
let url = format!("{}/register/{}", crate::base_url(), token);
info!(invite_id = %stored.id, user_id = %auth_user.0.id, "invite link created");
if is_datastar_request(&headers) {
use serde_json::Value;
let signals = vec![
("_invite-url", Value::String(url)),
("_invite-created", Value::Bool(true)),
("_creating-invite", Value::Bool(false)),
];
render_signals_json(&signals).map_err(ApiError::from)
} else {
Ok(Json(CreateInviteResponse {
url,
token,
expires_at: stored.expires_at,
})
.into_response())
}
}

View file

@ -1,3 +1,2 @@
pub(crate) mod invites;
pub(crate) mod tokens; pub(crate) mod tokens;
pub(crate) mod webauthn; pub(crate) mod webauthn;

View file

@ -7,7 +7,7 @@ pub(crate) mod system;
// Re-exports for backward compatibility // Re-exports for backward compatibility
pub(crate) use analytics::stats; pub(crate) use analytics::stats;
pub(crate) use auth::{invites, tokens, webauthn}; pub(crate) use auth::{tokens, webauthn};
pub(crate) use coffee::{bags, brews, cafes, checkin, cups, gear, roasters, roasts, scan}; pub(crate) use coffee::{bags, brews, cafes, checkin, cups, gear, roasters, roasts, scan};
pub(crate) use system::{admin, backup, timeline}; pub(crate) use system::{admin, backup, timeline};
@ -91,7 +91,6 @@ pub(super) fn router() -> axum::Router<AppState> {
post(tokens::create_token).get(tokens::list_tokens), post(tokens::create_token).get(tokens::list_tokens),
) )
.route("/tokens/{id}/revoke", post(tokens::revoke_token)) .route("/tokens/{id}/revoke", post(tokens::revoke_token))
.route("/invites", post(invites::create_invite))
.route("/passkeys", get(admin::list_passkeys)) .route("/passkeys", get(admin::list_passkeys))
.route( .route(
"/passkeys/{id}", "/passkeys/{id}",

View file

@ -197,8 +197,8 @@ pub trait GearRepository: Send + Sync {
#[async_trait] #[async_trait]
pub trait BrewRepository: Send + Sync { pub trait BrewRepository: Send + Sync {
/// Insert a new brew and deduct `coffee_weight` from the bag's remaining amount, /// Insert a new brew and deduct `coffee_weight` from the bag's remaining amount.
/// clamping to zero. Rejects if the bag is closed. This is a transactional operation. /// This is a transactional operation.
async fn insert(&self, brew: NewBrew) -> Result<Brew, RepositoryError>; async fn insert(&self, brew: NewBrew) -> Result<Brew, RepositoryError>;
async fn get(&self, id: BrewId) -> Result<Brew, RepositoryError>; async fn get(&self, id: BrewId) -> Result<Brew, RepositoryError>;
async fn get_with_details(&self, id: BrewId) -> Result<BrewWithDetails, RepositoryError>; async fn get_with_details(&self, id: BrewId) -> Result<BrewWithDetails, RepositoryError>;

View file

@ -8,9 +8,7 @@ pub const OPENROUTER_URL: &str = "https://openrouter.ai/api/v1/chat/completions"
const USER_AGENT: &str = "Brewlog/1.0"; const USER_AGENT: &str = "Brewlog/1.0";
const REQUEST_TIMEOUT: Duration = Duration::from_secs(90); const REQUEST_TIMEOUT: Duration = Duration::from_secs(90);
const ROASTER_PROMPT: &str = r#"Resolve the input as a coffee roaster lookup, then extract information about the roaster. The input may be only a short or ambiguous brand name (for example, "Prolog"). Always use web search to identify the coffee-roasting business that best matches the input and verify its details, preferring the roaster's official website. Use coffee-specific context to disambiguate it from unrelated businesses or meanings. If no coffee roaster can be identified with confidence, return an empty JSON object. const ROASTER_PROMPT: &str = r#"Extract coffee roaster information from this input. Use web search to look up any details you cannot determine from the input alone (e.g. the roaster's website, location, or background). Return a JSON object with these fields (only include fields you can identify with confidence):
Return a JSON object with these fields (only include fields you can identify with confidence):
- "name": the roaster's name - "name": the roaster's name
- "country": the country the roaster is based in - "country": the country the roaster is based in
- "city": the city the roaster is based in - "city": the city the roaster is based in
@ -18,9 +16,7 @@ Return a JSON object with these fields (only include fields you can identify wit
Return ONLY the JSON object, no other text."#; Return ONLY the JSON object, no other text."#;
const ROAST_PROMPT: &str = r#"Resolve the input as a coffee roaster or specific coffee lookup, then extract information about the coffee. The input may contain only a short or ambiguous product or roaster name. Always use web search to identify the coffee product that best matches the input and verify its details, preferring the roaster's official product page. Use coffee-specific context to disambiguate it from unrelated products or meanings. If no specific coffee can be identified with confidence, return an empty JSON object. const ROAST_PROMPT: &str = r#"Extract coffee roast information from this input. Use web search to look up any details you cannot determine from the input alone (e.g. origin, region, producer, processing method, tasting notes). Return a JSON object with these fields (only include fields you can identify with confidence):
Return a JSON object with these fields (only include fields you can identify with confidence):
- "roaster_name": the name of the roaster - "roaster_name": the name of the roaster
- "name": the name of this specific coffee/roast - "name": the name of this specific coffee/roast
- "origin": the country (or countries, comma-separated) of origin of the coffee beans (e.g. "Ethiopia" or "Ethiopia, Colombia") - "origin": the country (or countries, comma-separated) of origin of the coffee beans (e.g. "Ethiopia" or "Ethiopia, Colombia")
@ -31,9 +27,7 @@ Return a JSON object with these fields (only include fields you can identify wit
Return ONLY the JSON object, no other text."#; Return ONLY the JSON object, no other text."#;
const SCAN_PROMPT: &str = r#"Resolve the input as a coffee bag lookup, then extract both the roaster and coffee information. The input may be an image, a short name, or incomplete bag text. Always use web search to identify and verify the best coffee-specific match, preferring the roaster's official website or product page. Use visible bag details and coffee-specific context to disambiguate it from unrelated products or meanings. If no coffee product can be identified with confidence, return empty "roaster" and "roast" objects. const SCAN_PROMPT: &str = r#"Extract both the coffee roaster and the roast information from this input. Use web search to look up any details you cannot determine from the input alone (e.g. the roaster's website, location, tasting notes, processing method). Return a JSON object with two top-level keys:
Return a JSON object with two top-level keys:
{ {
"roaster": { "roaster": {
@ -192,9 +186,6 @@ async fn call_openrouter(
role: "user".to_string(), role: "user".to_string(),
content: content_parts, content: content_parts,
}], }],
tools: vec![ServerTool {
tool_type: "openrouter:web_search",
}],
}; };
let response = client let response = client
@ -225,29 +216,20 @@ async fn call_openrouter(
let chat_response: ChatResponse = serde_json::from_str(&body) let chat_response: ChatResponse = serde_json::from_str(&body)
.map_err(|e| AppError::unexpected(format!("Failed to parse OpenRouter response: {e}")))?; .map_err(|e| AppError::unexpected(format!("Failed to parse OpenRouter response: {e}")))?;
response_content(chat_response) let content = chat_response
} .choices
fn response_content(response: ChatResponse) -> Result<(String, Option<Usage>), AppError> {
let ChatResponse { choices, usage } = response;
let choice = choices
.into_iter() .into_iter()
.next() .next()
.ok_or_else(|| AppError::unexpected("OpenRouter returned no choices"))?; .map(|c| c.message.content)
.unwrap_or_default();
let content = choice if content.trim().is_empty() {
.message return Err(AppError::unexpected(
.content "OpenRouter returned an empty response".to_string(),
.filter(|content| !content.trim().is_empty()) ));
.ok_or_else(|| { }
AppError::unexpected(format!(
"OpenRouter returned no content (finish_reason={}, native_finish_reason={})",
choice.finish_reason.as_deref().unwrap_or("unknown"),
choice.native_finish_reason.as_deref().unwrap_or("unknown")
))
})?;
Ok((content, usage)) Ok((content, chat_response.usage))
} }
/// Extract a JSON object from a model response that may contain markdown /// Extract a JSON object from a model response that may contain markdown
@ -283,13 +265,6 @@ fn extract_json(raw: &str) -> &str {
struct ChatRequest { struct ChatRequest {
model: String, model: String,
messages: Vec<Message>, messages: Vec<Message>,
tools: Vec<ServerTool>,
}
#[derive(Debug, Serialize)]
struct ServerTool {
#[serde(rename = "type")]
tool_type: &'static str,
} }
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
@ -321,13 +296,11 @@ struct ChatResponse {
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
struct Choice { struct Choice {
message: ResponseMessage, message: ResponseMessage,
finish_reason: Option<String>,
native_finish_reason: Option<String>,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
struct ResponseMessage { struct ResponseMessage {
content: Option<String>, content: String,
} }
#[cfg(test)] #[cfg(test)]
@ -360,7 +333,7 @@ mod tests {
let response: ChatResponse = serde_json::from_str(json).unwrap(); let response: ChatResponse = serde_json::from_str(json).unwrap();
assert_eq!(response.choices.len(), 1); assert_eq!(response.choices.len(), 1);
let content = response.choices[0].message.content.as_deref().unwrap(); let content = &response.choices[0].message.content;
let roaster: ExtractedRoaster = serde_json::from_str(content).unwrap(); let roaster: ExtractedRoaster = serde_json::from_str(content).unwrap();
assert_eq!(roaster.name.as_deref(), Some("Square Mile")); assert_eq!(roaster.name.as_deref(), Some("Square Mile"));
assert_eq!(roaster.country.as_deref(), Some("United Kingdom")); assert_eq!(roaster.country.as_deref(), Some("United Kingdom"));
@ -395,27 +368,6 @@ mod tests {
assert!(response.usage.is_none()); assert!(response.usage.is_none());
} }
#[test]
fn report_finish_reasons_when_response_content_is_null() {
let json = r#"{
"choices": [{
"message": { "role": "assistant", "content": null },
"finish_reason": "error",
"native_finish_reason": "MALFORMED_FUNCTION_CALL"
}]
}"#;
let response: ChatResponse = serde_json::from_str(json).unwrap();
let error = response_content(response).unwrap_err();
assert!(error.to_string().contains("finish_reason=error"));
assert!(
error
.to_string()
.contains("native_finish_reason=MALFORMED_FUNCTION_CALL")
);
}
#[test] #[test]
fn parse_roast_extraction() { fn parse_roast_extraction() {
let json = r#"{ let json = r#"{
@ -467,16 +419,12 @@ mod tests {
}, },
], ],
}], }],
tools: vec![ServerTool {
tool_type: "openrouter:web_search",
}],
}; };
let json = serde_json::to_value(&request).unwrap(); let json = serde_json::to_value(&request).unwrap();
assert_eq!(json["model"], "test-model"); assert_eq!(json["model"], "test-model");
assert_eq!(json["messages"][0]["content"][0]["type"], "text"); assert_eq!(json["messages"][0]["content"][0]["type"], "text");
assert_eq!(json["messages"][0]["content"][1]["type"], "image_url"); assert_eq!(json["messages"][0]["content"][1]["type"], "image_url");
assert_eq!(json["tools"][0]["type"], "openrouter:web_search");
} }
#[test] #[test]

View file

@ -4,7 +4,6 @@ use anyhow::{Context, bail};
use chrono::{DateTime, NaiveDate, Utc}; use chrono::{DateTime, NaiveDate, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::{from_str, to_string}; use serde_json::{from_str, to_string};
use sqlx::AssertSqlSafe;
use crate::domain::bags::Bag; use crate::domain::bags::Bag;
use crate::domain::brews::{Brew, QuickNote}; use crate::domain::brews::{Brew, QuickNote};
@ -176,7 +175,7 @@ impl BackupService {
for table in tables { for table in tables {
let query = format!("DELETE FROM {table}"); let query = format!("DELETE FROM {table}");
sqlx::query(AssertSqlSafe(query)) sqlx::query(&query)
.execute(&mut *tx) .execute(&mut *tx)
.await .await
.with_context(|| format!("failed to delete from {table}"))?; .with_context(|| format!("failed to delete from {table}"))?;
@ -317,7 +316,7 @@ impl BackupService {
for table in tables { for table in tables {
let query = format!("SELECT COUNT(*) as count FROM {table}"); let query = format!("SELECT COUNT(*) as count FROM {table}");
let row: (i64,) = sqlx::query_as(AssertSqlSafe(query)) let row: (i64,) = sqlx::query_as(&query)
.fetch_one(&self.pool) .fetch_one(&self.pool)
.await .await
.with_context(|| format!("failed to check table {table}"))?; .with_context(|| format!("failed to check table {table}"))?;

View file

@ -1,34 +0,0 @@
use anyhow::Result;
use chrono::{DateTime, Utc};
use serde::Deserialize;
use super::BrewlogClient;
pub struct InvitesClient<'a> {
client: &'a BrewlogClient,
}
impl<'a> InvitesClient<'a> {
pub fn new(client: &'a BrewlogClient) -> Self {
Self { client }
}
pub async fn create(&self) -> Result<InviteResponse> {
let url = self.client.endpoint("api/v1/invites")?;
let response = self
.client
.request(reqwest::Method::POST, url)
.send()
.await?;
self.client.handle_response(response).await
}
}
#[derive(Debug, Deserialize)]
pub struct InviteResponse {
pub url: String,
pub token: String,
pub expires_at: DateTime<Utc>,
}

View file

@ -4,7 +4,6 @@ pub mod brews;
pub mod cafes; pub mod cafes;
pub mod cups; pub mod cups;
pub mod gear; pub mod gear;
pub mod invites;
pub mod roasters; pub mod roasters;
pub mod roasts; pub mod roasts;
pub mod timeline; pub mod timeline;
@ -63,10 +62,6 @@ impl BrewlogClient {
tokens::TokensClient::new(self) tokens::TokensClient::new(self)
} }
pub fn invites(&self) -> invites::InvitesClient<'_> {
invites::InvitesClient::new(self)
}
pub fn bags(&self) -> bags::BagsClient<'_> { pub fn bags(&self) -> bags::BagsClient<'_> {
bags::BagsClient::new(self) bags::BagsClient::new(self)
} }

View file

@ -1,6 +1,6 @@
use async_trait::async_trait; use async_trait::async_trait;
use chrono::{DateTime, NaiveDate, Utc}; use chrono::{DateTime, NaiveDate, Utc};
use sqlx::{AssertSqlSafe, QueryBuilder, query_as}; use sqlx::{QueryBuilder, query_as};
use crate::domain::RepositoryError; use crate::domain::RepositoryError;
use crate::domain::bags::{Bag, BagFilter, BagSortKey, BagWithRoast, NewBag, UpdateBag}; use crate::domain::bags::{Bag, BagFilter, BagSortKey, BagWithRoast, NewBag, UpdateBag};
@ -119,7 +119,7 @@ impl BagRepository for SqlBagRepository {
async fn get_with_roast(&self, id: BagId) -> Result<BagWithRoast, RepositoryError> { async fn get_with_roast(&self, id: BagId) -> Result<BagWithRoast, RepositoryError> {
let query = format!("{BASE_SELECT} WHERE b.id = ?"); let query = format!("{BASE_SELECT} WHERE b.id = ?");
let record = query_as::<_, BagWithRoastRecord>(AssertSqlSafe(query)) let record = query_as::<_, BagWithRoastRecord>(&query)
.bind(id.into_inner()) .bind(id.into_inner())
.fetch_optional(&self.pool) .fetch_optional(&self.pool)
.await .await

View file

@ -1,6 +1,6 @@
use async_trait::async_trait; use async_trait::async_trait;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use sqlx::{AssertSqlSafe, QueryBuilder, query_as}; use sqlx::{QueryBuilder, query_as};
use crate::domain::RepositoryError; use crate::domain::RepositoryError;
use crate::domain::brews::{ use crate::domain::brews::{
@ -116,22 +116,25 @@ impl BrewRepository for SqlBrewRepository {
.await .await
.map_err(|err| RepositoryError::unexpected(err.to_string()))?; .map_err(|err| RepositoryError::unexpected(err.to_string()))?;
// Deduct coffee weight from bag's remaining amount, clamping to zero // Deduct coffee weight from bag's remaining amount
let update_bag_query = r" let update_bag_query = r"
UPDATE bags UPDATE bags
SET remaining = MAX(remaining - ?, 0), updated_at = CURRENT_TIMESTAMP SET remaining = remaining - ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ? AND closed = FALSE WHERE id = ? AND remaining >= ? AND closed = FALSE
"; ";
let result = sqlx::query(update_bag_query) let result = sqlx::query(update_bag_query)
.bind(brew.coffee_weight) .bind(brew.coffee_weight)
.bind(brew.bag_id.into_inner()) .bind(brew.bag_id.into_inner())
.bind(brew.coffee_weight)
.execute(&mut *tx) .execute(&mut *tx)
.await .await
.map_err(|err| RepositoryError::unexpected(err.to_string()))?; .map_err(|err| RepositoryError::unexpected(err.to_string()))?;
if result.rows_affected() == 0 { if result.rows_affected() == 0 {
return Err(RepositoryError::conflict("Bag is closed or not found")); return Err(RepositoryError::conflict(
"Insufficient coffee remaining in bag or bag is closed",
));
} }
// Insert the brew // Insert the brew
@ -189,7 +192,7 @@ impl BrewRepository for SqlBrewRepository {
async fn get_with_details(&self, id: BrewId) -> Result<BrewWithDetails, RepositoryError> { async fn get_with_details(&self, id: BrewId) -> Result<BrewWithDetails, RepositoryError> {
let query = format!("{BASE_SELECT} WHERE br.id = ?"); let query = format!("{BASE_SELECT} WHERE br.id = ?");
let record = query_as::<_, BrewWithDetailsRecord>(AssertSqlSafe(query)) let record = query_as::<_, BrewWithDetailsRecord>(&query)
.bind(id.into_inner()) .bind(id.into_inner())
.fetch_optional(&self.pool) .fetch_optional(&self.pool)
.await .await

View file

@ -1,6 +1,6 @@
use async_trait::async_trait; use async_trait::async_trait;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use sqlx::{AssertSqlSafe, QueryBuilder, query, query_as}; use sqlx::{QueryBuilder, query, query_as};
use crate::domain::RepositoryError; use crate::domain::RepositoryError;
use crate::domain::cups::{Cup, CupFilter, CupSortKey, CupWithDetails, NewCup, UpdateCup}; use crate::domain::cups::{Cup, CupFilter, CupSortKey, CupWithDetails, NewCup, UpdateCup};
@ -113,7 +113,7 @@ impl CupRepository for SqlCupRepository {
async fn get_with_details(&self, id: CupId) -> Result<CupWithDetails, RepositoryError> { async fn get_with_details(&self, id: CupId) -> Result<CupWithDetails, RepositoryError> {
let query = format!("{BASE_SELECT} WHERE c.id = ?"); let query = format!("{BASE_SELECT} WHERE c.id = ?");
let record = query_as::<_, CupWithDetailsRecord>(AssertSqlSafe(query)) let record = query_as::<_, CupWithDetailsRecord>(&query)
.bind(id.into_inner()) .bind(id.into_inner())
.fetch_optional(&self.pool) .fetch_optional(&self.pool)
.await .await

View file

@ -1,4 +1,4 @@
use sqlx::{AssertSqlSafe, FromRow, QueryBuilder, query_scalar}; use sqlx::{FromRow, QueryBuilder, query_scalar};
use crate::domain::RepositoryError; use crate::domain::RepositoryError;
use crate::domain::listing::{ListRequest, Page, PageSize, SortKey}; use crate::domain::listing::{ListRequest, Page, PageSize, SortKey};
@ -102,7 +102,7 @@ where
qb.push(" OFFSET "); qb.push(" OFFSET ");
qb.push_bind(offset); qb.push_bind(offset);
} }
qb.build_query_as::<R>() qb.build_query_as()
.fetch_all(pool) .fetch_all(pool)
.await .await
.map_err(|err| RepositoryError::unexpected(err.to_string())) .map_err(|err| RepositoryError::unexpected(err.to_string()))
@ -117,13 +117,13 @@ async fn fetch_count(
let mut qb = QueryBuilder::new(count_query); let mut qb = QueryBuilder::new(count_query);
append_search_condition(&mut qb, count_query, sf); append_search_condition(&mut qb, count_query, sf);
let row: (i64,) = qb let row: (i64,) = qb
.build_query_as::<(i64,)>() .build_query_as()
.fetch_one(pool) .fetch_one(pool)
.await .await
.map_err(|err| RepositoryError::unexpected(err.to_string()))?; .map_err(|err| RepositoryError::unexpected(err.to_string()))?;
Ok(row.0) Ok(row.0)
} else { } else {
query_scalar(AssertSqlSafe(count_query)) query_scalar(count_query)
.fetch_one(pool) .fetch_one(pool)
.await .await
.map_err(|err| RepositoryError::unexpected(err.to_string())) .map_err(|err| RepositoryError::unexpected(err.to_string()))
@ -131,7 +131,7 @@ async fn fetch_count(
} }
fn append_search_condition( fn append_search_condition(
qb: &mut QueryBuilder<DatabaseDriver>, qb: &mut QueryBuilder<'_, DatabaseDriver>,
base_sql: &str, base_sql: &str,
search: &SearchFilter, search: &SearchFilter,
) { ) {

View file

@ -3,8 +3,7 @@ use brewlog::application::{ServerConfig, serve};
use brewlog::infrastructure::backup::BackupData; use brewlog::infrastructure::backup::BackupData;
use brewlog::infrastructure::client::BrewlogClient; use brewlog::infrastructure::client::BrewlogClient;
use brewlog::presentation::cli::{ use brewlog::presentation::cli::{
Cli, Commands, ServeCommand, bags, brews, cafes, cups, gear, invites, roasters, roasts, Cli, Commands, ServeCommand, bags, brews, cafes, cups, gear, roasters, roasts, timeline, tokens,
timeline, tokens,
}; };
use clap::Parser; use clap::Parser;
use tracing_subscriber::{EnvFilter, layer::SubscriberExt, util::SubscriberInitExt}; use tracing_subscriber::{EnvFilter, layer::SubscriberExt, util::SubscriberInitExt};
@ -52,10 +51,6 @@ async fn main() -> Result<()> {
let client = BrewlogClient::from_base_url(&cli.api_url)?; let client = BrewlogClient::from_base_url(&cli.api_url)?;
tokens::run(&client, command).await tokens::run(&client, command).await
} }
Commands::Invite { command } => {
let client = BrewlogClient::from_base_url(&cli.api_url)?;
invites::run(&client, command).await
}
Commands::Timeline { command } => { Commands::Timeline { command } => {
let client = BrewlogClient::from_base_url(&cli.api_url)?; let client = BrewlogClient::from_base_url(&cli.api_url)?;
match command { match command {

View file

@ -1,26 +0,0 @@
use anyhow::Result;
use clap::Subcommand;
use crate::infrastructure::client::BrewlogClient;
#[derive(Debug, Subcommand)]
pub enum InviteCommands {
/// Create a registration link for onboarding a new user
Create,
}
pub async fn run(client: &BrewlogClient, cmd: InviteCommands) -> Result<()> {
match cmd {
InviteCommands::Create => create_invite(client).await,
}
}
pub async fn create_invite(client: &BrewlogClient) -> Result<()> {
let invite = client.invites().create().await?;
println!("Invite link created (expires {}):", invite.expires_at);
println!("\n {}\n", invite.url);
println!("Share this link. Opening it lets someone register a passkey and create an account.");
Ok(())
}

View file

@ -4,7 +4,6 @@ pub mod brews;
pub mod cafes; pub mod cafes;
pub mod cups; pub mod cups;
pub mod gear; pub mod gear;
pub mod invites;
mod macros; mod macros;
pub mod roasters; pub mod roasters;
pub mod roasts; pub mod roasts;
@ -22,7 +21,6 @@ use cafes::CafeCommands;
use clap::{Args, Parser, Subcommand}; use clap::{Args, Parser, Subcommand};
use cups::CupCommands; use cups::CupCommands;
use gear::GearCommands; use gear::GearCommands;
use invites::InviteCommands;
use roasters::RoasterCommands; use roasters::RoasterCommands;
use roasts::RoastCommands; use roasts::RoastCommands;
use timeline::TimelineCommands; use timeline::TimelineCommands;
@ -96,12 +94,6 @@ pub enum Commands {
command: TokenCommands, command: TokenCommands,
}, },
/// Manage invite links for onboarding new users
Invite {
#[command(subcommand)]
command: InviteCommands,
},
/// Manage timeline events /// Manage timeline events
Timeline { Timeline {
#[command(subcommand)] #[command(subcommand)]

View file

@ -291,85 +291,6 @@
</div> </div>
</section> </section>
<!-- Invite -->
<section
class="rounded-lg border bg-surface p-5"
data-signals:_creating-invite="false"
data-signals:_invite-created="false"
data-signals:_invite-url="''"
data-signals:_invite-error="''"
>
<div class="flex flex-col gap-4">
<div>
<h2 class="text-lg font-semibold text-text">Invite</h2>
<p class="mt-1 text-sm text-text-secondary">
Generate a registration link to onboard another user. The link is
valid for 7 days and can be used once.
</p>
</div>
<p
data-show="$_inviteError"
data-text="$_inviteError"
style="display: none"
class="rounded-md bg-error-bg border border-error-border p-2 text-sm text-error-text"
role="alert"
></p>
<!-- One-time invite link display -->
<div
data-show="$_inviteCreated"
style="display: none"
class="relative rounded-md border border-success-border bg-success-bg p-4"
>
<button
type="button"
onclick="window.location.reload()"
class="absolute top-3.5 right-3 inline-flex h-6 w-6 items-center justify-center rounded text-success-text transition hover:text-accent-text"
aria-label="Dismiss"
>
{{ icons::x_mark("h-4 w-4") }}
</button>
<p class="pr-6 text-sm font-medium text-success-text">
Invite link created! Share it with the new user — it expires in 7
days and can be used once.
</p>
<div class="mt-3 flex items-center gap-2">
<code
id="invite-url"
data-text="$_inviteUrl"
class="flex-1 rounded bg-surface px-3 py-2 text-sm font-mono text-text border border-success-border break-all select-all"
></code>
<button
type="button"
onclick="copyInvite(this)"
class="shrink-0 inline-flex items-center gap-2 rounded-md bg-success px-3 py-1.5 text-sm font-medium text-accent-text transition hover:bg-success"
>
{{ icons::clipboard("h-4 w-4") }} Copy
</button>
</div>
</div>
<div data-show="!$_inviteCreated">
<button
type="button"
data-on:click="$_creatingInvite = true; $_inviteError = ''; @post('/api/v1/invites')"
data-on:datastar-fetch="if (!$_creatingInvite) return;
if (evt.detail.type === 'finished') { $_creatingInvite = false }
else if (evt.detail.type === 'error') { $_creatingInvite = false; $_inviteError = 'Failed to create invite link.' }"
data-attr:disabled="$_creatingInvite"
class="inline-flex w-full items-center justify-center gap-2 rounded-md bg-accent px-4 py-2 text-sm font-semibold text-accent-text transition hover:bg-accent-hover disabled:opacity-50 disabled:cursor-not-allowed sm:w-auto sm:min-w-44"
>
<span data-show="!$_creatingInvite">{{ icons::user("h-4 w-4") }}</span>
<span data-show="$_creatingInvite" style="display:none"
>{{ icons::spinner("h-4 w-4") }}</span
>
New Invite
</button>
</div>
</div>
</section>
<!-- Data --> <!-- Data -->
<section class="rounded-lg border bg-surface p-5"> <section class="rounded-lg border bg-surface p-5">
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-4">
@ -543,26 +464,6 @@
} }
}; };
// --- Invite ---
const copyInvite = (btn) => {
const url = document.getElementById("invite-url").textContent;
if (navigator.clipboard) {
navigator.clipboard.writeText(url).then(() => {
btn.textContent = "Copied!";
setTimeout(() => {
btn.textContent = "Copy";
}, 2000);
});
} else {
const range = document.createRange();
range.selectNodeContents(document.getElementById("invite-url"));
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(range);
}
};
// --- Data management --- // --- Data management ---
const restoreFromFile = async (input) => { const restoreFromFile = async (input) => {

View file

@ -1,26 +0,0 @@
use crate::helpers::{create_token, run_brewlog};
use crate::test_macros::define_cli_auth_test;
define_cli_auth_test!(
test_create_invite_requires_authentication,
&["invite", "create"]
);
#[test]
fn test_create_invite_with_authentication() {
let token = create_token("test-create-invite");
let output = run_brewlog(&["invite", "create"], &[("BREWLOG_TOKEN", &token)]);
assert!(
output.status.success(),
"invite create with auth should succeed: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("/register/"),
"Should print a registration link, got: {stdout}"
);
}

View file

@ -5,7 +5,6 @@ pub mod cafes_cli;
pub mod cups_cli; pub mod cups_cli;
pub mod gear_cli; pub mod gear_cli;
pub mod helpers; pub mod helpers;
pub mod invites_cli;
pub mod roasters_cli; pub mod roasters_cli;
pub mod roasts_cli; pub mod roasts_cli;
pub mod test_macros; pub mod test_macros;

View file

@ -233,10 +233,7 @@ async fn checkin_with_new_cafe_and_scanned_roast() {
.unwrap(); .unwrap();
prompt_input.send_keys(Key::Enter).await.unwrap(); prompt_input.send_keys(Key::Enter).await.unwrap();
// Wait for scan to complete and populate the review before submitting. // Wait for scan to complete — step 3 becomes visible with the submit button
wait_for_text(&session.driver, "body", "Kiandu AA")
.await
.unwrap();
let submit_btn = wait_for_visible(&session.driver, "button[type='submit']") let submit_btn = wait_for_visible(&session.driver, "button[type='submit']")
.await .await
.unwrap(); .unwrap();

View file

@ -274,109 +274,6 @@ async fn test_read_endpoints_dont_require_authentication() {
assert_eq!(response.status(), StatusCode::OK); assert_eq!(response.status(), StatusCode::OK);
} }
// --- Invite endpoint tests ---
#[tokio::test]
async fn create_invite_requires_authentication() {
let app = spawn_app_with_auth().await;
let client = Client::new();
let response = client
.post(&app.api_url("/invites"))
.send()
.await
.expect("Failed to send request");
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn create_invite_with_bearer_auth_returns_link() {
let app = spawn_app_with_auth().await;
let client = Client::new();
let auth_token = app.auth_token.as_ref().unwrap();
let response = client
.post(&app.api_url("/invites"))
.bearer_auth(auth_token)
.send()
.await
.expect("Failed to send request");
assert_eq!(response.status(), StatusCode::OK);
let body: serde_json::Value = response.json().await.expect("Failed to parse response");
let url = body.get("url").unwrap().as_str().unwrap();
assert!(
url.contains("/register/"),
"invite url should point at the register page, got {url}"
);
let token = body.get("token").unwrap().as_str().unwrap();
assert!(!token.is_empty(), "invite token should be non-empty");
assert!(
url.ends_with(token),
"invite url should end with the raw token"
);
assert!(
body.get("expires_at").is_some(),
"invite response should include an expiry"
);
}
#[tokio::test]
async fn created_invite_link_opens_register_page() {
let app = spawn_app_with_auth().await;
let client = Client::new();
let auth_token = app.auth_token.as_ref().unwrap();
let create: serde_json::Value = client
.post(&app.api_url("/invites"))
.bearer_auth(auth_token)
.send()
.await
.expect("Failed to send request")
.json()
.await
.expect("Failed to parse response");
let token = create.get("token").unwrap().as_str().unwrap();
// The freshly minted token must be redeemable at the register page.
let response = client
.get(&app.page_url(&format!("/register/{token}")))
.send()
.await
.expect("Failed to send request");
assert_eq!(response.status(), StatusCode::OK);
}
#[tokio::test]
async fn create_invite_datastar_returns_signals() {
let app = spawn_app_with_auth().await;
let client = Client::new();
let auth_token = app.auth_token.as_ref().unwrap();
let response = client
.post(&app.api_url("/invites"))
.bearer_auth(auth_token)
.header("datastar-request", "true")
.send()
.await
.expect("Failed to send request");
assert_eq!(response.status(), StatusCode::OK);
let body = response.text().await.expect("Failed to read response");
assert!(
body.contains("_inviteUrl") && body.contains("_inviteCreated"),
"datastar response should patch invite signals, got {body}"
);
}
// --- Admin passkey endpoint tests --- // --- Admin passkey endpoint tests ---
#[tokio::test] #[tokio::test]

View file

@ -141,7 +141,7 @@ async fn creating_a_brew_deducts_from_bag_remaining() {
} }
#[tokio::test] #[tokio::test]
async fn creating_a_brew_with_excess_coffee_clamps_remaining_to_zero() { async fn creating_a_brew_fails_if_insufficient_coffee_in_bag() {
// Arrange // Arrange
let app = spawn_app_with_auth().await; let app = spawn_app_with_auth().await;
let roaster = create_default_roaster(&app).await; let roaster = create_default_roaster(&app).await;
@ -175,75 +175,7 @@ async fn creating_a_brew_with_excess_coffee_clamps_remaining_to_zero() {
.expect("Failed to execute request"); .expect("Failed to execute request");
// Assert // Assert
assert_eq!(response.status(), 201); assert_eq!(response.status(), 409); // Conflict
let brew: Brew = response.json().await.expect("Failed to parse response");
assert_eq!(brew.coffee_weight, 300.0);
let bag_response = client
.get(app.api_url(&format!("/bags/{}", bag.id)))
.send()
.await
.expect("Failed to get bag");
let updated_bag: Bag = bag_response.json().await.expect("Failed to parse bag");
assert_eq!(updated_bag.remaining, 0.0);
}
#[tokio::test]
async fn creating_a_brew_against_empty_open_bag_succeeds() {
// Arrange
let app = spawn_app_with_auth().await;
let roaster = create_default_roaster(&app).await;
let roast = create_default_roast(&app, roaster.id).await;
let bag = create_default_bag(&app, roast.id).await;
let grinder = create_default_gear(&app, "grinder", "Comandante", "C40 MK4").await;
let brewer = create_default_gear(&app, "brewer", "Hario", "V60 02").await;
let client = reqwest::Client::new();
let update_payload = serde_json::json!({ "remaining": 0.0 });
client
.put(app.api_url(&format!("/bags/{}", bag.id)))
.bearer_auth(app.auth_token.as_ref().unwrap())
.json(&update_payload)
.send()
.await
.expect("Failed to update bag");
let new_brew = NewBrew {
bag_id: bag.id,
coffee_weight: 15.0,
grinder_id: grinder.id,
grind_setting: 24.0,
brewer_id: brewer.id,
filter_paper_id: None,
water_volume: 250,
water_temp: 92.0,
quick_notes: Vec::new(),
brew_time: None,
created_at: None,
};
// Act
let response = client
.post(app.api_url("/brews"))
.bearer_auth(app.auth_token.as_ref().unwrap())
.json(&new_brew)
.send()
.await
.expect("Failed to execute request");
// Assert
assert_eq!(response.status(), 201);
let bag_response = client
.get(app.api_url(&format!("/bags/{}", bag.id)))
.send()
.await
.expect("Failed to get bag");
let updated_bag: Bag = bag_response.json().await.expect("Failed to parse bag");
assert_eq!(updated_bag.remaining, 0.0);
} }
#[tokio::test] #[tokio::test]