diff --git a/CLAUDE.md b/CLAUDE.md index 66e55e3..2226c64 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -429,7 +429,6 @@ Each extraction-enabled page uses this structure: ```html
- {% if has_ai_extract %}

- {% endif %}
@@ -468,7 +466,7 @@ The only inline JS is the `onchange` handler for FileReader (reading photos as d The cafes page uses the [Foursquare Places API](https://docs.foursquare.com/developer/reference/place-search) to search for nearby cafes. The integration lives in `infrastructure/foursquare.rs`. -**Configuration**: Set `BREWLOG_FOURSQUARE_API_KEY` (a Foursquare service API key). The nearby search feature is only available when this key is configured. +**Configuration**: Set `BREWLOG_FOURSQUARE_API_KEY` (a Foursquare service API key). **Search modes** via the `SearchLocation` enum: diff --git a/README.md b/README.md index ee42fc7..d518734 100644 --- a/README.md +++ b/README.md @@ -20,13 +20,17 @@ B{rew}log ships as one executable. You decide whether it acts as a server or a c ### First-time setup -On first start, you must set an admin username and password via the `BREWLOG_ADMIN_USERNAME` and `BREWLOG_ADMIN_PASSWORD` environment variables: +The server requires `BREWLOG_OPENROUTER_API_KEY` and `BREWLOG_FOURSQUARE_API_KEY` to be set. On first start, you must also set an admin username and password: ```bash -BREWLOG_ADMIN_USERNAME="admin" BREWLOG_ADMIN_PASSWORD="your-secure-password" brewlog serve +BREWLOG_ADMIN_USERNAME="admin" \ +BREWLOG_ADMIN_PASSWORD="your-secure-password" \ +BREWLOG_OPENROUTER_API_KEY="sk-or-..." \ +BREWLOG_FOURSQUARE_API_KEY="fsq3..." \ +brewlog serve ``` -This creates the admin user in the database. On subsequent starts, the environment variables are not required. +This creates the admin user in the database. On subsequent starts, the admin environment variables are not required. ### Authentication @@ -154,27 +158,28 @@ All configuration is via environment variables or CLI flags. A `.env` file in th | `BREWLOG_URL` | Server URL for CLI commands | `http://127.0.0.1:3000` | | `BREWLOG_TOKEN` | API token for authenticated CLI operations | — | -### Optional Integrations +### Integrations | Variable | Purpose | Default | |----------|---------|---------| -| `BREWLOG_OPENROUTER_API_KEY` | [OpenRouter](https://openrouter.ai/) API key — enables AI extraction | — | +| `BREWLOG_OPENROUTER_API_KEY` | [OpenRouter](https://openrouter.ai/) API key for AI extraction | — (required) | | `BREWLOG_OPENROUTER_MODEL` | LLM model for AI extraction | `openrouter/free` | -| `BREWLOG_FOURSQUARE_API_KEY` | [Foursquare](https://foursquare.com/) Places API key — enables nearby cafe search | — | +| `BREWLOG_FOURSQUARE_API_KEY` | [Foursquare](https://foursquare.com/) Places API key for nearby cafe search | — (required) | -## Optional Features +## Integrations ### AI Extraction -When `BREWLOG_OPENROUTER_API_KEY` is configured, the web UI gains the ability to extract roaster and roast details from photos or text descriptions using an LLM. This powers: +The web UI uses an LLM via [OpenRouter](https://openrouter.ai/) to extract roaster and roast details from photos or text descriptions. `BREWLOG_OPENROUTER_API_KEY` is required. It powers: - Photo extraction buttons on the roaster and roast forms - Text-based extraction from typed descriptions -- The **/scan** page, which extracts both roaster and roast data from a single coffee bag label photo +- The **Scan Bag** feature on the home page, which extracts both roaster and roast data from a single coffee bag label photo +- The **Scan Bag** feature on the check-in page, which identifies a roast from a bag photo ### Nearby Cafe Search -When `BREWLOG_FOURSQUARE_API_KEY` is configured, the cafes page can search for nearby coffee shops via the Foursquare Places API. Searches can be made by GPS coordinates or city name. +The check-in and cafes pages search for nearby coffee shops via the [Foursquare Places API](https://docs.foursquare.com/developer/reference/place-search). `BREWLOG_FOURSQUARE_API_KEY` is required. Searches can be made by GPS coordinates or city name. ## Database diff --git a/src/application/routes/auth.rs b/src/application/routes/auth.rs index 53be9cb..ca75d9a 100644 --- a/src/application/routes/auth.rs +++ b/src/application/routes/auth.rs @@ -20,7 +20,6 @@ const SESSION_COOKIE_NAME: &str = "brewlog_session"; struct LoginTemplate { nav_active: &'static str, is_authenticated: bool, - has_ai_extract: bool, error: Option, } @@ -43,7 +42,7 @@ pub(crate) async fn login_page( let template = LoginTemplate { nav_active: "login", is_authenticated: false, - has_ai_extract: false, + error: None, }; @@ -132,7 +131,7 @@ fn show_login_error(message: &str) -> Result { let template = LoginTemplate { nav_active: "login", is_authenticated: false, - has_ai_extract: false, + error: Some(message.to_string()), }; diff --git a/src/application/routes/bags.rs b/src/application/routes/bags.rs index 2b90949..d786a48 100644 --- a/src/application/routes/bags.rs +++ b/src/application/routes/bags.rs @@ -99,7 +99,6 @@ pub(crate) async fn bags_page( let template = BagsTemplate { nav_active: "bags", is_authenticated, - has_ai_extract: state.has_ai_extract(), open_bags, bags, roaster_options, diff --git a/src/application/routes/brews.rs b/src/application/routes/brews.rs index 2dd0c11..12a821b 100644 --- a/src/application/routes/brews.rs +++ b/src/application/routes/brews.rs @@ -186,7 +186,6 @@ pub(crate) async fn brews_page( let template = BrewsTemplate { nav_active: "brews", is_authenticated, - has_ai_extract: state.has_ai_extract(), brews, bag_options, grinder_options, diff --git a/src/application/routes/cafes.rs b/src/application/routes/cafes.rs index cfe620d..1182a5b 100644 --- a/src/application/routes/cafes.rs +++ b/src/application/routes/cafes.rs @@ -71,7 +71,6 @@ pub(crate) async fn cafes_page( let template = CafesTemplate { nav_active: "cafes", is_authenticated, - has_ai_extract: state.has_ai_extract(), cafes, navigator, }; @@ -97,7 +96,6 @@ pub(crate) async fn cafe_page( let template = CafeDetailTemplate { nav_active: "cafes", is_authenticated, - has_ai_extract: state.has_ai_extract(), cafe: cafe_view, }; @@ -227,15 +225,10 @@ pub(crate) async fn nearby_cafes( foursquare::SearchLocation::Coordinates { lat, lng } }; - let api_key = state - .foursquare_api_key - .as_deref() - .ok_or_else(|| AppError::unexpected("Foursquare API key not configured"))?; - let cafes = foursquare::search_nearby( &state.http_client, &state.foursquare_url, - api_key, + &state.foursquare_api_key, &location, q, ) diff --git a/src/application/routes/checkin.rs b/src/application/routes/checkin.rs index e4e2bdb..0f45dc4 100644 --- a/src/application/routes/checkin.rs +++ b/src/application/routes/checkin.rs @@ -32,8 +32,6 @@ pub(crate) async fn checkin_page( let template = CheckInTemplate { nav_active: "home", is_authenticated: true, - has_ai_extract: state.has_ai_extract(), - has_foursquare: state.has_foursquare(), roast_options, cafe_options, }; diff --git a/src/application/routes/cups.rs b/src/application/routes/cups.rs index 6ce3977..70daba8 100644 --- a/src/application/routes/cups.rs +++ b/src/application/routes/cups.rs @@ -74,7 +74,6 @@ pub(crate) async fn cups_page( let template = CupsTemplate { nav_active: "cups", is_authenticated, - has_ai_extract: state.has_ai_extract(), cups, roast_options, cafe_options, diff --git a/src/application/routes/gear.rs b/src/application/routes/gear.rs index a44738c..4dc2473 100644 --- a/src/application/routes/gear.rs +++ b/src/application/routes/gear.rs @@ -71,7 +71,6 @@ pub(crate) async fn gear_page( let template = GearTemplate { nav_active: "gear", is_authenticated, - has_ai_extract: state.has_ai_extract(), gear, navigator, }; diff --git a/src/application/routes/home.rs b/src/application/routes/home.rs index a94a2a4..dab18b3 100644 --- a/src/application/routes/home.rs +++ b/src/application/routes/home.rs @@ -31,8 +31,6 @@ pub(crate) async fn home_page( let template = HomeTemplate { nav_active: "home", is_authenticated, - has_ai_extract: state.has_ai_extract(), - has_foursquare: state.has_foursquare(), last_brew: content.last_brew, open_bags: content.open_bags, recent_events: content.recent_events, diff --git a/src/application/routes/roasters.rs b/src/application/routes/roasters.rs index 9218254..b493aa4 100644 --- a/src/application/routes/roasters.rs +++ b/src/application/routes/roasters.rs @@ -70,7 +70,6 @@ pub(crate) async fn roasters_page( let template = RoastersTemplate { nav_active: "roasters", is_authenticated, - has_ai_extract: state.has_ai_extract(), roasters, navigator, }; @@ -101,7 +100,6 @@ pub(crate) async fn roaster_page( let template = RoasterDetailTemplate { nav_active: "roasters", is_authenticated, - has_ai_extract: state.has_ai_extract(), roaster: roaster_view, roasts: roasts.into_iter().map(RoastView::from_list_item).collect(), }; @@ -192,15 +190,15 @@ pub(crate) async fn extract_roaster( headers: HeaderMap, payload: FlexiblePayload, ) -> Result { - let api_key = state - .openrouter_api_key - .as_deref() - .ok_or_else(|| AppError::validation("AI extraction is not configured"))?; - let (input, _) = payload.into_parts(); - let result = ai::extract_roaster(&state.http_client, api_key, &state.openrouter_model, &input) - .await - .map_err(ApiError::from)?; + let result = ai::extract_roaster( + &state.http_client, + &state.openrouter_api_key, + &state.openrouter_model, + &input, + ) + .await + .map_err(ApiError::from)?; if is_datastar_request(&headers) { use serde_json::Value; diff --git a/src/application/routes/roasts.rs b/src/application/routes/roasts.rs index 9648894..f258aa0 100644 --- a/src/application/routes/roasts.rs +++ b/src/application/routes/roasts.rs @@ -76,7 +76,6 @@ pub(crate) async fn roasts_page( let template = RoastsTemplate { nav_active: "roasts", is_authenticated, - has_ai_extract: state.has_ai_extract(), roasts, roaster_options, navigator, @@ -121,7 +120,6 @@ pub(crate) async fn roast_page( let template = RoastDetailTemplate { nav_active: "roasts", is_authenticated, - has_ai_extract: state.has_ai_extract(), roast: RoastView::from_domain(roast, &roaster.name, &roaster.slug), bags: bag_views, }; @@ -348,15 +346,15 @@ pub(crate) async fn extract_roast_info( headers: HeaderMap, payload: FlexiblePayload, ) -> Result { - let api_key = state - .openrouter_api_key - .as_deref() - .ok_or_else(|| AppError::validation("AI extraction is not configured"))?; - let (input, _) = payload.into_parts(); - let result = ai::extract_roast(&state.http_client, api_key, &state.openrouter_model, &input) - .await - .map_err(ApiError::from)?; + let result = ai::extract_roast( + &state.http_client, + &state.openrouter_api_key, + &state.openrouter_model, + &input, + ) + .await + .map_err(ApiError::from)?; if is_datastar_request(&headers) { use serde_json::Value; diff --git a/src/application/routes/scan.rs b/src/application/routes/scan.rs index fe0be72..7ff6540 100644 --- a/src/application/routes/scan.rs +++ b/src/application/routes/scan.rs @@ -21,15 +21,15 @@ pub(crate) async fn extract_bag_scan( headers: HeaderMap, payload: FlexiblePayload, ) -> Result { - let api_key = state - .openrouter_api_key - .as_deref() - .ok_or_else(|| AppError::validation("AI extraction is not configured"))?; - let (input, _) = payload.into_parts(); - let result = ai::extract_bag_scan(&state.http_client, api_key, &state.openrouter_model, &input) - .await - .map_err(ApiError::from)?; + let result = ai::extract_bag_scan( + &state.http_client, + &state.openrouter_api_key, + &state.openrouter_model, + &input, + ) + .await + .map_err(ApiError::from)?; if is_datastar_request(&headers) { use serde_json::Value; @@ -128,18 +128,18 @@ async fn extract_into_submission( state: &AppState, submission: &mut BagScanSubmission, ) -> Result<(), ApiError> { - let api_key = state - .openrouter_api_key - .as_deref() - .ok_or_else(|| AppError::validation("AI extraction is not configured"))?; - let input = ExtractionInput { image: submission.image.take(), prompt: submission.prompt.take(), }; - let result = ai::extract_bag_scan(&state.http_client, api_key, &state.openrouter_model, &input) - .await - .map_err(ApiError::from)?; + let result = ai::extract_bag_scan( + &state.http_client, + &state.openrouter_api_key, + &state.openrouter_model, + &input, + ) + .await + .map_err(ApiError::from)?; if let Some(name) = result.roaster.name { submission.roaster_name = name; diff --git a/src/application/routes/timeline.rs b/src/application/routes/timeline.rs index bc3dd53..5a8985b 100644 --- a/src/application/routes/timeline.rs +++ b/src/application/routes/timeline.rs @@ -93,7 +93,6 @@ pub(crate) async fn timeline_page( let template = TimelineTemplate { nav_active: "timeline", is_authenticated, - has_ai_extract: state.has_ai_extract(), events: data.events, navigator: data.navigator, months: data.months, diff --git a/src/application/server.rs b/src/application/server.rs index d55dc2e..53d06b5 100644 --- a/src/application/server.rs +++ b/src/application/server.rs @@ -32,9 +32,9 @@ pub struct ServerConfig { pub database_url: String, pub admin_password: Option, pub admin_username: Option, - pub openrouter_api_key: Option, + pub openrouter_api_key: String, pub openrouter_model: String, - pub foursquare_api_key: Option, + pub foursquare_api_key: String, } #[derive(Clone)] @@ -52,8 +52,8 @@ pub struct AppState { pub session_repo: Arc, pub http_client: reqwest::Client, pub foursquare_url: String, - pub foursquare_api_key: Option, - pub openrouter_api_key: Option, + pub foursquare_api_key: String, + pub openrouter_api_key: String, pub openrouter_model: String, } @@ -73,8 +73,8 @@ impl AppState { session_repo: Arc, http_client: reqwest::Client, foursquare_url: String, - foursquare_api_key: Option, - openrouter_api_key: Option, + foursquare_api_key: String, + openrouter_api_key: String, openrouter_model: String, ) -> Self { Self { @@ -96,14 +96,6 @@ impl AppState { openrouter_model, } } - - pub fn has_ai_extract(&self) -> bool { - self.openrouter_api_key.is_some() - } - - pub fn has_foursquare(&self) -> bool { - self.foursquare_api_key.is_some() - } } pub async fn serve(config: ServerConfig) -> anyhow::Result<()> { diff --git a/src/main.rs b/src/main.rs index 102b6d6..1f0bf97 100644 --- a/src/main.rs +++ b/src/main.rs @@ -79,14 +79,28 @@ async fn main() -> Result<()> { } async fn run_server(command: ServeCommand) -> Result<()> { + let openrouter_api_key = command.openrouter_api_key.ok_or_else(|| { + anyhow::anyhow!( + "BREWLOG_OPENROUTER_API_KEY is required. Set this environment variable \ + to your OpenRouter API key for AI-powered extraction features." + ) + })?; + + let foursquare_api_key = command.foursquare_api_key.ok_or_else(|| { + anyhow::anyhow!( + "BREWLOG_FOURSQUARE_API_KEY is required. Set this environment variable \ + to your Foursquare API key for nearby cafe search." + ) + })?; + let config = ServerConfig { bind_address: command.bind_address, database_url: command.database_url, admin_password: command.admin_password, admin_username: command.admin_username, - openrouter_api_key: command.openrouter_api_key, + openrouter_api_key, openrouter_model: command.openrouter_model, - foursquare_api_key: command.foursquare_api_key, + foursquare_api_key, }; serve(config).await diff --git a/src/presentation/web/templates.rs b/src/presentation/web/templates.rs index a117fb7..dbcac89 100644 --- a/src/presentation/web/templates.rs +++ b/src/presentation/web/templates.rs @@ -19,7 +19,7 @@ use crate::domain::timeline::TimelineSortKey; pub struct RoastersTemplate { pub nav_active: &'static str, pub is_authenticated: bool, - pub has_ai_extract: bool, + pub roasters: Paginated, pub navigator: ListNavigator, } @@ -37,7 +37,7 @@ pub struct RoasterListTemplate { pub struct RoasterDetailTemplate { pub nav_active: &'static str, pub is_authenticated: bool, - pub has_ai_extract: bool, + pub roaster: RoasterView, pub roasts: Vec, } @@ -47,7 +47,7 @@ pub struct RoasterDetailTemplate { pub struct RoastsTemplate { pub nav_active: &'static str, pub is_authenticated: bool, - pub has_ai_extract: bool, + pub roasts: Paginated, pub roaster_options: Vec, pub navigator: ListNavigator, @@ -58,7 +58,7 @@ pub struct RoastsTemplate { pub struct RoastDetailTemplate { pub nav_active: &'static str, pub is_authenticated: bool, - pub has_ai_extract: bool, + pub roast: RoastView, pub bags: Vec, } @@ -76,7 +76,7 @@ pub struct RoastListTemplate { pub struct TimelineTemplate { pub nav_active: &'static str, pub is_authenticated: bool, - pub has_ai_extract: bool, + pub events: Paginated, pub navigator: ListNavigator, pub months: Vec, @@ -96,7 +96,7 @@ pub struct TimelineChunkTemplate { pub struct BagsTemplate { pub nav_active: &'static str, pub is_authenticated: bool, - pub has_ai_extract: bool, + pub open_bags: Vec, pub bags: Paginated, pub roaster_options: Vec, @@ -117,7 +117,7 @@ pub struct BagListTemplate { pub struct GearTemplate { pub nav_active: &'static str, pub is_authenticated: bool, - pub has_ai_extract: bool, + pub gear: Paginated, pub navigator: ListNavigator, } @@ -141,7 +141,7 @@ pub struct RoastOptionsTemplate { pub struct BrewsTemplate { pub nav_active: &'static str, pub is_authenticated: bool, - pub has_ai_extract: bool, + pub brews: Paginated, pub bag_options: Vec, pub grinder_options: Vec, @@ -164,7 +164,7 @@ pub struct BrewListTemplate { pub struct CafesTemplate { pub nav_active: &'static str, pub is_authenticated: bool, - pub has_ai_extract: bool, + pub cafes: Paginated, pub navigator: ListNavigator, } @@ -182,7 +182,7 @@ pub struct CafeListTemplate { pub struct CafeDetailTemplate { pub nav_active: &'static str, pub is_authenticated: bool, - pub has_ai_extract: bool, + pub cafe: CafeView, } @@ -191,7 +191,7 @@ pub struct CafeDetailTemplate { pub struct CupsTemplate { pub nav_active: &'static str, pub is_authenticated: bool, - pub has_ai_extract: bool, + pub cups: Paginated, pub roast_options: Vec, pub cafe_options: Vec, @@ -211,8 +211,7 @@ pub struct CupListTemplate { pub struct HomeTemplate { pub nav_active: &'static str, pub is_authenticated: bool, - pub has_ai_extract: bool, - pub has_foursquare: bool, + pub last_brew: Option, pub open_bags: Vec, pub recent_events: Vec, @@ -224,8 +223,7 @@ pub struct HomeTemplate { pub struct CheckInTemplate { pub nav_active: &'static str, pub is_authenticated: bool, - pub has_ai_extract: bool, - pub has_foursquare: bool, + pub roast_options: Vec, pub cafe_options: Vec, } diff --git a/templates/checkin.html b/templates/checkin.html index 9eac872..b20df11 100644 --- a/templates/checkin.html +++ b/templates/checkin.html @@ -1,7 +1,6 @@ {% extends "base.html" %} {% block title %}Brewlog · Check In{% endblock %} {% block head %} -{% if has_foursquare %} -{% endif %} {% endblock %} {% block content %} @@ -60,11 +58,9 @@ function locateUser() { data-signals:_reviewing-cafe="false" data-signals:_scan-waiting="false" data-signals:_scan-success="''" - {% if has_foursquare %} data-on:location-found="$_locating = false; $_locationFound = true; $_userLat = evt.detail.lat; $_userLng = evt.detail.lng; @get('/api/v1/nearby-cafes?lat=' + evt.detail.lat + '&lng=' + evt.detail.lng + '&q=coffee', {responseOverrides: {selector: '#nearby-results', mode: 'replace'}})" data-on:location-error="$_locating = false; $_error = evt.detail.message" data-on:location-start="$_locating = true" - {% endif %} >

Check In

@@ -151,7 +147,6 @@ function locateUser() { - {% endif %} {% if !cafe_options.is_empty() %} @@ -270,7 +260,6 @@ function locateUser() {

What are you drinking?

- {% if has_ai_extract %} - {% endif %}
TestApp { token_repo, session_repo, brewlog::infrastructure::foursquare::FOURSQUARE_SEARCH_URL.to_string(), - None, + String::new(), None, ) .await @@ -111,7 +111,7 @@ async fn spawn_app_inner( token_repo: Arc, session_repo: Arc, foursquare_url: String, - foursquare_api_key: Option, + foursquare_api_key: String, mock_server: Option, ) -> TestApp { // Create application state @@ -130,7 +130,7 @@ async fn spawn_app_inner( reqwest::Client::new(), foursquare_url, foursquare_api_key, - None, + String::new(), "openrouter/free".to_string(), ); @@ -212,7 +212,7 @@ pub async fn spawn_app_with_foursquare_mock() -> TestApp { token_repo, session_repo, foursquare_url, - Some("test-api-key".to_string()), + "test-api-key".to_string(), Some(mock_server), ) .await;