use std::fmt::Write as _;
use axum::Json;
use axum::extract::{Path, Query, State};
use axum::http::StatusCode as ReqwestStatusCode;
use axum::http::header;
use axum::response::{IntoResponse as _, Response};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::auth::{CurrentUser, uuid_from_bytes};
use crate::error::{self, Error};
use crate::library::{self, Destination, GlwDataRow, GlwDataSourceKind, GlwDataView};
use crate::state::AppState;
#[derive(Debug, Deserialize)]
pub struct ListQuery {
pub scope: String,
#[serde(default)]
pub event_id: Option<u32>,
#[serde(default)]
pub event_key: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct ListGlwDataResponse {
pub glw_data: Vec<GlwDataView>,
}
#[expect(
clippy::module_name_repetitions,
reason = "matches the workspace convention: <Entity>Response in the <entity> route module"
)]
#[derive(Debug, Serialize)]
pub struct GlwDataResponse {
pub glw_data: GlwDataView,
}
pub async fn list(
user: CurrentUser,
State(state): State<AppState>,
Query(query): Query<ListQuery>,
) -> Result<Json<ListGlwDataResponse>, Error> {
let destination = Destination::parse(&query.scope)?;
library::assert_can_view(&state.db, user.user_id, destination).await?;
let rows = fetch_glw_data_for(
&state,
user.user_id,
destination,
query.event_id,
query.event_key.as_deref(),
)
.await?;
Ok(Json(ListGlwDataResponse { glw_data: rows }))
}
pub async fn get(
user: CurrentUser,
State(state): State<AppState>,
Path(glw_data_id): Path<Uuid>,
) -> Result<Json<GlwDataResponse>, Error> {
let row = library::assert_can_read_glw_data(&state.db, user.user_id, glw_data_id).await?;
let destination =
library::destination_from_columns(row.owner_user_id.clone(), row.owner_group_id.clone())?;
let (created_by_username, created_by_legacy_name) = match row.created_by {
Some(id) => match lookup_user_names(&state, id).await {
Ok((u, l)) => (Some(u), Some(l)),
Err(_) => (None, None),
},
None => (None, None),
};
let view = build_view(
&row,
destination,
created_by_username,
created_by_legacy_name,
);
Ok(Json(GlwDataResponse { glw_data: view }))
}
pub async fn payload(
user: CurrentUser,
State(state): State<AppState>,
Path(glw_data_id): Path<Uuid>,
) -> Result<Response, Error> {
let row = library::assert_can_read_glw_data(&state.db, user.user_id, glw_data_id).await?;
let filename = format!(
"{}.json",
crate::routes::notecards::sanitise_for_filename(&row.name)
.unwrap_or_else(|| glw_data_id.to_string())
);
let disposition = format!("attachment; filename=\"{filename}\"");
let headers = [
(header::CONTENT_TYPE, "application/json".to_owned()),
(header::CONTENT_DISPOSITION, disposition),
];
Ok((headers, row.payload_json).into_response())
}
#[derive(Debug, Deserialize)]
pub struct RenameRequest {
pub name: String,
}
pub async fn rename(
user: CurrentUser,
State(state): State<AppState>,
Path(glw_data_id): Path<Uuid>,
Json(body): Json<RenameRequest>,
) -> Result<Json<GlwDataResponse>, Error> {
let trimmed = library::sanitise_display_name(&body.name, "name")?;
let row = library::assert_can_delete_glw_data(&state.db, user.user_id, glw_data_id).await?;
sqlx::query("UPDATE saved_glw_data SET name = ?1 WHERE glw_data_id = ?2")
.bind(&trimmed)
.bind(glw_data_id.as_bytes().to_vec())
.execute(&state.db)
.await
.map_err(|err| {
tracing::error!("glw data rename failed: {err}");
Error::Database
})?;
let destination =
library::destination_from_columns(row.owner_user_id.clone(), row.owner_group_id.clone())?;
let (created_by_username, created_by_legacy_name) = match row.created_by {
Some(id) => match lookup_user_names(&state, id).await {
Ok((u, l)) => (Some(u), Some(l)),
Err(_) => (None, None),
},
None => (None, None),
};
let mut row_with_new_name = row;
row_with_new_name.name = trimmed;
let view = build_view(
&row_with_new_name,
destination,
created_by_username,
created_by_legacy_name,
);
Ok(Json(GlwDataResponse { glw_data: view }))
}
pub async fn delete(
user: CurrentUser,
State(state): State<AppState>,
Path(glw_data_id): Path<Uuid>,
) -> Result<Response, Error> {
library::assert_can_delete_glw_data(&state.db, user.user_id, glw_data_id).await?;
let result = sqlx::query("DELETE FROM saved_glw_data WHERE glw_data_id = ?1")
.bind(glw_data_id.as_bytes().to_vec())
.execute(&state.db)
.await;
match result {
Ok(_) => Ok((ReqwestStatusCode::NO_CONTENT, "").into_response()),
Err(err) => {
if error::is_fk_violation(&err) {
return Err(Error::BadRequest(
"cannot delete this GLW data: one or more saved renders still reference it. \
Delete those renders first."
.to_owned(),
));
}
tracing::error!("glw data delete failed: {err}");
Err(Error::Database)
}
}
}
#[expect(
clippy::module_name_repetitions,
reason = "matches the workspace convention: <Entity>Defaults/Response in the <entity> route module"
)]
#[derive(Debug, Serialize)]
pub struct GlwStyleDefaults {
pub margin_band: bool,
pub area_outline_color: String,
pub circle_outline_color: String,
pub margin_outline_color: String,
pub wind_color: String,
pub current_color: String,
pub wave_color: String,
pub label_color: String,
}
fn rgb_hex(c: image::Rgba<u8>) -> String {
let [r, g, b, _a] = c.0;
format!("#{r:02x}{g:02x}{b:02x}")
}
pub async fn style_defaults() -> Json<GlwStyleDefaults> {
let style = sl_glw::GlwStyle::default();
let p = style.palette;
Json(GlwStyleDefaults {
margin_band: style.draw_margin_band,
area_outline_color: rgb_hex(p.area_outline),
circle_outline_color: rgb_hex(p.circle_outline),
margin_outline_color: rgb_hex(p.margin_outline),
wind_color: rgb_hex(p.wind_arrow),
current_color: rgb_hex(p.current_arrow),
wave_color: rgb_hex(p.wave_glyph),
label_color: rgb_hex(p.label_fg),
})
}
#[derive(Debug)]
pub struct InsertGlwData<'a> {
pub destination: Destination,
pub created_by: Uuid,
pub name: &'a str,
pub source_kind: GlwDataSourceKind,
pub source_event_id: Option<u32>,
pub source_event_key: Option<&'a str>,
pub payload_json: &'a str,
pub event_id: Option<u32>,
pub event_key: Option<&'a str>,
pub event_name: Option<&'a str>,
pub fetched_at: DateTime<Utc>,
}
pub async fn insert_glw_data_row(
state: &AppState,
insert: &InsertGlwData<'_>,
) -> Result<Uuid, Error> {
let glw_data_id = Uuid::new_v4();
let now = Utc::now();
let (owner_user, owner_group) = match insert.destination {
Destination::Personal => (Some(insert.created_by.as_bytes().to_vec()), None),
Destination::Group { group_id } => (None, Some(group_id.as_bytes().to_vec())),
};
sqlx::query(
"INSERT INTO saved_glw_data \
(glw_data_id, owner_user_id, owner_group_id, created_by, name, \
source_kind, source_event_id, source_event_key, payload_json, \
event_id, event_key, event_name, fetched_at, created_at) \
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)",
)
.bind(glw_data_id.as_bytes().to_vec())
.bind(owner_user)
.bind(owner_group)
.bind(insert.created_by.as_bytes().to_vec())
.bind(insert.name)
.bind(insert.source_kind.as_db_str())
.bind(insert.source_event_id.map(i64::from))
.bind(insert.source_event_key)
.bind(insert.payload_json)
.bind(insert.event_id.map(i64::from))
.bind(insert.event_key)
.bind(insert.event_name)
.bind(insert.fetched_at)
.bind(now)
.execute(&state.db)
.await
.map_err(|err| {
tracing::error!("insert saved_glw_data failed: {err}");
Error::Database
})?;
Ok(glw_data_id)
}
#[derive(sqlx::FromRow)]
struct GlwDataListRow {
glw_data_id: Vec<u8>,
owner_user_id: Option<Vec<u8>>,
owner_group_id: Option<Vec<u8>>,
created_by: Option<Vec<u8>>,
created_by_username: Option<String>,
created_by_legacy_name: Option<String>,
name: String,
source_kind: String,
source_event_id: Option<i64>,
source_event_key: Option<String>,
event_id: Option<i64>,
event_key: Option<String>,
event_name: Option<String>,
fetched_at: DateTime<Utc>,
created_at: DateTime<Utc>,
}
async fn fetch_glw_data_for(
state: &AppState,
current_user: Uuid,
destination: Destination,
filter_event_id: Option<u32>,
filter_event_key: Option<&str>,
) -> Result<Vec<GlwDataView>, Error> {
let mut sql = String::from(
"SELECT g.glw_data_id, g.owner_user_id, g.owner_group_id, g.created_by, \
u.username AS created_by_username, u.legacy_name AS created_by_legacy_name, \
g.name, g.source_kind, g.source_event_id, g.source_event_key, \
g.event_id, g.event_key, g.event_name, g.fetched_at, g.created_at \
FROM saved_glw_data AS g \
LEFT JOIN users AS u ON u.user_id = g.created_by ",
);
match destination {
Destination::Personal => {
sql.push_str("WHERE g.owner_user_id = ?1 ");
}
Destination::Group { .. } => {
sql.push_str(
"JOIN group_memberships AS gm \
ON gm.group_id = g.owner_group_id AND gm.user_id = ?2 \
WHERE g.owner_group_id = ?1 ",
);
}
}
if filter_event_id.is_some() {
sql.push_str("AND g.source_event_id = ?3 ");
}
if filter_event_key.is_some() {
let idx = 3_u8.saturating_add(u8::from(filter_event_id.is_some()));
write!(sql, "AND g.source_event_key = ?{idx} ").unwrap_or(());
}
sql.push_str("ORDER BY g.fetched_at DESC");
let mut query = sqlx::query_as::<_, GlwDataListRow>(sqlx::AssertSqlSafe(sql));
match destination {
Destination::Personal => {
query = query.bind(current_user.as_bytes().to_vec());
}
Destination::Group { group_id } => {
query = query
.bind(group_id.as_bytes().to_vec())
.bind(current_user.as_bytes().to_vec());
}
}
if let Some(id) = filter_event_id {
query = query.bind(i64::from(id));
}
if let Some(key) = filter_event_key {
query = query.bind(key);
}
let rows: Vec<GlwDataListRow> = query.fetch_all(&state.db).await.map_err(|err| {
tracing::error!("list saved_glw_data failed: {err}");
Error::Database
})?;
let mut out = Vec::with_capacity(rows.len());
for row in rows {
let glw_data_id = uuid_from_bytes(&row.glw_data_id).ok_or_else(|| {
tracing::error!("bad glw_data uuid");
Error::Database
})?;
let row_dest = library::destination_from_columns(row.owner_user_id, row.owner_group_id)?;
let created_by = row
.created_by
.as_deref()
.map(uuid_from_bytes)
.map(|opt| {
opt.ok_or_else(|| {
tracing::error!("bad created_by uuid in saved_glw_data");
Error::Database
})
})
.transpose()?;
let source_kind = GlwDataSourceKind::from_db_str(&row.source_kind).ok_or_else(|| {
tracing::error!(
"unrecognised source_kind `{}` in saved_glw_data",
row.source_kind
);
Error::Database
})?;
out.push(GlwDataView {
glw_data_id,
destination: row_dest,
created_by,
created_by_username: row.created_by_username,
created_by_legacy_name: row.created_by_legacy_name,
name: row.name,
source_kind,
source_event_id: row.source_event_id.and_then(|v| u32::try_from(v).ok()),
source_event_key: row.source_event_key,
event_id: row.event_id.and_then(|v| u32::try_from(v).ok()),
event_key: row.event_key,
event_name: row.event_name,
fetched_at: row.fetched_at,
created_at: row.created_at,
});
}
Ok(out)
}
fn build_view(
row: &GlwDataRow,
destination: Destination,
created_by_username: Option<String>,
created_by_legacy_name: Option<String>,
) -> GlwDataView {
GlwDataView {
glw_data_id: row.glw_data_id,
destination,
created_by: row.created_by,
created_by_username,
created_by_legacy_name,
name: row.name.clone(),
source_kind: row.source_kind,
source_event_id: row.source_event_id,
source_event_key: row.source_event_key.clone(),
event_id: row.event_id,
event_key: row.event_key.clone(),
event_name: row.event_name.clone(),
fetched_at: row.fetched_at,
created_at: row.created_at,
}
}
async fn lookup_user_names(state: &AppState, user_id: Uuid) -> Result<(String, String), Error> {
let row: Option<(String, String)> =
sqlx::query_as("SELECT username, legacy_name FROM users WHERE user_id = ?1")
.bind(user_id.as_bytes().to_vec())
.fetch_optional(&state.db)
.await
.map_err(|err| {
tracing::error!("user name lookup failed: {err}");
Error::Database
})?;
row.ok_or_else(|| Error::NotFound(format!("user {user_id}")))
}
#[cfg(test)]
mod tests {
use pretty_assertions::assert_eq;
use super::{rgb_hex, style_defaults};
#[test]
fn rgb_hex_drops_alpha_and_zero_pads() {
assert_eq!(rgb_hex(image::Rgba([40, 220, 40, 96])), "#28dc28");
assert_eq!(rgb_hex(image::Rgba([0, 0, 0, 255])), "#000000");
assert_eq!(rgb_hex(image::Rgba([255, 255, 255, 240])), "#ffffff");
}
#[tokio::test]
async fn style_defaults_match_renderer_palette() {
let style = sl_glw::GlwStyle::default();
let p = style.palette;
let got = style_defaults().await.0;
assert_eq!(got.margin_band, style.draw_margin_band);
assert_eq!(got.area_outline_color, rgb_hex(p.area_outline));
assert_eq!(got.circle_outline_color, rgb_hex(p.circle_outline));
assert_eq!(got.margin_outline_color, rgb_hex(p.margin_outline));
assert_eq!(got.wind_color, rgb_hex(p.wind_arrow));
assert_eq!(got.current_color, rgb_hex(p.current_arrow));
assert_eq!(got.wave_color, rgb_hex(p.wave_glyph));
assert_eq!(got.label_color, rgb_hex(p.label_fg));
assert_eq!(got.wind_color, "#ffffff");
assert_eq!(got.area_outline_color, "#28dc28");
assert_eq!(got.label_color, "#ffffff");
assert!(!got.margin_band);
}
}