diff --git a/crates/nvisy-cli/src/config/mod.rs b/crates/nvisy-cli/src/config/mod.rs index dd780126..03f80d47 100644 --- a/crates/nvisy-cli/src/config/mod.rs +++ b/crates/nvisy-cli/src/config/mod.rs @@ -104,7 +104,9 @@ impl Cli { /// Initializes tracing with environment-based filtering. pub fn init_tracing() { - let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")); + let filter = EnvFilter::try_from_default_env() + .unwrap_or_else(|_| EnvFilter::new("info")) + .add_directive("async_nats=warn".parse().expect("valid log directive")); tracing_subscriber::registry() .with(filter) diff --git a/crates/nvisy-postgres/src/model/workspace_members.rs b/crates/nvisy-postgres/src/model/workspace_members.rs index c8a05865..581404c7 100644 --- a/crates/nvisy-postgres/src/model/workspace_members.rs +++ b/crates/nvisy-postgres/src/model/workspace_members.rs @@ -48,8 +48,8 @@ pub struct NewWorkspaceMember { pub member_role: WorkspaceRole, /// Whether to send email notifications. pub notify_via_email: bool, - /// Notification events to receive in-app. - pub notification_events_app: Vec>, + /// In-app events: None uses the database default; Some(empty) opts out. + pub notification_events_app: Option>>, /// Notification events to receive via email. pub notification_events_email: Vec>, /// Created by. diff --git a/crates/nvisy-postgres/src/query/mod.rs b/crates/nvisy-postgres/src/query/mod.rs index 472d6de8..7c1a4903 100644 --- a/crates/nvisy-postgres/src/query/mod.rs +++ b/crates/nvisy-postgres/src/query/mod.rs @@ -68,7 +68,7 @@ pub use workspace_connections::{ pub use workspace_detection_jobs::DetectionJobOutboxRepository; pub use workspace_detection_policy_versions::DetectionPolicyVersionRepository; pub use workspace_detections::{ - DetectionCursor, DetectionDocuments, DetectionListRow, EditWriteOutcome, + DetectionCursor, DetectionDocuments, DetectionListRow, DetectionSummary, EditWriteOutcome, WorkspaceDetectionRepository, }; pub use workspace_documents::{ @@ -82,7 +82,9 @@ pub use workspace_members::{ pub use workspace_policies::{PolicyCursor, PolicyWithVersion, WorkspacePolicyRepository}; pub use workspace_policy_versions::WorkspacePolicyVersionRepository; pub use workspace_providers::{ProviderCursor, WorkspaceProviderRepository}; -pub use workspace_redactions::{RedactionCursor, WorkspaceRedactionRepository}; +pub use workspace_redactions::{ + DetectionRedactedOutput, RedactionCursor, WorkspaceRedactionRepository, +}; pub use workspace_review_comments::WorkspaceReviewCommentRepository; pub use workspace_review_events::{TimelineCursor, TimelineSource, WorkspaceReviewEventRepository}; pub use workspace_reviews::{ diff --git a/crates/nvisy-postgres/src/query/workspace_detections.rs b/crates/nvisy-postgres/src/query/workspace_detections.rs index 4a99c2a8..609c9575 100644 --- a/crates/nvisy-postgres/src/query/workspace_detections.rs +++ b/crates/nvisy-postgres/src/query/workspace_detections.rs @@ -12,6 +12,11 @@ use crate::model::{ DetectionCallRecord, NewWorkspaceDetection, NewWorkspaceDetectionCall, NewWorkspaceDetectionCallMeter, UpdateWorkspaceDetection, WorkspaceDetection, }; +use crate::query::search::ilike_contains; +use crate::schema::{ + workspace_blobs as blobs, workspace_detections as detections, workspace_documents as documents, + workspace_redactions as redactions, +}; use crate::types::{ AccountRefRow, CursorPage, CursorPagination, DetectionFilter, DetectionStatus, Json, keyset, }; @@ -50,13 +55,91 @@ pub struct DetectionDocuments { pub input: Option, } +diesel::alias!(schema::workspace_documents as artifact_outputs: ArtifactOutputs); + +// Typed correlated subqueries preserve one row per detection. Retention and +// deletion predicates match the artifacts endpoint. +#[diesel::dsl::auto_type] +fn detection_error() -> _ { + schema::workspace_detections::metadata + .retrieve_as_text("error") + .nullable() +} + +#[diesel::dsl::auto_type] +fn retained_intermediate() -> _ { + diesel::dsl::exists( + blobs::table.filter( + blobs::id + .nullable() + .eq(detections::intermediate_blob_id) + .and(blobs::workspace_id.eq(detections::workspace_id)) + .and(blobs::purged_at.is_null()), + ), + ) +} + +#[diesel::dsl::auto_type] +fn live_redacted_output_count() -> _ { + let outputs: diesel::query_source::Alias = artifact_outputs; + let output_id: diesel::query_source::AliasedField = + outputs.field(documents::id); + let blob_id: diesel::query_source::AliasedField = + outputs.field(documents::blob_id); + let workspace_id: diesel::query_source::AliasedField = + outputs.field(documents::workspace_id); + let deleted_at: diesel::query_source::AliasedField = + outputs.field(documents::deleted_at); + redactions::table + .inner_join(outputs.on(redactions::output_document_id.eq(output_id.nullable()))) + .inner_join(blobs::table.on(blob_id.eq(blobs::id.nullable()))) + .filter(redactions::detection_id.eq(detections::id)) + .filter(redactions::deleted_at.is_null()) + .filter(deleted_at.is_null()) + .filter(workspace_id.eq(detections::workspace_id)) + .filter(blobs::workspace_id.eq(detections::workspace_id)) + .filter(blobs::purged_at.is_null()) + .count() + .single_value() + .assume_not_null() +} + +/// Compact database projection for detection listings; excludes edits and usage JSON. +#[derive(Debug, Clone, Queryable, Selectable)] +#[diesel(table_name = schema::workspace_detections)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct DetectionSummary { + /// Detection identifier. + pub id: Uuid, + /// Input document identifier. + pub input_document_id: Uuid, + /// Current execution status. + pub status: DetectionStatus, + /// When the detection started. + pub started_at: jiff_diesel::Timestamp, + /// When the detection finished, if terminal. + pub completed_at: Option, + /// Failure reason, without loading the rest of the metadata. + #[diesel(select_expression = detection_error())] + #[diesel(select_expression_type = detection_error)] + pub error: Option, + /// Whether an intermediate artifact is retained. + #[diesel(select_expression = retained_intermediate())] + #[diesel(select_expression_type = retained_intermediate)] + pub has_intermediate: bool, + /// Number of live redacted output files. + #[diesel(select_expression = live_redacted_output_count())] + #[diesel(select_expression_type = live_redacted_output_count)] + pub redacted_output_count: i64, +} + /// One row of a detection listing: the detection plus the context a response /// needs to render it without follow-up lookups — the triggering account and the /// input document's display name (`None` if the document was removed). -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Queryable)] pub struct DetectionListRow { /// The detection. - pub detection: WorkspaceDetection, + pub detection: DetectionSummary, /// The account that triggered the detection. pub account: AccountRefRow, /// Display name of the detection's input document, if still present. @@ -337,6 +420,19 @@ impl WorkspaceDetectionRepository for PgConnection { .filter(detections::workspace_id.eq(workspace_id)) .filter(detections::deleted_at.is_null()) .into_boxed(); + if let Some(search) = filter + .search + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + { + let pattern = ilike_contains(search); + query = query.filter( + documents::display_name + .ilike(pattern.clone()) + .or(documents::original_filename.ilike(pattern)), + ); + } if let Some(status) = filter.status { query = query.filter(detections::status.eq(status)); } @@ -376,8 +472,10 @@ impl WorkspaceDetectionRepository for PgConnection { None }; + // Correlated aggregates preserve one row per detection, so counts and + // cursor boundaries cannot be inflated by multiple redaction outputs. let selection = ( - WorkspaceDetection::as_select(), + DetectionSummary::as_select(), ( accounts::id, accounts::username, @@ -390,7 +488,7 @@ impl WorkspaceDetectionRepository for PgConnection { let after = pagination .after_key() .map(|k| (jiff_diesel::Timestamp::from(k.started_at), k.id)); - let rows: Vec<(WorkspaceDetection, AccountRefRow, Option)> = keyset!( + let items = keyset!( scoped(), detections::started_at, detections::id, @@ -399,21 +497,10 @@ impl WorkspaceDetectionRepository for PgConnection { ) .select(selection) .limit(pagination.fetch_limit()) - .load(self) + .load::(self) .await .map_err(Error::from)?; - let items = rows - .into_iter() - .map( - |(detection, account, input_document_name)| DetectionListRow { - detection, - account, - input_document_name, - }, - ) - .collect(); - Ok(CursorPage::new(items, total, pagination.limit, |row| { DetectionCursor { started_at: row.detection.started_at.into(), @@ -932,6 +1019,8 @@ mod tests { .collect::>(), vec![pending.id] ); + assert!(!page.items[0].detection.has_intermediate); + assert_eq!(page.items[0].detection.redacted_output_count, 0); assert!( page.items[0].input_document_name.is_some(), "input document name should resolve through the join" @@ -939,6 +1028,147 @@ mod tests { Ok(()) } + async fn seed_searchable_runs( + db: &TestDatabase, + ) -> anyhow::Result<(crate::test_util::SeededDocument, Vec)> { + use crate::model::{NewBlob, NewWorkspaceDocument, NewWorkspaceRedaction}; + use crate::query::{ + WorkspaceBlobRepository, WorkspaceDocumentRepository, WorkspaceRedactionRepository, + }; + + let seeded = db.seed_document().await; + let mut conn = db.client.get_connection().await?; + let mut input = + NewWorkspaceDocument::test(seeded.workspace_id, seeded.account_id, Uuid::nil()); + input.display_name = Some("Report_50%.pdf".into()); + input.original_filename = Some("original-contract.pdf".into()); + let input = conn + .create_workspace_document(input, NewBlob::test(seeded.workspace_id)) + .await?; + let intermediate = conn + .find_or_create_blob(NewBlob::test(seeded.workspace_id)) + .await?; + let mut ids = Vec::new(); + for _ in 0..2 { + let mut new = + NewWorkspaceDetection::test(seeded.workspace_id, seeded.account_id, input.id); + new.intermediate_blob_id = Some(intermediate.id); + new.metadata = Some(Json::encode(&crate::types::DetectionMetadata { + error: Some("example failure".into()), + usage: Some(serde_json::json!({"large": "detail"})), + ..Default::default() + })); + let detection = conn.create_workspace_detection(new).await?; + ids.push(detection.id); + for _ in 0..3 { + let mut output = NewWorkspaceRedaction::test(detection.id, seeded.account_id); + output.output_document_id = Some(seeded.document_id); + conn.create_redaction(output).await?; + } + } + // A non-matching run in the same workspace must not affect search totals. + conn.create_workspace_detection(NewWorkspaceDetection::test( + seeded.workspace_id, + seeded.account_id, + seeded.document_id, + )) + .await?; + Ok((seeded, ids)) + } + + #[tokio::test] + async fn cursor_list_search_and_artifact_counts_preserve_pagination() -> anyhow::Result<()> { + use crate::query::{WorkspaceDocumentRepository, WorkspaceRedactionRepository}; + + let db = TestDatabase::start().await; + let (seeded, ids) = seed_searchable_runs(&db).await?; + let mut conn = db.client.get_connection().await?; + let filter = DetectionFilter { + search: Some("rEpOrT_50%".into()), + ..Default::default() + }; + let first = conn + .cursor_list_workspace_detections( + seeded.workspace_id, + CursorPagination::new(1).with_count(), + &filter, + ) + .await?; + assert_eq!(first.total, Some(2)); + assert_eq!(first.items.len(), 1); + assert!(first.items[0].detection.has_intermediate); + assert_eq!(first.items[0].detection.redacted_output_count, 3); + assert_eq!( + first.items[0].detection.error.as_deref(), + Some("example failure") + ); + let second = conn + .cursor_list_workspace_detections( + seeded.workspace_id, + CursorPagination::from_cursor_string(1, first.next_cursor.as_deref()).with_count(), + &filter, + ) + .await?; + assert_eq!(second.total, Some(2)); + assert_eq!(second.items.len(), 1); + assert_ne!(first.items[0].detection.id, second.items[0].detection.id); + assert!(second.next_cursor.is_none()); + let by_original = conn + .cursor_list_workspace_detections( + seeded.workspace_id, + CursorPagination::new(10), + &DetectionFilter { + search: Some("CONTRACT".into()), + ..Default::default() + }, + ) + .await?; + assert_eq!(by_original.items.len(), 2); + let wildcard = conn + .cursor_list_workspace_detections( + seeded.workspace_id, + CursorPagination::new(10), + &DetectionFilter { + search: Some("%".into()), + ..Default::default() + }, + ) + .await?; + assert_eq!(wildcard.items.len(), 2, "percent must be matched literally"); + let elsewhere = conn + .cursor_list_workspace_detections( + Uuid::new_v4(), + CursorPagination::new(10).with_count(), + &filter, + ) + .await?; + assert_eq!(elsewhere.total, Some(0)); + + // Indicators agree with the artifacts endpoint after an output is deleted. + conn.delete_workspace_document(seeded.document_id).await?; + let remaining = conn + .cursor_list_workspace_detections( + seeded.workspace_id, + CursorPagination::new(10), + &filter, + ) + .await?; + assert!( + remaining + .items + .iter() + .all(|row| row.detection.redacted_output_count == 0) + ); + for id in ids { + assert!( + conn.detection_outputs(seeded.workspace_id, id) + .await? + .is_empty() + ); + } + Ok(()) + } + #[tokio::test] async fn cursor_list_filters_by_policy() -> anyhow::Result<()> { use crate::model::NewWorkspacePolicy; diff --git a/crates/nvisy-postgres/src/query/workspace_documents.rs b/crates/nvisy-postgres/src/query/workspace_documents.rs index 3be97296..ac7919bf 100644 --- a/crates/nvisy-postgres/src/query/workspace_documents.rs +++ b/crates/nvisy-postgres/src/query/workspace_documents.rs @@ -556,13 +556,18 @@ impl WorkspaceDocumentRepository for PgConnection { .filter(dsl::deleted_at.is_null()) .into_boxed(); + if let Some(kind) = filter.kind { + query = query.filter(dsl::kind.eq(kind)); + } + // Hybrid name search: ILIKE substring (works for short queries) OR // trigram similarity (typo tolerance); both served by the trgm index. if let Some(ref term) = search_term { query = query.filter( dsl::display_name .ilike(ilike_contains(term)) - .or(dsl::display_name.trgm_similar_to(term)), + .or(dsl::display_name.trgm_similar_to(term)) + .or(dsl::original_filename.ilike(ilike_contains(term))), ); } @@ -693,6 +698,88 @@ mod tests { use crate::query::{WorkspaceBlobRepository, WorkspaceDocumentRepository}; use crate::test_util::TestDatabase; + #[tokio::test] + async fn cursor_list_combines_kind_and_original_filename_search() -> anyhow::Result<()> { + let db = TestDatabase::start().await; + let seeded = db.seed_account_and_workspace().await; + let mut conn = db.client.get_connection().await?; + for kind in [ + DocumentKind::Original, + DocumentKind::Original, + DocumentKind::Redacted, + ] { + let mut document = + NewWorkspaceDocument::test(seeded.workspace_id, seeded.account_id, Uuid::nil()); + document.kind = Some(kind); + document.display_name = Some("Renamed document".into()); + document.original_filename = Some("Contract_50%.pdf".into()); + document.file_extension = Some("pdf".into()); + conn.create_workspace_document(document, NewBlob::test(seeded.workspace_id)) + .await?; + } + let filter = DocumentFilter { + kind: Some(DocumentKind::Original), + search: Some("cOnTrAcT_50%".into()), + extensions: Some(vec!["pdf".into()]), + ..Default::default() + }; + let first = conn + .cursor_list_workspace_documents( + seeded.workspace_id, + CursorPagination::new(1).with_count(), + filter.clone(), + ) + .await?; + assert_eq!(first.total, Some(2)); + assert_eq!(first.items.len(), 1); + assert_eq!(first.items[0].item.document.kind, DocumentKind::Original); + let second = conn + .cursor_list_workspace_documents( + seeded.workspace_id, + CursorPagination::from_cursor_string(1, first.next_cursor.as_deref()).with_count(), + filter, + ) + .await?; + assert_eq!(second.total, Some(2)); + assert_eq!(second.items.len(), 1); + assert_ne!( + first.items[0].item.document.id, + second.items[0].item.document.id + ); + assert!(second.next_cursor.is_none()); + let redacted = conn + .cursor_list_workspace_documents( + seeded.workspace_id, + CursorPagination::new(10), + DocumentFilter { + kind: Some(DocumentKind::Redacted), + ..Default::default() + }, + ) + .await?; + assert_eq!(redacted.items.len(), 1); + let both = conn + .cursor_list_workspace_documents( + seeded.workspace_id, + CursorPagination::new(10), + DocumentFilter::default(), + ) + .await?; + assert_eq!(both.items.len(), 3); + let missing = conn + .cursor_list_workspace_documents( + seeded.workspace_id, + CursorPagination::new(10), + DocumentFilter { + search: Some("%no-match".into()), + ..Default::default() + }, + ) + .await?; + assert_eq!(missing.items.len(), 0); + Ok(()) + } + #[tokio::test] async fn create_find_update_and_soft_delete_drops_the_blob_reference() -> anyhow::Result<()> { let db = TestDatabase::start().await; diff --git a/crates/nvisy-postgres/src/query/workspace_members.rs b/crates/nvisy-postgres/src/query/workspace_members.rs index 61bf85dd..35420ee6 100644 --- a/crates/nvisy-postgres/src/query/workspace_members.rs +++ b/crates/nvisy-postgres/src/query/workspace_members.rs @@ -119,9 +119,8 @@ pub trait WorkspaceMemberRepository { /// Returns the account ids of members holding any of `roles` who accept /// `event` as an in-app notification. /// - /// A member accepts the event when their `notification_events_app` is empty - /// (the default, meaning "all") or contains it. One query resolves the whole - /// recipient set for a broadcast. + /// A member accepts the event when their `notification_events_app` contains + /// it. One query resolves the whole recipient set for a broadcast. fn notification_recipients_by_roles( &mut self, workspace_id: Uuid, @@ -383,20 +382,14 @@ impl WorkspaceMemberRepository for PgConnection { ) -> Result> { use schema::workspace_members::{self, dsl}; - // A member accepts the event when their in-app preference list is empty - // (default = all) or contains it (`@>`). The array column is + // An empty preference list opts out of every event. The array column is // Array>, so the needle matches that shape. let needle: Vec> = vec![Some(event)]; - let empty: Vec> = Vec::new(); let account_ids = workspace_members::table .filter(dsl::workspace_id.eq(workspace_id)) .filter(dsl::member_role.eq_any(roles.to_vec())) - .filter( - dsl::notification_events_app - .eq(empty) - .or(dsl::notification_events_app.contains(needle)), - ) + .filter(dsl::notification_events_app.contains(needle)) .select(dsl::account_id) .load::(self) .await @@ -659,20 +652,16 @@ mod tests { let seeded = db.seed_account_and_workspace().await; let mut conn = db.client.get_connection().await?; - // An owner with default (empty) prefs accepts every event. - let _ = conn - .add_workspace_member(NewWorkspaceMember::new( - seeded.workspace_id, - seeded.account_id, - WorkspaceRole::Owner, - )) - .await?; + // A newly created member receives all current events by default. + let owner = + NewWorkspaceMember::new(seeded.workspace_id, seeded.account_id, WorkspaceRole::Owner); + let _ = conn.add_workspace_member(owner).await?; // A viewer who opted in to ONLY `member.joined`. let viewer_id = conn.create_account(NewAccount::test()).await?.id; let mut viewer = NewWorkspaceMember::new(seeded.workspace_id, viewer_id, WorkspaceRole::Reviewer); - viewer.notification_events_app = vec![Some(NotificationEvent::MemberJoined)]; + viewer.notification_events_app = Some(vec![Some(NotificationEvent::MemberJoined)]); let _ = conn.add_workspace_member(viewer).await?; // For `member.joined`, restricted to owners: only the owner matches. @@ -698,7 +687,7 @@ mod tests { expected.sort(); assert_eq!(both, expected); - // For an event the viewer did NOT opt into: only the all-events owner. + // For an event the viewer did NOT opt into: only the owner receives it. let detection = conn .notification_recipients_by_roles( seeded.workspace_id, @@ -707,6 +696,25 @@ mod tests { ) .await?; assert_eq!(detection, vec![seeded.account_id]); + + // Clearing the preference list opts the viewer out of all events. + conn.update_workspace_member( + seeded.workspace_id, + viewer_id, + UpdateWorkspaceMember { + notification_events_app: Some(Vec::new()), + ..Default::default() + }, + ) + .await?; + let cleared = conn + .notification_recipients_by_roles( + seeded.workspace_id, + &[WorkspaceRole::Reviewer], + NotificationEvent::MemberJoined, + ) + .await?; + assert_eq!(cleared, [] as [uuid::Uuid; 0]); Ok(()) } diff --git a/crates/nvisy-postgres/src/query/workspace_redactions.rs b/crates/nvisy-postgres/src/query/workspace_redactions.rs index d908582a..8a3e9e57 100644 --- a/crates/nvisy-postgres/src/query/workspace_redactions.rs +++ b/crates/nvisy-postgres/src/query/workspace_redactions.rs @@ -22,12 +22,34 @@ pub struct RedactionCursor { pub id: uuid::Uuid, } +/// Redacted output metadata for one detection, used by the artifacts endpoint. +#[derive(Debug, Clone)] +pub struct DetectionRedactedOutput { + /// Redaction pass that produced the output. + pub redaction_id: Uuid, + /// Output document id. + pub document_id: Uuid, + /// Display name of the output document. + pub display_name: String, + /// File extension without the dot. + pub extension: String, + /// Plaintext size in bytes. + pub size: i64, +} + /// Repository for workspace redaction database operations. /// /// A redaction is one redact pass over a detection's analysis; a detection can /// have many. Each redaction owns the review audit it applied and the redacted /// document it produced. pub trait WorkspaceRedactionRepository { + /// Lists live redacted output files for a detection, newest first. + fn detection_outputs( + &mut self, + workspace_id: Uuid, + detection_id: Uuid, + ) -> impl Future>> + Send; + /// Creates a new workspace redaction record. fn create_redaction( &mut self, @@ -68,6 +90,62 @@ pub trait WorkspaceRedactionRepository { } impl WorkspaceRedactionRepository for PgConnection { + async fn detection_outputs( + &mut self, + workspace_id: Uuid, + detection_id: Uuid, + ) -> Result> { + use schema::workspace_detections::dsl as detections; + use schema::workspace_documents::dsl as documents; + use schema::workspace_redactions::dsl as redactions; + use schema::{ + workspace_blobs, workspace_detections, workspace_documents, workspace_redactions, + }; + + let rows = workspace_redactions::table + .inner_join(workspace_detections::table) + .inner_join( + workspace_documents::table + .on(redactions::output_document_id.eq(documents::id.nullable())), + ) + .inner_join( + workspace_blobs::table.on(documents::blob_id.eq(workspace_blobs::id.nullable())), + ) + .filter(detections::workspace_id.eq(workspace_id)) + .filter(detections::deleted_at.is_null()) + .filter(redactions::deleted_at.is_null()) + .filter(documents::deleted_at.is_null()) + .filter(documents::workspace_id.eq(workspace_id)) + .filter(workspace_blobs::workspace_id.eq(workspace_id)) + .filter(workspace_blobs::purged_at.is_null()) + .order((redactions::created_at.desc(), redactions::id.desc())) + .filter(redactions::detection_id.eq(detection_id)) + .select(( + redactions::id, + documents::id, + documents::display_name, + documents::file_extension, + workspace_blobs::file_size_bytes, + )) + .load::<(Uuid, Uuid, String, String, i64)>(self) + .await + .map_err(Error::from)?; + Ok(rows + .into_iter() + .map( + |(redaction_id, document_id, display_name, extension, size)| { + DetectionRedactedOutput { + redaction_id, + document_id, + display_name, + extension, + size, + } + }, + ) + .collect()) + } + async fn create_redaction( &mut self, new_redaction: NewWorkspaceRedaction, @@ -240,6 +318,52 @@ mod tests { use crate::query::{WorkspaceDetectionRepository, WorkspaceRedactionRepository}; use crate::test_util::{TestDatabase, backdate}; + #[tokio::test] + async fn detection_outputs_exclude_other_workspaces_and_deleted_files() -> anyhow::Result<()> { + use crate::model::{NewBlob, NewWorkspaceDocument}; + use crate::query::WorkspaceDocumentRepository; + use crate::types::DocumentKind; + + let db = TestDatabase::start().await; + let (account_id, workspace_id, detection_id) = seed_detection(&db).await?; + let mut conn = db.client.get_connection().await?; + let mut document = NewWorkspaceDocument::test(workspace_id, account_id, Uuid::nil()); + document.kind = Some(DocumentKind::Redacted); + document.display_name = Some("redacted.txt".into()); + document.file_extension = Some("txt".into()); + let document = conn + .create_workspace_document(document, NewBlob::test(workspace_id)) + .await?; + let mut redaction = NewWorkspaceRedaction::test(detection_id, account_id); + redaction.output_document_id = Some(document.id); + let redaction = conn.create_redaction(redaction).await?; + + let outputs = conn.detection_outputs(workspace_id, detection_id).await?; + assert_eq!(outputs.len(), 1); + assert_eq!(outputs[0].document_id, document.id); + assert_eq!(outputs[0].redaction_id, redaction.id); + assert_eq!(outputs[0].display_name, "redacted.txt"); + assert_eq!(outputs[0].extension, "txt"); + assert!( + conn.detection_outputs(Uuid::new_v4(), detection_id) + .await? + .is_empty() + ); + assert!( + conn.detection_outputs(workspace_id, Uuid::new_v4()) + .await? + .is_empty() + ); + + conn.delete_workspace_document(document.id).await?; + assert!( + conn.detection_outputs(workspace_id, detection_id) + .await? + .is_empty() + ); + Ok(()) + } + /// Seeds a detection and returns `(account_id, workspace_id, detection_id)` — a /// redaction's FK parent plus the context tests scope on. async fn seed_detection(db: &TestDatabase) -> anyhow::Result<(Uuid, Uuid, Uuid)> { diff --git a/crates/nvisy-postgres/src/types/filtering/detections.rs b/crates/nvisy-postgres/src/types/filtering/detections.rs index dd572b70..9c0eefdf 100644 --- a/crates/nvisy-postgres/src/types/filtering/detections.rs +++ b/crates/nvisy-postgres/src/types/filtering/detections.rs @@ -13,6 +13,9 @@ use crate::types::DetectionStatus; #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] #[serde(rename_all = "camelCase")] pub struct DetectionFilter { + /// Case-insensitive substring search of the input display name or original filename. + #[serde(skip_serializing_if = "Option::is_none")] + pub search: Option, /// Filter by detection status. #[serde(skip_serializing_if = "Option::is_none")] pub status: Option, diff --git a/crates/nvisy-postgres/src/types/filtering/documents.rs b/crates/nvisy-postgres/src/types/filtering/documents.rs index 972b1dda..bce53184 100644 --- a/crates/nvisy-postgres/src/types/filtering/documents.rs +++ b/crates/nvisy-postgres/src/types/filtering/documents.rs @@ -1,5 +1,7 @@ //! Filtering options for document queries. +use crate::types::DocumentKind; + /// Filter options for documents. /// /// Each field narrows the result when set; unset fields impose no constraint. @@ -11,6 +13,8 @@ /// taxonomy, which lives in the engine's codec registry. #[derive(Debug, Default, Clone)] pub struct DocumentFilter { + /// Document role to include; absent includes both originals and redacted files. + pub kind: Option, /// Search by document name (case-insensitive, partial match). An empty string /// is resolved to `None` by the caller, so a set value is always a real /// search. diff --git a/crates/nvisy-server/src/domain/input/workspace_documents.rs b/crates/nvisy-server/src/domain/input/workspace_documents.rs index ab13cfb4..72d9ece3 100644 --- a/crates/nvisy-server/src/domain/input/workspace_documents.rs +++ b/crates/nvisy-server/src/domain/input/workspace_documents.rs @@ -3,7 +3,7 @@ use std::collections::HashSet; use nvisy_postgres::model::UpdateWorkspaceDocument as UpdateDocumentModel; -use nvisy_postgres::types::DocumentFilter; +use nvisy_postgres::types::{DocumentFilter, DocumentKind}; use crate::service::{EngineService, UnknownFormatToken}; @@ -13,6 +13,8 @@ use crate::service::{EngineService, UnknownFormatToken}; /// against the engine's codec registry at query time. #[derive(Default)] pub struct ListDocumentsInput { + /// Document role to include. + pub kind: Option, /// Search term over document names; an empty term imposes no constraint. pub search: Option, /// File-extension filter tokens (each expands to its format's full set). @@ -44,9 +46,13 @@ impl ListDocumentsInput { .modality .map(|t| engine.resolve_modalities(&t)) .transpose()?; - let search = self.search.filter(|s| !s.is_empty()); + let search = self + .search + .map(|s| s.trim().to_owned()) + .filter(|s| !s.is_empty()); Ok(DocumentFilter { + kind: self.kind, search, extensions: intersect_facets(formats, modality), hash: self.hash, diff --git a/crates/nvisy-server/src/domain/output/workspace_detections.rs b/crates/nvisy-server/src/domain/output/workspace_detections.rs index 14c55b3c..2f382a11 100644 --- a/crates/nvisy-server/src/domain/output/workspace_detections.rs +++ b/crates/nvisy-server/src/domain/output/workspace_detections.rs @@ -20,3 +20,13 @@ pub struct CreatedDetection { /// idempotency key replayed an existing one (`200`). pub created: bool, } + +/// Artifact metadata for one detection run. +pub struct DetectionArtifacts { + /// Live input document, if it has not been deleted. + pub input: Option, + /// Whether an intermediate artifact is retained. + pub intermediate_available: bool, + /// Live redacted output files. + pub redacted_outputs: Vec, +} diff --git a/crates/nvisy-server/src/domain/workspace_detections.rs b/crates/nvisy-server/src/domain/workspace_detections.rs index 3f5c1493..0efd82d3 100644 --- a/crates/nvisy-server/src/domain/workspace_detections.rs +++ b/crates/nvisy-server/src/domain/workspace_detections.rs @@ -20,8 +20,9 @@ use nvisy_postgres::model::{ }; use nvisy_postgres::query::{ DetectionCursor, DetectionDocuments, DetectionJobOutboxRepository, DetectionListRow, - DetectionPolicyVersionRepository, EditWriteOutcome, WorkspaceDetectionRepository, - WorkspaceDocumentRepository, WorkspacePolicyRepository, WorkspacePolicyVersionRepository, + DetectionPolicyVersionRepository, EditWriteOutcome, WorkspaceBlobRepository, + WorkspaceDetectionRepository, WorkspaceDocumentRepository, WorkspacePolicyRepository, + WorkspacePolicyVersionRepository, WorkspaceRedactionRepository, }; use nvisy_postgres::types::{CursorPage, CursorPagination, DetectionFilter, DetectionStatus, Json}; use nvisy_postgres::{AsyncConnection, PgClient, PgConn}; @@ -210,6 +211,41 @@ impl WorkspaceDetectionService { .await?) } + /// Returns the input, intermediate availability, and live output files for a + /// detection. + /// + /// # Errors + /// + /// - `NotFound` if the detection is missing or deleted. + /// - A database error if a connection or query fails. + pub async fn artifacts( + &self, + workspace_id: Uuid, + detection_id: Uuid, + ) -> Result { + let mut conn = self.postgres.get_connection().await?; + let detection = conn + .find_workspace_detection_by_id(workspace_id, detection_id) + .await? + .ok_or_else(|| Error::not_found("detection"))?; + let input = conn + .find_document_in_workspace(workspace_id, detection.input_document_id) + .await?; + let intermediate_available = if let Some(blob_id) = detection.intermediate_blob_id { + conn.find_blob_by_id(blob_id) + .await? + .is_some_and(|blob| blob.workspace_id == workspace_id && blob.purged_at.is_none()) + } else { + false + }; + let outputs = conn.detection_outputs(workspace_id, detection_id).await?; + Ok(crate::domain::output::DetectionArtifacts { + input, + intermediate_available, + redacted_outputs: outputs, + }) + } + /// Finds a detection by id within a workspace, with the triggering account and /// its input document name — the context a single-detection response renders from. /// diff --git a/crates/nvisy-server/src/handler/request/workspace_detections.rs b/crates/nvisy-server/src/handler/request/workspace_detections.rs index 887d4c41..772c7fc1 100644 --- a/crates/nvisy-server/src/handler/request/workspace_detections.rs +++ b/crates/nvisy-server/src/handler/request/workspace_detections.rs @@ -16,6 +16,8 @@ use crate::domain::input::CreateDetectionInput; #[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "camelCase")] pub struct WorkspaceDetectionsQuery { + /// Case-insensitive substring search of the input display name or original filename. + pub search: Option, /// Filter by detection status. pub status: Option, /// Filter by the source document the detection analyzes. @@ -29,6 +31,7 @@ pub struct WorkspaceDetectionsQuery { impl From for DetectionFilter { fn from(query: WorkspaceDetectionsQuery) -> Self { DetectionFilter { + search: query.search, status: query.status, input_document_id: query.document_id, policy_id: query.policy_id, diff --git a/crates/nvisy-server/src/handler/request/workspace_documents.rs b/crates/nvisy-server/src/handler/request/workspace_documents.rs index 6ce5c3e3..504b20d9 100644 --- a/crates/nvisy-server/src/handler/request/workspace_documents.rs +++ b/crates/nvisy-server/src/handler/request/workspace_documents.rs @@ -6,6 +6,7 @@ use std::collections::BTreeSet; use derive_more::{AsRef, Into}; use elide_pipeline::FormatRegistry; use garde::Validate; +use nvisy_postgres::types::DocumentKind; use schemars::{JsonSchema, Schema, SchemaGenerator}; use serde::{Deserialize, Serialize}; use uuid::Uuid; @@ -105,7 +106,10 @@ registry_token!( #[derive(Debug, Default, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "camelCase")] pub struct ListWorkspaceDocuments { - /// Search by document name (case-insensitive, partial match). + /// Filter by document role (`original` or `redacted`). Omit to include both. + #[serde(skip_serializing_if = "Option::is_none")] + pub kind: Option, + /// Search the display name or original filename (case-insensitive). #[serde(skip_serializing_if = "Option::is_none")] pub search: Option, /// Filter by file extension. Each entry expands to its format's full @@ -125,6 +129,7 @@ pub struct ListWorkspaceDocuments { impl From for ListDocumentsInput { fn from(query: ListWorkspaceDocuments) -> Self { ListDocumentsInput { + kind: query.kind, search: query.search, formats: query .formats diff --git a/crates/nvisy-server/src/handler/response/workspace_detections.rs b/crates/nvisy-server/src/handler/response/workspace_detections.rs index 812faefc..15ce9adb 100644 --- a/crates/nvisy-server/src/handler/response/workspace_detections.rs +++ b/crates/nvisy-server/src/handler/response/workspace_detections.rs @@ -57,8 +57,66 @@ pub struct WorkspaceDetection { pub completed_at: Option, } -/// Paginated response for detections. -pub type WorkspaceDetectionsPage = Page; +/// Compact detection list item. Full metadata and reviewer edits are available +/// from the individual detection endpoint. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct WorkspaceDetectionSummary { + /// Detection identifier. + pub id: DetectionId, + /// Owning workspace. + pub workspace_id: Uuid, + /// Workspace handle for display. + pub workspace_handle: Handle, + /// Input document identifier. + pub input_document_id: Uuid, + /// Input display name, omitted if the document was deleted. + #[serde(skip_serializing_if = "Option::is_none")] + pub input_document_name: Option, + /// Account that started the detection. + pub triggered_by: AccountRef, + /// Current execution status. + pub status: DetectionStatus, + /// Failure reason when present. + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, + /// When the detection started. + pub started_at: Timestamp, + /// When analysis finished. + #[serde(skip_serializing_if = "Option::is_none")] + pub completed_at: Option, + /// Whether intermediate bytes are retained. + pub has_intermediate: bool, + /// Number of live redacted output files in the artifacts view. + pub redacted_output_count: i64, +} + +impl WorkspaceDetectionSummary { + /// Builds a compact response from a list query's projection. + pub fn from_row( + row: nvisy_postgres::query::DetectionListRow, + workspace_id: Uuid, + workspace_handle: Handle, + ) -> Self { + Self { + id: DetectionId::from_uuid(row.detection.id), + workspace_id, + workspace_handle, + input_document_id: row.detection.input_document_id, + input_document_name: row.input_document_name, + triggered_by: row.account.into(), + status: row.detection.status, + error: row.detection.error, + started_at: row.detection.started_at.into(), + completed_at: row.detection.completed_at.map(Into::into), + has_intermediate: row.detection.has_intermediate, + redacted_output_count: row.detection.redacted_output_count, + } + } +} + +/// Paginated response containing compact detection summaries. +pub type WorkspaceDetectionsPage = Page; impl WorkspaceDetection { /// Creates a detection response from the database model, the owning workspace @@ -99,3 +157,76 @@ impl WorkspaceDetection { } } } + +/// File and intermediate metadata belonging to a single detection run. +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct WorkspaceDetectionArtifacts { + /// Input document metadata, omitted if the document was deleted. + #[serde(skip_serializing_if = "Option::is_none")] + pub input: Option, + /// Intermediate artifact availability; no artifact content is embedded. + pub intermediate: DetectionArtifactIntermediate, + /// Live redacted output documents, newest first. + pub redacted_outputs: Vec, +} + +/// The detection's input file. +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct DetectionArtifactInput { + /// Document identifier used by the document endpoints. + pub id: Uuid, + /// Display name. + pub display_name: String, +} + +/// Availability of the detection's intermediate artifact. +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +pub struct DetectionArtifactIntermediate { + /// True while the intermediate's bytes are retained. + pub available: bool, +} + +/// A redacted file produced from the detection. +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct DetectionArtifactOutput { + /// The redaction pass that produced this file. + pub redaction_id: nvisy_postgres::types::RedactionId, + /// Document identifier used by the document endpoints. + pub document_id: Uuid, + /// Display name. + pub display_name: String, + /// File extension without a dot. + pub extension: String, + /// Plaintext size in bytes. + pub size: i64, +} + +impl From for WorkspaceDetectionArtifacts { + fn from(artifacts: crate::domain::output::DetectionArtifacts) -> Self { + Self { + input: artifacts.input.map(|document| DetectionArtifactInput { + id: document.id, + display_name: document.display_name, + }), + intermediate: DetectionArtifactIntermediate { + available: artifacts.intermediate_available, + }, + redacted_outputs: artifacts + .redacted_outputs + .into_iter() + .map(|output| DetectionArtifactOutput { + redaction_id: nvisy_postgres::types::RedactionId::from_uuid( + output.redaction_id, + ), + document_id: output.document_id, + display_name: output.display_name, + extension: output.extension, + size: output.size, + }) + .collect(), + } + } +} diff --git a/crates/nvisy-server/src/handler/response/workspace_documents.rs b/crates/nvisy-server/src/handler/response/workspace_documents.rs index 9289e024..92b7ed41 100644 --- a/crates/nvisy-server/src/handler/response/workspace_documents.rs +++ b/crates/nvisy-server/src/handler/response/workspace_documents.rs @@ -68,6 +68,41 @@ impl WorkspaceDocument { } } +/// Full document metadata, returned by the read and update endpoints. +#[must_use] +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct WorkspaceDocumentDetails { + /// Standard file fields, flattened to preserve the existing response shape. + #[serde(flatten)] + pub document: WorkspaceDocument, + /// Custom metadata stored on the document. + pub metadata: serde_json::Value, +} + +impl WorkspaceDocumentDetails { + /// Builds a detail response including the stored custom metadata. + pub fn from_model( + mut document: DocumentModel, + blob: &Blob, + workspace_id: Uuid, + workspace_handle: Handle, + uploaded_by: AccountRef, + ) -> Self { + let metadata = std::mem::take(&mut document.metadata); + Self { + document: WorkspaceDocument::from_model( + document, + blob, + workspace_id, + workspace_handle, + uploaded_by, + ), + metadata, + } + } +} + /// Result of a bulk document deletion. /// /// The deletion is idempotent: `deleted` holds the ids that resolved to live @@ -90,3 +125,48 @@ pub type WorkspaceDocuments = Vec; /// Paginated response for document listing. pub type WorkspaceDocumentsPage = Page; + +#[cfg(test)] +mod tests { + use nvisy_postgres::model::UpdateWorkspaceDocument; + use nvisy_postgres::query::WorkspaceDocumentRepository; + use nvisy_postgres::test_util::TestDatabase; + + use super::*; + + #[tokio::test] + async fn document_details_return_stored_custom_metadata_at_the_top_level() { + let db = TestDatabase::start().await; + let seeded = db.seed_document().await; + let mut conn = db.client.get_connection().await.unwrap(); + let metadata = serde_json::json!({"tags": ["confidential"], "source": "client"}); + conn.update_workspace_document( + seeded.document_id, + UpdateWorkspaceDocument { + metadata: Some(metadata.clone()), + ..Default::default() + }, + ) + .await + .unwrap(); + let found = conn + .find_document_in_workspace_with_creator(seeded.workspace_id, seeded.document_id) + .await + .unwrap() + .unwrap(); + let details = WorkspaceDocumentDetails::from_model( + found.item.document, + &found.item.blob, + seeded.workspace_id, + "test-workspace".parse().unwrap(), + found.account.into(), + ); + let json = serde_json::to_value(details).unwrap(); + assert_eq!(json["id"], seeded.document_id.to_string()); + assert_eq!(json["metadata"], metadata); + assert!( + json.get("document").is_none(), + "file fields must remain at the top level" + ); + } +} diff --git a/crates/nvisy-server/src/handler/workspace_detections.rs b/crates/nvisy-server/src/handler/workspace_detections.rs index 6d213e63..533d0982 100644 --- a/crates/nvisy-server/src/handler/workspace_detections.rs +++ b/crates/nvisy-server/src/handler/workspace_detections.rs @@ -26,8 +26,7 @@ use nvisy_postgres::model::{ WorkspaceDetection as WorkspaceDetectionModel, WorkspaceDocument, }; use nvisy_postgres::query::{ - DetectionDocuments, WorkspaceBlobRepository, WorkspaceDocumentRepository, - WorkspaceRedactionRepository, + WorkspaceBlobRepository, WorkspaceDocumentRepository, WorkspaceRedactionRepository, }; use nvisy_postgres::types::{DetectionStatus, DocumentKind}; use nvisy_postgres::{AsyncConnection, PgClient, PgConn}; @@ -42,7 +41,8 @@ use crate::handler::request::{ WorkspaceDetectionPathParams, WorkspaceDetectionsQuery, }; use crate::handler::response::{ - WorkspaceDetection, WorkspaceDetectionsPage, WorkspaceRedactionResult, + WorkspaceDetection, WorkspaceDetectionArtifacts, WorkspaceDetectionSummary, + WorkspaceDetectionsPage, WorkspaceRedactionResult, }; use crate::handler::utility::resolve_account_ref; use crate::response::{Error, ErrorKind, ErrorResponse, Result, SseResponse}; @@ -164,15 +164,7 @@ async fn list_workspace_detections( Ok(( StatusCode::OK, Json(WorkspaceDetectionsPage::from_cursor_page(page, |row| { - WorkspaceDetection::from_model( - &row.detection, - workspace.id, - workspace.handle.clone(), - row.account.into(), - DetectionDocuments { - input: row.input_document_name, - }, - ) + WorkspaceDetectionSummary::from_row(row, workspace.id, workspace.handle.clone()) })), )) } @@ -181,7 +173,9 @@ fn list_workspace_detections_docs(op: TransformOperation) -> TransformOperation op.summary("List workspace detections") .description( "Returns all detections across the workspace, most recent first, \ - with optional status, document, policy, and trigger-account filters.", + with optional status, document, policy, trigger-account, and input filename search filters. \ + Returns compact summaries and artifact indicators; fetch an individual detection \ + for full metadata and reviewer edits.", ) .response::<200, Json>() .response::<401, Json>() @@ -237,6 +231,28 @@ fn get_detection_docs(op: TransformOperation) -> TransformOperation { .response::<404, Json>() } +/// Returns metadata for the files and intermediate produced by a detection. +#[tracing::instrument(skip_all)] +async fn get_detection_artifacts( + State(detections): State, + authz: Authorized, + Path(path_params): Path, +) -> Result> { + let artifacts = detections + .artifacts(authz.workspace.id, path_params.detection_id.as_uuid()) + .await?; + Ok(Json(artifacts.into())) +} + +fn get_detection_artifacts_docs(op: TransformOperation) -> TransformOperation { + op.summary("Get detection artifacts") + .description("Returns the input file, intermediate availability, and live redacted output file metadata for one detection. Deleted input files are omitted; deleted or reclaimed outputs are omitted. No file content is embedded.") + .response::<200, Json>() + .response::<401, Json>() + .response::<403, Json>() + .response::<404, Json>() +} + /// Streams a detection's status changes as Server-Sent Events until it settles. /// /// Emits one `status` event with the detection's current status immediately (so a @@ -808,6 +824,10 @@ pub fn routes() -> ApiRouter { get_with(get_detection, get_detection_docs) .patch_with(edit_detection, edit_detection_docs), ) + .api_route( + "/workspaces/{workspaceId}/detections/{detectionId}/artifacts", + get_with(get_detection_artifacts, get_detection_artifacts_docs), + ) .api_route( "/workspaces/{workspaceId}/detections/{detectionId}/events", get_with(stream_detection_events, stream_detection_events_docs), diff --git a/crates/nvisy-server/src/handler/workspace_documents.rs b/crates/nvisy-server/src/handler/workspace_documents.rs index 3e66c488..8618a557 100644 --- a/crates/nvisy-server/src/handler/workspace_documents.rs +++ b/crates/nvisy-server/src/handler/workspace_documents.rs @@ -40,7 +40,7 @@ use crate::handler::request::{ UpdateWorkspaceDocument, WorkspaceDocumentPathParams, }; use crate::handler::response::{ - self, WorkspaceDocument, WorkspaceDocuments, WorkspaceDocumentsPage, + self, WorkspaceDocument, WorkspaceDocumentDetails, WorkspaceDocuments, WorkspaceDocumentsPage, }; use crate::handler::utility::{DownloadDocs, resolve_account_ref}; use crate::middleware::UploadConfig; @@ -101,7 +101,7 @@ async fn list_documents( fn list_documents_docs(op: TransformOperation) -> TransformOperation { op.summary("List documents") .description( - "Lists documents in a workspace with cursor-based pagination. Use the `after` parameter with the `nextCursor` value from the response to fetch subsequent pages. Pass `hash` (a hex SHA-256) to find documents with identical content — a non-empty result means the document already exists, so an upload can be skipped.", + "Lists documents in a workspace with cursor-based pagination. Use the `after` parameter with the `nextCursor` value from the response to fetch subsequent pages. Use `kind=original` or `kind=redacted` to filter by role, and `search` to find a display name or original filename. Pass `hash` (a hex SHA-256) to find documents with identical content — a non-empty result means the document already exists, so an upload can be skipped.", ) .response::<200, Json>() .response::<400, Json>() @@ -364,16 +364,16 @@ async fn upload_document( ); } - // Re-acquire a connection only for the final commit, so the pool is free - // during the streaming above. - let mut conn = pg_client.get_connection().await?; let origin = event::EventOrigin { workspace_id: workspace.id, account_id: authz.account_id, security: &security, }; - let created = match conn - .transaction(async |conn| { + // Acquisition and commit share the cleanup path: any failure after staging + // must reclaim the objects, including an exhausted connection pool. + let created = match async { + let mut conn = pg_client.get_connection().await?; + conn.transaction(async |conn| { let mut created: Vec = Vec::with_capacity(staged.len()); for staged_document in &staged { let document = conn @@ -406,6 +406,8 @@ async fn upload_document( Ok::<_, Error>(created) }) .await + } + .await { Ok(created) => created, Err(err) => { @@ -469,7 +471,7 @@ async fn read_document( State(documents): State, authz: Authorized, Path(path_params): Path, -) -> Result<(StatusCode, Json)> { +) -> Result<(StatusCode, Json)> { tracing::debug!(target: TRACING_TARGET, "Reading document metadata"); let workspace = authz.workspace; @@ -481,7 +483,7 @@ async fn read_document( Ok(( StatusCode::OK, - Json(WorkspaceDocument::from_model( + Json(WorkspaceDocumentDetails::from_model( found.item.document, &found.item.blob, workspace.id, @@ -494,7 +496,7 @@ async fn read_document( fn read_document_docs(op: TransformOperation) -> TransformOperation { op.summary("Get document metadata") .description("Returns document metadata without downloading the document content.") - .response::<200, Json>() + .response::<200, Json>() .response::<401, Json>() .response::<403, Json>() .response::<404, Json>() @@ -515,7 +517,7 @@ async fn update_document( Path(path_params): Path, security: SecurityContext, ValidateJson(request): ValidateJson, -) -> Result<(StatusCode, Json)> { +) -> Result<(StatusCode, Json)> { tracing::debug!(target: TRACING_TARGET, "Updating document"); let workspace = authz.workspace; @@ -532,7 +534,7 @@ async fn update_document( Ok(( StatusCode::OK, - Json(response::WorkspaceDocument::from_model( + Json(response::WorkspaceDocumentDetails::from_model( found.item.document, &found.item.blob, workspace.id, @@ -544,8 +546,10 @@ async fn update_document( fn update_document_docs(op: TransformOperation) -> TransformOperation { op.summary("Update document") - .description("Updates document metadata such as display name, tags, or metadata.") - .response::<200, Json>() + .description( + "Updates the display name or replaces custom metadata; returns the updated metadata.", + ) + .response::<200, Json>() .response::<400, Json>() .response::<401, Json>() .response::<403, Json>() diff --git a/crates/nvisy-server/src/service/emitter/notification.rs b/crates/nvisy-server/src/service/emitter/notification.rs index 67480837..ee217067 100644 --- a/crates/nvisy-server/src/service/emitter/notification.rs +++ b/crates/nvisy-server/src/service/emitter/notification.rs @@ -113,9 +113,8 @@ impl NotificationEmitter { /// notification preferences within `workspace_id`. /// /// The notification is skipped (returning `false`) when the account is not a - /// member of the workspace, or has narrowed its `notification_events_app` to - /// exclude this event. A member with the default (empty is treated as "all", - /// as is any list that contains the event) receives it. + /// member of the workspace, or has not selected this event in + /// `notification_events_app`. An empty list opts out of all events. /// /// Best-effort by contract: callers log-and-continue on error so a failed /// notification never fails the operation that triggered it. @@ -135,13 +134,12 @@ impl NotificationEmitter { let mut conn = self.infra.postgres.get_connection().await?; - // Respect the member's in-app preferences. An empty preference list means - // "all events" (the column defaults to every event), so only a member who - // has narrowed the set and excluded this event is skipped. + // Deliver only explicitly selected events; an empty list opts out. + // New memberships default to the full set of current events. let member = conn.find_workspace_member(workspace_id, account_id).await?; let deliver = if let Some(member) = member { let prefs = member.app_notification_events(); - prefs.is_empty() || prefs.contains(&event) + prefs.contains(&event) } else { tracing::debug!( target: TRACING_TARGET, @@ -222,3 +220,93 @@ impl NotificationEmitter { Ok(created) } } + +#[cfg(all(test, feature = "test_util"))] +mod tests { + use nvisy_postgres::model::UpdateWorkspaceMember; + use nvisy_postgres::test_util::TestDatabase; + use nvisy_postgres::types::{MemberJoinedParams, NotificationEvent}; + + use super::*; + + #[tokio::test] + async fn direct_notifications_respect_empty_and_selected_preferences() { + let db = TestDatabase::start().await; + let nats = nvisy_nats::TestNats::start().await; + let blobs = nvisy_s3::TestBlobStore::start().await; + let seeded = db.seed_account_and_workspace().await; + db.seed_workspace_member(seeded.workspace_id, seeded.account_id, WorkspaceRole::Owner) + .await; + let emitter = NotificationEmitter::new(Infra { + postgres: db.client.clone(), + nats: nats.client.clone(), + blobs: blobs.store.clone(), + }); + let payload = || { + NotificationPayload::MemberJoined(MemberJoinedParams { + workspace_id: seeded.workspace_id, + member_id: seeded.account_id, + }) + }; + assert!( + emitter + .notify_account(seeded.workspace_id, seeded.account_id, payload()) + .await + .unwrap() + ); + + let mut conn = db.client.get_connection().await.unwrap(); + for preferences in [Vec::new(), vec![Some(NotificationEvent::DetectionFailed)]] { + conn.update_workspace_member( + seeded.workspace_id, + seeded.account_id, + UpdateWorkspaceMember { + notification_events_app: Some(preferences), + ..Default::default() + }, + ) + .await + .unwrap(); + assert!( + !emitter + .notify_account(seeded.workspace_id, seeded.account_id, payload()) + .await + .unwrap() + ); + assert_eq!( + conn.count_unread_account_notifications(seeded.account_id) + .await + .unwrap(), + 1 + ); + } + conn.update_workspace_member( + seeded.workspace_id, + seeded.account_id, + UpdateWorkspaceMember { + notification_events_app: Some(vec![Some(NotificationEvent::MemberJoined)]), + ..Default::default() + }, + ) + .await + .unwrap(); + assert!( + emitter + .notify_account(seeded.workspace_id, seeded.account_id, payload()) + .await + .unwrap() + ); + assert_eq!( + conn.count_unread_account_notifications(seeded.account_id) + .await + .unwrap(), + 2 + ); + assert!( + !emitter + .notify_account(Uuid::new_v4(), seeded.account_id, payload()) + .await + .unwrap() + ); + } +} diff --git a/crates/nvisy-server/src/worker/assistant/drainer.rs b/crates/nvisy-server/src/worker/assistant/drainer.rs index 712cf3d5..dd726838 100644 --- a/crates/nvisy-server/src/worker/assistant/drainer.rs +++ b/crates/nvisy-server/src/worker/assistant/drainer.rs @@ -89,7 +89,6 @@ impl Worker for AssistantOutboxDrainer { } } - tracing::info!(target: TRACING_TARGET, "Assistant-job drainer stopped"); Ok(()) } } diff --git a/crates/nvisy-server/src/worker/assistant/worker.rs b/crates/nvisy-server/src/worker/assistant/worker.rs index 6e85c16c..c3b12cdf 100644 --- a/crates/nvisy-server/src/worker/assistant/worker.rs +++ b/crates/nvisy-server/src/worker/assistant/worker.rs @@ -75,11 +75,8 @@ impl Worker for AssistantWorker { let result = self.run_inner(cancel).await; - match &result { - Ok(()) => tracing::info!(target: TRACING_TARGET, "Assistant worker stopped"), - Err(err) => { - tracing::error!(target: TRACING_TARGET, error = %err, "Assistant worker failed"); - } + if let Err(err) = &result { + tracing::error!(target: TRACING_TARGET, error = %err, "Assistant worker failed"); } result @@ -120,7 +117,6 @@ impl AssistantWorker { // stream's redelivery lease, does not advance while every slot is busy. let permit = tokio::select! { () = cancel.cancelled() => { - tracing::info!(target: TRACING_TARGET, "Assistant worker shutdown requested"); break; } Some(_) = tasks.join_next() => continue, @@ -132,7 +128,6 @@ impl AssistantWorker { tokio::select! { () = cancel.cancelled() => { - tracing::info!(target: TRACING_TARGET, "Assistant worker shutdown requested"); break; } result = stream.next_with_timeout(Duration::from_secs(5)) => { diff --git a/crates/nvisy-server/src/worker/detection/drainer.rs b/crates/nvisy-server/src/worker/detection/drainer.rs index d6a67a73..cda97e10 100644 --- a/crates/nvisy-server/src/worker/detection/drainer.rs +++ b/crates/nvisy-server/src/worker/detection/drainer.rs @@ -94,7 +94,6 @@ impl Worker for DetectionOutboxDrainer { } } - tracing::info!(target: TRACING_TARGET, "Detection-job drainer stopped"); Ok(()) } } diff --git a/crates/nvisy-server/src/worker/detection/worker.rs b/crates/nvisy-server/src/worker/detection/worker.rs index 9ff355c1..c83db3e3 100644 --- a/crates/nvisy-server/src/worker/detection/worker.rs +++ b/crates/nvisy-server/src/worker/detection/worker.rs @@ -78,11 +78,8 @@ impl Worker for DetectionWorker { let result = self.run_inner(cancel).await; - match &result { - Ok(()) => tracing::info!(target: TRACING_TARGET, "Detection worker stopped"), - Err(err) => { - tracing::error!(target: TRACING_TARGET, error = %err, "Detection worker failed"); - } + if let Err(err) = &result { + tracing::error!(target: TRACING_TARGET, error = %err, "Detection worker failed"); } result @@ -133,7 +130,6 @@ impl DetectionWorker { // closed semaphore — treat that as fatal for the loop. let permit = tokio::select! { () = cancel.cancelled() => { - tracing::info!(target: TRACING_TARGET, "Detection worker shutdown requested"); break; } // Reap completed tasks as they finish, so the set stays bounded by @@ -147,7 +143,6 @@ impl DetectionWorker { tokio::select! { () = cancel.cancelled() => { - tracing::info!(target: TRACING_TARGET, "Detection worker shutdown requested"); break; } result = stream.next_with_timeout(Duration::from_secs(5)) => { diff --git a/crates/nvisy-server/src/worker/event/drainer.rs b/crates/nvisy-server/src/worker/event/drainer.rs index 6bd88a2a..7b734b59 100644 --- a/crates/nvisy-server/src/worker/event/drainer.rs +++ b/crates/nvisy-server/src/worker/event/drainer.rs @@ -90,7 +90,6 @@ impl Worker for EventOutboxDrainer { } } - tracing::info!(target: TRACING_TARGET, "Event-outbox drainer stopped"); Ok(()) } } diff --git a/crates/nvisy-server/src/worker/integration/worker.rs b/crates/nvisy-server/src/worker/integration/worker.rs index 61ba7914..daa725ff 100644 --- a/crates/nvisy-server/src/worker/integration/worker.rs +++ b/crates/nvisy-server/src/worker/integration/worker.rs @@ -105,11 +105,8 @@ impl Worker for ConnectionSyncWorker { let result = self.run_inner(cancel).await; - match &result { - Ok(()) => tracing::info!(target: TRACING_TARGET, "Connection sync worker stopped"), - Err(err) => { - tracing::error!(target: TRACING_TARGET, error = %err, "Connection sync worker failed"); - } + if let Err(err) = &result { + tracing::error!(target: TRACING_TARGET, error = %err, "Connection sync worker failed"); } result @@ -250,7 +247,6 @@ impl ConnectionSyncWorker { loop { tokio::select! { () = cancel.cancelled() => { - tracing::info!(target: TRACING_TARGET, "Connection sync worker shutdown requested"); break; } result = stream.next_with_timeout(Duration::from_secs(5)) => { diff --git a/crates/nvisy-server/src/worker/purge/worker.rs b/crates/nvisy-server/src/worker/purge/worker.rs index 217d545b..e8a90099 100644 --- a/crates/nvisy-server/src/worker/purge/worker.rs +++ b/crates/nvisy-server/src/worker/purge/worker.rs @@ -50,7 +50,6 @@ impl Worker for WorkspacePurgeWorker { } } - tracing::info!(target: TRACING_TARGET, "Workspace purge worker stopped"); Ok(()) } } diff --git a/crates/nvisy-server/src/worker/reaper/worker.rs b/crates/nvisy-server/src/worker/reaper/worker.rs index aac89117..03b82bbd 100644 --- a/crates/nvisy-server/src/worker/reaper/worker.rs +++ b/crates/nvisy-server/src/worker/reaper/worker.rs @@ -45,7 +45,6 @@ impl Worker for BlobReaper { } } - tracing::info!(target: TRACING_TARGET, "Blob reaper stopped"); Ok(()) } } diff --git a/crates/nvisy-server/src/worker/webhook/worker.rs b/crates/nvisy-server/src/worker/webhook/worker.rs index 53fc877a..182ad38f 100644 --- a/crates/nvisy-server/src/worker/webhook/worker.rs +++ b/crates/nvisy-server/src/worker/webhook/worker.rs @@ -108,20 +108,12 @@ impl Worker for WebhookDeliveryWorker { let result = self.run_inner(cancel).await; - match &result { - Ok(()) => { - tracing::info!( - target: TRACING_TARGET, - "Webhook worker stopped" - ); - } - Err(err) => { - tracing::error!( - target: TRACING_TARGET, - error = %err, - "Webhook worker failed" - ); - } + if let Err(err) = &result { + tracing::error!( + target: TRACING_TARGET, + error = %err, + "Webhook worker failed" + ); } result @@ -148,10 +140,6 @@ impl WebhookDeliveryWorker { loop { tokio::select! { () = cancel.cancelled() => { - tracing::info!( - target: TRACING_TARGET, - "Webhook worker shutdown requested" - ); break; } result = stream.next_with_timeout(Duration::from_secs(5)) => { diff --git a/migrations/2025-05-21-222840_workspaces/up.sql b/migrations/2025-05-21-222840_workspaces/up.sql index 40c69185..c7d14612 100644 --- a/migrations/2025-05-21-222840_workspaces/up.sql +++ b/migrations/2025-05-21-222840_workspaces/up.sql @@ -116,12 +116,10 @@ CREATE TABLE workspace_members ( -- Notification preferences notify_via_email BOOLEAN NOT NULL DEFAULT FALSE, - -- An empty in-app set means "every event": a member is notified out of the - -- box and narrows the set by replacing it (see the notification service). The - -- default is therefore empty rather than an explicit list of every value, so - -- it needs no maintenance as the NOTIFICATION_EVENT enum grows. Email stays - -- opt-in (also empty, but never expanded to "all" by the service). - notification_events_app NOTIFICATION_EVENT[] NOT NULL DEFAULT '{}', + -- An empty in-app set means "no events". New members receive all currently + -- defined events by default; enum_range also includes values added later. + -- Email stays opt-in and an empty list means no email event types. + notification_events_app NOTIFICATION_EVENT[] NOT NULL DEFAULT enum_range(NULL::NOTIFICATION_EVENT), notification_events_email NOTIFICATION_EVENT[] NOT NULL DEFAULT '{}', -- Audit tracking