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
<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) -->
<input type="file" id="{id}-photo" accept="image/*" capture="environment" class="hidden"
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>
<p data-show="$_extractError" data-text="$_extractError" style="display:none"></p>
</form>
{% endif %}
<!-- Main form with data-bind fields -->
<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`.
**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:

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
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

View file

@ -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<String>,
}
@ -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<Response, StatusCode> {
let template = LoginTemplate {
nav_active: "login",
is_authenticated: false,
has_ai_extract: false,
error: Some(message.to_string()),
};

View file

@ -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,

View file

@ -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,

View file

@ -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,
)

View file

@ -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,
};

View file

@ -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,

View file

@ -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,
};

View file

@ -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,

View file

@ -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<ExtractionInput>,
) -> 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 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;

View file

@ -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<ExtractionInput>,
) -> 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 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;

View file

@ -21,15 +21,15 @@ pub(crate) async fn extract_bag_scan(
headers: HeaderMap,
payload: FlexiblePayload<ExtractionInput>,
) -> 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 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;

View file

@ -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,

View file

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

View file

@ -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

View file

@ -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<RoasterView>,
pub navigator: ListNavigator<RoasterSortKey>,
}
@ -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<RoastView>,
}
@ -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<RoastView>,
pub roaster_options: Vec<RoasterOptionView>,
pub navigator: ListNavigator<RoastSortKey>,
@ -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<BagView>,
}
@ -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<TimelineEventView>,
pub navigator: ListNavigator<TimelineSortKey>,
pub months: Vec<TimelineMonthView>,
@ -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<BagView>,
pub bags: Paginated<BagView>,
pub roaster_options: Vec<RoasterOptionView>,
@ -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<GearView>,
pub navigator: ListNavigator<GearSortKey>,
}
@ -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<BrewView>,
pub bag_options: Vec<BagOptionView>,
pub grinder_options: Vec<GearOptionView>,
@ -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<CafeView>,
pub navigator: ListNavigator<CafeSortKey>,
}
@ -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<CupView>,
pub roast_options: Vec<RoastOptionView>,
pub cafe_options: Vec<CafeOptionView>,
@ -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<BrewView>,
pub open_bags: Vec<BagView>,
pub recent_events: Vec<TimelineEventView>,
@ -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<RoastOptionView>,
pub cafe_options: Vec<CafeOptionView>,
}

View file

@ -1,7 +1,6 @@
{% extends "base.html" %} {% block title %}Brewlog · Check In{% endblock %}
{% block head %}
{% if has_foursquare %}
<script>
function locateUser() {
const root = document.getElementById('checkin-root');
@ -30,7 +29,6 @@ function locateUser() {
);
}
</script>
{% 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 %}
>
<header class="flex flex-col gap-2">
<h1 class="text-3xl font-semibold">Check In</h1>
@ -151,7 +147,6 @@ function locateUser() {
<!-- Step 1: Cafe selection -->
<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">
{% if has_foursquare %}
<div class="flex flex-wrap items-center gap-3 mb-4" data-show="!$_reviewingCafe">
<button
type="button"
@ -185,7 +180,6 @@ function locateUser() {
Search by city
</label>
</div>
{% endif %}
<input
type="text"
@ -193,13 +187,10 @@ function locateUser() {
data-show="!$_reviewingCafe"
class="input-field w-full text-sm"
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'}}) }"
{% endif %}
/>
{% if has_foursquare %}
<!-- Foursquare results (replaced by server fragment) -->
<!-- Nearby 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>
<!-- Review form for new cafe from Foursquare -->
@ -245,7 +236,6 @@ function locateUser() {
</button>
</div>
</div>
{% endif %}
<!-- Saved cafes -->
{% 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">
<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">
<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">
<p class="text-xs text-stone-500 mb-2">Or select an existing roast:</p>
{% else %}
<div>
{% endif %}
<select
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)"

View file

@ -38,7 +38,6 @@
data-signals:_scan-submitting="false"
data-signals:_scan-error="''"
>
{% if has_ai_extract %}
<button
type="button"
data-on:click="$_showScan = !$_showScan; !$_showScan && ($_scanExtracted = false, $_extracting = false, $_extractError = '', $_scanError = '')"
@ -49,7 +48,6 @@
</svg>
Scan Bag
</button>
{% endif %}
<a
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"
@ -63,7 +61,6 @@
</section>
<!-- Inline Scan Section (hidden by default) -->
{% if has_ai_extract %}
<section data-show="$_showScan" style="display: none"
data-signals:_roaster-name="''"
data-signals:_roaster-country="''"
@ -211,7 +208,6 @@
</div>
</section>
{% endif %}
{% endif %}
<!-- Last Brew -->
{% if let Some(brew) = last_brew %}

View file

@ -33,7 +33,6 @@
</p>
</div>
{% if has_ai_extract %}
<!-- Hidden file input — minimal JS for FileReader API -->
<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=''}" />
@ -80,7 +79,6 @@
</div>
<p data-show="$_extractError" data-text="$_extractError" style="display:none" class="mt-2 text-sm text-red-600"></p>
</form>
{% endif %}
<form
id="roaster-form"

View file

@ -47,7 +47,6 @@
</p>
</div>
{% if has_ai_extract %}
<!-- Hidden file input — minimal JS for FileReader API -->
<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=''}" />
@ -94,7 +93,6 @@
</div>
<p data-show="$_extractError" data-text="$_extractError" style="display:none" class="mt-2 text-sm text-red-600"></p>
</form>
{% endif %}
<form
id="roast-form"

View file

@ -90,7 +90,7 @@ pub async fn spawn_app() -> 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<dyn TokenRepository>,
session_repo: Arc<dyn SessionRepository>,
foursquare_url: String,
foursquare_api_key: Option<String>,
foursquare_api_key: String,
mock_server: Option<wiremock::MockServer>,
) -> 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;