use std::sync::Arc; use std::time::{Duration, Instant}; use metrics::gauge; use parking_lot::RwLock; use reqwest::Client; use serde::{Deserialize, Serialize}; use tracing::{info, warn}; use crate::state::AppState; /// Cache TTL for the superuser token. PocketBase superuser JWTs are valid for /// ~14 days by default, so 10 minutes is very conservative while eliminating /// nearly all redundant auth requests (metrics poller, newsletter, invites, etc.). const SUPERUSER_TOKEN_TTL_SECS: u64 = 600; pub struct SuperuserTokenCache { token: RwLock>, } impl SuperuserTokenCache { pub fn new() -> Self { Self { token: RwLock::new(None), } } } /// Get a cached superuser token, or authenticate fresh if expired/missing. pub async fn get_superuser_token(state: &AppState) -> anyhow::Result { // Check cache first (read lock — cheap, non-blocking for other readers) { let cached = state.superuser_token_cache.token.read(); if let Some((token, created)) = cached.as_ref() { if created.elapsed().as_secs() < SUPERUSER_TOKEN_TTL_SECS { return Ok(token.clone()); } } } // Cache miss or expired — fetch a fresh token let pb_url = state.pocketbase_url.trim_end_matches('/'); let token = auth_superuser( &state.http_client, pb_url, &state.pocketbase_admin_email, &state.pocketbase_admin_password, ) .await?; // Store in cache { let mut cached = state.superuser_token_cache.token.write(); *cached = Some((token.clone(), Instant::now())); } Ok(token) } #[derive(Deserialize)] struct AuthResponse { token: String, } #[derive(Deserialize)] struct CollectionList { items: Vec, } #[derive(Deserialize)] struct CollectionItem { name: String, } #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct CreateCollection { name: String, r#type: String, fields: Vec, #[serde(skip_serializing_if = "Option::is_none")] list_rule: Option, #[serde(skip_serializing_if = "Option::is_none")] view_rule: Option, #[serde(skip_serializing_if = "Option::is_none")] create_rule: Option, #[serde(skip_serializing_if = "Option::is_none")] update_rule: Option, #[serde(skip_serializing_if = "Option::is_none")] delete_rule: Option, } #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct Field { name: String, r#type: String, #[serde(skip_serializing_if = "Option::is_none")] required: Option, #[serde(skip_serializing_if = "Option::is_none")] max_select: Option, #[serde(skip_serializing_if = "Option::is_none")] collection_id: Option, #[serde(skip_serializing_if = "Option::is_none")] max_size: Option, #[serde(skip_serializing_if = "Option::is_none")] mime_types: Option>, #[serde(skip_serializing_if = "Option::is_none")] on_create: Option, #[serde(skip_serializing_if = "Option::is_none")] on_update: Option, } impl Field { fn text(name: &str, required: bool) -> Self { Self { name: name.to_string(), r#type: "text".to_string(), required: Some(required), max_select: None, collection_id: None, max_size: None, mime_types: None, on_create: None, on_update: None, } } fn file(name: &str, mime_types: Vec<&str>) -> Self { Self { name: name.to_string(), r#type: "file".to_string(), required: Some(false), max_select: Some(1), collection_id: None, max_size: Some(10 * 1024 * 1024), // 10 MB mime_types: Some(mime_types.into_iter().map(String::from).collect()), on_create: None, on_update: None, } } fn relation(name: &str, collection_id: &str) -> Self { Self { name: name.to_string(), r#type: "relation".to_string(), required: Some(true), max_select: Some(1), collection_id: Some(collection_id.to_string()), max_size: None, mime_types: None, on_create: None, on_update: None, } } fn number(name: &str) -> Self { Self { name: name.to_string(), r#type: "number".to_string(), required: None, max_select: None, collection_id: None, max_size: None, mime_types: None, on_create: None, on_update: None, } } fn autodate(name: &str, on_create: bool, on_update: bool) -> Self { Self { name: name.to_string(), r#type: "autodate".to_string(), required: None, max_select: None, collection_id: None, max_size: None, mime_types: None, on_create: Some(on_create), on_update: Some(on_update), } } } pub async fn auth_superuser( client: &Client, base_url: &str, email: &str, password: &str, ) -> anyhow::Result { let url = format!("{base_url}/api/collections/_superusers/auth-with-password"); let resp = client .post(&url) .json(&serde_json::json!({ "identity": email, "password": password, })) .send() .await?; if !resp.status().is_success() { let status = resp.status(); let text = resp.text().await.unwrap_or_default(); anyhow::bail!("PocketBase superuser auth failed ({status}): {text}"); } let body: AuthResponse = resp.json().await?; Ok(body.token) } async fn list_collections( client: &Client, base_url: &str, token: &str, ) -> anyhow::Result> { let url = format!("{base_url}/api/collections?perPage=200"); let resp = client .get(&url) .header("Authorization", format!("Bearer {token}")) .send() .await?; if !resp.status().is_success() { let status = resp.status(); let text = resp.text().await.unwrap_or_default(); anyhow::bail!("Failed to list PocketBase collections ({status}): {text}"); } let body: CollectionList = resp.json().await?; Ok(body.items.into_iter().map(|c| c.name).collect()) } async fn create_collection( client: &Client, base_url: &str, token: &str, collection: CreateCollection, ) -> anyhow::Result<()> { let name = collection.name.clone(); let resp = client .post(format!("{base_url}/api/collections")) .header("Authorization", format!("Bearer {token}")) .json(&collection) .send() .await?; if !resp.status().is_success() { let status = resp.status(); let text = resp.text().await.unwrap_or_default(); anyhow::bail!("Failed to create collection '{name}' ({status}): {text}"); } info!("Created PocketBase collection: {name}"); Ok(()) } /// Look up the internal ID of the `users` auth collection. async fn find_users_collection_id( client: &Client, base_url: &str, token: &str, ) -> anyhow::Result { let url = format!("{base_url}/api/collections/users"); let resp = client .get(&url) .header("Authorization", format!("Bearer {token}")) .send() .await?; if !resp.status().is_success() { let status = resp.status(); let text = resp.text().await.unwrap_or_default(); anyhow::bail!("Failed to fetch users collection ({status}): {text}"); } let body: serde_json::Value = resp.json().await?; let id = body["id"] .as_str() .ok_or_else(|| anyhow::anyhow!("users collection has no id field"))?; Ok(id.to_string()) } /// Ensure `is_admin` (bool) and `subscription` (text) fields exist on the `users` collection. /// PocketBase PATCH replaces the entire `fields` array, so we must preserve existing fields. async fn ensure_user_fields(client: &Client, base_url: &str, token: &str) -> anyhow::Result<()> { let url = format!("{base_url}/api/collections/users"); let resp = client .get(&url) .header("Authorization", format!("Bearer {token}")) .send() .await?; if !resp.status().is_success() { let status = resp.status(); let text = resp.text().await.unwrap_or_default(); anyhow::bail!("Failed to fetch users collection ({status}): {text}"); } let body: serde_json::Value = resp.json().await?; let fields = body["fields"] .as_array() .ok_or_else(|| anyhow::anyhow!("users collection has no fields array"))?; let has_is_admin = fields.iter().any(|f| f["name"] == "is_admin"); let has_subscription = fields.iter().any(|f| f["name"] == "subscription"); let has_newsletter = fields.iter().any(|f| f["name"] == "newsletter"); let has_ai_tokens_used = fields.iter().any(|f| f["name"] == "ai_tokens_used"); let has_ai_tokens_week = fields.iter().any(|f| f["name"] == "ai_tokens_week"); if has_is_admin && has_subscription && has_newsletter && has_ai_tokens_used && has_ai_tokens_week { info!("PocketBase users collection already has all required fields"); return Ok(()); } let mut new_fields = fields.clone(); if !has_is_admin { new_fields.push(serde_json::json!({ "name": "is_admin", "type": "bool", })); } if !has_subscription { new_fields.push(serde_json::json!({ "name": "subscription", "type": "text", })); } if !has_newsletter { new_fields.push(serde_json::json!({ "name": "newsletter", "type": "bool", })); } if !has_ai_tokens_used { new_fields.push(serde_json::json!({ "name": "ai_tokens_used", "type": "number", })); } if !has_ai_tokens_week { new_fields.push(serde_json::json!({ "name": "ai_tokens_week", "type": "number", })); } let patch_resp = client .patch(&url) .header("Authorization", format!("Bearer {token}")) .json(&serde_json::json!({ "fields": new_fields })) .send() .await?; if !patch_resp.status().is_success() { let status = patch_resp.status(); let text = patch_resp.text().await.unwrap_or_default(); anyhow::bail!("Failed to patch users collection ({status}): {text}"); } info!("Added missing fields to PocketBase users collection"); Ok(()) } /// Ensure a collection has API rules allowing users to manage their own records. async fn ensure_user_owned_rules( client: &Client, base_url: &str, token: &str, collection_name: &str, ) -> anyhow::Result<()> { let url = format!("{base_url}/api/collections/{collection_name}"); let user_only = "user = @request.auth.id"; let resp = client .patch(&url) .header("Authorization", format!("Bearer {token}")) .json(&serde_json::json!({ "listRule": user_only, "viewRule": user_only, "createRule": user_only, "updateRule": user_only, "deleteRule": user_only, })) .send() .await?; if !resp.status().is_success() { let status = resp.status(); let text = resp.text().await.unwrap_or_default(); anyhow::bail!("Failed to update {collection_name} API rules ({status}): {text}"); } info!("PocketBase collection '{collection_name}' API rules updated"); Ok(()) } /// Ensure the `saved_searches` collection has API rules allowing users to manage their own records. async fn ensure_saved_searches_rules( client: &Client, base_url: &str, token: &str, ) -> anyhow::Result<()> { ensure_user_owned_rules(client, base_url, token, "saved_searches").await } /// Ensure the `saved_searches` collection has a `screenshot` file field. /// This field was added after the initial collection schema — existing deployments /// need it patched in so the frontend can attach screenshot JPEGs to saved searches. async fn ensure_screenshot_field( client: &Client, base_url: &str, token: &str, ) -> anyhow::Result<()> { let url = format!("{base_url}/api/collections/saved_searches"); let resp = client .get(&url) .header("Authorization", format!("Bearer {token}")) .send() .await?; if !resp.status().is_success() { let status = resp.status(); let text = resp.text().await.unwrap_or_default(); anyhow::bail!("Failed to fetch saved_searches collection ({status}): {text}"); } let body: serde_json::Value = resp.json().await?; let fields = body["fields"] .as_array() .ok_or_else(|| anyhow::anyhow!("saved_searches collection has no fields array"))?; if fields.iter().any(|f| f["name"] == "screenshot") { return Ok(()); } let mut new_fields = fields.clone(); new_fields.push(serde_json::json!({ "name": "screenshot", "type": "file", "required": false, "maxSelect": 1, "maxSize": 10485760, "mimeTypes": ["image/png", "image/jpeg", "image/webp"], })); let patch_resp = client .patch(&url) .header("Authorization", format!("Bearer {token}")) .json(&serde_json::json!({ "fields": new_fields })) .send() .await?; if !patch_resp.status().is_success() { let status = patch_resp.status(); let text = patch_resp.text().await.unwrap_or_default(); anyhow::bail!("Failed to add screenshot field to saved_searches ({status}): {text}"); } info!("Added screenshot file field to PocketBase collection 'saved_searches'"); Ok(()) } /// Ensure a collection has a `notes` text field for user annotations. async fn ensure_notes_field( client: &Client, base_url: &str, token: &str, collection_name: &str, ) -> anyhow::Result<()> { let url = format!("{base_url}/api/collections/{collection_name}"); let resp = client .get(&url) .header("Authorization", format!("Bearer {token}")) .send() .await?; if !resp.status().is_success() { let status = resp.status(); let text = resp.text().await.unwrap_or_default(); anyhow::bail!("Failed to fetch {collection_name} collection ({status}): {text}"); } let body: serde_json::Value = resp.json().await?; let fields = body["fields"] .as_array() .ok_or_else(|| anyhow::anyhow!("{collection_name} collection has no fields array"))?; if fields.iter().any(|f| f["name"] == "notes") { return Ok(()); } let mut new_fields = fields.clone(); new_fields.push(serde_json::json!({ "name": "notes", "type": "text", "required": false, })); let patch_resp = client .patch(&url) .header("Authorization", format!("Bearer {token}")) .json(&serde_json::json!({ "fields": new_fields })) .send() .await?; if !patch_resp.status().is_success() { let status = patch_resp.status(); let text = patch_resp.text().await.unwrap_or_default(); anyhow::bail!("Failed to add notes field to {collection_name} ({status}): {text}"); } info!("Added notes text field to PocketBase collection '{collection_name}'"); Ok(()) } /// Ensure a collection has `created` and `updated` autodate fields. /// PocketBase 0.23+ no longer adds these automatically — they must be explicit. async fn ensure_autodate_fields( client: &Client, base_url: &str, token: &str, collection_name: &str, ) -> anyhow::Result<()> { let url = format!("{base_url}/api/collections/{collection_name}"); let resp = client .get(&url) .header("Authorization", format!("Bearer {token}")) .send() .await?; if !resp.status().is_success() { let status = resp.status(); let text = resp.text().await.unwrap_or_default(); anyhow::bail!("Failed to fetch {collection_name} collection ({status}): {text}"); } let body: serde_json::Value = resp.json().await?; let fields = body["fields"] .as_array() .ok_or_else(|| anyhow::anyhow!("{collection_name} collection has no fields array"))?; let has_created = fields.iter().any(|f| f["name"] == "created"); let has_updated = fields.iter().any(|f| f["name"] == "updated"); if has_created && has_updated { return Ok(()); } let mut new_fields = fields.clone(); if !has_created { new_fields.push(serde_json::json!({ "name": "created", "type": "autodate", "onCreate": true, "onUpdate": false, })); } if !has_updated { new_fields.push(serde_json::json!({ "name": "updated", "type": "autodate", "onCreate": true, "onUpdate": true, })); } let patch_resp = client .patch(&url) .header("Authorization", format!("Bearer {token}")) .json(&serde_json::json!({ "fields": new_fields })) .send() .await?; if !patch_resp.status().is_success() { let status = patch_resp.status(); let text = patch_resp.text().await.unwrap_or_default(); anyhow::bail!("Failed to add autodate fields to {collection_name} ({status}): {text}"); } info!("Added created/updated autodate fields to PocketBase collection '{collection_name}'"); Ok(()) } /// Ensure the `saved_searches` and `short_urls` collections exist in PocketBase, /// and that the `users` collection has `is_admin` and `subscription` fields. /// Authenticates as superuser, checks existing collections, and creates any that are missing. pub async fn ensure_collections( client: &Client, base_url: &str, admin_email: &str, admin_password: &str, ) -> anyhow::Result<()> { let base_url = base_url.trim_end_matches('/'); let token = auth_superuser(client, base_url, admin_email, admin_password).await?; let existing = list_collections(client, base_url, &token).await?; ensure_user_fields(client, base_url, &token).await?; if !existing.iter().any(|n| n == "saved_searches") { let users_id = find_users_collection_id(client, base_url, &token).await?; let user_only = Some("user = @request.auth.id".to_string()); create_collection( client, base_url, &token, CreateCollection { name: "saved_searches".to_string(), r#type: "base".to_string(), fields: vec![ Field::relation("user", &users_id), Field::text("name", true), Field::text("params", true), Field::file("screenshot", vec!["image/png", "image/jpeg", "image/webp"]), Field::text("notes", false), Field::autodate("created", true, false), Field::autodate("updated", true, true), ], list_rule: user_only.clone(), view_rule: user_only.clone(), create_rule: user_only.clone(), update_rule: user_only.clone(), delete_rule: user_only, }, ) .await?; } else { ensure_saved_searches_rules(client, base_url, &token).await?; ensure_autodate_fields(client, base_url, &token, "saved_searches").await?; ensure_screenshot_field(client, base_url, &token).await?; ensure_notes_field(client, base_url, &token, "saved_searches").await?; } if !existing.iter().any(|n| n == "saved_properties") { let users_id = find_users_collection_id(client, base_url, &token).await?; let user_only = Some("user = @request.auth.id".to_string()); create_collection( client, base_url, &token, CreateCollection { name: "saved_properties".to_string(), r#type: "base".to_string(), fields: vec![ Field::relation("user", &users_id), Field::text("address", true), Field::text("postcode", true), Field::text("data", false), Field::text("notes", false), Field::autodate("created", true, false), Field::autodate("updated", true, true), ], list_rule: user_only.clone(), view_rule: user_only.clone(), create_rule: user_only.clone(), update_rule: user_only.clone(), delete_rule: user_only, }, ) .await?; } else { ensure_user_owned_rules(client, base_url, &token, "saved_properties").await?; ensure_autodate_fields(client, base_url, &token, "saved_properties").await?; ensure_notes_field(client, base_url, &token, "saved_properties").await?; } if !existing.iter().any(|n| n == "invites") { create_collection( client, base_url, &token, CreateCollection { name: "invites".to_string(), r#type: "base".to_string(), fields: vec![ Field::text("code", true), Field::text("created_by", true), Field::text("invite_type", true), Field::text("used_by_id", false), Field::text("used_at", false), Field::autodate("created", true, false), Field::autodate("updated", true, true), ], list_rule: None, view_rule: None, create_rule: None, update_rule: None, delete_rule: None, }, ) .await?; } else { ensure_autodate_fields(client, base_url, &token, "invites").await?; } if !existing.iter().any(|n| n == "short_urls") { create_collection( client, base_url, &token, CreateCollection { name: "short_urls".to_string(), r#type: "base".to_string(), fields: vec![ Field::text("code", true), Field::text("params", true), Field::autodate("created", true, false), Field::autodate("updated", true, true), ], list_rule: None, view_rule: None, create_rule: None, update_rule: None, delete_rule: None, }, ) .await?; } else { ensure_autodate_fields(client, base_url, &token, "short_urls").await?; } if !existing.iter().any(|n| n == "location_logs") { let users_id = find_users_collection_id(client, base_url, &token).await?; create_collection( client, base_url, &token, CreateCollection { name: "location_logs".to_string(), r#type: "base".to_string(), fields: vec![ Field::relation("user", &users_id), Field::number("latitude"), Field::number("longitude"), Field::text("postcode", true), Field::autodate("created", true, false), Field::autodate("updated", true, true), ], list_rule: None, view_rule: None, create_rule: None, update_rule: None, delete_rule: None, }, ) .await?; } else { ensure_autodate_fields(client, base_url, &token, "location_logs").await?; } if !existing.iter().any(|n| n == "ai_query_logs") { let users_id = find_users_collection_id(client, base_url, &token).await?; create_collection( client, base_url, &token, CreateCollection { name: "ai_query_logs".to_string(), r#type: "base".to_string(), fields: vec![ Field::relation("user", &users_id), Field::text("query", true), Field::text("response_filters", false), Field::text("response_notes", false), Field::number("tokens_used"), Field::number("rounds"), Field::text("model", false), Field::autodate("created", true, false), Field::autodate("updated", true, true), ], list_rule: None, view_rule: None, create_rule: None, update_rule: None, delete_rule: None, }, ) .await?; } else { ensure_autodate_fields(client, base_url, &token, "ai_query_logs").await?; } Ok(()) } /// Configure Google OAuth2 provider in PocketBase settings. /// Also sets `meta.appUrl` so OAuth callbacks route to `{public_url}/pb`. pub async fn ensure_oauth_providers( client: &Client, base_url: &str, admin_email: &str, admin_password: &str, public_url: &str, google_client_id: &str, google_client_secret: &str, ) -> anyhow::Result<()> { let base_url = base_url.trim_end_matches('/'); let token = auth_superuser(client, base_url, admin_email, admin_password).await?; // Set meta.appURL in global settings for OAuth redirects let app_url = format!("{}/pb", public_url.trim_end_matches('/')); let settings_url = format!("{base_url}/api/settings"); let patch_resp = client .patch(&settings_url) .header("Authorization", format!("Bearer {token}")) .json(&serde_json::json!({ "meta": { "appURL": app_url } })) .send() .await?; if !patch_resp.status().is_success() { let status = patch_resp.status(); let text = patch_resp.text().await.unwrap_or_default(); anyhow::bail!("Failed to update PocketBase meta.appURL ({status}): {text}"); } info!("PocketBase meta.appURL set to {app_url}"); // PocketBase 0.23+: OAuth providers are configured per-collection, not in global settings. // GET the users collection to update its oauth2 config. let collection_url = format!("{base_url}/api/collections/users"); let resp = client .get(&collection_url) .header("Authorization", format!("Bearer {token}")) .send() .await?; if !resp.status().is_success() { let status = resp.status(); let text = resp.text().await.unwrap_or_default(); anyhow::bail!("Failed to fetch users collection ({status}): {text}"); } let mut collection: serde_json::Value = resp.json().await?; let oauth2 = collection .get_mut("oauth2") .ok_or_else(|| anyhow::anyhow!("users collection missing oauth2 field"))?; // Ensure enabled oauth2["enabled"] = serde_json::json!(true); let providers = oauth2 .get_mut("providers") .and_then(|v| v.as_array_mut()) .ok_or_else(|| anyhow::anyhow!("users collection missing oauth2.providers array"))?; let google = match providers .iter() .position(|p| p.get("name").and_then(|n| n.as_str()) == Some("google")) { Some(idx) => &mut providers[idx], None => { info!("Google provider not found — adding it"); providers.push(serde_json::json!({"name": "google"})); providers.last_mut().expect("just pushed") } }; google["clientId"] = serde_json::json!(google_client_id); google["clientSecret"] = serde_json::json!(google_client_secret); // PATCH the collection let patch_resp = client .patch(&collection_url) .header("Authorization", format!("Bearer {token}")) .json(&serde_json::json!({ "oauth2": oauth2 })) .send() .await?; if !patch_resp.status().is_success() { let status = patch_resp.status(); let text = patch_resp.text().await.unwrap_or_default(); anyhow::bail!("Failed to update users collection OAuth ({status}): {text}"); } info!("PocketBase OAuth configured on users collection"); Ok(()) } /// Spawn a background task that polls PocketBase every 60 seconds for collection counts /// and exposes them as Prometheus gauges. pub fn start_metrics_poller(shared: Arc) { tokio::spawn(async move { let mut interval = tokio::time::interval(Duration::from_secs(60)); loop { interval.tick().await; let state = shared.load_state(); poll_pocketbase_counts(&state).await; } }); } async fn poll_pocketbase_counts(state: &AppState) { let token = match get_superuser_token(state).await { Ok(tk) => tk, Err(err) => { warn!("PocketBase metrics poll auth failed: {err}"); return; } }; let pb_url = state.pocketbase_url.trim_end_matches('/'); // Simple collection counts for (collection, metric_name) in [ ("users", "pocketbase_users_total"), ("saved_searches", "pocketbase_saved_searches_total"), ("saved_properties", "pocketbase_saved_properties_total"), ] { if let Some(total) = pb_count(&state.http_client, pb_url, &token, collection, None).await { gauge!(metric_name).set(total as f64); } } // Invite metrics: by type and redeemed status for (filter, metric, labels) in [ (None, "invites_total", ("type", "all")), ( Some(r#"invite_type="admin""#), "invites_total", ("type", "admin"), ), ( Some(r#"invite_type="referral""#), "invites_total", ("type", "referral"), ), ( Some(r#"used_by_id!="""#), "invites_total", ("type", "redeemed"), ), ] { if let Some(total) = pb_count(&state.http_client, pb_url, &token, "invites", filter).await { gauge!(metric, labels.0 => labels.1.to_string()).set(total as f64); } } } /// Insert a record into the `location_logs` collection. /// Best-effort — logs warnings on failure but does not propagate errors. pub async fn log_user_location( state: &AppState, user_id: &str, latitude: f64, longitude: f64, postcode: &str, ) { let token = match get_superuser_token(state).await { Ok(tk) => tk, Err(err) => { warn!("Failed to auth superuser for location log: {err}"); return; } }; let pb_url = state.pocketbase_url.trim_end_matches('/'); let url = format!("{pb_url}/api/collections/location_logs/records"); let res = state .http_client .post(&url) .header("Authorization", format!("Bearer {token}")) .json(&serde_json::json!({ "user": user_id, "latitude": latitude, "longitude": longitude, "postcode": postcode, })) .send() .await; match res { Ok(resp) if resp.status().is_success() => {} Ok(resp) => { let status = resp.status(); warn!("Failed to log user location ({status})"); } Err(err) => warn!("Failed to log user location: {err}"), } } /// Insert a record into the `ai_query_logs` collection. /// Best-effort — logs warnings on failure but does not propagate errors. #[allow(clippy::too_many_arguments)] pub async fn log_ai_query( state: &AppState, user_id: &str, query: &str, response_filters: &str, response_notes: &str, tokens_used: u64, rounds: u64, ) { let token = match get_superuser_token(state).await { Ok(tk) => tk, Err(err) => { warn!("Failed to auth superuser for AI query log: {err}"); return; } }; let pb_url = state.pocketbase_url.trim_end_matches('/'); let url = format!("{pb_url}/api/collections/ai_query_logs/records"); let res = state .http_client .post(&url) .header("Authorization", format!("Bearer {token}")) .json(&serde_json::json!({ "user": user_id, "query": query, "response_filters": response_filters, "response_notes": response_notes, "tokens_used": tokens_used, "rounds": rounds, "model": &state.gemini_model, })) .send() .await; match res { Ok(resp) if resp.status().is_success() => {} Ok(resp) => { let status = resp.status(); warn!("Failed to log AI query ({status})"); } Err(err) => warn!("Failed to log AI query: {err}"), } } async fn pb_count( client: &reqwest::Client, pb_url: &str, token: &str, collection: &str, filter: Option<&str>, ) -> Option { let mut url = format!("{pb_url}/api/collections/{collection}/records?perPage=1"); if let Some(f) = filter { url.push_str(&format!("&filter={}", urlencoding::encode(f))); } match client .get(&url) .header("Authorization", format!("Bearer {token}")) .send() .await { Ok(resp) if resp.status().is_success() => { if let Ok(body) = resp.json::().await { return body.get("totalItems").and_then(|v| v.as_u64()); } None } Ok(resp) => { warn!( "PocketBase {collection} count query failed: {}", resp.status() ); None } Err(err) => { warn!("PocketBase {collection} count query error: {err}"); None } } }