use axum::{ extract::{Path, Query, State}, Json, }; use serde::{Deserialize, Serialize}; use crate::{ astronomy::{ astro_twilight, compute_visibility, compute_visibility_with_step, julian_date, moon_altitude, moon_illumination, moon_position, HorizonPoint, MoonState, TonightWindow, }, config::{LAT, LON}, filters::{get_workflow, recommend_filters}, }; use super::{AppError, AppState}; #[derive(Debug, Deserialize)] pub struct TargetsQuery { #[serde(rename = "type")] pub obj_type: Option, pub constellation: Option, pub filter: Option, pub tonight: Option, pub search: Option, pub sort: Option, pub page: Option, pub limit: Option, pub min_alt_deg: Option, pub min_usable_min: Option, pub mosaic_only: Option, pub not_imaged: Option, } #[derive(Debug, Serialize, sqlx::FromRow)] pub struct TargetRow { pub id: String, pub name: String, pub common_name: Option, pub obj_type: String, pub ra_deg: f64, pub dec_deg: f64, pub ra_h: String, pub dec_dms: String, pub constellation: Option, pub size_arcmin_maj: Option, pub size_arcmin_min: Option, pub mag_v: Option, pub surface_brightness: Option, pub hubble_type: Option, pub messier_num: Option, pub is_highlight: bool, pub fov_fill_pct: Option, pub mosaic_flag: bool, pub mosaic_panels_w: i32, pub mosaic_panels_h: i32, pub difficulty: Option, pub guide_star_density: Option, // From nightly_cache pub max_alt_deg: Option, pub usable_min: Option, pub transit_utc: Option, pub recommended_filter: Option, pub best_start_utc: Option, pub best_end_utc: Option, pub moon_sep_deg: Option, pub is_visible_tonight: Option, } pub async fn list_targets( State(state): State, Query(params): Query, ) -> Result, AppError> { let today = chrono::Utc::now().naive_utc().date().to_string(); let page = params.page.unwrap_or(1).max(1); let limit = params.limit.unwrap_or(100).min(500); let offset = (page - 1) * limit; let tonight_filter = params.tonight.unwrap_or(true); let mut conditions = vec!["1=1".to_string()]; let mut bind_values: Vec = vec![]; if let Some(ref t) = params.obj_type { conditions.push("c.obj_type = ?".to_string()); bind_values.push(t.clone()); } if let Some(ref con) = params.constellation { conditions.push("c.constellation = ?".to_string()); bind_values.push(con.clone()); } if let Some(ref f) = params.filter { // Filter by filter suitability: use object-type compatibility, not moon-state-dependent recommended_filter. // This ensures these filters always return results regardless of current moon phase. match f.as_str() { "uvir" => conditions.push("c.obj_type IN ('galaxy', 'reflection_nebula', 'open_cluster', 'globular_cluster', 'dark_nebula')".to_string()), "c2" | "sv220" => conditions.push("c.obj_type IN ('emission_nebula', 'snr', 'planetary_nebula')".to_string()), "sv260" => {}, // LP filter works for all object types — no restriction _ => { conditions.push("nc.recommended_filter = ?".to_string()); bind_values.push(f.clone()); } } } if let Some(min_alt) = params.min_alt_deg { conditions.push("nc.max_alt_deg >= ?".to_string()); bind_values.push(min_alt.to_string()); } if let Some(min_min) = params.min_usable_min { conditions.push("nc.usable_min >= ?".to_string()); bind_values.push(min_min.to_string()); } if params.mosaic_only.unwrap_or(false) { conditions.push("c.mosaic_flag = 1".to_string()); } if params.not_imaged.unwrap_or(false) { conditions.push("log_sum.total_min IS NULL".to_string()); } // Tonight filter: show objects above MIN_ALT (15°) at any point tonight. // Using max_alt_deg >= 15 (not usable_min > 0) so objects that peak at 15-30° // (e.g. globular clusters, dark nebulae, open clusters) still appear. // Skip filter when search is active so you can find objects like M31 off-season. if tonight_filter && params.search.as_deref().unwrap_or("").is_empty() { // Allow objects with no nightly_cache entry yet (newly ingested, NULL max_alt_deg) // so freshly added VdB/LDN objects are visible before the first nightly precompute. conditions.push("(nc.max_alt_deg >= 15 OR nc.max_alt_deg IS NULL)".to_string()); } if let Some(ref s) = params.search { let like = format!("%{}%", s); // Support M-number search (e.g. "M42" → messier_num = 42) let m_num: Option = s.trim() .strip_prefix(['M', 'm']) .and_then(|n| n.parse().ok()); if let Some(m) = m_num { conditions.push(format!( "(c.name LIKE ? OR c.common_name LIKE ? OR c.constellation LIKE ? OR c.messier_num = {})", m )); bind_values.push(like.clone()); bind_values.push(like.clone()); bind_values.push(like); } else { conditions.push("(c.name LIKE ? OR c.common_name LIKE ? OR c.constellation LIKE ?)".to_string()); bind_values.push(like.clone()); bind_values.push(like.clone()); bind_values.push(like); } } let where_clause = conditions.join(" AND "); // "best" sort: composite score balancing altitude, FOV fill, usable time, moon separation. // Score = alt_score * 0.4 + fill_score * 0.3 + time_score * 0.2 + moon_score * 0.1 // Targets outside 20–150% FOV fill are penalised (too small or too large single-panel). let best_score_expr = r#"( COALESCE(nc.max_alt_deg, 0) / 90.0 * 0.40 + CASE WHEN c.fov_fill_pct IS NULL THEN 0.15 WHEN c.fov_fill_pct BETWEEN 20 AND 80 THEN (1.0 - ABS(c.fov_fill_pct - 50) / 50.0) * 0.30 WHEN c.fov_fill_pct > 80 THEN 0.10 ELSE 0.05 END + MIN(COALESCE(nc.usable_min, 0), 300) / 300.0 * 0.20 + COALESCE(nc.moon_sep_deg, 90) / 180.0 * 0.10 ) DESC"#; let sort_col = match params.sort.as_deref() { Some("transit") => "nc.transit_utc", Some("size") => "c.size_arcmin_maj DESC", Some("magnitude") => "c.mag_v", Some("difficulty") => "c.difficulty", Some("integration") => "total_integration DESC", Some("altitude") => "nc.max_alt_deg DESC", _ => best_score_expr, }; let sql = format!( r#" SELECT c.id, c.name, c.common_name, c.obj_type, c.ra_deg, c.dec_deg, c.ra_h, c.dec_dms, c.constellation, c.size_arcmin_maj, c.size_arcmin_min, c.mag_v, c.surface_brightness, c.hubble_type, c.messier_num, c.is_highlight, c.fov_fill_pct, c.mosaic_flag, c.mosaic_panels_w, c.mosaic_panels_h, c.difficulty, c.guide_star_density, nc.max_alt_deg, nc.usable_min, nc.transit_utc, nc.recommended_filter, nc.best_start_utc, nc.best_end_utc, nc.moon_sep_deg, CASE WHEN nc.max_alt_deg >= 15 THEN 1 ELSE 0 END as is_visible_tonight, COALESCE(log_sum.total_min, 0) as total_integration FROM catalog c LEFT JOIN nightly_cache nc ON nc.catalog_id = c.id AND nc.night_date = '{today}' LEFT JOIN ( SELECT catalog_id, SUM(integration_min) as total_min FROM imaging_log GROUP BY catalog_id ) log_sum ON log_sum.catalog_id = c.id WHERE {where_clause} ORDER BY {sort_col} LIMIT {limit} OFFSET {offset} "#, today = today, where_clause = where_clause, sort_col = sort_col, limit = limit, offset = offset ); // Use dynamic binding workaround since sqlx requires compile-time queries let mut query = sqlx::query(&sql); for val in &bind_values { query = query.bind(val); } let rows = query .fetch_all(&state.pool) .await .map_err(AppError::from)?; let items: Vec = rows.iter().map(|row| { use sqlx::Row; serde_json::json!({ "id": row.try_get::("id").unwrap_or_default(), "name": row.try_get::("name").unwrap_or_default(), "common_name": row.try_get::, _>("common_name").unwrap_or_default(), "obj_type": row.try_get::("obj_type").unwrap_or_default(), "ra_deg": row.try_get::("ra_deg").unwrap_or_default(), "dec_deg": row.try_get::("dec_deg").unwrap_or_default(), "ra_h": row.try_get::("ra_h").unwrap_or_default(), "dec_dms": row.try_get::("dec_dms").unwrap_or_default(), "constellation": row.try_get::, _>("constellation").unwrap_or_default(), "size_arcmin_maj": row.try_get::, _>("size_arcmin_maj").unwrap_or_default(), "size_arcmin_min": row.try_get::, _>("size_arcmin_min").unwrap_or_default(), "mag_v": row.try_get::, _>("mag_v").unwrap_or_default(), "surface_brightness": row.try_get::, _>("surface_brightness").unwrap_or_default(), "hubble_type": row.try_get::, _>("hubble_type").unwrap_or_default(), "messier_num": row.try_get::, _>("messier_num").unwrap_or_default(), "is_highlight": row.try_get::("is_highlight").unwrap_or_default(), "fov_fill_pct": row.try_get::, _>("fov_fill_pct").unwrap_or_default(), "mosaic_flag": row.try_get::("mosaic_flag").unwrap_or_default(), "mosaic_panels_w": row.try_get::("mosaic_panels_w").unwrap_or(1), "mosaic_panels_h": row.try_get::("mosaic_panels_h").unwrap_or(1), "difficulty": row.try_get::, _>("difficulty").unwrap_or_default(), "guide_star_density": row.try_get::, _>("guide_star_density").unwrap_or_default(), "max_alt_deg": row.try_get::, _>("max_alt_deg").unwrap_or_default(), "usable_min": row.try_get::, _>("usable_min").unwrap_or_default(), "transit_utc": row.try_get::, _>("transit_utc").unwrap_or_default(), "recommended_filter": row.try_get::, _>("recommended_filter").unwrap_or_default(), "best_start_utc": row.try_get::, _>("best_start_utc").unwrap_or_default(), "best_end_utc": row.try_get::, _>("best_end_utc").unwrap_or_default(), "moon_sep_deg": row.try_get::, _>("moon_sep_deg").unwrap_or_default(), "is_visible_tonight": row.try_get::, _>("is_visible_tonight").unwrap_or_default(), "total_integration_min": row.try_get::("total_integration").unwrap_or(0), }) }).collect(); // Count with the same filters applied let count_sql = format!( r#"SELECT COUNT(*) FROM catalog c LEFT JOIN nightly_cache nc ON nc.catalog_id = c.id AND nc.night_date = '{today}' LEFT JOIN ( SELECT catalog_id, SUM(integration_min) as total_min FROM imaging_log GROUP BY catalog_id ) log_sum ON log_sum.catalog_id = c.id WHERE {where_clause}"#, today = today, where_clause = where_clause, ); let mut count_query = sqlx::query_scalar::<_, i64>(&count_sql); for val in &bind_values { count_query = count_query.bind(val); } let total: i64 = count_query.fetch_one(&state.pool).await.unwrap_or(0); Ok(Json(serde_json::json!({ "items": items, "total": total, "page": page, "limit": limit }))) } pub async fn get_target( State(state): State, Path(id): Path, ) -> Result, AppError> { // Support both NGC/IC IDs and M-number IDs (e.g. "M42") let m_num: Option = id.trim() .strip_prefix(['M', 'm']) .and_then(|n| n.parse().ok()); let row = if let Some(n) = m_num { sqlx::query("SELECT * FROM catalog WHERE messier_num = ?") .bind(n) .fetch_optional(&state.pool) .await? } else { sqlx::query("SELECT * FROM catalog WHERE id = ?") .bind(&id) .fetch_optional(&state.pool) .await? } .ok_or_else(|| AppError::NotFound(format!("Target {} not found", id)))?; use sqlx::Row; Ok(Json(serde_json::json!({ "id": row.try_get::("id").unwrap_or_default(), "name": row.try_get::("name").unwrap_or_default(), "common_name": row.try_get::, _>("common_name").unwrap_or_default(), "obj_type": row.try_get::("obj_type").unwrap_or_default(), "ra_deg": row.try_get::("ra_deg").unwrap_or_default(), "dec_deg": row.try_get::("dec_deg").unwrap_or_default(), "ra_h": row.try_get::("ra_h").unwrap_or_default(), "dec_dms": row.try_get::("dec_dms").unwrap_or_default(), "constellation": row.try_get::, _>("constellation").unwrap_or_default(), "size_arcmin_maj": row.try_get::, _>("size_arcmin_maj").unwrap_or_default(), "size_arcmin_min": row.try_get::, _>("size_arcmin_min").unwrap_or_default(), "pos_angle_deg": row.try_get::, _>("pos_angle_deg").unwrap_or_default(), "mag_v": row.try_get::, _>("mag_v").unwrap_or_default(), "surface_brightness": row.try_get::, _>("surface_brightness").unwrap_or_default(), "hubble_type": row.try_get::, _>("hubble_type").unwrap_or_default(), "messier_num": row.try_get::, _>("messier_num").unwrap_or_default(), "is_highlight": row.try_get::("is_highlight").unwrap_or_default(), "fov_fill_pct": row.try_get::, _>("fov_fill_pct").unwrap_or_default(), "mosaic_flag": row.try_get::("mosaic_flag").unwrap_or_default(), "mosaic_panels_w": row.try_get::("mosaic_panels_w").unwrap_or(1), "mosaic_panels_h": row.try_get::("mosaic_panels_h").unwrap_or(1), "difficulty": row.try_get::, _>("difficulty").unwrap_or_default(), "guide_star_density": row.try_get::, _>("guide_star_density").unwrap_or_default(), }))) } pub async fn get_visibility( State(state): State, Path(id): Path, ) -> Result, AppError> { let today = chrono::Utc::now().naive_utc().date().to_string(); let row = sqlx::query( "SELECT * FROM nightly_cache WHERE catalog_id = ? AND night_date = ?", ) .bind(&id) .bind(&today) .fetch_optional(&state.pool) .await?; match row { Some(r) => { use sqlx::Row; Ok(Json(serde_json::json!({ "catalog_id": id, "night_date": today, "max_alt_deg": r.try_get::, _>("max_alt_deg").unwrap_or_default(), "transit_utc": r.try_get::, _>("transit_utc").unwrap_or_default(), "rise_utc": r.try_get::, _>("rise_utc").unwrap_or_default(), "set_utc": r.try_get::, _>("set_utc").unwrap_or_default(), "best_start_utc": r.try_get::, _>("best_start_utc").unwrap_or_default(), "best_end_utc": r.try_get::, _>("best_end_utc").unwrap_or_default(), "usable_min": r.try_get::, _>("usable_min").unwrap_or_default(), "meridian_flip_utc": r.try_get::, _>("meridian_flip_utc").unwrap_or_default(), "airmass_at_transit": r.try_get::, _>("airmass_at_transit").unwrap_or_default(), "extinction_mag": r.try_get::, _>("extinction_mag").unwrap_or_default(), "moon_sep_deg": r.try_get::, _>("moon_sep_deg").unwrap_or_default(), "recommended_filter": r.try_get::, _>("recommended_filter").unwrap_or_default(), }))) } None => compute_visibility_live(&state, &id).await, } } async fn compute_visibility_live(state: &AppState, id: &str) -> Result, AppError> { let cat_row = sqlx::query("SELECT ra_deg, dec_deg, obj_type FROM catalog WHERE id = ?") .bind(id) .fetch_optional(&state.pool) .await? .ok_or_else(|| AppError::NotFound(format!("Target {} not found", id)))?; use sqlx::Row; let ra: f64 = cat_row.try_get("ra_deg").unwrap_or_default(); let dec: f64 = cat_row.try_get("dec_deg").unwrap_or_default(); let obj_type: String = cat_row.try_get("obj_type").unwrap_or_default(); let today = chrono::Utc::now().naive_utc().date(); let (dusk, dawn) = astro_twilight(today, LAT, LON) .map_err(|e| AppError::Internal(e.to_string()))?; let jd = julian_date(dusk + (dawn - dusk) / 2); let (moon_ra, moon_dec) = moon_position(jd); let moon_illum = moon_illumination(jd); let moon_alt = moon_altitude(jd, LAT, LON); let horizon: Vec = sqlx::query_as( "SELECT az_deg, alt_deg FROM horizon ORDER BY az_deg", ) .fetch_all(&state.pool) .await?; let moon_state = MoonState { ra_deg: moon_ra, dec_deg: moon_dec, illumination: moon_illum, alt_at_midnight: moon_alt, }; let window = TonightWindow { dusk, dawn }; let vis = compute_visibility(ra, dec, &window, &horizon, &moon_state); let rec_filter = crate::filters::top_filter(&obj_type, moon_illum * 100.0, moon_alt, vis.moon_sep_deg); Ok(Json(serde_json::json!({ "catalog_id": id, "max_alt_deg": vis.max_alt_deg, "transit_utc": vis.transit_utc.map(|t| t.to_rfc3339()), "rise_utc": vis.rise_utc.map(|t| t.to_rfc3339()), "set_utc": vis.set_utc.map(|t| t.to_rfc3339()), "best_start_utc": vis.best_start_utc.map(|t| t.to_rfc3339()), "best_end_utc": vis.best_end_utc.map(|t| t.to_rfc3339()), "usable_min": vis.usable_min, "meridian_flip_utc": vis.meridian_flip_utc.map(|t| t.to_rfc3339()), "airmass_at_transit": vis.airmass_at_transit, "extinction_mag": vis.extinction_at_transit, "moon_sep_deg": vis.moon_sep_deg, "recommended_filter": rec_filter, "is_visible_tonight": vis.is_visible_tonight, }))) } pub async fn get_curve( State(state): State, Path(id): Path, ) -> Result, AppError> { // Always compute live at 1-minute resolution for the interactive chart. // The cached visibility_json uses 10-minute steps and lacks moon_alt_deg. let cat_row = sqlx::query("SELECT ra_deg, dec_deg FROM catalog WHERE id = ?") .bind(&id) .fetch_optional(&state.pool) .await? .ok_or_else(|| AppError::NotFound(format!("Target {} not found", id)))?; use sqlx::Row; let ra: f64 = cat_row.try_get("ra_deg").unwrap_or_default(); let dec: f64 = cat_row.try_get("dec_deg").unwrap_or_default(); let date = chrono::Utc::now().naive_utc().date(); let (dusk, dawn) = astro_twilight(date, LAT, LON) .map_err(|e| AppError::Internal(e.to_string()))?; let jd = julian_date(dusk + (dawn - dusk) / 2); let (moon_ra, moon_dec) = moon_position(jd); let moon_illum = moon_illumination(jd); let moon_alt = moon_altitude(jd, LAT, LON); let horizon: Vec = sqlx::query_as( "SELECT az_deg, alt_deg FROM horizon ORDER BY az_deg", ) .fetch_all(&state.pool) .await?; let moon_state = MoonState { ra_deg: moon_ra, dec_deg: moon_dec, illumination: moon_illum, alt_at_midnight: moon_alt, }; let window = TonightWindow { dusk, dawn }; // Use 1-minute resolution for the interactive altitude curve let vis = compute_visibility_with_step(ra, dec, &window, &horizon, &moon_state, 1); let curve = serde_json::to_value(&vis.curve).unwrap_or(serde_json::json!([])); Ok(Json(serde_json::json!({ "catalog_id": id, "curve": curve }))) } pub async fn get_filters( State(state): State, Path(id): Path, ) -> Result, AppError> { let cat_row = sqlx::query("SELECT obj_type FROM catalog WHERE id = ?") .bind(&id) .fetch_optional(&state.pool) .await? .ok_or_else(|| AppError::NotFound(format!("Target {} not found", id)))?; use sqlx::Row; let obj_type: String = cat_row.try_get("obj_type").unwrap_or_default(); let tonight_row = sqlx::query( "SELECT moon_illumination, moon_ra_deg, moon_dec_deg FROM tonight WHERE id = 1", ) .fetch_optional(&state.pool) .await?; let (moon_illum, moon_ra, moon_dec) = match tonight_row { Some(r) => ( r.try_get::, _>("moon_illumination").unwrap_or_default().unwrap_or(0.5), r.try_get::, _>("moon_ra_deg").unwrap_or_default().unwrap_or(0.0), r.try_get::, _>("moon_dec_deg").unwrap_or_default().unwrap_or(0.0), ), None => (0.5, 0.0, 0.0), }; let now_jd = julian_date(chrono::Utc::now()); let moon_alt = moon_altitude(now_jd, LAT, LON); let target_row = sqlx::query("SELECT ra_deg, dec_deg FROM catalog WHERE id = ?") .bind(&id) .fetch_optional(&state.pool) .await?; let moon_sep = match target_row { Some(r) => { let ra: f64 = r.try_get("ra_deg").unwrap_or_default(); let dec: f64 = r.try_get("dec_deg").unwrap_or_default(); crate::astronomy::moon_separation(moon_ra, moon_dec, ra, dec) } None => 90.0, }; let recs = recommend_filters(&obj_type, moon_illum * 100.0, moon_alt, moon_sep); Ok(Json(serde_json::json!({ "recommendations": recs }))) } pub async fn get_workflow_handler( State(state): State, Path((id, filter_id)): Path<(String, String)>, ) -> Result, AppError> { let cat_row = sqlx::query("SELECT obj_type FROM catalog WHERE id = ?") .bind(&id) .fetch_optional(&state.pool) .await? .ok_or_else(|| AppError::NotFound(format!("Target {} not found", id)))?; use sqlx::Row; let obj_type: String = cat_row.try_get("obj_type").unwrap_or_default(); let workflow = get_workflow(&obj_type, &filter_id); Ok(Json(serde_json::to_value(workflow).unwrap())) } /// Yearly visibility graph: for each of the next 365 nights compute the /// object's altitude at local astronomical midnight and the theoretical time /// above 30°. This correctly shows seasonal variation (transit altitude is /// constant but transit *time* shifts ~4 min/day so the midnight alt varies). pub async fn get_yearly( State(state): State, Path(id): Path, ) -> Result, AppError> { use chrono::Duration; use crate::astronomy::{ julian_date as jd_fn, moon_illumination, coords::radec_to_altaz, time::local_sidereal_time, }; let cat_row = sqlx::query("SELECT ra_deg, dec_deg, obj_type FROM catalog WHERE id = ?") .bind(&id) .fetch_optional(&state.pool) .await? .ok_or_else(|| AppError::NotFound(format!("Target {} not found", id)))?; use sqlx::Row; let ra: f64 = cat_row.try_get("ra_deg").unwrap_or_default(); let dec: f64 = cat_row.try_get("dec_deg").unwrap_or_default(); let obj_type: String = cat_row.try_get("obj_type").unwrap_or_default(); // Transit altitude: maximum the object can ever reach (constant for a DSO). let transit_alt = (90.0 - (LAT - dec).abs()).max(0.0_f64).min(90.0_f64); // Time above 30° per night (theoretical full night, unaffected by season). // cos(H30) = (sin30 - sin_dec*sin_lat) / (cos_dec*cos_lat) let sin_lat = LAT.to_radians().sin(); let cos_lat = LAT.to_radians().cos(); let sin_dec = dec.to_radians().sin(); let cos_dec = dec.to_radians().cos(); let cos_h30 = (30_f64.to_radians().sin() - sin_dec * sin_lat) / (cos_dec * cos_lat); let usable_theoretical_min: u32 = if cos_h30.abs() <= 1.0 { // 2 * H30 degrees * 4 min/degree of HA (2.0 * cos_h30.acos().to_degrees() * 4.0) as u32 } else { 0 }; let today = chrono::Utc::now().naive_utc().date(); let mut points = Vec::with_capacity(365); for day_offset in 0..365i64 { let date = today + Duration::days(day_offset); // Use 21:00 UTC as proxy for astronomical midnight in France // (local midnight ≈ 23:00 local = 22:00 UTC in winter, 22:00 local = 20:00 UTC in summer) // 21:00 UTC is a reasonable all-year compromise let midnight_utc = date.and_hms_opt(21, 0, 0).unwrap().and_utc(); let jd = jd_fn(midnight_utc); // Actual altitude at this midnight — this varies with date because LST shifts let lst = local_sidereal_time(jd, LON); let (alt_at_midnight, _az) = radec_to_altaz(ra, dec, lst, LAT); // Moon illumination at this date let moon_illum = moon_illumination(jd); points.push(serde_json::json!({ "date": date.to_string(), // Altitude at local midnight — varies seasonally "alt_at_midnight": (alt_at_midnight * 10.0).round() / 10.0, // Maximum possible altitude (at transit) — constant but useful reference "transit_alt": (transit_alt * 10.0).round() / 10.0, // Theoretical time above 30° if the whole night is available "usable_min": usable_theoretical_min, "moon_illumination": (moon_illum * 100.0).round() / 100.0, "obj_type": &obj_type, })); } Ok(Json(serde_json::json!({ "catalog_id": id, "points": points }))) } pub async fn get_notes( State(state): State, Path(id): Path, ) -> Result, AppError> { let notes: Option = sqlx::query_scalar( "SELECT notes FROM target_notes WHERE catalog_id = ?", ) .bind(&id) .fetch_optional(&state.pool) .await? .flatten(); Ok(Json(serde_json::json!({ "catalog_id": id, "notes": notes.unwrap_or_default(), }))) } #[derive(serde::Deserialize)] pub struct NotesBody { pub notes: String, } pub async fn put_notes( State(state): State, Path(id): Path, Json(body): Json, ) -> Result, AppError> { sqlx::query( "INSERT OR REPLACE INTO target_notes (catalog_id, notes, updated_at) VALUES (?, ?, unixepoch())", ) .bind(&id) .bind(&body.notes) .execute(&state.pool) .await?; Ok(Json(serde_json::json!({ "catalog_id": id, "status": "updated" }))) }