Compare commits

..

10 commits

Author SHA1 Message Date
Niels Göttsch
8c5697ccc2 Merge feat/additional-invite-links into dev
Add additional invite-link generation (admin page + CLI + API).
2026-07-16 12:38:43 +02:00
Niels Göttsch
9cf8ce5d79 feat(auth): generate additional invite links for onboarding users
Add an authenticated POST /api/v1/invites endpoint that mints a
single-use, 7-day registration link after the first-user bootstrap.
Expose it via a new "Invite" section on the admin page and a
`brewlog invite create` CLI command.

The registration_tokens table, repository, and NewRegistrationToken
already supported additional tokens; this adds the authenticated
surfaces (session cookie or bearer token) to trigger creation.
2026-07-16 12:38:36 +02:00
Jon Seager
22c0a30d38
fix(ai): ground ambiguous coffee lookups with web search 2026-07-15 13:38:48 +01:00
Jon Seager
7563250809 fix: support sqlx 0.9 2026-06-08 10:56:18 +00:00
dependabot[bot]
deefd10a71 build(deps): bump sqlx from 0.8.6 to 0.9.0
Bumps [sqlx](https://github.com/launchbadge/sqlx) from 0.8.6 to 0.9.0.
- [Changelog](https://github.com/transact-rs/sqlx/blob/main/CHANGELOG.md)
- [Commits](https://github.com/launchbadge/sqlx/compare/v0.8.6...v0.9.0)

---
updated-dependencies:
- dependency-name: sqlx
  dependency-version: 0.9.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-08 10:56:03 +00:00
dependabot[bot]
a259b3d06a build(deps): bump thirtyfour from 0.36.1 to 0.37.1
Bumps [thirtyfour](https://github.com/stevepryde/thirtyfour) from 0.36.1 to 0.37.1.
- [Release notes](https://github.com/stevepryde/thirtyfour/releases)
- [Commits](https://github.com/stevepryde/thirtyfour/compare/v0.36.1...v0.37.1)

---
updated-dependencies:
- dependency-name: thirtyfour
  dependency-version: 0.37.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-08 10:40:00 +00:00
dependabot[bot]
d295173458 build(deps): bump askama from 0.15.4 to 0.16.0
Bumps [askama](https://github.com/askama-rs/askama) from 0.15.4 to 0.16.0.
- [Release notes](https://github.com/askama-rs/askama/releases)
- [Commits](https://github.com/askama-rs/askama/compare/v0.15.4...v0.16.0)

---
updated-dependencies:
- dependency-name: askama
  dependency-version: 0.16.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-08 10:38:39 +00:00
dependabot[bot]
ba9eb98815 build(deps): bump uuid from 1.23.1 to 1.23.2 in the cargo-deps group
Bumps the cargo-deps group with 1 update: [uuid](https://github.com/uuid-rs/uuid).

Updates `uuid` from 1.23.1 to 1.23.2
- [Release notes](https://github.com/uuid-rs/uuid/releases)
- [Commits](https://github.com/uuid-rs/uuid/compare/v1.23.1...v1.23.2)

---
updated-dependencies:
- dependency-name: uuid
  dependency-version: 1.23.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: cargo-deps
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-08 10:35:18 +00:00
Jon Seager
f5fcf69e33 fix: clamp bag remaining to zero instead of rejecting brew with excess coffee
Allow creating a brew when coffee_weight exceeds the bag's remaining
amount — remaining is clamped to 0 rather than returning a 409 error.
Brews against open bags with 0g remaining also succeed, keeping
remaining at 0. Only closed bags are rejected.
2026-05-22 15:37:08 +00:00
Jon Seager
bd7d4f3c5f build: run brewlog as non-root user 1000:1000
Add brewlog user/group (UID/GID 1000) to chiseled rootfs via
base-passwd_data slice and useradd. Set USER 1000:1000 in the
runtime stage. Requires a one-time remediation deploy (with
gosu + chown entrypoint) to fix existing volume permissions
before this can be used.
2026-05-22 15:00:04 +00:00
26 changed files with 1153 additions and 398 deletions

View file

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

925
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

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

View file

@ -4,16 +4,23 @@
# Uses chisel to create a minimal Ubuntu rootfs for the runtime image.
# ---------------------------------------------------------------------------
# Builder — Ubuntu with Rust toolchain pre-installed
# Builder — Ubuntu with Rust toolchain installed
# ---------------------------------------------------------------------------
FROM ubuntu/rust:1.93-26.04_edge AS builder
FROM ubuntu:26.04 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 \
ca-certificates \
build-essential \
pkg-config \
libssl-dev \
mold \
binutils \
curl \
&& 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)
RUN mkdir -p /usr/local/bin \
@ -43,17 +50,22 @@ RUN curl -sL "https://github.com/canonical/chisel/releases/download/v1.4.1/chise
RUN mkdir /rootfs && chisel cut --root /rootfs \
base-files_base \
base-files_release-info \
base-passwd_data \
ca-certificates_data \
libgcc-s1_libs \
libc6_libs \
libssl3t64_libs \
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
# ---------------------------------------------------------------------------
FROM scratch
COPY --from=chisel /rootfs /
COPY --from=builder /out/brewlog /usr/local/bin/brewlog
USER 65534:65534
USER 1000:1000
ENTRYPOINT ["brewlog", "serve", "--database-url", "sqlite:///data/brewlog.db"]

View file

@ -59,6 +59,10 @@ This link expires in 1 hour.
Open that URL, choose a display name, and register a passkey. This creates an account and
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
To build and install from source, you'll need a working Rust toolchain:
@ -89,6 +93,13 @@ export BREWLOG_TOKEN="<token from above>"
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.
## Configuration

View file

@ -0,0 +1,75 @@
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,2 +1,3 @@
pub(crate) mod invites;
pub(crate) mod tokens;
pub(crate) mod webauthn;

View file

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

View file

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

View file

@ -8,7 +8,9 @@ pub const OPENROUTER_URL: &str = "https://openrouter.ai/api/v1/chat/completions"
const USER_AGENT: &str = "Brewlog/1.0";
const REQUEST_TIMEOUT: Duration = Duration::from_secs(90);
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):
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.
Return a JSON object with these fields (only include fields you can identify with confidence):
- "name": the roaster's name
- "country": the country the roaster is based in
- "city": the city the roaster is based in
@ -16,7 +18,9 @@ const ROASTER_PROMPT: &str = r#"Extract coffee roaster information from this inp
Return ONLY the JSON object, no other text."#;
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):
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.
Return a JSON object with these fields (only include fields you can identify with confidence):
- "roaster_name": the name of the roaster
- "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")
@ -27,7 +31,9 @@ const ROAST_PROMPT: &str = r#"Extract coffee roast information from this input.
Return ONLY the JSON object, no other text."#;
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:
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.
Return a JSON object with two top-level keys:
{
"roaster": {
@ -186,6 +192,9 @@ async fn call_openrouter(
role: "user".to_string(),
content: content_parts,
}],
tools: vec![ServerTool {
tool_type: "openrouter:web_search",
}],
};
let response = client
@ -216,20 +225,29 @@ async fn call_openrouter(
let chat_response: ChatResponse = serde_json::from_str(&body)
.map_err(|e| AppError::unexpected(format!("Failed to parse OpenRouter response: {e}")))?;
let content = chat_response
.choices
response_content(chat_response)
}
fn response_content(response: ChatResponse) -> Result<(String, Option<Usage>), AppError> {
let ChatResponse { choices, usage } = response;
let choice = choices
.into_iter()
.next()
.map(|c| c.message.content)
.unwrap_or_default();
.ok_or_else(|| AppError::unexpected("OpenRouter returned no choices"))?;
if content.trim().is_empty() {
return Err(AppError::unexpected(
"OpenRouter returned an empty response".to_string(),
));
}
let content = choice
.message
.content
.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, chat_response.usage))
Ok((content, usage))
}
/// Extract a JSON object from a model response that may contain markdown
@ -265,6 +283,13 @@ fn extract_json(raw: &str) -> &str {
struct ChatRequest {
model: String,
messages: Vec<Message>,
tools: Vec<ServerTool>,
}
#[derive(Debug, Serialize)]
struct ServerTool {
#[serde(rename = "type")]
tool_type: &'static str,
}
#[derive(Debug, Serialize)]
@ -296,11 +321,13 @@ struct ChatResponse {
#[derive(Debug, Deserialize)]
struct Choice {
message: ResponseMessage,
finish_reason: Option<String>,
native_finish_reason: Option<String>,
}
#[derive(Debug, Deserialize)]
struct ResponseMessage {
content: String,
content: Option<String>,
}
#[cfg(test)]
@ -333,7 +360,7 @@ mod tests {
let response: ChatResponse = serde_json::from_str(json).unwrap();
assert_eq!(response.choices.len(), 1);
let content = &response.choices[0].message.content;
let content = response.choices[0].message.content.as_deref().unwrap();
let roaster: ExtractedRoaster = serde_json::from_str(content).unwrap();
assert_eq!(roaster.name.as_deref(), Some("Square Mile"));
assert_eq!(roaster.country.as_deref(), Some("United Kingdom"));
@ -368,6 +395,27 @@ mod tests {
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]
fn parse_roast_extraction() {
let json = r#"{
@ -419,12 +467,16 @@ mod tests {
},
],
}],
tools: vec![ServerTool {
tool_type: "openrouter:web_search",
}],
};
let json = serde_json::to_value(&request).unwrap();
assert_eq!(json["model"], "test-model");
assert_eq!(json["messages"][0]["content"][0]["type"], "text");
assert_eq!(json["messages"][0]["content"][1]["type"], "image_url");
assert_eq!(json["tools"][0]["type"], "openrouter:web_search");
}
#[test]

View file

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

View file

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

View file

@ -1,6 +1,6 @@
use async_trait::async_trait;
use chrono::{DateTime, NaiveDate, Utc};
use sqlx::{QueryBuilder, query_as};
use sqlx::{AssertSqlSafe, QueryBuilder, query_as};
use crate::domain::RepositoryError;
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> {
let query = format!("{BASE_SELECT} WHERE b.id = ?");
let record = query_as::<_, BagWithRoastRecord>(&query)
let record = query_as::<_, BagWithRoastRecord>(AssertSqlSafe(query))
.bind(id.into_inner())
.fetch_optional(&self.pool)
.await

View file

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

View file

@ -1,6 +1,6 @@
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use sqlx::{QueryBuilder, query, query_as};
use sqlx::{AssertSqlSafe, QueryBuilder, query, query_as};
use crate::domain::RepositoryError;
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> {
let query = format!("{BASE_SELECT} WHERE c.id = ?");
let record = query_as::<_, CupWithDetailsRecord>(&query)
let record = query_as::<_, CupWithDetailsRecord>(AssertSqlSafe(query))
.bind(id.into_inner())
.fetch_optional(&self.pool)
.await

View file

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

View file

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

View file

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

View file

@ -291,6 +291,85 @@
</div>
</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 -->
<section class="rounded-lg border bg-surface p-5">
<div class="flex flex-col gap-4">
@ -464,6 +543,26 @@
}
};
// --- 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 ---
const restoreFromFile = async (input) => {

26
tests/cli/invites_cli.rs Normal file
View file

@ -0,0 +1,26 @@
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,6 +5,7 @@ pub mod cafes_cli;
pub mod cups_cli;
pub mod gear_cli;
pub mod helpers;
pub mod invites_cli;
pub mod roasters_cli;
pub mod roasts_cli;
pub mod test_macros;

View file

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

View file

@ -274,6 +274,109 @@ async fn test_read_endpoints_dont_require_authentication() {
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 ---
#[tokio::test]

View file

@ -141,7 +141,7 @@ async fn creating_a_brew_deducts_from_bag_remaining() {
}
#[tokio::test]
async fn creating_a_brew_fails_if_insufficient_coffee_in_bag() {
async fn creating_a_brew_with_excess_coffee_clamps_remaining_to_zero() {
// Arrange
let app = spawn_app_with_auth().await;
let roaster = create_default_roaster(&app).await;
@ -175,7 +175,75 @@ async fn creating_a_brew_fails_if_insufficient_coffee_in_bag() {
.expect("Failed to execute request");
// Assert
assert_eq!(response.status(), 409); // Conflict
assert_eq!(response.status(), 201);
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]