refactor: require OpenRouter and Foursquare API keys at startup

- Remove has_ai_extract/has_foursquare conditionals from all templates
- Remove boolean fields from template structs and route handlers
- Remove has_ai_extract()/has_foursquare() methods from AppState
- Change API key fields from Option<String> to String in ServerConfig
  and AppState
- Validate keys in run_server() with clear error messages
- Remove runtime key checks from extraction/nearby route handlers
- Update README and CLAUDE.md to reflect required configuration
This commit is contained in:
Jon Seager 2026-02-04 16:13:04 +00:00
parent 98c8391670
commit a31c91211c
No known key found for this signature in database
22 changed files with 91 additions and 127 deletions

View file

@ -429,7 +429,6 @@ Each extraction-enabled page uses this structure:
```html ```html
<section data-signals:_extracting="false" data-signals:_extract-error="''" data-signals:_submitting="false"> <section data-signals:_extracting="false" data-signals:_extract-error="''" data-signals:_submitting="false">
{% if has_ai_extract %}
<!-- Hidden file input — only JS needed (FileReader API) --> <!-- Hidden file input — only JS needed (FileReader API) -->
<input type="file" id="{id}-photo" accept="image/*" capture="environment" class="hidden" <input type="file" id="{id}-photo" accept="image/*" capture="environment" class="hidden"
onchange="if(this.files[0]){const r=new FileReader();r.onload=()=>{ onchange="if(this.files[0]){const r=new FileReader();r.onload=()=>{
@ -452,7 +451,6 @@ Each extraction-enabled page uses this structure:
<div data-show="$_extracting" style="display:none"><!-- spinner --></div> <div data-show="$_extracting" style="display:none"><!-- spinner --></div>
<p data-show="$_extractError" data-text="$_extractError" style="display:none"></p> <p data-show="$_extractError" data-text="$_extractError" style="display:none"></p>
</form> </form>
{% endif %}
<!-- Main form with data-bind fields --> <!-- Main form with data-bind fields -->
<form data-on:submit="$_submitting = true; @post(...)"> <form data-on:submit="$_submitting = true; @post(...)">
@ -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`. 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: **Search modes** via the `SearchLocation` enum:

View file

@ -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 ### 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 ```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 ### 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_URL` | Server URL for CLI commands | `http://127.0.0.1:3000` |
| `BREWLOG_TOKEN` | API token for authenticated CLI operations | — | | `BREWLOG_TOKEN` | API token for authenticated CLI operations | — |
### Optional Integrations ### Integrations
| Variable | Purpose | Default | | 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_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 ### 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 - Photo extraction buttons on the roaster and roast forms
- Text-based extraction from typed descriptions - 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 ### 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 ## Database

View file

@ -20,7 +20,6 @@ const SESSION_COOKIE_NAME: &str = "brewlog_session";
struct LoginTemplate { struct LoginTemplate {
nav_active: &'static str, nav_active: &'static str,
is_authenticated: bool, is_authenticated: bool,
has_ai_extract: bool,
error: Option<String>, error: Option<String>,
} }
@ -43,7 +42,7 @@ pub(crate) async fn login_page(
let template = LoginTemplate { let template = LoginTemplate {
nav_active: "login", nav_active: "login",
is_authenticated: false, is_authenticated: false,
has_ai_extract: false,
error: None, error: None,
}; };
@ -132,7 +131,7 @@ fn show_login_error(message: &str) -> Result<Response, StatusCode> {
let template = LoginTemplate { let template = LoginTemplate {
nav_active: "login", nav_active: "login",
is_authenticated: false, is_authenticated: false,
has_ai_extract: false,
error: Some(message.to_string()), error: Some(message.to_string()),
}; };

View file

@ -99,7 +99,6 @@ pub(crate) async fn bags_page(
let template = BagsTemplate { let template = BagsTemplate {
nav_active: "bags", nav_active: "bags",
is_authenticated, is_authenticated,
has_ai_extract: state.has_ai_extract(),
open_bags, open_bags,
bags, bags,
roaster_options, roaster_options,

View file

@ -186,7 +186,6 @@ pub(crate) async fn brews_page(
let template = BrewsTemplate { let template = BrewsTemplate {
nav_active: "brews", nav_active: "brews",
is_authenticated, is_authenticated,
has_ai_extract: state.has_ai_extract(),
brews, brews,
bag_options, bag_options,
grinder_options, grinder_options,

View file

@ -71,7 +71,6 @@ pub(crate) async fn cafes_page(
let template = CafesTemplate { let template = CafesTemplate {
nav_active: "cafes", nav_active: "cafes",
is_authenticated, is_authenticated,
has_ai_extract: state.has_ai_extract(),
cafes, cafes,
navigator, navigator,
}; };
@ -97,7 +96,6 @@ pub(crate) async fn cafe_page(
let template = CafeDetailTemplate { let template = CafeDetailTemplate {
nav_active: "cafes", nav_active: "cafes",
is_authenticated, is_authenticated,
has_ai_extract: state.has_ai_extract(),
cafe: cafe_view, cafe: cafe_view,
}; };
@ -227,15 +225,10 @@ pub(crate) async fn nearby_cafes(
foursquare::SearchLocation::Coordinates { lat, lng } 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( let cafes = foursquare::search_nearby(
&state.http_client, &state.http_client,
&state.foursquare_url, &state.foursquare_url,
api_key, &state.foursquare_api_key,
&location, &location,
q, q,
) )

View file

@ -32,8 +32,6 @@ pub(crate) async fn checkin_page(
let template = CheckInTemplate { let template = CheckInTemplate {
nav_active: "home", nav_active: "home",
is_authenticated: true, is_authenticated: true,
has_ai_extract: state.has_ai_extract(),
has_foursquare: state.has_foursquare(),
roast_options, roast_options,
cafe_options, cafe_options,
}; };

View file

@ -74,7 +74,6 @@ pub(crate) async fn cups_page(
let template = CupsTemplate { let template = CupsTemplate {
nav_active: "cups", nav_active: "cups",
is_authenticated, is_authenticated,
has_ai_extract: state.has_ai_extract(),
cups, cups,
roast_options, roast_options,
cafe_options, cafe_options,

View file

@ -71,7 +71,6 @@ pub(crate) async fn gear_page(
let template = GearTemplate { let template = GearTemplate {
nav_active: "gear", nav_active: "gear",
is_authenticated, is_authenticated,
has_ai_extract: state.has_ai_extract(),
gear, gear,
navigator, navigator,
}; };

View file

@ -31,8 +31,6 @@ pub(crate) async fn home_page(
let template = HomeTemplate { let template = HomeTemplate {
nav_active: "home", nav_active: "home",
is_authenticated, is_authenticated,
has_ai_extract: state.has_ai_extract(),
has_foursquare: state.has_foursquare(),
last_brew: content.last_brew, last_brew: content.last_brew,
open_bags: content.open_bags, open_bags: content.open_bags,
recent_events: content.recent_events, recent_events: content.recent_events,

View file

@ -70,7 +70,6 @@ pub(crate) async fn roasters_page(
let template = RoastersTemplate { let template = RoastersTemplate {
nav_active: "roasters", nav_active: "roasters",
is_authenticated, is_authenticated,
has_ai_extract: state.has_ai_extract(),
roasters, roasters,
navigator, navigator,
}; };
@ -101,7 +100,6 @@ pub(crate) async fn roaster_page(
let template = RoasterDetailTemplate { let template = RoasterDetailTemplate {
nav_active: "roasters", nav_active: "roasters",
is_authenticated, is_authenticated,
has_ai_extract: state.has_ai_extract(),
roaster: roaster_view, roaster: roaster_view,
roasts: roasts.into_iter().map(RoastView::from_list_item).collect(), roasts: roasts.into_iter().map(RoastView::from_list_item).collect(),
}; };
@ -192,13 +190,13 @@ pub(crate) async fn extract_roaster(
headers: HeaderMap, headers: HeaderMap,
payload: FlexiblePayload<ExtractionInput>, payload: FlexiblePayload<ExtractionInput>,
) -> Result<Response, ApiError> { ) -> Result<Response, ApiError> {
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 (input, _) = payload.into_parts();
let result = ai::extract_roaster(&state.http_client, api_key, &state.openrouter_model, &input) let result = ai::extract_roaster(
&state.http_client,
&state.openrouter_api_key,
&state.openrouter_model,
&input,
)
.await .await
.map_err(ApiError::from)?; .map_err(ApiError::from)?;

View file

@ -76,7 +76,6 @@ pub(crate) async fn roasts_page(
let template = RoastsTemplate { let template = RoastsTemplate {
nav_active: "roasts", nav_active: "roasts",
is_authenticated, is_authenticated,
has_ai_extract: state.has_ai_extract(),
roasts, roasts,
roaster_options, roaster_options,
navigator, navigator,
@ -121,7 +120,6 @@ pub(crate) async fn roast_page(
let template = RoastDetailTemplate { let template = RoastDetailTemplate {
nav_active: "roasts", nav_active: "roasts",
is_authenticated, is_authenticated,
has_ai_extract: state.has_ai_extract(),
roast: RoastView::from_domain(roast, &roaster.name, &roaster.slug), roast: RoastView::from_domain(roast, &roaster.name, &roaster.slug),
bags: bag_views, bags: bag_views,
}; };
@ -348,13 +346,13 @@ pub(crate) async fn extract_roast_info(
headers: HeaderMap, headers: HeaderMap,
payload: FlexiblePayload<ExtractionInput>, payload: FlexiblePayload<ExtractionInput>,
) -> Result<Response, ApiError> { ) -> Result<Response, ApiError> {
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 (input, _) = payload.into_parts();
let result = ai::extract_roast(&state.http_client, api_key, &state.openrouter_model, &input) let result = ai::extract_roast(
&state.http_client,
&state.openrouter_api_key,
&state.openrouter_model,
&input,
)
.await .await
.map_err(ApiError::from)?; .map_err(ApiError::from)?;

View file

@ -21,13 +21,13 @@ pub(crate) async fn extract_bag_scan(
headers: HeaderMap, headers: HeaderMap,
payload: FlexiblePayload<ExtractionInput>, payload: FlexiblePayload<ExtractionInput>,
) -> Result<Response, ApiError> { ) -> Result<Response, ApiError> {
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 (input, _) = payload.into_parts();
let result = ai::extract_bag_scan(&state.http_client, api_key, &state.openrouter_model, &input) let result = ai::extract_bag_scan(
&state.http_client,
&state.openrouter_api_key,
&state.openrouter_model,
&input,
)
.await .await
.map_err(ApiError::from)?; .map_err(ApiError::from)?;
@ -128,16 +128,16 @@ async fn extract_into_submission(
state: &AppState, state: &AppState,
submission: &mut BagScanSubmission, submission: &mut BagScanSubmission,
) -> Result<(), ApiError> { ) -> Result<(), ApiError> {
let api_key = state
.openrouter_api_key
.as_deref()
.ok_or_else(|| AppError::validation("AI extraction is not configured"))?;
let input = ExtractionInput { let input = ExtractionInput {
image: submission.image.take(), image: submission.image.take(),
prompt: submission.prompt.take(), prompt: submission.prompt.take(),
}; };
let result = ai::extract_bag_scan(&state.http_client, api_key, &state.openrouter_model, &input) let result = ai::extract_bag_scan(
&state.http_client,
&state.openrouter_api_key,
&state.openrouter_model,
&input,
)
.await .await
.map_err(ApiError::from)?; .map_err(ApiError::from)?;

View file

@ -93,7 +93,6 @@ pub(crate) async fn timeline_page(
let template = TimelineTemplate { let template = TimelineTemplate {
nav_active: "timeline", nav_active: "timeline",
is_authenticated, is_authenticated,
has_ai_extract: state.has_ai_extract(),
events: data.events, events: data.events,
navigator: data.navigator, navigator: data.navigator,
months: data.months, months: data.months,

View file

@ -32,9 +32,9 @@ pub struct ServerConfig {
pub database_url: String, pub database_url: String,
pub admin_password: Option<String>, pub admin_password: Option<String>,
pub admin_username: Option<String>, pub admin_username: Option<String>,
pub openrouter_api_key: Option<String>, pub openrouter_api_key: String,
pub openrouter_model: String, pub openrouter_model: String,
pub foursquare_api_key: Option<String>, pub foursquare_api_key: String,
} }
#[derive(Clone)] #[derive(Clone)]
@ -52,8 +52,8 @@ pub struct AppState {
pub session_repo: Arc<dyn SessionRepository>, pub session_repo: Arc<dyn SessionRepository>,
pub http_client: reqwest::Client, pub http_client: reqwest::Client,
pub foursquare_url: String, pub foursquare_url: String,
pub foursquare_api_key: Option<String>, pub foursquare_api_key: String,
pub openrouter_api_key: Option<String>, pub openrouter_api_key: String,
pub openrouter_model: String, pub openrouter_model: String,
} }
@ -73,8 +73,8 @@ impl AppState {
session_repo: Arc<dyn SessionRepository>, session_repo: Arc<dyn SessionRepository>,
http_client: reqwest::Client, http_client: reqwest::Client,
foursquare_url: String, foursquare_url: String,
foursquare_api_key: Option<String>, foursquare_api_key: String,
openrouter_api_key: Option<String>, openrouter_api_key: String,
openrouter_model: String, openrouter_model: String,
) -> Self { ) -> Self {
Self { Self {
@ -96,14 +96,6 @@ impl AppState {
openrouter_model, 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<()> { pub async fn serve(config: ServerConfig) -> anyhow::Result<()> {

View file

@ -79,14 +79,28 @@ async fn main() -> Result<()> {
} }
async fn run_server(command: ServeCommand) -> 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 { let config = ServerConfig {
bind_address: command.bind_address, bind_address: command.bind_address,
database_url: command.database_url, database_url: command.database_url,
admin_password: command.admin_password, admin_password: command.admin_password,
admin_username: command.admin_username, admin_username: command.admin_username,
openrouter_api_key: command.openrouter_api_key, openrouter_api_key,
openrouter_model: command.openrouter_model, openrouter_model: command.openrouter_model,
foursquare_api_key: command.foursquare_api_key, foursquare_api_key,
}; };
serve(config).await serve(config).await

View file

@ -19,7 +19,7 @@ use crate::domain::timeline::TimelineSortKey;
pub struct RoastersTemplate { pub struct RoastersTemplate {
pub nav_active: &'static str, pub nav_active: &'static str,
pub is_authenticated: bool, pub is_authenticated: bool,
pub has_ai_extract: bool,
pub roasters: Paginated<RoasterView>, pub roasters: Paginated<RoasterView>,
pub navigator: ListNavigator<RoasterSortKey>, pub navigator: ListNavigator<RoasterSortKey>,
} }
@ -37,7 +37,7 @@ pub struct RoasterListTemplate {
pub struct RoasterDetailTemplate { pub struct RoasterDetailTemplate {
pub nav_active: &'static str, pub nav_active: &'static str,
pub is_authenticated: bool, pub is_authenticated: bool,
pub has_ai_extract: bool,
pub roaster: RoasterView, pub roaster: RoasterView,
pub roasts: Vec<RoastView>, pub roasts: Vec<RoastView>,
} }
@ -47,7 +47,7 @@ pub struct RoasterDetailTemplate {
pub struct RoastsTemplate { pub struct RoastsTemplate {
pub nav_active: &'static str, pub nav_active: &'static str,
pub is_authenticated: bool, pub is_authenticated: bool,
pub has_ai_extract: bool,
pub roasts: Paginated<RoastView>, pub roasts: Paginated<RoastView>,
pub roaster_options: Vec<RoasterOptionView>, pub roaster_options: Vec<RoasterOptionView>,
pub navigator: ListNavigator<RoastSortKey>, pub navigator: ListNavigator<RoastSortKey>,
@ -58,7 +58,7 @@ pub struct RoastsTemplate {
pub struct RoastDetailTemplate { pub struct RoastDetailTemplate {
pub nav_active: &'static str, pub nav_active: &'static str,
pub is_authenticated: bool, pub is_authenticated: bool,
pub has_ai_extract: bool,
pub roast: RoastView, pub roast: RoastView,
pub bags: Vec<BagView>, pub bags: Vec<BagView>,
} }
@ -76,7 +76,7 @@ pub struct RoastListTemplate {
pub struct TimelineTemplate { pub struct TimelineTemplate {
pub nav_active: &'static str, pub nav_active: &'static str,
pub is_authenticated: bool, pub is_authenticated: bool,
pub has_ai_extract: bool,
pub events: Paginated<TimelineEventView>, pub events: Paginated<TimelineEventView>,
pub navigator: ListNavigator<TimelineSortKey>, pub navigator: ListNavigator<TimelineSortKey>,
pub months: Vec<TimelineMonthView>, pub months: Vec<TimelineMonthView>,
@ -96,7 +96,7 @@ pub struct TimelineChunkTemplate {
pub struct BagsTemplate { pub struct BagsTemplate {
pub nav_active: &'static str, pub nav_active: &'static str,
pub is_authenticated: bool, pub is_authenticated: bool,
pub has_ai_extract: bool,
pub open_bags: Vec<BagView>, pub open_bags: Vec<BagView>,
pub bags: Paginated<BagView>, pub bags: Paginated<BagView>,
pub roaster_options: Vec<RoasterOptionView>, pub roaster_options: Vec<RoasterOptionView>,
@ -117,7 +117,7 @@ pub struct BagListTemplate {
pub struct GearTemplate { pub struct GearTemplate {
pub nav_active: &'static str, pub nav_active: &'static str,
pub is_authenticated: bool, pub is_authenticated: bool,
pub has_ai_extract: bool,
pub gear: Paginated<GearView>, pub gear: Paginated<GearView>,
pub navigator: ListNavigator<GearSortKey>, pub navigator: ListNavigator<GearSortKey>,
} }
@ -141,7 +141,7 @@ pub struct RoastOptionsTemplate {
pub struct BrewsTemplate { pub struct BrewsTemplate {
pub nav_active: &'static str, pub nav_active: &'static str,
pub is_authenticated: bool, pub is_authenticated: bool,
pub has_ai_extract: bool,
pub brews: Paginated<BrewView>, pub brews: Paginated<BrewView>,
pub bag_options: Vec<BagOptionView>, pub bag_options: Vec<BagOptionView>,
pub grinder_options: Vec<GearOptionView>, pub grinder_options: Vec<GearOptionView>,
@ -164,7 +164,7 @@ pub struct BrewListTemplate {
pub struct CafesTemplate { pub struct CafesTemplate {
pub nav_active: &'static str, pub nav_active: &'static str,
pub is_authenticated: bool, pub is_authenticated: bool,
pub has_ai_extract: bool,
pub cafes: Paginated<CafeView>, pub cafes: Paginated<CafeView>,
pub navigator: ListNavigator<CafeSortKey>, pub navigator: ListNavigator<CafeSortKey>,
} }
@ -182,7 +182,7 @@ pub struct CafeListTemplate {
pub struct CafeDetailTemplate { pub struct CafeDetailTemplate {
pub nav_active: &'static str, pub nav_active: &'static str,
pub is_authenticated: bool, pub is_authenticated: bool,
pub has_ai_extract: bool,
pub cafe: CafeView, pub cafe: CafeView,
} }
@ -191,7 +191,7 @@ pub struct CafeDetailTemplate {
pub struct CupsTemplate { pub struct CupsTemplate {
pub nav_active: &'static str, pub nav_active: &'static str,
pub is_authenticated: bool, pub is_authenticated: bool,
pub has_ai_extract: bool,
pub cups: Paginated<CupView>, pub cups: Paginated<CupView>,
pub roast_options: Vec<RoastOptionView>, pub roast_options: Vec<RoastOptionView>,
pub cafe_options: Vec<CafeOptionView>, pub cafe_options: Vec<CafeOptionView>,
@ -211,8 +211,7 @@ pub struct CupListTemplate {
pub struct HomeTemplate { pub struct HomeTemplate {
pub nav_active: &'static str, pub nav_active: &'static str,
pub is_authenticated: bool, pub is_authenticated: bool,
pub has_ai_extract: bool,
pub has_foursquare: bool,
pub last_brew: Option<BrewView>, pub last_brew: Option<BrewView>,
pub open_bags: Vec<BagView>, pub open_bags: Vec<BagView>,
pub recent_events: Vec<TimelineEventView>, pub recent_events: Vec<TimelineEventView>,
@ -224,8 +223,7 @@ pub struct HomeTemplate {
pub struct CheckInTemplate { pub struct CheckInTemplate {
pub nav_active: &'static str, pub nav_active: &'static str,
pub is_authenticated: bool, pub is_authenticated: bool,
pub has_ai_extract: bool,
pub has_foursquare: bool,
pub roast_options: Vec<RoastOptionView>, pub roast_options: Vec<RoastOptionView>,
pub cafe_options: Vec<CafeOptionView>, pub cafe_options: Vec<CafeOptionView>,
} }

View file

@ -1,7 +1,6 @@
{% extends "base.html" %} {% block title %}Brewlog · Check In{% endblock %} {% extends "base.html" %} {% block title %}Brewlog · Check In{% endblock %}
{% block head %} {% block head %}
{% if has_foursquare %}
<script> <script>
function locateUser() { function locateUser() {
const root = document.getElementById('checkin-root'); const root = document.getElementById('checkin-root');
@ -30,7 +29,6 @@ function locateUser() {
); );
} }
</script> </script>
{% endif %}
{% endblock %} {% endblock %}
{% block content %} {% block content %}
@ -60,11 +58,9 @@ function locateUser() {
data-signals:_reviewing-cafe="false" data-signals:_reviewing-cafe="false"
data-signals:_scan-waiting="false" data-signals:_scan-waiting="false"
data-signals:_scan-success="''" 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-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-error="$_locating = false; $_error = evt.detail.message"
data-on:location-start="$_locating = true" data-on:location-start="$_locating = true"
{% endif %}
> >
<header class="flex flex-col gap-2"> <header class="flex flex-col gap-2">
<h1 class="text-3xl font-semibold">Check In</h1> <h1 class="text-3xl font-semibold">Check In</h1>
@ -151,7 +147,6 @@ function locateUser() {
<!-- Step 1: Cafe selection --> <!-- Step 1: Cafe selection -->
<div class="mt-4" data-show="$_step === 1" style="display: none"> <div class="mt-4" data-show="$_step === 1" style="display: none">
<div class="rounded-lg border border-amber-300 bg-amber-100/80 p-5 shadow-sm"> <div class="rounded-lg border border-amber-300 bg-amber-100/80 p-5 shadow-sm">
{% if has_foursquare %}
<div class="flex flex-wrap items-center gap-3 mb-4" data-show="!$_reviewingCafe"> <div class="flex flex-wrap items-center gap-3 mb-4" data-show="!$_reviewingCafe">
<button <button
type="button" type="button"
@ -185,7 +180,6 @@ function locateUser() {
Search by city Search by city
</label> </label>
</div> </div>
{% endif %}
<input <input
type="text" type="text"
@ -193,13 +187,10 @@ function locateUser() {
data-show="!$_reviewingCafe" data-show="!$_reviewingCafe"
class="input-field w-full text-sm" class="input-field w-full text-sm"
placeholder="Search for a cafe&hellip;" placeholder="Search for a cafe&hellip;"
{% if has_foursquare %}
data-on:input__debounce.350ms="if ($_searchByCity) { $_cityName.length >= 2 && $_cafeSearch.length >= 2 && @get('/api/v1/nearby-cafes?near=' + encodeURIComponent($_cityName) + '&q=' + encodeURIComponent($_cafeSearch), {responseOverrides: {selector: '#nearby-results', mode: 'replace'}}) } else { $_locationFound && @get('/api/v1/nearby-cafes?lat=' + $_userLat + '&lng=' + $_userLng + '&q=' + encodeURIComponent($_cafeSearch), {responseOverrides: {selector: '#nearby-results', mode: 'replace'}}) }" data-on:input__debounce.350ms="if ($_searchByCity) { $_cityName.length >= 2 && $_cafeSearch.length >= 2 && @get('/api/v1/nearby-cafes?near=' + encodeURIComponent($_cityName) + '&q=' + encodeURIComponent($_cafeSearch), {responseOverrides: {selector: '#nearby-results', mode: 'replace'}}) } else { $_locationFound && @get('/api/v1/nearby-cafes?lat=' + $_userLat + '&lng=' + $_userLng + '&q=' + encodeURIComponent($_cafeSearch), {responseOverrides: {selector: '#nearby-results', mode: 'replace'}}) }"
{% endif %}
/> />
{% if has_foursquare %} <!-- Nearby results (replaced by server fragment) -->
<!-- Foursquare results (replaced by server fragment) -->
<div data-show="!$_reviewingCafe" id="nearby-results" class="hidden mt-3 max-h-60 overflow-y-auto rounded-lg border border-amber-200 bg-white"></div> <div data-show="!$_reviewingCafe" id="nearby-results" class="hidden mt-3 max-h-60 overflow-y-auto rounded-lg border border-amber-200 bg-white"></div>
<!-- Review form for new cafe from Foursquare --> <!-- Review form for new cafe from Foursquare -->
@ -245,7 +236,6 @@ function locateUser() {
</button> </button>
</div> </div>
</div> </div>
{% endif %}
<!-- Saved cafes --> <!-- Saved cafes -->
{% if !cafe_options.is_empty() %} {% if !cafe_options.is_empty() %}
@ -270,7 +260,6 @@ function locateUser() {
<div class="rounded-lg border border-amber-300 bg-amber-100/80 p-5 shadow-sm"> <div class="rounded-lg border border-amber-300 bg-amber-100/80 p-5 shadow-sm">
<h3 class="text-base font-semibold text-amber-700 mb-3">What are you drinking?</h3> <h3 class="text-base font-semibold text-amber-700 mb-3">What are you drinking?</h3>
{% if has_ai_extract %}
<div data-show="!$_scanWaiting && !$_scanSuccess" style="display: none" class="mb-4"> <div data-show="!$_scanWaiting && !$_scanSuccess" style="display: none" class="mb-4">
<p class="text-sm text-stone-600 mb-3">Scan a bag to identify the coffee, or select from your existing roasts below.</p> <p class="text-sm text-stone-600 mb-3">Scan a bag to identify the coffee, or select from your existing roasts below.</p>
@ -340,9 +329,6 @@ function locateUser() {
<div class="border-t border-amber-200 pt-3"> <div class="border-t border-amber-200 pt-3">
<p class="text-xs text-stone-500 mb-2">Or select an existing roast:</p> <p class="text-xs text-stone-500 mb-2">Or select an existing roast:</p>
{% else %}
<div>
{% endif %}
<select <select
class="input-field w-full text-sm" class="input-field w-full text-sm"
data-on:change="$_roastId = el.value; $_roastName = el.options[el.selectedIndex].dataset.name || ''; $_roasterName = el.options[el.selectedIndex].dataset.roaster || ''; el.value && ($_step = 3)" data-on:change="$_roastId = el.value; $_roastName = el.options[el.selectedIndex].dataset.name || ''; $_roasterName = el.options[el.selectedIndex].dataset.roaster || ''; el.value && ($_step = 3)"

View file

@ -38,7 +38,6 @@
data-signals:_scan-submitting="false" data-signals:_scan-submitting="false"
data-signals:_scan-error="''" data-signals:_scan-error="''"
> >
{% if has_ai_extract %}
<button <button
type="button" type="button"
data-on:click="$_showScan = !$_showScan; !$_showScan && ($_scanExtracted = false, $_extracting = false, $_extractError = '', $_scanError = '')" data-on:click="$_showScan = !$_showScan; !$_showScan && ($_scanExtracted = false, $_extracting = false, $_extractError = '', $_scanError = '')"
@ -49,7 +48,6 @@
</svg> </svg>
Scan Bag Scan Bag
</button> </button>
{% endif %}
<a <a
href="/check-in" href="/check-in"
class="flex items-center justify-center gap-3 rounded-lg border-2 border-amber-500 px-6 py-5 text-lg font-semibold text-amber-700 shadow-sm transition hover:bg-amber-50" class="flex items-center justify-center gap-3 rounded-lg border-2 border-amber-500 px-6 py-5 text-lg font-semibold text-amber-700 shadow-sm transition hover:bg-amber-50"
@ -63,7 +61,6 @@
</section> </section>
<!-- Inline Scan Section (hidden by default) --> <!-- Inline Scan Section (hidden by default) -->
{% if has_ai_extract %}
<section data-show="$_showScan" style="display: none" <section data-show="$_showScan" style="display: none"
data-signals:_roaster-name="''" data-signals:_roaster-name="''"
data-signals:_roaster-country="''" data-signals:_roaster-country="''"
@ -211,7 +208,6 @@
</div> </div>
</section> </section>
{% endif %} {% endif %}
{% endif %}
<!-- Last Brew --> <!-- Last Brew -->
{% if let Some(brew) = last_brew %} {% if let Some(brew) = last_brew %}

View file

@ -33,7 +33,6 @@
</p> </p>
</div> </div>
{% if has_ai_extract %}
<!-- Hidden file input — minimal JS for FileReader API --> <!-- Hidden file input — minimal JS for FileReader API -->
<input type="file" id="roaster-photo" accept="image/*" capture="environment" class="hidden" <input type="file" id="roaster-photo" accept="image/*" capture="environment" class="hidden"
onchange="if(this.files[0]){const r=new FileReader();r.onload=()=>{document.getElementById('roaster-image').value=r.result;document.getElementById('roaster-extract-form').requestSubmit()};r.readAsDataURL(this.files[0]);this.value=''}" /> onchange="if(this.files[0]){const r=new FileReader();r.onload=()=>{document.getElementById('roaster-image').value=r.result;document.getElementById('roaster-extract-form').requestSubmit()};r.readAsDataURL(this.files[0]);this.value=''}" />
@ -80,7 +79,6 @@
</div> </div>
<p data-show="$_extractError" data-text="$_extractError" style="display:none" class="mt-2 text-sm text-red-600"></p> <p data-show="$_extractError" data-text="$_extractError" style="display:none" class="mt-2 text-sm text-red-600"></p>
</form> </form>
{% endif %}
<form <form
id="roaster-form" id="roaster-form"

View file

@ -47,7 +47,6 @@
</p> </p>
</div> </div>
{% if has_ai_extract %}
<!-- Hidden file input — minimal JS for FileReader API --> <!-- Hidden file input — minimal JS for FileReader API -->
<input type="file" id="roast-photo" accept="image/*" capture="environment" class="hidden" <input type="file" id="roast-photo" accept="image/*" capture="environment" class="hidden"
onchange="if(this.files[0]){const r=new FileReader();r.onload=()=>{document.getElementById('roast-image').value=r.result;document.getElementById('roast-extract-form').requestSubmit()};r.readAsDataURL(this.files[0]);this.value=''}" /> onchange="if(this.files[0]){const r=new FileReader();r.onload=()=>{document.getElementById('roast-image').value=r.result;document.getElementById('roast-extract-form').requestSubmit()};r.readAsDataURL(this.files[0]);this.value=''}" />
@ -94,7 +93,6 @@
</div> </div>
<p data-show="$_extractError" data-text="$_extractError" style="display:none" class="mt-2 text-sm text-red-600"></p> <p data-show="$_extractError" data-text="$_extractError" style="display:none" class="mt-2 text-sm text-red-600"></p>
</form> </form>
{% endif %}
<form <form
id="roast-form" id="roast-form"

View file

@ -90,7 +90,7 @@ pub async fn spawn_app() -> TestApp {
token_repo, token_repo,
session_repo, session_repo,
brewlog::infrastructure::foursquare::FOURSQUARE_SEARCH_URL.to_string(), brewlog::infrastructure::foursquare::FOURSQUARE_SEARCH_URL.to_string(),
None, String::new(),
None, None,
) )
.await .await
@ -111,7 +111,7 @@ async fn spawn_app_inner(
token_repo: Arc<dyn TokenRepository>, token_repo: Arc<dyn TokenRepository>,
session_repo: Arc<dyn SessionRepository>, session_repo: Arc<dyn SessionRepository>,
foursquare_url: String, foursquare_url: String,
foursquare_api_key: Option<String>, foursquare_api_key: String,
mock_server: Option<wiremock::MockServer>, mock_server: Option<wiremock::MockServer>,
) -> TestApp { ) -> TestApp {
// Create application state // Create application state
@ -130,7 +130,7 @@ async fn spawn_app_inner(
reqwest::Client::new(), reqwest::Client::new(),
foursquare_url, foursquare_url,
foursquare_api_key, foursquare_api_key,
None, String::new(),
"openrouter/free".to_string(), "openrouter/free".to_string(),
); );
@ -212,7 +212,7 @@ pub async fn spawn_app_with_foursquare_mock() -> TestApp {
token_repo, token_repo,
session_repo, session_repo,
foursquare_url, foursquare_url,
Some("test-api-key".to_string()), "test-api-key".to_string(),
Some(mock_server), Some(mock_server),
) )
.await; .await;