Compare commits
10 commits
1d2b071fe9
...
8c5697ccc2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8c5697ccc2 | ||
|
|
9cf8ce5d79 | ||
|
|
22c0a30d38 | ||
|
|
7563250809 | ||
|
|
deefd10a71 | ||
|
|
a259b3d06a | ||
|
|
d295173458 | ||
|
|
ba9eb98815 | ||
|
|
f5fcf69e33 | ||
|
|
bd7d4f3c5f |
26 changed files with 1153 additions and 398 deletions
|
|
@ -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
925
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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"
|
||||
|
|
|
|||
18
Dockerfile
18
Dockerfile
|
|
@ -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"]
|
||||
|
|
|
|||
11
README.md
11
README.md
|
|
@ -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
|
||||
|
|
|
|||
75
src/application/routes/api/auth/invites.rs
Normal file
75
src/application/routes/api/auth/invites.rs
Normal 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())
|
||||
}
|
||||
}
|
||||
|
|
@ -1,2 +1,3 @@
|
|||
pub(crate) mod invites;
|
||||
pub(crate) mod tokens;
|
||||
pub(crate) mod webauthn;
|
||||
|
|
|
|||
|
|
@ -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}",
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
.into_iter()
|
||||
.next()
|
||||
.map(|c| c.message.content)
|
||||
.unwrap_or_default();
|
||||
|
||||
if content.trim().is_empty() {
|
||||
return Err(AppError::unexpected(
|
||||
"OpenRouter returned an empty response".to_string(),
|
||||
));
|
||||
response_content(chat_response)
|
||||
}
|
||||
|
||||
Ok((content, chat_response.usage))
|
||||
fn response_content(response: ChatResponse) -> Result<(String, Option<Usage>), AppError> {
|
||||
let ChatResponse { choices, usage } = response;
|
||||
let choice = choices
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or_else(|| AppError::unexpected("OpenRouter returned no choices"))?;
|
||||
|
||||
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, 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]
|
||||
|
|
|
|||
|
|
@ -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}"))?;
|
||||
|
|
|
|||
34
src/infrastructure/client/invites.rs
Normal file
34
src/infrastructure/client/invites.rs
Normal 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>,
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
26
src/presentation/cli/invites.rs
Normal file
26
src/presentation/cli/invites.rs
Normal 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(())
|
||||
}
|
||||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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
26
tests/cli/invites_cli.rs
Normal 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}"
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
Loading…
Reference in a new issue