feat: add raw artifact audit trail

This commit is contained in:
2026-06-24 09:38:15 +08:00
parent 6a17c9a336
commit a770070b34
12 changed files with 670 additions and 16 deletions

View File

@@ -5,10 +5,11 @@ use serde::Serialize;
use crate::error::{ApiError, ApiResult};
use crate::import_templates::validate_import_payload;
use crate::models::{
AreaScore, AreaScoreSummary, CreateDataSource, CreateIngestionRun, CreateWatchlistItem,
DataSource, FinishIngestionRun, ImportValidationRequest, ImportValidationResponse,
IngestionRun, IngestionRunQuery, MarketOverview, MonthQuery, Neighborhood, NeighborhoodQuery,
NeighborhoodScore, NeighborhoodScoreQuery, UpdateWatchlistItem, WatchlistItem,
AreaScore, AreaScoreSummary, CreateDataSource, CreateIngestionRun, CreateRawArtifact,
CreateWatchlistItem, DataSource, FinishIngestionRun, ImportValidationRequest,
ImportValidationResponse, IngestionRun, IngestionRunQuery, MarketOverview, MonthQuery,
Neighborhood, NeighborhoodQuery, NeighborhoodScore, NeighborhoodScoreQuery, RawArtifact,
RawArtifactQuery, UpdateWatchlistItem, WatchlistItem,
};
use crate::state::AppState;
@@ -265,6 +266,7 @@ pub async fn list_ingestion_runs(
r.run_id,
r.source_id,
s.name AS source_name,
r.raw_artifact_id,
r.status,
r.started_at::text AS started_at,
r.finished_at::text AS finished_at,
@@ -299,10 +301,11 @@ pub async fn create_ingestion_run(
source_id,
status,
raw_uri,
raw_artifact_id,
row_count,
error_message
)
VALUES ($1, COALESCE($2, 'running'), $3, $4, $5)
VALUES ($1, COALESCE($2, 'running'), $3, $4, $5, $6)
RETURNING
run_id,
source_id,
@@ -311,6 +314,7 @@ pub async fn create_ingestion_run(
FROM audit.data_sources
WHERE source_id = audit.ingestion_runs.source_id
) AS source_name,
raw_artifact_id,
status,
started_at::text AS started_at,
finished_at::text AS finished_at,
@@ -322,6 +326,7 @@ pub async fn create_ingestion_run(
.bind(payload.source_id)
.bind(payload.status)
.bind(payload.raw_uri)
.bind(payload.raw_artifact_id)
.bind(payload.row_count)
.bind(payload.error_message)
.fetch_one(&state.pool)
@@ -355,6 +360,7 @@ pub async fn finish_ingestion_run(
FROM audit.data_sources
WHERE source_id = audit.ingestion_runs.source_id
) AS source_name,
raw_artifact_id,
status,
started_at::text AS started_at,
finished_at::text AS finished_at,
@@ -374,6 +380,154 @@ pub async fn finish_ingestion_run(
Ok(Json(run))
}
pub async fn list_raw_artifacts(
State(state): State<AppState>,
Query(query): Query<RawArtifactQuery>,
) -> ApiResult<Json<Vec<RawArtifact>>> {
if let Some(sha256) = query.sha256.as_deref() {
validate_sha256(sha256)?;
}
let artifacts = sqlx::query_as::<_, RawArtifact>(
r#"
SELECT
a.artifact_id,
a.run_id,
a.source_id,
s.name AS source_name,
a.original_filename,
a.raw_uri,
a.sha256,
a.size_bytes,
a.mime_type,
a.uploaded_by,
a.notes,
a.created_at::text AS created_at
FROM audit.raw_artifacts a
LEFT JOIN audit.data_sources s ON s.source_id = a.source_id
WHERE ($1::bigint IS NULL OR a.source_id = $1)
AND (
$2::bigint IS NULL
OR a.run_id = $2
OR EXISTS (
SELECT 1
FROM audit.ingestion_runs r
WHERE r.run_id = $2
AND r.raw_artifact_id = a.artifact_id
)
)
AND ($3::text IS NULL OR a.sha256 = $3)
ORDER BY a.created_at DESC, a.artifact_id DESC
LIMIT 100
"#,
)
.bind(query.source_id)
.bind(query.run_id)
.bind(query.sha256)
.fetch_all(&state.pool)
.await?;
Ok(Json(artifacts))
}
pub async fn create_raw_artifact(
State(state): State<AppState>,
Json(payload): Json<CreateRawArtifact>,
) -> ApiResult<Json<RawArtifact>> {
payload
.validate()
.map_err(|message| ApiError::BadRequest(message.to_string()))?;
let mut tx = state.pool.begin().await?;
let artifact = sqlx::query_as::<_, RawArtifact>(
r#"
INSERT INTO audit.raw_artifacts (
run_id,
source_id,
original_filename,
raw_uri,
sha256,
size_bytes,
mime_type,
uploaded_by,
notes
)
VALUES (
$1,
COALESCE(
$2,
(
SELECT source_id
FROM audit.ingestion_runs
WHERE run_id = $1
)
),
$3,
$4,
$5,
$6,
$7,
COALESCE($8, 'system'),
COALESCE($9, '')
)
ON CONFLICT (sha256) DO UPDATE
SET run_id = COALESCE(audit.raw_artifacts.run_id, EXCLUDED.run_id),
source_id = COALESCE(audit.raw_artifacts.source_id, EXCLUDED.source_id),
notes = CASE
WHEN audit.raw_artifacts.notes = '' THEN EXCLUDED.notes
ELSE audit.raw_artifacts.notes
END
RETURNING
artifact_id,
run_id,
source_id,
(
SELECT name
FROM audit.data_sources
WHERE source_id = audit.raw_artifacts.source_id
) AS source_name,
original_filename,
raw_uri,
sha256,
size_bytes,
mime_type,
uploaded_by,
notes,
created_at::text AS created_at
"#,
)
.bind(payload.run_id)
.bind(payload.source_id)
.bind(payload.original_filename.trim().to_string())
.bind(payload.raw_uri.trim().to_string())
.bind(payload.sha256.trim().to_string())
.bind(payload.size_bytes)
.bind(payload.mime_type)
.bind(payload.uploaded_by)
.bind(payload.notes)
.fetch_one(&mut *tx)
.await?;
if let Some(run_id) = payload.run_id {
sqlx::query(
r#"
UPDATE audit.ingestion_runs
SET raw_artifact_id = $2,
raw_uri = COALESCE(raw_uri, $3)
WHERE run_id = $1
"#,
)
.bind(run_id)
.bind(artifact.artifact_id)
.bind(&artifact.raw_uri)
.execute(&mut *tx)
.await?;
}
tx.commit().await?;
Ok(Json(artifact))
}
pub async fn list_watchlist_items(
State(state): State<AppState>,
) -> ApiResult<Json<Vec<WatchlistItem>>> {
@@ -563,14 +717,29 @@ fn validate_month(month: &str) -> ApiResult<()> {
}
}
fn validate_sha256(value: &str) -> ApiResult<()> {
let valid = value.len() == 64
&& value
.chars()
.all(|item| item.is_ascii_hexdigit() && !item.is_ascii_uppercase());
if valid {
Ok(())
} else {
Err(ApiError::BadRequest(
"sha256 must be a lowercase 64-character hex digest".to_string(),
))
}
}
#[cfg(test)]
mod tests {
use crate::models::{
CreateDataSource, CreateIngestionRun, CreateWatchlistItem, FinishIngestionRun,
UpdateWatchlistItem,
CreateDataSource, CreateIngestionRun, CreateRawArtifact, CreateWatchlistItem,
FinishIngestionRun, UpdateWatchlistItem,
};
use super::validate_month;
use super::{validate_month, validate_sha256};
#[test]
fn accepts_valid_month() {
@@ -674,6 +843,7 @@ mod tests {
source_id: Some(1),
status: Some("running".to_string()),
raw_uri: Some("raw/official/2026-05.csv".to_string()),
raw_artifact_id: None,
row_count: Some(10),
error_message: None,
}
@@ -684,6 +854,7 @@ mod tests {
source_id: Some(1),
status: Some("unknown".to_string()),
raw_uri: None,
raw_artifact_id: None,
row_count: None,
error_message: None,
}
@@ -706,4 +877,42 @@ mod tests {
.validate()
.is_err());
}
#[test]
fn validates_raw_artifact_payload() {
assert!(CreateRawArtifact {
run_id: None,
source_id: None,
original_filename: "area.csv".to_string(),
raw_uri: "raw://sha256/area.csv".to_string(),
sha256: "a".repeat(64),
size_bytes: 128,
mime_type: Some("text/csv".to_string()),
uploaded_by: Some("researcher".to_string()),
notes: None,
}
.validate()
.is_ok());
assert!(CreateRawArtifact {
run_id: None,
source_id: None,
original_filename: "area.csv".to_string(),
raw_uri: "raw://sha256/area.csv".to_string(),
sha256: "A".repeat(64),
size_bytes: 128,
mime_type: None,
uploaded_by: None,
notes: None,
}
.validate()
.is_err());
}
#[test]
fn validates_sha256_query_value() {
assert!(validate_sha256(&"0".repeat(64)).is_ok());
assert!(validate_sha256(&"G".repeat(64)).is_err());
assert!(validate_sha256("short").is_err());
}
}