diff --git a/crates/nvisy-postgres/src/model/account.rs b/crates/nvisy-postgres/src/model/account.rs index 33eb1853..f9182351 100644 --- a/crates/nvisy-postgres/src/model/account.rs +++ b/crates/nvisy-postgres/src/model/account.rs @@ -24,8 +24,6 @@ use crate::types::Handle; pub struct Account { /// Unique account identifier. pub id: Uuid, - /// Administrative privileges across the entire system. - pub is_admin: bool, /// Account identity verification status (email confirmation, etc.). pub is_verified: bool, /// Temporarily disables account access while preserving data. @@ -54,8 +52,8 @@ pub struct Account { impl Account { /// An account row with a fresh unique handle and matching email, for tests. - /// Not admin/verified/suspended; timestamps are now. Useful to downstream - /// crates that need an `Account` without a database. + /// Not verified/suspended; timestamps are now. Useful to downstream crates + /// that need an `Account` without a database. #[cfg(any(feature = "test_util", test))] #[must_use] pub fn test() -> Self { @@ -64,7 +62,6 @@ impl Account { let now: Timestamp = jiff::Timestamp::now().into(); Self { id: Uuid::now_v7(), - is_admin: false, is_verified: false, is_suspended: false, username, @@ -142,8 +139,6 @@ pub struct UpdateAccount { pub timezone: Option, /// Preferred locale code. pub locale: Option, - /// Administrative privileges. - pub is_admin: Option, /// Account identity verification status. pub is_verified: Option, /// Account suspension status. diff --git a/crates/nvisy-postgres/src/model/mod.rs b/crates/nvisy-postgres/src/model/mod.rs index a6446538..eff366de 100644 --- a/crates/nvisy-postgres/src/model/mod.rs +++ b/crates/nvisy-postgres/src/model/mod.rs @@ -13,6 +13,7 @@ mod event_outbox; mod pipeline_reference; mod workspace; mod workspace_activity; +mod workspace_assignment; mod workspace_connection; mod workspace_connection_schedule; mod workspace_connection_sync; @@ -45,6 +46,9 @@ pub use pipeline_reference::PipelinePolicy; // Workspace models pub use workspace::{NewWorkspace, UpdateWorkspace, Workspace}; pub use workspace_activity::{NewWorkspaceActivity, WorkspaceActivity}; +pub use workspace_assignment::{ + NewWorkspaceAssignment, UpdateWorkspaceAssignment, WorkspaceAssignment, +}; pub use workspace_connection::{ NewWorkspaceConnection, UpdateWorkspaceConnection, WorkspaceConnection, }; diff --git a/crates/nvisy-postgres/src/model/workspace_assignment.rs b/crates/nvisy-postgres/src/model/workspace_assignment.rs new file mode 100644 index 00000000..b2846e81 --- /dev/null +++ b/crates/nvisy-postgres/src/model/workspace_assignment.rs @@ -0,0 +1,81 @@ +//! Workspace assignment model for PostgreSQL database operations. + +use diesel::prelude::*; +use jiff_diesel::Timestamp; +use uuid::Uuid; + +use crate::schema::workspace_assignments; +use crate::types::AssignmentStatus; + +/// An assignment: one reviewer's assignment of one file for redaction review. +/// +/// A file may be assigned to several reviewers at once (like GitHub assignees); +/// each reviewer's assignment is its own row with its own [`AssignmentStatus`]. +#[derive(Debug, Clone, PartialEq, Queryable, Selectable)] +#[diesel(table_name = workspace_assignments)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct WorkspaceAssignment { + /// Unique assignment identifier. + pub id: Uuid, + /// Workspace this assignment belongs to (denormalized for fast per-workspace + /// queries). + pub workspace_id: Uuid, + /// File under review. + pub file_id: Uuid, + /// Reviewer the file is assigned to. + pub assignee_account_id: Uuid, + /// Account that created the assignment, for the audit trail. `None` if that + /// account was since removed. + pub assigned_account_id: Option, + /// The reviewer's current review status for this file. + pub status: AssignmentStatus, + /// When the assignment was created. + pub created_at: Timestamp, + /// When the assignment was last updated. + pub updated_at: Timestamp, +} + +/// Data for creating a new workspace assignment. +#[derive(Debug, Default, Clone, Insertable)] +#[diesel(table_name = workspace_assignments)] +#[diesel(check_for_backend(diesel::pg::Pg))] +#[must_use] +pub struct NewWorkspaceAssignment { + /// Workspace ID (required). + pub workspace_id: Uuid, + /// File ID (required). + pub file_id: Uuid, + /// Reviewer the file is assigned to (required). + pub assignee_account_id: Uuid, + /// Account that created the assignment (the assigner). + pub assigned_account_id: Option, + /// Initial status. + pub status: Option, +} + +impl NewWorkspaceAssignment { + /// A minimal assignment of `file_id` to `assignee_account_id`, for tests. + /// The status takes its database default (`assigned`). + #[cfg(any(feature = "test_util", test))] + pub fn test(workspace_id: Uuid, file_id: Uuid, assignee_account_id: Uuid) -> Self { + Self { + workspace_id, + file_id, + assignee_account_id, + ..Default::default() + } + } +} + +/// Data for updating a workspace assignment. +/// +/// Only the review status is mutable: reassignment is remove-then-add, not a +/// field change. +#[derive(Debug, Clone, Default, AsChangeset)] +#[diesel(table_name = workspace_assignments)] +#[diesel(check_for_backend(diesel::pg::Pg))] +#[must_use] +pub struct UpdateWorkspaceAssignment { + /// The reviewer's review status for this file. + pub status: Option, +} diff --git a/crates/nvisy-postgres/src/query/mod.rs b/crates/nvisy-postgres/src/query/mod.rs index 1712aada..75725a6e 100644 --- a/crates/nvisy-postgres/src/query/mod.rs +++ b/crates/nvisy-postgres/src/query/mod.rs @@ -25,6 +25,7 @@ mod pipeline_reference; mod search; mod workspace; mod workspace_activity; +mod workspace_assignment; mod workspace_connection; mod workspace_connection_schedule; mod workspace_connection_sync; @@ -53,6 +54,9 @@ pub use event_outbox::EventOutboxRepository; pub use pipeline_reference::PipelineReferenceRepository; pub use workspace::WorkspaceRepository; pub use workspace_activity::{ActivityFilter, WorkspaceActivityRepository}; +pub use workspace_assignment::{ + AssignmentListRow, CreateAssignmentOutcome, WorkspaceAssignmentRepository, +}; pub use workspace_connection::{ScheduledConnection, WorkspaceConnectionRepository}; pub use workspace_connection_schedule::WorkspaceConnectionScheduleRepository; pub use workspace_connection_sync::WorkspaceConnectionSyncRepository; diff --git a/crates/nvisy-postgres/src/query/workspace_assignment.rs b/crates/nvisy-postgres/src/query/workspace_assignment.rs new file mode 100644 index 00000000..c07db316 --- /dev/null +++ b/crates/nvisy-postgres/src/query/workspace_assignment.rs @@ -0,0 +1,552 @@ +//! Workspace assignments repository for distributing file review work. + +use std::future::Future; + +use diesel::prelude::*; +use diesel_async::RunQueryDsl; +use uuid::Uuid; + +use crate::model::{NewWorkspaceAssignment, UpdateWorkspaceAssignment, WorkspaceAssignment}; +use crate::types::{ + AccountRefRow, AssignmentFilter, ConstraintViolation, CursorPage, CursorPagination, + WorkspaceAssignmentConstraints, +}; +use crate::{Error, PgConnection, Result, schema}; + +/// One assignment paired with the reviewer's account reference and the name of +/// the file under review. +/// +/// The file is LEFT-joined, so an assignment whose file was removed by retention +/// yields a `None` name rather than dropping the row. +#[derive(Debug, Clone, PartialEq)] +pub struct AssignmentListRow { + /// The assignment row. + pub assignment: WorkspaceAssignment, + /// The reviewer the file is assigned to. + pub assignee: AccountRefRow, + /// Display name of the file under review, when the file still exists. + pub file_name: Option, +} + +/// The result of a +/// [`create_workspace_assignment`](WorkspaceAssignmentRepository::create_workspace_assignment) +/// call. +#[derive(Debug, Clone, PartialEq)] +pub enum CreateAssignmentOutcome { + /// The assignment was created by this call. + Created(WorkspaceAssignment), + /// The reviewer is already assigned this file; the call is a no-op. + AlreadyAssigned, +} + +/// Repository for workspace assignment database operations. +/// +/// An assignment is one reviewer's assignment of one file; a file may have +/// several (like GitHub assignees). Assignments live in their own table rather +/// than as columns on the file so a file can carry many at once. +pub trait WorkspaceAssignmentRepository { + /// Assigns a file to a reviewer. + /// + /// A reviewer is assigned a given file at most once: a repeat is reported as + /// [`AlreadyAssigned`](CreateAssignmentOutcome::AlreadyAssigned) rather than + /// inserting a second row. + fn create_workspace_assignment( + &mut self, + new_assignment: NewWorkspaceAssignment, + ) -> impl Future> + Send; + + /// Finds an assignment by id within a specific workspace. + fn find_assignment_in_workspace( + &mut self, + workspace_id: Uuid, + assignment_id: Uuid, + ) -> impl Future>> + Send; + + /// Finds a reviewer's assignment on a file, if any — the `(file, assignee)` + /// unique row. + fn find_file_assignment_for_assignee( + &mut self, + workspace_id: Uuid, + file_id: Uuid, + assignee_account_id: Uuid, + ) -> impl Future>> + Send; + + /// Lists a file's assignments, each paired with the reviewer's account + /// reference ("who is assigned to this file"). + fn list_file_assignments( + &mut self, + workspace_id: Uuid, + file_id: Uuid, + ) -> impl Future>> + Send; + + /// Lists a workspace's assignments with cursor pagination, each paired with + /// the reviewer's account reference and the file name. + fn cursor_list_workspace_assignments( + &mut self, + workspace_id: Uuid, + pagination: CursorPagination, + filter: &AssignmentFilter, + ) -> impl Future>> + Send; + + /// Updates an assignment's review status. + fn update_workspace_assignment( + &mut self, + assignment_id: Uuid, + updates: UpdateWorkspaceAssignment, + ) -> impl Future> + Send; + + /// Deletes an assignment (unassigns the reviewer). + fn delete_workspace_assignment( + &mut self, + assignment_id: Uuid, + ) -> impl Future> + Send; +} + +impl WorkspaceAssignmentRepository for PgConnection { + async fn create_workspace_assignment( + &mut self, + new_assignment: NewWorkspaceAssignment, + ) -> Result { + use schema::workspace_assignments; + + let insert = diesel::insert_into(workspace_assignments::table) + .values(&new_assignment) + .returning(WorkspaceAssignment::as_returning()) + .get_result(self) + .await; + + match insert { + Ok(assignment) => Ok(CreateAssignmentOutcome::Created(assignment)), + Err(err) => { + let err = Error::from(err); + if matches!( + err.constraint_violation(), + Some(ConstraintViolation::WorkspaceAssignment( + WorkspaceAssignmentConstraints::FileAssigneeUnique + )) + ) { + Ok(CreateAssignmentOutcome::AlreadyAssigned) + } else { + Err(err) + } + } + } + } + + async fn find_assignment_in_workspace( + &mut self, + workspace_id: Uuid, + assignment_id: Uuid, + ) -> Result> { + use schema::workspace_assignments::{self, dsl}; + + workspace_assignments::table + .filter(dsl::id.eq(assignment_id)) + .filter(dsl::workspace_id.eq(workspace_id)) + .select(WorkspaceAssignment::as_select()) + .first(self) + .await + .optional() + .map_err(Error::from) + } + + async fn find_file_assignment_for_assignee( + &mut self, + workspace_id: Uuid, + file_id: Uuid, + assignee_account_id: Uuid, + ) -> Result> { + use schema::workspace_assignments::{self, dsl}; + + // Matches the `(file_id, assignee_account_id)` unique key; the workspace + // filter keeps the lookup scoped even though the pair is already unique. + workspace_assignments::table + .filter(dsl::workspace_id.eq(workspace_id)) + .filter(dsl::file_id.eq(file_id)) + .filter(dsl::assignee_account_id.eq(assignee_account_id)) + .select(WorkspaceAssignment::as_select()) + .first(self) + .await + .optional() + .map_err(Error::from) + } + + async fn list_file_assignments( + &mut self, + workspace_id: Uuid, + file_id: Uuid, + ) -> Result> { + use schema::workspace_assignments::dsl; + use schema::{accounts, workspace_assignments, workspace_files}; + + // The assignee is one of two account FKs on the row, so the join names the + // column explicitly rather than relying on an inferred `joinable!`. + let rows: Vec<(WorkspaceAssignment, AccountRefRow, Option)> = + workspace_assignments::table + .inner_join(accounts::table.on(dsl::assignee_account_id.eq(accounts::id))) + .left_join(workspace_files::table.on(dsl::file_id.eq(workspace_files::id))) + .filter(dsl::workspace_id.eq(workspace_id)) + .filter(dsl::file_id.eq(file_id)) + .select(( + WorkspaceAssignment::as_select(), + ( + accounts::username, + accounts::display_name, + accounts::avatar_url, + ), + workspace_files::display_name.nullable(), + )) + .order((dsl::created_at.desc(), dsl::id.desc())) + .load(self) + .await + .map_err(Error::from)?; + + Ok(rows + .into_iter() + .map(|(assignment, assignee, file_name)| AssignmentListRow { + assignment, + assignee, + file_name, + }) + .collect()) + } + + async fn cursor_list_workspace_assignments( + &mut self, + workspace_id: Uuid, + pagination: CursorPagination, + filter: &AssignmentFilter, + ) -> Result> { + use schema::workspace_assignments::dsl; + use schema::{accounts, workspace_assignments, workspace_files}; + + // One scoped builder for both the count and the page, so a future filter + // cannot be added to one and forgotten on the other. The assignee is one + // of two account FKs, so the join names it explicitly; the file is + // LEFT-joined so a removed file yields a null name rather than dropping + // the row. + let scoped = || { + let mut query = workspace_assignments::table + .inner_join(accounts::table.on(dsl::assignee_account_id.eq(accounts::id))) + .left_join(workspace_files::table.on(dsl::file_id.eq(workspace_files::id))) + .filter(dsl::workspace_id.eq(workspace_id)) + .into_boxed(); + if let Some(assignee_account_id) = filter.assignee_account_id { + query = query.filter(dsl::assignee_account_id.eq(assignee_account_id)); + } + if let Some(status) = filter.status { + query = query.filter(dsl::status.eq(status)); + } + if let Some(file_id) = filter.file_id { + query = query.filter(dsl::file_id.eq(file_id)); + } + query + }; + + let total = if pagination.include_count { + Some( + scoped() + .count() + .get_result::(self) + .await + .map_err(Error::from)?, + ) + } else { + None + }; + + let query = scoped(); + let limit = pagination.fetch_limit(); + let selection = ( + WorkspaceAssignment::as_select(), + ( + accounts::username, + accounts::display_name, + accounts::avatar_url, + ), + workspace_files::display_name.nullable(), + ); + + let rows: Vec<(WorkspaceAssignment, AccountRefRow, Option)> = + if let Some(cursor) = &pagination.after { + let cursor_time = jiff_diesel::Timestamp::from(cursor.timestamp); + + query + .filter( + dsl::created_at + .lt(&cursor_time) + .or(dsl::created_at.eq(&cursor_time).and(dsl::id.lt(cursor.id))), + ) + .select(selection) + .order((dsl::created_at.desc(), dsl::id.desc())) + .limit(limit) + .load(self) + .await + .map_err(Error::from)? + } else { + query + .select(selection) + .order((dsl::created_at.desc(), dsl::id.desc())) + .limit(limit) + .load(self) + .await + .map_err(Error::from)? + }; + + let items = rows + .into_iter() + .map(|(assignment, assignee, file_name)| AssignmentListRow { + assignment, + assignee, + file_name, + }) + .collect(); + + Ok(CursorPage::new(items, total, pagination.limit, |row| { + (row.assignment.created_at.into(), row.assignment.id) + })) + } + + async fn update_workspace_assignment( + &mut self, + assignment_id: Uuid, + updates: UpdateWorkspaceAssignment, + ) -> Result { + use schema::workspace_assignments::{self, dsl}; + + // An all-`None` changeset would make Diesel emit an empty `SET`, which + // Postgres rejects as a syntax error. Reject it up front so a caller with + // nothing to change gets a clear error rather than a raw SQL failure. + if updates.status.is_none() { + return Err(Error::unexpected( + "update_workspace_assignment called with no fields to update", + )); + } + + diesel::update(workspace_assignments::table.filter(dsl::id.eq(assignment_id))) + .set(&updates) + .returning(WorkspaceAssignment::as_returning()) + .get_result(self) + .await + .map_err(Error::from) + } + + async fn delete_workspace_assignment(&mut self, assignment_id: Uuid) -> Result<()> { + use schema::workspace_assignments::{self, dsl}; + + diesel::delete(workspace_assignments::table.filter(dsl::id.eq(assignment_id))) + .execute(self) + .await + .map_err(Error::from)?; + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::{CreateAssignmentOutcome, WorkspaceAssignmentRepository}; + use crate::model::{ + NewAccount, NewWorkspaceAssignment, NewWorkspaceFile, UpdateWorkspaceAssignment, + }; + use crate::query::{AccountRepository, WorkspaceFileRepository}; + use crate::test_util::TestDatabase; + use crate::types::{AssignmentFilter, AssignmentStatus, CursorPagination}; + + #[tokio::test] + async fn create_is_idempotent_per_file_and_reviewer() -> anyhow::Result<()> { + let db = TestDatabase::start().await; + let (_assigner, workspace_id, _pipeline_id, file_id) = db.seed_pipeline_and_file().await; + let reviewer = db.seed_account().await; + let mut conn = db.client.get_connection().await?; + + let created = conn + .create_workspace_assignment(NewWorkspaceAssignment::test( + workspace_id, + file_id, + reviewer, + )) + .await?; + assert!(matches!(created, CreateAssignmentOutcome::Created(_))); + + // Assigning the same reviewer the same file again is a reported no-op, not + // a second row. + let again = conn + .create_workspace_assignment(NewWorkspaceAssignment::test( + workspace_id, + file_id, + reviewer, + )) + .await?; + assert_eq!(again, CreateAssignmentOutcome::AlreadyAssigned); + + let file_rows = conn.list_file_assignments(workspace_id, file_id).await?; + assert_eq!(file_rows.len(), 1); + assert_eq!(file_rows[0].assignment.assignee_account_id, reviewer); + + // The targeted (file, assignee) lookup finds the same row, and returns + // None for a reviewer who has no assignment on the file. + let found = conn + .find_file_assignment_for_assignee(workspace_id, file_id, reviewer) + .await?; + assert_eq!(found.map(|a| a.assignee_account_id), Some(reviewer)); + let other = db.seed_account().await; + assert!( + conn.find_file_assignment_for_assignee(workspace_id, file_id, other) + .await? + .is_none() + ); + Ok(()) + } + + #[tokio::test] + async fn status_update_and_delete_round_trip() -> anyhow::Result<()> { + let db = TestDatabase::start().await; + let (_assigner, workspace_id, _pipeline_id, file_id) = db.seed_pipeline_and_file().await; + let reviewer = db.seed_account().await; + let mut conn = db.client.get_connection().await?; + + let CreateAssignmentOutcome::Created(assignment) = conn + .create_workspace_assignment(NewWorkspaceAssignment::test( + workspace_id, + file_id, + reviewer, + )) + .await? + else { + panic!("expected a fresh assignment"); + }; + assert_eq!(assignment.status, AssignmentStatus::Assigned); + + let updated = conn + .update_workspace_assignment( + assignment.id, + UpdateWorkspaceAssignment { + status: Some(AssignmentStatus::Done), + }, + ) + .await?; + assert_eq!(updated.status, AssignmentStatus::Done); + + conn.delete_workspace_assignment(assignment.id).await?; + assert!( + conn.find_assignment_in_workspace(workspace_id, assignment.id) + .await? + .is_none() + ); + Ok(()) + } + + #[tokio::test] + async fn cursor_list_filters_by_assignee_and_status() -> anyhow::Result<()> { + let db = TestDatabase::start().await; + let (_assigner, workspace_id, _pipeline_id, file_id) = db.seed_pipeline_and_file().await; + let alice = db.seed_account().await; + let bob = db.seed_account().await; + let mut conn = db.client.get_connection().await?; + + for reviewer in [alice, bob] { + let _ = conn + .create_workspace_assignment(NewWorkspaceAssignment::test( + workspace_id, + file_id, + reviewer, + )) + .await?; + } + + // No filter: both reviewers' assignments. + let all = conn + .cursor_list_workspace_assignments( + workspace_id, + CursorPagination::new(50), + &AssignmentFilter::default(), + ) + .await?; + assert_eq!(all.items.len(), 2); + + // Filter to one reviewer. + let just_alice = conn + .cursor_list_workspace_assignments( + workspace_id, + CursorPagination::new(50), + &AssignmentFilter { + assignee_account_id: Some(alice), + ..Default::default() + }, + ) + .await?; + assert_eq!(just_alice.items.len(), 1); + assert_eq!(just_alice.items[0].assignment.assignee_account_id, alice); + + // A status no assignment holds returns nothing. + let none = conn + .cursor_list_workspace_assignments( + workspace_id, + CursorPagination::new(50), + &AssignmentFilter { + status: Some(AssignmentStatus::Done), + ..Default::default() + }, + ) + .await?; + assert!(none.items.is_empty()); + Ok(()) + } + + #[tokio::test] + async fn assigner_attribution_round_trips() -> anyhow::Result<()> { + let db = TestDatabase::start().await; + let (assigner, workspace_id, _pipeline_id, file_id) = db.seed_pipeline_and_file().await; + let reviewer = db.seed_account().await; + let mut conn = db.client.get_connection().await?; + + // `assigned_account_id` records who made the assignment, distinct from the + // assignee it is made to. + let CreateAssignmentOutcome::Created(assignment) = conn + .create_workspace_assignment(NewWorkspaceAssignment { + assigned_account_id: Some(assigner), + ..NewWorkspaceAssignment::test(workspace_id, file_id, reviewer) + }) + .await? + else { + panic!("expected a fresh assignment"); + }; + assert_eq!(assignment.assigned_account_id, Some(assigner)); + assert_eq!(assignment.assignee_account_id, reviewer); + Ok(()) + } + + #[tokio::test] + async fn list_file_assignments_joins_assignee_and_file_name() -> anyhow::Result<()> { + let db = TestDatabase::start().await; + let (assigner, workspace_id) = db.seed_account_and_workspace().await; + let mut conn = db.client.get_connection().await?; + + // A named reviewer and a named file, so the joins have distinct values to + // return. + let reviewer = conn.create_account(NewAccount::test()).await?; + let file = conn + .create_workspace_file(NewWorkspaceFile { + display_name: Some("quarterly-report.pdf".to_owned()), + ..NewWorkspaceFile::test(workspace_id, assigner) + }) + .await?; + + let _ = conn + .create_workspace_assignment(NewWorkspaceAssignment::test( + workspace_id, + file.id, + reviewer.id, + )) + .await?; + + let rows = conn.list_file_assignments(workspace_id, file.id).await?; + assert_eq!(rows.len(), 1); + // The assignee join names the reviewer, not the assigner. + assert_eq!(rows[0].assignee.username, reviewer.username); + // The file join names the file under review. + assert_eq!(rows[0].file_name.as_deref(), Some("quarterly-report.pdf")); + Ok(()) + } +} diff --git a/crates/nvisy-postgres/src/schema.rs b/crates/nvisy-postgres/src/schema.rs index 5051aa97..37a26a46 100644 --- a/crates/nvisy-postgres/src/schema.rs +++ b/crates/nvisy-postgres/src/schema.rs @@ -9,6 +9,10 @@ pub mod sql_types { #[diesel(postgres_type(name = "api_token_type"))] pub struct ApiTokenType; + #[derive(diesel::query_builder::QueryId, diesel::sql_types::SqlType)] + #[diesel(postgres_type(name = "assignment_status"))] + pub struct AssignmentStatus; + #[derive(diesel::query_builder::QueryId, diesel::sql_types::SqlType)] #[diesel(postgres_type(name = "chat_role"))] pub struct ChatRole; @@ -137,7 +141,6 @@ diesel::table! { accounts (id) { id -> Uuid, - is_admin -> Bool, is_verified -> Bool, is_suspended -> Bool, username -> Text, @@ -217,6 +220,22 @@ diesel::table! { } } +diesel::table! { + use diesel::sql_types::*; + use super::sql_types::AssignmentStatus; + + workspace_assignments (id) { + id -> Uuid, + workspace_id -> Uuid, + file_id -> Uuid, + assignee_account_id -> Uuid, + assigned_account_id -> Nullable, + status -> AssignmentStatus, + created_at -> Timestamptz, + updated_at -> Timestamptz, + } +} + diesel::table! { use diesel::sql_types::*; use super::sql_types::SyncMode; @@ -545,6 +564,7 @@ diesel::joinable!(event_outbox -> accounts (account_id)); diesel::joinable!(event_outbox -> workspaces (workspace_id)); diesel::joinable!(workspace_activities -> accounts (account_id)); diesel::joinable!(workspace_activities -> workspaces (workspace_id)); +diesel::joinable!(workspace_assignments -> workspaces (workspace_id)); diesel::joinable!(workspace_connection_schedule -> workspace_connections (connection_id)); diesel::joinable!(workspace_connection_syncs -> accounts (account_id)); diesel::joinable!(workspace_connection_syncs -> workspace_connections (connection_id)); @@ -584,6 +604,7 @@ diesel::allow_tables_to_appear_in_same_query!( chat_sessions, event_outbox, workspace_activities, + workspace_assignments, workspace_connection_schedule, workspace_connection_syncs, workspace_connections, diff --git a/crates/nvisy-postgres/src/types/constraint/assignments.rs b/crates/nvisy-postgres/src/types/constraint/assignments.rs new file mode 100644 index 00000000..855e324e --- /dev/null +++ b/crates/nvisy-postgres/src/types/constraint/assignments.rs @@ -0,0 +1,11 @@ +//! Workspace assignments table constraint violations. + +use strum::EnumString; + +/// Workspace assignments table constraint violations. +#[derive(Debug, Clone, Copy, Eq, PartialEq, EnumString)] +pub enum WorkspaceAssignmentConstraints { + /// A reviewer is assigned a given file at most once. + #[strum(serialize = "workspace_assignments_file_assignee_key")] + FileAssigneeUnique, +} diff --git a/crates/nvisy-postgres/src/types/constraint/mod.rs b/crates/nvisy-postgres/src/types/constraint/mod.rs index 7f51d723..b5e66def 100644 --- a/crates/nvisy-postgres/src/types/constraint/mod.rs +++ b/crates/nvisy-postgres/src/types/constraint/mod.rs @@ -28,6 +28,9 @@ mod detections; mod pipeline_references; mod pipelines; +// Assignment-related constraint modules +mod assignments; + mod workspace_connection_syncs; mod workspace_connections; mod workspace_policies; @@ -36,6 +39,7 @@ pub use self::account_api_tokens::AccountApiTokenConstraints; pub use self::account_identities::AccountIdentityConstraints; pub use self::account_notifications::AccountNotificationConstraints; pub use self::accounts::AccountConstraints; +pub use self::assignments::WorkspaceAssignmentConstraints; pub use self::chat_messages::ChatMessageConstraints; pub use self::chat_sessions::ChatSessionConstraints; pub use self::detections::WorkspaceDetectionConstraints; @@ -78,6 +82,9 @@ pub enum ConstraintViolation { // File-related constraints WorkspaceFile(WorkspaceFileConstraints), + // Assignment-related constraints + WorkspaceAssignment(WorkspaceAssignmentConstraints), + // Detection / pipeline-related constraints WorkspacePipeline(WorkspacePipelineConstraints), WorkspaceDetection(WorkspaceDetectionConstraints), @@ -135,6 +142,7 @@ impl ConstraintViolation { WorkspaceActivityLog, WorkspaceWebhook, WorkspaceFile, + WorkspaceAssignment, WorkspacePipeline, WorkspaceDetection, WorkspacePipelineReference, diff --git a/crates/nvisy-postgres/src/types/enums/activity_type.rs b/crates/nvisy-postgres/src/types/enums/activity_type.rs index 6ba01097..93dd81e7 100644 --- a/crates/nvisy-postgres/src/types/enums/activity_type.rs +++ b/crates/nvisy-postgres/src/types/enums/activity_type.rs @@ -58,6 +58,12 @@ db_enum! { FileUpdated = "file.updated", /// File was deleted. FileDeleted = "file.deleted", + /// File was assigned to a reviewer. + FileAssigned = "file.assigned", + /// A reviewer was unassigned from a file. + FileUnassigned = "file.unassigned", + /// A file assignment's review status changed. + AssignmentStatusChanged = "file.assignment.updated", /// Pipeline was created. PipelineCreated = "pipeline.created", /// Pipeline was updated. diff --git a/crates/nvisy-postgres/src/types/enums/assignment_status.rs b/crates/nvisy-postgres/src/types/enums/assignment_status.rs new file mode 100644 index 00000000..135dab50 --- /dev/null +++ b/crates/nvisy-postgres/src/types/enums/assignment_status.rs @@ -0,0 +1,46 @@ +//! Assignment status enumeration indicating one reviewer's review-workflow state. + +use super::db_enum; + +db_enum! { + /// The review-workflow status of one reviewer's assignment on a file. + /// + /// Corresponds to the `ASSIGNMENT_STATUS` PostgreSQL enum. A file may be + /// assigned to several reviewers at once (like GitHub assignees); each + /// reviewer's assignment carries its own status. This is the human + /// review-workflow axis and is independent of a detection's execution status, + /// which is driven by the analysis worker. + pub enum AssignmentStatus: Default = Assigned, "crate::schema::sql_types::AssignmentStatus" { + /// Assigned to the reviewer; not yet started. + Assigned = "assigned", + /// The reviewer has started reviewing. + InReview = "in_review", + /// The reviewer has finished their review. + Done = "done", + } +} + +impl AssignmentStatus { + /// Returns whether the reviewer has finished their review. + #[inline] + pub fn is_done(self) -> bool { + matches!(self, AssignmentStatus::Done) + } +} + +#[cfg(test)] +mod tests { + use super::AssignmentStatus::{self, Assigned, Done, InReview}; + + #[test] + fn default_is_assigned() { + assert_eq!(AssignmentStatus::default(), Assigned); + } + + #[test] + fn is_done_only_for_done() { + assert!(!Assigned.is_done()); + assert!(!InReview.is_done()); + assert!(Done.is_done()); + } +} diff --git a/crates/nvisy-postgres/src/types/enums/mod.rs b/crates/nvisy-postgres/src/types/enums/mod.rs index db7164f7..392b92b7 100644 --- a/crates/nvisy-postgres/src/types/enums/mod.rs +++ b/crates/nvisy-postgres/src/types/enums/mod.rs @@ -36,8 +36,12 @@ pub mod detection_status; pub mod pipeline_status; pub mod pipeline_trigger_type; +// Assignment-related enumerations +pub mod assignment_status; + pub use activity_type::ActivityType; pub use api_token_type::ApiTokenType; +pub use assignment_status::AssignmentStatus; pub use chat_role::ChatRole; pub use connection_type::ConnectionType; pub use detection_status::DetectionStatus; diff --git a/crates/nvisy-postgres/src/types/enums/notification_event.rs b/crates/nvisy-postgres/src/types/enums/notification_event.rs index 0c112050..d41fc9bf 100644 --- a/crates/nvisy-postgres/src/types/enums/notification_event.rs +++ b/crates/nvisy-postgres/src/types/enums/notification_event.rs @@ -10,8 +10,6 @@ db_enum! { /// The values mirror the [`WebhookEvent`](super::WebhookEvent) naming for the /// events the two channels share. pub enum NotificationEvent = "crate::schema::sql_types::NotificationEvent" { - /// User was invited to a workspace. - MemberInvited = "member.invited", /// A new member joined a workspace. MemberJoined = "member.joined", /// A connection sync completed. @@ -24,5 +22,9 @@ db_enum! { RedactionCreated = "pipeline.redaction.created", /// A detection failed. DetectionFailed = "pipeline.detection.failed", + /// A file was assigned to the reviewer. + FileAssigned = "file.assigned", + /// The reviewer was unassigned from a file. + FileUnassigned = "file.unassigned", } } diff --git a/crates/nvisy-postgres/src/types/enums/webhook_event.rs b/crates/nvisy-postgres/src/types/enums/webhook_event.rs index 95e7f287..8a44361b 100644 --- a/crates/nvisy-postgres/src/types/enums/webhook_event.rs +++ b/crates/nvisy-postgres/src/types/enums/webhook_event.rs @@ -38,6 +38,12 @@ db_enum! { ProviderUpdated = "provider.updated", /// A provider was deleted. ProviderDeleted = "provider.deleted", + /// A file was assigned to a reviewer. + FileAssigned = "file.assigned", + /// A reviewer was unassigned from a file. + FileUnassigned = "file.unassigned", + /// A file assignment's review status changed. + AssignmentStatusChanged = "file.assignment.updated", /// A pipeline was created. PipelineCreated = "pipeline.created", /// A pipeline was updated. @@ -65,9 +71,12 @@ impl WebhookEvent { /// Returns the event category as a string. pub fn category(&self) -> &'static str { match self { - WebhookEvent::FileCreated | WebhookEvent::FileUpdated | WebhookEvent::FileDeleted => { - "file" - } + WebhookEvent::FileCreated + | WebhookEvent::FileUpdated + | WebhookEvent::FileDeleted + | WebhookEvent::FileAssigned + | WebhookEvent::FileUnassigned + | WebhookEvent::AssignmentStatusChanged => "file", WebhookEvent::MemberAdded | WebhookEvent::MemberDeleted | WebhookEvent::MemberUpdated => "member", diff --git a/crates/nvisy-postgres/src/types/filtering/assignments.rs b/crates/nvisy-postgres/src/types/filtering/assignments.rs new file mode 100644 index 00000000..a7c51a40 --- /dev/null +++ b/crates/nvisy-postgres/src/types/filtering/assignments.rs @@ -0,0 +1,25 @@ +//! Filtering options for assignment queries. + +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::types::AssignmentStatus; + +/// Filter options for workspace assignments. +/// +/// Each field narrows the result when set; unset fields impose no constraint. +/// The workspace scope is applied by the query itself, not carried here. +#[derive(Debug, Default, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase")] +pub struct AssignmentFilter { + /// Filter by the reviewer the file is assigned to. + #[serde(skip_serializing_if = "Option::is_none")] + pub assignee_account_id: Option, + /// Filter by review status. + #[serde(skip_serializing_if = "Option::is_none")] + pub status: Option, + /// Filter by the file under review. + #[serde(skip_serializing_if = "Option::is_none")] + pub file_id: Option, +} diff --git a/crates/nvisy-postgres/src/types/filtering/mod.rs b/crates/nvisy-postgres/src/types/filtering/mod.rs index 106ee3b8..7df9322e 100644 --- a/crates/nvisy-postgres/src/types/filtering/mod.rs +++ b/crates/nvisy-postgres/src/types/filtering/mod.rs @@ -1,10 +1,12 @@ //! Filtering options for database queries. +mod assignments; mod detections; mod files; mod invites; mod members; +pub use assignments::AssignmentFilter; pub use detections::DetectionFilter; pub use files::FileFilter; pub use invites::InviteFilter; diff --git a/crates/nvisy-postgres/src/types/json/activity_params.rs b/crates/nvisy-postgres/src/types/json/activity_params.rs index 79c9b739..532d6e36 100644 --- a/crates/nvisy-postgres/src/types/json/activity_params.rs +++ b/crates/nvisy-postgres/src/types/json/activity_params.rs @@ -10,8 +10,8 @@ use serde::{Deserialize, Serialize}; use uuid::Uuid; use crate::types::{ - ActivityType, ConnectionId, DetectionId, Handle, ProviderId, RedactionId, WebhookEvent, - WebhookId, + ActivityType, AssignmentStatus, ConnectionId, DetectionId, Handle, ProviderId, RedactionId, + WebhookEvent, WebhookId, }; /// Params of a workspace-scoped activity (`workspace.*`). @@ -89,6 +89,24 @@ pub struct FileActivityParams { pub file_name: String, } +/// Params of a file-assignment activity (`file.assigned`, `file.unassigned`, +/// `file.assignment.updated`). +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase")] +pub struct AssignmentActivityParams { + /// Id of the assignment. + pub assignment_id: Uuid, + /// Display name of the file under review, when it still exists. `None` (and + /// omitted) if the file was removed (e.g. by retention). + #[serde(skip_serializing_if = "Option::is_none")] + pub file_name: Option, + /// Username of the reviewer the file is assigned to. + pub assignee_username: Handle, + /// The reviewer's review status at the time of the activity. + pub status: AssignmentStatus, +} + /// Params of a pipeline activity (`pipeline.*`, non-run). #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] @@ -223,6 +241,15 @@ pub enum ActivityPayload { /// A file was deleted. #[serde(rename = "file.deleted")] FileDeleted(FileActivityParams), + /// A file was assigned to a reviewer. + #[serde(rename = "file.assigned")] + FileAssigned(AssignmentActivityParams), + /// A reviewer was unassigned from a file. + #[serde(rename = "file.unassigned")] + FileUnassigned(AssignmentActivityParams), + /// A file assignment's review status changed. + #[serde(rename = "file.assignment.updated")] + AssignmentStatusChanged(AssignmentActivityParams), /// A pipeline was created. #[serde(rename = "pipeline.created")] @@ -287,6 +314,9 @@ impl ActivityPayload { ActivityPayload::FileCreated(_) => ActivityType::FileCreated, ActivityPayload::FileUpdated(_) => ActivityType::FileUpdated, ActivityPayload::FileDeleted(_) => ActivityType::FileDeleted, + ActivityPayload::FileAssigned(_) => ActivityType::FileAssigned, + ActivityPayload::FileUnassigned(_) => ActivityType::FileUnassigned, + ActivityPayload::AssignmentStatusChanged(_) => ActivityType::AssignmentStatusChanged, ActivityPayload::PipelineCreated(_) => ActivityType::PipelineCreated, ActivityPayload::PipelineUpdated(_) => ActivityType::PipelineUpdated, ActivityPayload::PipelineDeleted(_) => ActivityType::PipelineDeleted, @@ -332,6 +362,9 @@ impl ActivityPayload { ActivityPayload::FileCreated(_) => W::FileCreated, ActivityPayload::FileUpdated(_) => W::FileUpdated, ActivityPayload::FileDeleted(_) => W::FileDeleted, + ActivityPayload::FileAssigned(_) => W::FileAssigned, + ActivityPayload::FileUnassigned(_) => W::FileUnassigned, + ActivityPayload::AssignmentStatusChanged(_) => W::AssignmentStatusChanged, ActivityPayload::PipelineCreated(_) => W::PipelineCreated, ActivityPayload::PipelineUpdated(_) => W::PipelineUpdated, ActivityPayload::PipelineDeleted(_) => W::PipelineDeleted, @@ -378,6 +411,10 @@ impl ActivityPayload { | ActivityPayload::FileUpdated(p) | ActivityPayload::FileDeleted(p) => Some(p.file_id.to_string()), + ActivityPayload::FileAssigned(p) + | ActivityPayload::FileUnassigned(p) + | ActivityPayload::AssignmentStatusChanged(p) => Some(p.assignment_id.to_string()), + ActivityPayload::DetectionStarted(p) | ActivityPayload::DetectionCompleted(p) | ActivityPayload::DetectionFailed(p) => Some(p.detection_id.to_string()), @@ -422,6 +459,10 @@ impl ActivityPayload { | ActivityPayload::FileUpdated(p) | ActivityPayload::FileDeleted(p) => Some(p.file_name.clone()), + ActivityPayload::FileAssigned(p) + | ActivityPayload::FileUnassigned(p) + | ActivityPayload::AssignmentStatusChanged(p) => p.file_name.clone(), + ActivityPayload::PipelineCreated(p) | ActivityPayload::PipelineUpdated(p) | ActivityPayload::PipelineDeleted(p) => Some(p.pipeline_slug.to_string()), diff --git a/crates/nvisy-postgres/src/types/json/mod.rs b/crates/nvisy-postgres/src/types/json/mod.rs index c5df4e4c..0a9da15f 100644 --- a/crates/nvisy-postgres/src/types/json/mod.rs +++ b/crates/nvisy-postgres/src/types/json/mod.rs @@ -15,16 +15,16 @@ mod workspace_metadata; mod workspace_settings; pub use activity_params::{ - ActivityPayload, ConnectionActivityParams, DetectionActivityParams, FileActivityParams, - InviteActivityParams, MemberActivityParams, PipelineActivityParams, PolicyActivityParams, - ProviderActivityParams, RedactionActivityParams, WebhookActivityParams, + ActivityPayload, AssignmentActivityParams, ConnectionActivityParams, DetectionActivityParams, + FileActivityParams, InviteActivityParams, MemberActivityParams, PipelineActivityParams, + PolicyActivityParams, ProviderActivityParams, RedactionActivityParams, WebhookActivityParams, WorkspaceActivityParams, }; pub use detection_metadata::DetectionMetadata; pub use notification_params::{ ConnectionSyncCompletedParams, ConnectionSyncFailedParams, DetectionCompletedParams, - DetectionFailedParams, MemberInvitedParams, MemberJoinedParams, NotificationPayload, - RedactionCreatedParams, + DetectionFailedParams, FileAssignedParams, FileUnassignedParams, MemberJoinedParams, + NotificationPayload, RedactionCreatedParams, }; pub use pipeline_metadata::{PipelineMetadata, RetentionOverride}; pub use retention::{Retention, RetentionScope, RetentionSettings}; diff --git a/crates/nvisy-postgres/src/types/json/notification_params.rs b/crates/nvisy-postgres/src/types/json/notification_params.rs index 482ce3e6..a612fee8 100644 --- a/crates/nvisy-postgres/src/types/json/notification_params.rs +++ b/crates/nvisy-postgres/src/types/json/notification_params.rs @@ -6,22 +6,11 @@ //! text is stored; the client localizes copy from `type` and the params. use serde::{Deserialize, Serialize}; +use uuid::Uuid; use super::Json; use crate::types::{ConnectionId, DetectionId, Handle, NotificationEvent, RedactionId}; -/// Params of a `member.invited` notification. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] -#[serde(rename_all = "camelCase")] -pub struct MemberInvitedParams { - /// Slug of the workspace the account was invited to. - pub workspace_slug: Handle, - /// Username of the account that sent the invite, if known. - #[serde(skip_serializing_if = "Option::is_none")] - pub invited_by: Option, -} - /// Params of a `member.joined` notification. #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] @@ -108,6 +97,32 @@ pub struct DetectionFailedParams { pub error: Option, } +/// Params of a `file.assigned` notification, sent to the reviewer. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase")] +pub struct FileAssignedParams { + /// Id of the assignment. + pub assignment_id: Uuid, + /// Id of the file the reviewer was assigned. + pub file_id: Uuid, + /// Display name of the file the reviewer was assigned. + pub file_name: String, +} + +/// Params of a `file.unassigned` notification, sent to the former reviewer. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase")] +pub struct FileUnassignedParams { + /// Id of the file the reviewer was unassigned from. + pub file_id: Uuid, + /// Display name of the file the reviewer was unassigned from, when the file + /// still exists. `None` (and omitted) if it was removed (e.g. by retention). + #[serde(skip_serializing_if = "Option::is_none")] + pub file_name: Option, +} + /// The typed payload of a notification, tagged by `type` with its params under /// `data` (the same `{type, data}` envelope the activity log and outbox event use). /// @@ -118,10 +133,6 @@ pub struct DetectionFailedParams { #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] #[serde(tag = "type", content = "data")] pub enum NotificationPayload { - /// The account was invited to a workspace. - #[serde(rename = "member.invited")] - MemberInvited(MemberInvitedParams), - /// A new member joined a workspace. #[serde(rename = "member.joined")] MemberJoined(MemberJoinedParams), @@ -145,13 +156,20 @@ pub enum NotificationPayload { /// A detection failed. #[serde(rename = "pipeline.detection.failed")] DetectionFailed(DetectionFailedParams), + + /// A file was assigned to the reviewer for review. + #[serde(rename = "file.assigned")] + FileAssigned(FileAssignedParams), + + /// The reviewer was unassigned from a file. + #[serde(rename = "file.unassigned")] + FileUnassigned(FileUnassignedParams), } impl NotificationPayload { /// The [`NotificationEvent`] this payload is for (its `type` tag). pub fn event(&self) -> NotificationEvent { match self { - NotificationPayload::MemberInvited(_) => NotificationEvent::MemberInvited, NotificationPayload::MemberJoined(_) => NotificationEvent::MemberJoined, NotificationPayload::ConnectionSyncCompleted(_) => { NotificationEvent::ConnectionSyncCompleted @@ -160,6 +178,8 @@ impl NotificationPayload { NotificationPayload::DetectionCompleted(_) => NotificationEvent::DetectionCompleted, NotificationPayload::RedactionCreated(_) => NotificationEvent::RedactionCreated, NotificationPayload::DetectionFailed(_) => NotificationEvent::DetectionFailed, + NotificationPayload::FileAssigned(_) => NotificationEvent::FileAssigned, + NotificationPayload::FileUnassigned(_) => NotificationEvent::FileUnassigned, } } diff --git a/crates/nvisy-postgres/src/types/mod.rs b/crates/nvisy-postgres/src/types/mod.rs index 78bebefb..d71c878f 100644 --- a/crates/nvisy-postgres/src/types/mod.rs +++ b/crates/nvisy-postgres/src/types/mod.rs @@ -13,29 +13,30 @@ mod utilities; pub use constraint::{ AccountApiTokenConstraints, AccountConstraints, AccountIdentityConstraints, AccountNotificationConstraints, ChatMessageConstraints, ChatSessionConstraints, - ConstraintViolation, WorkspaceActivitiesConstraints, WorkspaceConnectionConstraints, - WorkspaceConnectionSyncConstraints, WorkspaceConstraints, WorkspaceDetectionConstraints, - WorkspaceFileConstraints, WorkspaceInviteConstraints, WorkspaceMemberConstraints, - WorkspacePipelineConstraints, WorkspacePipelineReferenceConstraints, - WorkspacePolicyConstraints, WorkspaceWebhookConstraints, + ConstraintViolation, WorkspaceActivitiesConstraints, WorkspaceAssignmentConstraints, + WorkspaceConnectionConstraints, WorkspaceConnectionSyncConstraints, WorkspaceConstraints, + WorkspaceDetectionConstraints, WorkspaceFileConstraints, WorkspaceInviteConstraints, + WorkspaceMemberConstraints, WorkspacePipelineConstraints, + WorkspacePipelineReferenceConstraints, WorkspacePolicyConstraints, WorkspaceWebhookConstraints, }; pub use enums::{ - ActivityType, ApiTokenType, ChatRole, ConnectionType, DetectionStatus, FileKind, - IdentityProvider, InviteStatus, NotificationEvent, OutboxStatus, PipelineStatus, + ActivityType, ApiTokenType, AssignmentStatus, ChatRole, ConnectionType, DetectionStatus, + FileKind, IdentityProvider, InviteStatus, NotificationEvent, OutboxStatus, PipelineStatus, PipelineTriggerType, ProviderType, SyncDeletionPolicy, SyncMode, SyncStatus, SyncTriggerType, WebhookEvent, WebhookStatus, WorkspaceRole, }; -pub use filtering::{DetectionFilter, FileFilter, InviteFilter, MemberFilter}; +pub use filtering::{AssignmentFilter, DetectionFilter, FileFilter, InviteFilter, MemberFilter}; pub use handle::{HANDLE_MAX_LENGTH, HANDLE_MIN_LENGTH, Handle, HandleError}; pub use json::{ - ActivityPayload, ConnectionActivityParams, ConnectionSyncCompletedParams, - ConnectionSyncFailedParams, DetectionActivityParams, DetectionCompletedParams, - DetectionFailedParams, DetectionMetadata, FileActivityParams, InvalidHeader, - InviteActivityParams, Json, MemberActivityParams, MemberInvitedParams, MemberJoinedParams, - NotificationPayload, PipelineActivityParams, PipelineMetadata, PolicyActivityParams, - ProviderActivityParams, RasterPolicy, RedactionActivityParams, RedactionCreatedParams, - Retention, RetentionOverride, RetentionScope, RetentionSettings, WebhookActivityParams, - WebhookHeaders, WorkspaceActivityParams, WorkspaceMetadata, WorkspaceSettings, + ActivityPayload, AssignmentActivityParams, ConnectionActivityParams, + ConnectionSyncCompletedParams, ConnectionSyncFailedParams, DetectionActivityParams, + DetectionCompletedParams, DetectionFailedParams, DetectionMetadata, FileActivityParams, + FileAssignedParams, FileUnassignedParams, InvalidHeader, InviteActivityParams, Json, + MemberActivityParams, MemberJoinedParams, NotificationPayload, PipelineActivityParams, + PipelineMetadata, PolicyActivityParams, ProviderActivityParams, RasterPolicy, + RedactionActivityParams, RedactionCreatedParams, Retention, RetentionOverride, RetentionScope, + RetentionSettings, WebhookActivityParams, WebhookHeaders, WorkspaceActivityParams, + WorkspaceMetadata, WorkspaceSettings, }; pub use pagination::{Cursor, CursorPage, CursorPagination, OffsetPage, OffsetPagination}; pub use prefixed_id::{ diff --git a/crates/nvisy-server/src/extract/auth/auth_state.rs b/crates/nvisy-server/src/extract/auth/auth_state.rs index ecb276f8..3efc84e2 100644 --- a/crates/nvisy-server/src/extract/auth/auth_state.rs +++ b/crates/nvisy-server/src/extract/auth/auth_state.rs @@ -22,7 +22,7 @@ use serde::Deserialize; use uuid::Uuid; use super::{AuthClaims, Permission, SessionToken}; -use crate::handler::{Error, ErrorKind, Result}; +use crate::response::{Error, ErrorKind, Result}; use crate::service::SessionKeys; /// Tracing target for authentication operations. @@ -36,14 +36,14 @@ const TRACING_TARGET: &str = "nvisy_server::authentication"; /// /// - A cryptographically valid JWT token /// - A verified and active account -/// - Current privilege levels matching the database +/// - A token that has not been revoked /// /// # Security Guarantees /// /// When [`AuthState`] extraction succeeds, you can be confident that: /// - The user is who they claim to be (authentication) /// - Their account is in good standing -/// - Their privileges are current and accurate +/// - The backing token is still active (not revoked or expired) /// /// # Performance Characteristics /// @@ -78,12 +78,10 @@ impl AuthState { } /// Authorizes the caller for `permission` in `workspace_id`, returning their - /// membership on success (or `None` for a global admin, who is authorized - /// without being a member). + /// membership on success. /// - /// A global admin bypasses the workspace check. Otherwise the caller must be a - /// member whose role satisfies `permission`; a non-member or an insufficient - /// role is `403 Forbidden`. + /// The caller must be a member whose role satisfies `permission`; a non-member + /// or an insufficient role is `403 Forbidden`. /// /// # Errors /// @@ -94,19 +92,7 @@ impl AuthState { conn: &mut PgConn, workspace_id: Uuid, permission: Permission, - ) -> Result> { - // Global administrators bypass workspace-level permissions. - if self.0.is_admin { - tracing::debug!( - target: TRACING_TARGET, - account_id = %self.0.account_id, - workspace_id = %workspace_id, - permission = ?permission, - "access granted: global administrator" - ); - return Ok(None); - } - + ) -> Result { let member = conn .find_workspace_member(workspace_id, self.0.account_id) .await @@ -133,7 +119,7 @@ impl AuthState { role = ?member.member_role, "access granted: sufficient role" ); - Ok(Some(member)) + Ok(member) } else { tracing::warn!( target: TRACING_TARGET, @@ -165,7 +151,7 @@ where /// 1. **JWT Token Extraction**: Extracts and validates JWT structure (including expiration) /// 2. **Database Connection**: Acquires connection with error handling /// 3. **Account Verification**: Validates account exists and is in good standing - /// 4. **Privilege Consistency**: Ensures token claims match database state + /// 4. **Token Revocation**: Ensures the backing token has not been revoked /// /// # Arguments /// @@ -181,7 +167,7 @@ where /// Returns specific error types for different failure modes: /// /// * [`ErrorKind::InternalServerError`]: Database connection or query failures - /// * [`ErrorKind::Unauthorized`]: Account not found or privilege mismatch + /// * [`ErrorKind::Unauthorized`]: Account not found or token revoked /// * [`ErrorKind::Forbidden`]: Account verification incomplete or suspended /// /// # Database Impact @@ -199,7 +185,6 @@ where token_id = %auth_claims.token_id, account_id = %auth_claims.account_id, expires_at = %auth_claims.expires_at, - is_admin_claim = auth_claims.is_admin, "beginning authentication verification" ); @@ -215,12 +200,9 @@ where })?; // Step 1: Verify account exists and is in good standing - let account = Self::verify_account_status(&mut conn, &auth_claims).await?; - - // Step 2: Ensure token claims match current account state - Self::verify_privilege_consistency(&auth_claims, &account)?; + Self::verify_account_status(&mut conn, &auth_claims).await?; - // Step 3: Ensure the token itself has not been revoked. The JWT's own + // Step 2: Ensure the token itself has not been revoked. The JWT's own // expiry bounds its lifetime, but revocation must take effect // immediately, so the backing token row is checked on every request. Self::verify_token_active(&mut conn, &auth_claims).await?; @@ -229,7 +211,6 @@ where target: TRACING_TARGET, account_id = %auth_claims.account_id, token_id = %auth_claims.token_id, - is_admin = account.is_admin, "authentication verification completed successfully" ); @@ -303,64 +284,12 @@ where tracing::debug!( target: TRACING_TARGET, account_id = %auth_claims.account_id, - is_admin = account.is_admin, "account validation successful" ); Ok(account) } - /// Verifies that privilege claims in the JWT token match the current database state. - /// - /// This critical security check ensures that privilege changes (admin promotion/demotion) - /// are immediately effective by comparing token claims with current database records. - /// - /// # Security Importance - /// - /// - **Real-time Privilege Enforcement**: Admin changes take effect immediately - /// - **Token Invalidation**: Forces re-authentication when privileges change - /// - **Privilege Escalation Prevention**: Prevents use of stale admin tokens - /// - **Audit Compliance**: Ensures privilege records are consistent - /// - /// # Arguments - /// - /// * `auth_claims` - JWT claims containing privilege assertions - /// * `account` - Current account record from database - /// - /// # Returns - /// - /// Returns `Ok(())` if privileges are consistent. - /// - /// # Errors - /// - /// Returns [`ErrorKind::Unauthorized`] if privilege claims don't match database. - fn verify_privilege_consistency(auth_claims: &AuthClaims, account: &Account) -> Result<()> { - if auth_claims.is_admin != account.is_admin { - tracing::error!( - target: TRACING_TARGET, - account_id = %auth_claims.account_id, - token_id = %auth_claims.token_id, - token_admin_claim = auth_claims.is_admin, - current_admin_status = account.is_admin, - "critical: admin privilege mismatch detected between token and database" - ); - - return Err(ErrorKind::Unauthorized - .with_message("Your account privileges have changed") - .with_context("Please sign in again to access your updated permissions") - .with_resource("authentication")); - } - - tracing::debug!( - target: TRACING_TARGET, - account_id = %auth_claims.account_id, - is_admin = account.is_admin, - "privilege consistency verification successful" - ); - - Ok(()) - } - /// Verifies that the token backing this request has not been revoked. /// /// The bearer credential is a self-contained JWT, so a revoked (soft-deleted) @@ -458,7 +387,7 @@ where parts: &mut Parts, state: &S, ) -> Result, Self::Rejection> { - use crate::handler::ErrorKind; + use crate::response::ErrorKind; match >::from_request_parts(parts, state).await { Ok(auth_state) => Ok(Some(auth_state)), @@ -492,56 +421,3 @@ where operation.security = vec![[("BearerAuth".to_string(), vec![])].into()]; } } - -#[cfg(test)] -mod tests { - use nvisy_postgres::model::{Account, AccountApiToken}; - use nvisy_postgres::types::ApiTokenType; - - use super::{AuthClaims, AuthState}; - - /// Builds claims for `account` (the claim's `is_admin` mirrors the account it - /// was minted from). - fn claims_for(account: &Account) -> AuthClaims<()> { - let token = AccountApiToken::test(account.id, ApiTokenType::Web); - AuthClaims::new(account, &token) - } - - #[test] - fn privilege_consistency_accepts_a_matching_admin_flag() { - let mut admin = Account::test(); - admin.is_admin = true; - assert!(AuthState::<()>::verify_privilege_consistency(&claims_for(&admin), &admin).is_ok()); - - let user = Account::test(); // is_admin: false - assert!(AuthState::<()>::verify_privilege_consistency(&claims_for(&user), &user).is_ok()); - } - - #[test] - fn privilege_consistency_rejects_a_stale_admin_claim() { - // Token was minted while the account was admin; the account has since been - // demoted. The stale admin claim must be rejected (fail closed). - let mut was_admin = Account::test(); - was_admin.is_admin = true; - let stale_claims = claims_for(&was_admin); - - let mut now_demoted = was_admin.clone(); - now_demoted.is_admin = false; - - assert!( - AuthState::<()>::verify_privilege_consistency(&stale_claims, &now_demoted).is_err() - ); - } - - #[test] - fn privilege_consistency_rejects_a_forged_admin_claim() { - // A non-admin account whose token nonetheless claims admin must be - // rejected — a claim can never grant a privilege the DB does not hold. - let mut forged = Account::test(); - forged.is_admin = true; - let forged_claims = claims_for(&forged); - - let real = Account::test(); // is_admin: false - assert!(AuthState::<()>::verify_privilege_consistency(&forged_claims, &real).is_err()); - } -} diff --git a/crates/nvisy-server/src/extract/auth/authorized.rs b/crates/nvisy-server/src/extract/auth/authorized.rs index f6975ed4..60a7816c 100644 --- a/crates/nvisy-server/src/extract/auth/authorized.rs +++ b/crates/nvisy-server/src/extract/auth/authorized.rs @@ -5,8 +5,8 @@ //! authorization in its signature rather than repeating a //! `get_connection` + `authorize_workspace` block. The permission is a marker //! type (one per `Permission`, generated by the `authz_permissions!` macro -//! below); the extractor yields the resolved [`Workspace`] and, for a member -//! (i.e. not a global admin), their [`WorkspaceMember`]. +//! below); the extractor yields the resolved [`Workspace`] and the caller's +//! [`WorkspaceMember`]. use std::marker::PhantomData; @@ -21,7 +21,7 @@ use uuid::Uuid; use super::{AuthState, Permission}; use crate::extract::WorkspaceContext; -use crate::handler::{Error, ErrorKind}; +use crate::response::{Error, ErrorKind}; /// A workspace permission required by a handler, expressed as a marker type so it /// can parameterize [`Authorized`]. Implemented for one zero-sized type per @@ -44,9 +44,8 @@ pub struct Authorized { pub account_id: Uuid, /// The workspace the `{workspaceSlug}` segment resolved to. pub workspace: Workspace, - /// The caller's membership in the workspace, or `None` for a global admin - /// (who is authorized without being a member). - pub member: Option, + /// The caller's membership in the workspace, whose role satisfied `P`. + pub member: WorkspaceMember, _permission: PhantomData

, } @@ -112,16 +111,26 @@ impl OperationInput for Authorized

{ /// Defines a [`RequiredPermission`] marker type per [`Permission`], for use as /// the `P` in [`Authorized

`]. +/// +/// The markers land in a dedicated `markers` module, kept namespaced through the +/// re-export chain so callers write `Authorized` and the +/// generated types never crowd the flat extractor namespace. macro_rules! authz_permissions { ($($marker:ident => $permission:expr),+ $(,)?) => { - $( - #[doc = concat!("Marker for [`Permission::", stringify!($marker), "`] as an [`Authorized`] parameter.")] - pub struct $marker; + /// Generated [`RequiredPermission`] marker types, one per [`Permission`] + /// variant, used as the `P` in [`Authorized

`](super::Authorized). + pub mod markers { + use super::{Permission, RequiredPermission}; + + $( + #[doc = concat!("Marker for [`Permission::", stringify!($marker), "`] as an [`Authorized`](super::Authorized) parameter.")] + pub struct $marker; - impl RequiredPermission for $marker { - const PERMISSION: Permission = $permission; - } - )+ + impl RequiredPermission for $marker { + const PERMISSION: Permission = $permission; + } + )+ + } }; } @@ -143,6 +152,8 @@ authz_permissions! { ViewDetections => Permission::ViewDetections, RunDetections => Permission::RunDetections, RunRedactions => Permission::RunRedactions, + ViewAssignments => Permission::ViewAssignments, + AssignTasks => Permission::AssignTasks, ViewAnalytics => Permission::ViewAnalytics, ViewActivity => Permission::ViewActivity, UseChat => Permission::UseChat, diff --git a/crates/nvisy-server/src/extract/auth/jwt_claims.rs b/crates/nvisy-server/src/extract/auth/jwt_claims.rs index 34ad90ef..802f6b1e 100644 --- a/crates/nvisy-server/src/extract/auth/jwt_claims.rs +++ b/crates/nvisy-server/src/extract/auth/jwt_claims.rs @@ -13,7 +13,7 @@ use nvisy_postgres::types::{ApiTokenType, session}; use serde::{Deserialize, Serialize}; use uuid::Uuid; -use crate::handler::{ErrorKind, Result}; +use crate::response::{ErrorKind, Result}; /// Tracing target for authentication operations. const TRACING_TARGET: &str = "nvisy_server::authentication"; @@ -53,9 +53,6 @@ pub struct AuthClaims { // Private (or custom) claims #[serde(flatten)] pub custom_claims: T, - /// Is administrator flag. - #[serde(rename = "adm")] - pub is_admin: bool, } impl AuthClaims<()> { @@ -135,7 +132,6 @@ impl AuthClaims { issued_at: issued_at.as_second(), expires_at: expires_at.as_second(), custom_claims, - is_admin: account_model.is_admin, } } } @@ -213,7 +209,7 @@ where validation.validate_aud = true; validation.set_audience(&[Self::JWT_AUDIENCE]); validation.set_issuer(&[Self::JWT_ISSUER]); - validation.set_required_spec_claims(&["iss", "aud", "jti", "sub", "iat", "exp", "adm"]); + validation.set_required_spec_claims(&["iss", "aud", "jti", "sub", "iat", "exp"]); tracing::debug!( target: TRACING_TARGET, @@ -239,7 +235,6 @@ where target: TRACING_TARGET, token_id = %claims.token_id, account_id = %claims.account_id, - is_admin = claims.is_admin, "JWT token validation completed successfully" ); diff --git a/crates/nvisy-server/src/extract/auth/mod.rs b/crates/nvisy-server/src/extract/auth/mod.rs index 075a04a4..cafff20b 100644 --- a/crates/nvisy-server/src/extract/auth/mod.rs +++ b/crates/nvisy-server/src/extract/auth/mod.rs @@ -12,7 +12,7 @@ mod permission; mod session_token; pub use self::auth_state::AuthState; -pub use self::authorized::*; +pub use self::authorized::{Authorized, RequiredPermission, markers}; pub use self::jwt_claims::AuthClaims; pub use self::optional_auth::OptionalAuth; pub use self::permission::Permission; diff --git a/crates/nvisy-server/src/extract/auth/optional_auth.rs b/crates/nvisy-server/src/extract/auth/optional_auth.rs index 0bb512f8..4ef05692 100644 --- a/crates/nvisy-server/src/extract/auth/optional_auth.rs +++ b/crates/nvisy-server/src/extract/auth/optional_auth.rs @@ -11,7 +11,7 @@ use nvisy_postgres::PgClient; use serde::Deserialize; use super::AuthState; -use crate::handler::{Error, Result}; +use crate::response::{Error, Result}; use crate::service::SessionKeys; /// Optional [`AuthState`] for an endpoint that runs with or without a token. diff --git a/crates/nvisy-server/src/extract/auth/permission.rs b/crates/nvisy-server/src/extract/auth/permission.rs index cb4cb9b5..4d8889d6 100644 --- a/crates/nvisy-server/src/extract/auth/permission.rs +++ b/crates/nvisy-server/src/extract/auth/permission.rs @@ -52,6 +52,12 @@ pub enum Permission { /// Can run redactions (apply policies and produce a redacted file). RunRedactions, + // Assignment permissions + /// Can view file review assignments (who is reviewing what). + ViewAssignments, + /// Can assign files to reviewers and unassign them. + AssignTasks, + // Reporting permissions /// Can view workspace analytics. ViewAnalytics, @@ -126,6 +132,7 @@ impl Permission { | Self::DownloadAudit | Self::ViewPipelines | Self::ViewDetections + | Self::ViewAssignments | Self::ViewAnalytics | Self::ViewActivity | Self::ViewMembers @@ -144,6 +151,7 @@ impl Permission { | Self::DeletePipelines | Self::RunDetections | Self::RunRedactions + | Self::AssignTasks | Self::UseChat | Self::RunConnectionSyncs => WorkspaceRole::Editor, diff --git a/crates/nvisy-server/src/extract/auth/session_token.rs b/crates/nvisy-server/src/extract/auth/session_token.rs index 52dc3ede..b00d37db 100644 --- a/crates/nvisy-server/src/extract/auth/session_token.rs +++ b/crates/nvisy-server/src/extract/auth/session_token.rs @@ -19,7 +19,7 @@ use serde::Deserialize; use super::AuthClaims; use crate::extract::auth::SESSION_COOKIE_NAME; -use crate::handler::{Error, ErrorKind, Result}; +use crate::response::{Error, ErrorKind, Result}; use crate::service::SessionKeys; /// Which transport carried the session token on a request. diff --git a/crates/nvisy-server/src/extract/avatar_upload.rs b/crates/nvisy-server/src/extract/avatar_upload.rs index 535a73cb..b81165c6 100644 --- a/crates/nvisy-server/src/extract/avatar_upload.rs +++ b/crates/nvisy-server/src/extract/avatar_upload.rs @@ -7,7 +7,7 @@ use axum::extract::{FromRequest, Request}; use bytes::Bytes; use crate::extract::Multipart; -use crate::handler::{Error, ErrorKind}; +use crate::response::{Error, ErrorKind}; /// The raw bytes of an uploaded avatar image, read from the first file field of a /// multipart request. diff --git a/crates/nvisy-server/src/extract/idempotency_key.rs b/crates/nvisy-server/src/extract/idempotency_key.rs index ea4d6738..8f8a7594 100644 --- a/crates/nvisy-server/src/extract/idempotency_key.rs +++ b/crates/nvisy-server/src/extract/idempotency_key.rs @@ -9,7 +9,7 @@ use axum::extract::FromRequestParts; use axum::http::HeaderName; use axum::http::request::Parts; -use crate::handler::{Error, ErrorKind}; +use crate::response::{Error, ErrorKind}; /// The idempotency header name, lowercased to match `HeaderMap` lookup. const IDEMPOTENCY_HEADER: HeaderName = HeaderName::from_static("idempotency-key"); @@ -56,7 +56,7 @@ mod tests { use axum::http::Request; use super::{IdempotencyKey, MAX_KEY_LENGTH}; - use crate::handler::ErrorKind; + use crate::response::ErrorKind; /// Drives the extractor against a request carrying `header` (or none). async fn extract(header: Option<&str>) -> Result, ErrorKind> { diff --git a/crates/nvisy-server/src/extract/mod.rs b/crates/nvisy-server/src/extract/mod.rs index 1bcca4ea..60803be3 100644 --- a/crates/nvisy-server/src/extract/mod.rs +++ b/crates/nvisy-server/src/extract/mod.rs @@ -10,19 +10,18 @@ mod avatar_upload; mod idempotency_key; mod reject; mod security_context; -mod typed_header; mod valid; mod version; mod workspace_context; -// Glob: the auth surface includes the permission markers (one per `Permission`, -// generated by a macro) used as `Authorized

`, alongside the named types. -pub use crate::extract::auth::*; +pub use crate::extract::auth::{ + AuthClaims, AuthState, AuthTransport, Authorized, CSRF_COOKIE_NAME, CSRF_HEADER_NAME, + OptionalAuth, Permission, RequiredPermission, SESSION_COOKIE_NAME, SessionToken, markers, +}; pub use crate::extract::avatar_upload::AvatarUpload; pub use crate::extract::idempotency_key::IdempotencyKey; pub use crate::extract::reject::{Form, Json, Multipart, Path, Query}; pub use crate::extract::security_context::SecurityContext; -pub use crate::extract::typed_header::TypedHeader; pub use crate::extract::valid::{ValidateJson, validators}; pub use crate::extract::version::Version; pub use crate::extract::workspace_context::WorkspaceContext; diff --git a/crates/nvisy-server/src/extract/reject/form_with_rej.rs b/crates/nvisy-server/src/extract/reject/form_with_rej.rs index b72a0e9f..44511586 100644 --- a/crates/nvisy-server/src/extract/reject/form_with_rej.rs +++ b/crates/nvisy-server/src/extract/reject/form_with_rej.rs @@ -13,7 +13,7 @@ use schemars::JsonSchema; use super::sanitize_error_message; use crate::extract::Query; -use crate::handler::{Error, ErrorKind}; +use crate::response::{Error, ErrorKind}; /// Enhanced form data extractor with improved error handling. /// diff --git a/crates/nvisy-server/src/extract/reject/json_with_rej.rs b/crates/nvisy-server/src/extract/reject/json_with_rej.rs index 7e22dc43..6d9ced0b 100644 --- a/crates/nvisy-server/src/extract/reject/json_with_rej.rs +++ b/crates/nvisy-server/src/extract/reject/json_with_rej.rs @@ -15,7 +15,7 @@ use serde::Serialize; use serde::de::DeserializeOwned; use super::sanitize_error_message; -use crate::handler::{Error, ErrorKind}; +use crate::response::{Error, ErrorKind}; /// Enhanced JSON extractor with improved error handling. /// diff --git a/crates/nvisy-server/src/extract/reject/mutlipart_with_rej.rs b/crates/nvisy-server/src/extract/reject/mutlipart_with_rej.rs index 45981c68..5396f98b 100644 --- a/crates/nvisy-server/src/extract/reject/mutlipart_with_rej.rs +++ b/crates/nvisy-server/src/extract/reject/mutlipart_with_rej.rs @@ -11,7 +11,7 @@ use axum::extract::{FromRequest, Multipart as AxumMultipart, Request}; use derive_more::{Deref, DerefMut, From}; use super::sanitize_error_message; -use crate::handler::{Error, ErrorKind}; +use crate::response::{Error, ErrorKind}; /// Enhanced Multipart extractor with improved error handling. /// diff --git a/crates/nvisy-server/src/extract/reject/path_with_rej.rs b/crates/nvisy-server/src/extract/reject/path_with_rej.rs index e8205a76..735930f7 100644 --- a/crates/nvisy-server/src/extract/reject/path_with_rej.rs +++ b/crates/nvisy-server/src/extract/reject/path_with_rej.rs @@ -14,7 +14,7 @@ use derive_more::{Deref, DerefMut, From}; use schemars::JsonSchema; use super::sanitize_error_message; -use crate::handler::{Error, ErrorKind}; +use crate::response::{Error, ErrorKind}; /// Enhanced path parameter extractor with improved error handling. /// diff --git a/crates/nvisy-server/src/extract/reject/query_with_rej.rs b/crates/nvisy-server/src/extract/reject/query_with_rej.rs index 00c7e876..451a5be9 100644 --- a/crates/nvisy-server/src/extract/reject/query_with_rej.rs +++ b/crates/nvisy-server/src/extract/reject/query_with_rej.rs @@ -13,7 +13,7 @@ use derive_more::{Deref, DerefMut, From}; use schemars::JsonSchema; use super::sanitize_error_message; -use crate::handler::{Error, ErrorKind}; +use crate::response::{Error, ErrorKind}; /// Enhanced query parameter extractor with improved error handling. /// diff --git a/crates/nvisy-server/src/extract/typed_header.rs b/crates/nvisy-server/src/extract/typed_header.rs deleted file mode 100644 index 488fd842..00000000 --- a/crates/nvisy-server/src/extract/typed_header.rs +++ /dev/null @@ -1,39 +0,0 @@ -//! Typed header extractor with aide OpenAPI compatibility. -//! -//! This module provides [`TypedHeader`], a wrapper around [`axum_extra::TypedHeader`] -//! that implements [`aide::OperationInput`] for OpenAPI documentation generation. - -use aide::OperationInput; -use axum::extract::FromRequestParts; -use axum::http::request::Parts; -use axum_extra::headers::Header; -use derive_more::{Deref, DerefMut, From}; - -/// Typed header extractor with OpenAPI support. -/// -/// This is a thin wrapper around [`axum_extra::TypedHeader`] that adds -/// [`aide::OperationInput`] implementation for OpenAPI schema generation. -/// It provides type-safe access to HTTP headers with automatic parsing. -/// -/// # Extractable Headers -/// -/// Any type implementing [`axum_extra::headers::Header`] can be extracted, -/// including standard headers like `Authorization`, `ContentType`, `Accept`, etc. -#[derive(Debug, Clone, Deref, DerefMut, From)] -pub struct TypedHeader(pub T); - -impl FromRequestParts for TypedHeader -where - S: Send + Sync, - T: Header, -{ - type Rejection = as FromRequestParts>::Rejection; - - async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { - let axum_extra::TypedHeader(header) = - axum_extra::TypedHeader::::from_request_parts(parts, state).await?; - Ok(Self(header)) - } -} - -impl OperationInput for TypedHeader {} diff --git a/crates/nvisy-server/src/extract/valid/validated_json.rs b/crates/nvisy-server/src/extract/valid/validated_json.rs index f65d1f19..870142dd 100644 --- a/crates/nvisy-server/src/extract/valid/validated_json.rs +++ b/crates/nvisy-server/src/extract/valid/validated_json.rs @@ -20,7 +20,7 @@ use schemars::JsonSchema; use serde::de::DeserializeOwned; use crate::extract::Json; -use crate::handler::{Error, ErrorKind}; +use crate::response::{Error, ErrorKind}; /// JSON extractor that deserializes and then validates the request body. /// diff --git a/crates/nvisy-server/src/extract/workspace_context.rs b/crates/nvisy-server/src/extract/workspace_context.rs index d2039bb8..186cdcd2 100644 --- a/crates/nvisy-server/src/extract/workspace_context.rs +++ b/crates/nvisy-server/src/extract/workspace_context.rs @@ -18,7 +18,7 @@ use schemars::JsonSchema; use serde::Deserialize; use crate::extract::Path; -use crate::handler::{Error, ErrorKind}; +use crate::response::{Error, ErrorKind}; /// The workspace addressed by the `{workspaceSlug}` path segment. /// diff --git a/crates/nvisy-server/src/handler/accounts.rs b/crates/nvisy-server/src/handler/accounts.rs index 90b63747..ccf9cfea 100644 --- a/crates/nvisy-server/src/handler/accounts.rs +++ b/crates/nvisy-server/src/handler/accounts.rs @@ -14,9 +14,9 @@ use nvisy_postgres::{PgClient, PgConn}; use uuid::Uuid; use super::request::{AccountPathParams, UpdateAccount}; -use super::response::{Account, ErrorResponse, PublicAccount}; +use super::response::{Account, PublicAccount}; use crate::extract::{AuthState, AvatarUpload, Json, Path, ValidateJson}; -use crate::handler::{Error, ErrorKind, Result}; +use crate::response::{Error, ErrorKind, ErrorResponse, Result}; use crate::service::{AvatarService, MAX_AVATAR_UPLOAD_BYTES, ServiceState}; /// Tracing target for account operations. diff --git a/crates/nvisy-server/src/handler/activities.rs b/crates/nvisy-server/src/handler/activities.rs index dde25f68..39945b00 100644 --- a/crates/nvisy-server/src/handler/activities.rs +++ b/crates/nvisy-server/src/handler/activities.rs @@ -18,15 +18,15 @@ use nvisy_postgres::query::WorkspaceActivityRepository; use nvisy_postgres::types::WithAccountRef; use serde::Serialize; -use crate::extract::{Authorized, Json, Query, ViewActivity}; +use crate::extract::{Authorized, Json, Query, markers}; +use crate::handler::ServiceState; use crate::handler::request::{ ActivityExportOptions, ActivityFilterQuery, CursorPagination, DateWindow, ExportFormat, MAX_EXPORT_ROWS, }; -use crate::handler::response::{ActivitiesPage, Activity, ErrorResponse}; +use crate::handler::response::{ActivitiesPage, Activity}; use crate::handler::utility::{ActorFilter, DownloadDocs, resolve_actor}; -use crate::handler::{Error, ErrorKind, Result, ServiceState}; -use crate::response::attachment_headers; +use crate::response::{Error, ErrorKind, ErrorResponse, Result, attachment_headers}; /// Tracing target for activity export operations. const TRACING_TARGET: &str = "nvisy_server::handler::activities"; @@ -112,7 +112,7 @@ impl ActivityExportRow { )] async fn list_activities( State(pg_client): State, - authz: Authorized, + authz: Authorized, Query(filter_query): Query, Query(window): Query, Query(pagination): Query, @@ -176,7 +176,7 @@ fn list_activities_docs(op: TransformOperation) -> TransformOperation { )] async fn export_activities( State(pg_client): State, - authz: Authorized, + authz: Authorized, Query(filter_query): Query, Query(window_query): Query, Query(export_query): Query, diff --git a/crates/nvisy-server/src/handler/analytics.rs b/crates/nvisy-server/src/handler/analytics.rs index 1f87ba9a..590ec45b 100644 --- a/crates/nvisy-server/src/handler/analytics.rs +++ b/crates/nvisy-server/src/handler/analytics.rs @@ -8,10 +8,11 @@ use axum::http::StatusCode; use nvisy_postgres::PgClient; use nvisy_postgres::query::WorkspaceAnalyticsRepository; -use crate::extract::{Authorized, Json, Query, ViewAnalytics}; +use crate::extract::{Authorized, Json, Query, markers}; +use crate::handler::ServiceState; use crate::handler::request::DateWindow; -use crate::handler::response::{DetectionTimeSeries, ErrorResponse, WorkspaceAnalytics}; -use crate::handler::{Result, ServiceState}; +use crate::handler::response::{DetectionTimeSeries, WorkspaceAnalytics}; +use crate::response::{ErrorResponse, Result}; /// Tracing target for workspace analytics operations. const TRACING_TARGET: &str = "nvisy_server::handler::analytics"; @@ -26,7 +27,7 @@ const TRACING_TARGET: &str = "nvisy_server::handler::analytics"; )] async fn get_analytics( State(pg_client): State, - authz: Authorized, + authz: Authorized, ) -> Result<(StatusCode, Json)> { tracing::debug!(target: TRACING_TARGET, "Computing workspace analytics"); @@ -60,7 +61,7 @@ fn get_analytics_docs(op: TransformOperation) -> TransformOperation { )] async fn get_detection_timeseries( State(pg_client): State, - authz: Authorized, + authz: Authorized, Query(window): Query, ) -> Result<(StatusCode, Json)> { tracing::debug!(target: TRACING_TARGET, "Computing detection time series"); diff --git a/crates/nvisy-server/src/handler/assignments.rs b/crates/nvisy-server/src/handler/assignments.rs new file mode 100644 index 00000000..83a75d27 --- /dev/null +++ b/crates/nvisy-server/src/handler/assignments.rs @@ -0,0 +1,497 @@ +//! Assignment handlers: assign a file to reviewers, track review status. +//! +//! An assignment is one reviewer's assignment of one file for redaction review. +//! A file may be assigned to several reviewers at once (like GitHub assignees); +//! each assignment is its own resource with its own review status. Creating and +//! removing assignments requires `AssignTasks`; a reviewer may change the status +//! of their own assignment, and anyone with `AssignTasks` may change any. + +use aide::axum::ApiRouter; +use aide::transform::TransformOperation; +use axum::extract::State; +use axum::http::StatusCode; +use nvisy_postgres::model::{NewWorkspaceAssignment, UpdateWorkspaceAssignment}; +use nvisy_postgres::query::{ + AccountRepository, CreateAssignmentOutcome, WorkspaceAssignmentRepository, + WorkspaceFileRepository, WorkspaceMemberRepository, +}; +use nvisy_postgres::types::Handle; +use nvisy_postgres::{AsyncConnection, PgClient, PgConn}; +use uuid::Uuid; + +use crate::extract::{ + Authorized, Json, Path, Permission, Query, SecurityContext, ValidateJson, markers, +}; +use crate::handler::request::{ + AssignmentPathParams, CreateAssignment, CursorPagination, UpdateAssignment, + WorkspaceAssignmentsQuery, WorkspaceFilePathParams, +}; +use crate::handler::response::{Assignment, AssignmentsPage}; +use crate::handler::utility::resolve_account_ref; +use crate::response::{Error, ErrorKind, ErrorResponse, Result}; +use crate::service::{ + AssignmentStatusChanged, EventEmitter, EventOrigin, FileAssigned, FileUnassigned, ServiceState, + WorkspaceEvent, +}; + +/// Tracing target for assignment operations. +const TRACING_TARGET: &str = "nvisy_server::handler::assignments"; + +/// The literal assignee filter that resolves to the caller's own account. +const ASSIGNEE_ME: &str = "me"; + +/// Assigns a file to a reviewer. +/// +/// A file may be assigned to several reviewers at once; assigning the same +/// reviewer again is a no-op that returns the existing assignment. The assignee +/// must be a member of the workspace. Requires `AssignTasks`. +#[tracing::instrument( + skip_all, + fields( + account_id = %authz.account_id, + workspace_id = %authz.workspace.id, + file_id = %path_params.file_id, + ) +)] +async fn create_assignment( + State(pg_client): State, + authz: Authorized, + Path(path_params): Path, + security: SecurityContext, + ValidateJson(request): ValidateJson, +) -> Result<(StatusCode, Json)> { + tracing::debug!(target: TRACING_TARGET, "Assigning file to reviewer"); + + let workspace = authz.workspace; + let mut conn = pg_client.get_connection().await?; + + // The file must exist in the workspace. + let file = conn + .find_file_in_workspace(workspace.id, path_params.file_id) + .await? + .ok_or_else(|| Error::not_found("file"))?; + + // The assignee must be a member of the workspace: a file is reviewed by the + // people in its workspace, not by arbitrary accounts. + let assignee = resolve_workspace_member(&mut conn, workspace.id, &request.assignee).await?; + + let new_assignment = NewWorkspaceAssignment { + workspace_id: workspace.id, + file_id: file.id, + assignee_account_id: assignee, + assigned_account_id: Some(authz.account_id), + status: None, + }; + + // Create the assignment and record it in one transaction so the row and its + // event commit or roll back together. A repeat assignment is a benign no-op: + // return the existing row, unchanged, with no second event. + let (assignment, created) = conn + .transaction( + async |conn| match conn.create_workspace_assignment(new_assignment).await? { + CreateAssignmentOutcome::Created(assignment) => { + emit_assignment_event( + conn, + workspace_origin(workspace.id, authz.account_id, &security), + WorkspaceEvent::FileAssigned(FileAssigned { + assignment_id: assignment.id, + file_id: assignment.file_id, + file_name: file.display_name.clone(), + assignee_username: request.assignee.clone(), + status: assignment.status, + // No self-notification: the actor already knows they + // assigned themselves. + notify: notify_target(assignee, authz.account_id), + }), + ) + .await?; + Ok::<_, Error>((assignment, true)) + } + CreateAssignmentOutcome::AlreadyAssigned => { + let existing = conn + .find_file_assignment_for_assignee(workspace.id, file.id, assignee) + .await? + .ok_or_else(|| Error::not_found("workspace_assignment"))?; + Ok((existing, false)) + } + }, + ) + .await?; + + let assignee_ref = resolve_account_ref(&mut conn, assignment.assignee_account_id).await?; + let status = if created { + StatusCode::CREATED + } else { + StatusCode::OK + }; + + tracing::info!(target: TRACING_TARGET, assignment_id = %assignment.id, created, "File assigned"); + + Ok(( + status, + Json(Assignment::from_model( + assignment, + assignee_ref, + Some(file.display_name), + )), + )) +} + +fn create_assignment_docs(op: TransformOperation) -> TransformOperation { + op.summary("Assign a file") + .description( + "Assigns a file to a workspace member for review. A file may have \ + several reviewers; assigning the same reviewer again returns the \ + existing assignment. Requires the AssignTasks permission.", + ) + .response::<201, Json>() + .response::<200, Json>() + .response::<401, Json>() + .response::<403, Json>() + .response::<404, Json>() +} + +/// Lists a file's reviewers (its assignments), most recent first. +/// +/// Requires `ViewAssignments`. +#[tracing::instrument( + skip_all, + fields( + account_id = %authz.account_id, + workspace_id = %authz.workspace.id, + file_id = %path_params.file_id, + ) +)] +async fn list_file_assignments( + State(pg_client): State, + authz: Authorized, + Path(path_params): Path, +) -> Result<(StatusCode, Json>)> { + tracing::debug!(target: TRACING_TARGET, "Listing file assignments"); + + let workspace = authz.workspace; + let mut conn = pg_client.get_connection().await?; + + // The file must exist in the workspace, so a missing file is a 404 rather than + // an empty list. + conn.find_file_in_workspace(workspace.id, path_params.file_id) + .await? + .ok_or_else(|| Error::not_found("file"))?; + + let rows = conn + .list_file_assignments(workspace.id, path_params.file_id) + .await?; + + let assignments = rows + .into_iter() + .map(|row| Assignment::from_model(row.assignment, row.assignee.into(), row.file_name)) + .collect(); + + Ok((StatusCode::OK, Json(assignments))) +} + +fn list_file_assignments_docs(op: TransformOperation) -> TransformOperation { + op.summary("List a file's reviewers") + .description("Returns the assignments on a file, most recent first.") + .response::<200, Json>>() + .response::<401, Json>() + .response::<403, Json>() + .response::<404, Json>() +} + +/// Lists a workspace's assignments with cursor pagination. +/// +/// Filter by `assignee` (a member handle, or `me` for the caller), `status`, and +/// `fileId`. Requires `ViewAssignments`. +#[tracing::instrument( + skip_all, + fields( + account_id = %authz.account_id, + workspace_id = %authz.workspace.id, + ) +)] +async fn list_workspace_assignments( + State(pg_client): State, + authz: Authorized, + Query(pagination): Query, + Query(query): Query, +) -> Result<(StatusCode, Json)> { + tracing::debug!(target: TRACING_TARGET, "Listing workspace assignments"); + + let workspace = authz.workspace; + let mut conn = pg_client.get_connection().await?; + + // Resolve the assignee filter: `me` is the caller, any other value is a member + // handle. An unknown handle is a 404, not a silently empty page. + let assignee_account_id = match query.assignee.as_deref() { + None => None, + Some(ASSIGNEE_ME) => Some(authz.account_id), + Some(handle) => { + let handle = Handle::try_from(handle.to_owned()) + .map_err(|_| Error::not_found("workspace_member"))?; + Some(resolve_workspace_member(&mut conn, workspace.id, &handle).await?) + } + }; + + let filter = query.into_filter(assignee_account_id); + let page = conn + .cursor_list_workspace_assignments(workspace.id, pagination.into(), &filter) + .await?; + + let response = AssignmentsPage::from_cursor_page(page, |row| { + Assignment::from_model(row.assignment, row.assignee.into(), row.file_name) + }); + + Ok((StatusCode::OK, Json(response))) +} + +fn list_workspace_assignments_docs(op: TransformOperation) -> TransformOperation { + op.summary("List workspace assignments") + .description( + "Returns the workspace's assignments, most recent first, with optional \ + assignee (a member handle or `me`), status, and file filters.", + ) + .response::<200, Json>() + .response::<401, Json>() + .response::<403, Json>() + .response::<404, Json>() +} + +/// Changes an assignment's review status. +/// +/// Allowed for the assignee (their own review status) or a member with +/// `AssignTasks`. +#[tracing::instrument( + skip_all, + fields( + account_id = %authz.account_id, + workspace_id = %authz.workspace.id, + assignment_id = %path_params.assignment_id, + ) +)] +async fn update_assignment( + State(pg_client): State, + authz: Authorized, + Path(path_params): Path, + security: SecurityContext, + ValidateJson(request): ValidateJson, +) -> Result<(StatusCode, Json)> { + tracing::debug!(target: TRACING_TARGET, "Updating assignment status"); + + let workspace = authz.workspace; + let mut conn = pg_client.get_connection().await?; + + let assignment = conn + .find_assignment_in_workspace(workspace.id, path_params.assignment_id) + .await? + .ok_or_else(|| Error::not_found("workspace_assignment"))?; + + // Split authorization: the assignee may set their own review status; anyone + // else needs AssignTasks (Editor tier), which the reviewer floor of this route + // (ViewAssignments) does not grant on its own. + let is_assignee = authz.account_id == assignment.assignee_account_id; + let may_assign = Permission::AssignTasks.is_permitted_by_role(authz.member.member_role); + if !is_assignee && !may_assign { + return Err(ErrorKind::Forbidden + .with_message("Only the assignee or a member who can assign tasks may change this") + .with_resource("workspace_assignment")); + } + + // Resolve the file name and the assignee reference (its handle drives the + // event, and the same reference is returned in the response) before the + // update. The assignee is a non-null FK, so it always resolves for a live row. + // The file name stays `None` when the file was removed, so the response says + // "gone" rather than blank; the event uses an empty string in that case. + let file_name = conn + .find_file_in_workspace(workspace.id, assignment.file_id) + .await? + .map(|f| f.display_name); + let assignee_ref = resolve_account_ref(&mut conn, assignment.assignee_account_id).await?; + let assignee_handle = assignee_ref.username.clone(); + + let updated = conn + .transaction(async |conn| { + let updated = conn + .update_workspace_assignment( + assignment.id, + UpdateWorkspaceAssignment { + status: Some(request.status), + }, + ) + .await?; + emit_assignment_event( + conn, + workspace_origin(workspace.id, authz.account_id, &security), + WorkspaceEvent::AssignmentStatusChanged(AssignmentStatusChanged { + assignment_id: updated.id, + file_id: updated.file_id, + file_name: file_name.clone(), + assignee_username: assignee_handle.clone(), + status: updated.status, + }), + ) + .await?; + Ok::<_, Error>(updated) + }) + .await?; + + tracing::info!(target: TRACING_TARGET, status = ?updated.status, "Assignment status changed"); + + Ok(( + StatusCode::OK, + Json(Assignment::from_model(updated, assignee_ref, file_name)), + )) +} + +fn update_assignment_docs(op: TransformOperation) -> TransformOperation { + op.summary("Change assignment status") + .description( + "Changes an assignment's review status. Allowed for the assignee or a \ + member with the AssignTasks permission.", + ) + .response::<200, Json>() + .response::<401, Json>() + .response::<403, Json>() + .response::<404, Json>() +} + +/// Unassigns a reviewer from a file (deletes the assignment). +/// +/// Requires `AssignTasks`. +#[tracing::instrument( + skip_all, + fields( + account_id = %authz.account_id, + workspace_id = %authz.workspace.id, + assignment_id = %path_params.assignment_id, + ) +)] +async fn delete_assignment( + State(pg_client): State, + authz: Authorized, + Path(path_params): Path, + security: SecurityContext, +) -> Result { + tracing::debug!(target: TRACING_TARGET, "Unassigning reviewer from file"); + + let workspace = authz.workspace; + let mut conn = pg_client.get_connection().await?; + + let assignment = conn + .find_assignment_in_workspace(workspace.id, path_params.assignment_id) + .await? + .ok_or_else(|| Error::not_found("workspace_assignment"))?; + + // Resolve the file name and assignee handle for the event before the delete. + // The assignee is a non-null FK, so it always resolves for a live row. + let file_name = conn + .find_file_in_workspace(workspace.id, assignment.file_id) + .await? + .map(|f| f.display_name); + let assignee_handle = resolve_account_ref(&mut conn, assignment.assignee_account_id) + .await? + .username; + + conn.transaction(async |conn| { + conn.delete_workspace_assignment(assignment.id).await?; + emit_assignment_event( + conn, + workspace_origin(workspace.id, authz.account_id, &security), + WorkspaceEvent::FileUnassigned(FileUnassigned { + assignment_id: assignment.id, + file_id: assignment.file_id, + file_name: file_name.clone(), + assignee_username: assignee_handle.clone(), + status: assignment.status, + // No self-notification when the actor unassigned themselves. + notify: notify_target(assignment.assignee_account_id, authz.account_id), + }), + ) + .await?; + Ok::<_, Error>(()) + }) + .await?; + + tracing::info!(target: TRACING_TARGET, "Reviewer unassigned"); + + Ok(StatusCode::OK) +} + +fn delete_assignment_docs(op: TransformOperation) -> TransformOperation { + op.summary("Unassign a reviewer") + .description("Removes an assignment, unassigning the reviewer. Requires AssignTasks.") + .response::<200, ()>() + .response::<401, Json>() + .response::<403, Json>() + .response::<404, Json>() +} + +/// Resolves a member handle to its account id within the workspace, rejecting a +/// handle that is not a member of the workspace. +async fn resolve_workspace_member( + conn: &mut PgConn, + workspace_id: Uuid, + username: &Handle, +) -> Result { + let account = conn + .find_account_by_username(username) + .await? + .ok_or_else(|| Error::not_found("workspace_member"))?; + // Presence of a membership row is the check: a non-member handle resolves to + // an account but no membership, so it is rejected the same as an unknown one. + conn.find_workspace_member(workspace_id, account.id) + .await? + .ok_or_else(|| Error::not_found("workspace_member"))?; + Ok(account.id) +} + +/// The in-app notification target for an assignment change: the reviewer, +/// unless they are the actor who made the change (no self-notification). +fn notify_target(reviewer: Uuid, actor: Uuid) -> Option { + (reviewer != actor).then_some(reviewer) +} + +/// Builds the event origin shared by every assignment event. +fn workspace_origin<'a>( + workspace_id: Uuid, + account_id: Uuid, + security: &'a SecurityContext, +) -> EventOrigin<'a> { + EventOrigin { + workspace_id, + account_id, + security, + } +} + +/// Emits one assignment event onto the outbox. +async fn emit_assignment_event( + conn: &mut PgConn, + origin: EventOrigin<'_>, + event: WorkspaceEvent, +) -> Result<()> { + conn.emit_event(origin, event).await?; + Ok(()) +} + +/// Returns an [`ApiRouter`] with all assignment routes. +pub fn routes() -> ApiRouter { + use aide::axum::routing::*; + + ApiRouter::new() + .api_route( + "/workspaces/{workspaceSlug}/files/{fileId}/assignments/", + post_with(create_assignment, create_assignment_docs) + .get_with(list_file_assignments, list_file_assignments_docs), + ) + .api_route( + "/workspaces/{workspaceSlug}/assignments/", + get_with(list_workspace_assignments, list_workspace_assignments_docs), + ) + .api_route( + "/workspaces/{workspaceSlug}/assignments/{assignmentId}/", + patch_with(update_assignment, update_assignment_docs) + .delete_with(delete_assignment, delete_assignment_docs), + ) + .with_path_items(|item| item.tag("Assignments")) +} diff --git a/crates/nvisy-server/src/handler/auth_oidc.rs b/crates/nvisy-server/src/handler/auth_oidc.rs index 329cff46..5935d6d0 100644 --- a/crates/nvisy-server/src/handler/auth_oidc.rs +++ b/crates/nvisy-server/src/handler/auth_oidc.rs @@ -57,9 +57,8 @@ use uuid::Uuid; use crate::extract::{AuthState, Json, Path, Query, SecurityContext, ValidateJson}; use crate::handler::request::{DesktopTokenRequest, IdentityPathParams, OidcCallbackQuery}; -use crate::handler::response::{DesktopToken, ErrorResponse}; -use crate::handler::{ErrorKind, Result}; -use crate::response::{CookieConfig, RedirectResult, WebSession}; +use crate::handler::response::DesktopToken; +use crate::response::{CookieConfig, ErrorKind, ErrorResponse, RedirectResult, Result, WebSession}; use crate::service::{ AccountProvisioner, AuthIssuer, OidcAuthorization, OidcService, RedirectKind, ServiceState, }; diff --git a/crates/nvisy-server/src/handler/authentication.rs b/crates/nvisy-server/src/handler/authentication.rs index f9002d4c..47d642c7 100644 --- a/crates/nvisy-server/src/handler/authentication.rs +++ b/crates/nvisy-server/src/handler/authentication.rs @@ -17,11 +17,9 @@ use nvisy_postgres::types::IdentityProvider; use nvisy_postgres::{AsyncConnection, Error as PgError, PgClient}; use super::request::{Login, Signup}; -use super::response::ErrorResponse; use crate::extract::{AuthState, Json, SecurityContext, ValidateJson}; use crate::handler::utility::build_password_user_inputs; -use crate::handler::{ErrorKind, Result}; -use crate::response::{ClearedSession, CookieConfig, WebSession}; +use crate::response::{ClearedSession, CookieConfig, ErrorKind, ErrorResponse, Result, WebSession}; use crate::service::{AuthIssuer, PasswordService, ServiceState}; /// Tracing target for authentication operations. diff --git a/crates/nvisy-server/src/handler/avatars.rs b/crates/nvisy-server/src/handler/avatars.rs index 49c16f88..28751556 100644 --- a/crates/nvisy-server/src/handler/avatars.rs +++ b/crates/nvisy-server/src/handler/avatars.rs @@ -16,9 +16,7 @@ use serde::{Deserialize, Serialize}; use uuid::Uuid; use crate::extract::{Json, Path}; -use crate::handler::response::ErrorResponse; -use crate::handler::{Error, Result}; -use crate::response::AvatarImage; +use crate::response::{AvatarImage, Error, ErrorResponse, Result}; use crate::service::{AvatarService, ServiceState}; /// Tracing target for public avatar serving. diff --git a/crates/nvisy-server/src/handler/catalog.rs b/crates/nvisy-server/src/handler/catalog.rs index ea7473b0..934e48dc 100644 --- a/crates/nvisy-server/src/handler/catalog.rs +++ b/crates/nvisy-server/src/handler/catalog.rs @@ -18,7 +18,8 @@ use schemars::JsonSchema; use serde::Serialize; use crate::extract::{AuthState, Json}; -use crate::handler::response::{ErrorResponse, RecognizerCatalog}; +use crate::handler::response::RecognizerCatalog; +use crate::response::ErrorResponse; use crate::service::{EngineService, ServiceState}; /// Lists the deployment's supported labels (the built-in taxonomy). diff --git a/crates/nvisy-server/src/handler/chat.rs b/crates/nvisy-server/src/handler/chat.rs index 4377ccd8..bb16b5c4 100644 --- a/crates/nvisy-server/src/handler/chat.rs +++ b/crates/nvisy-server/src/handler/chat.rs @@ -18,13 +18,12 @@ use nvisy_postgres::query::{AppendSessionUpdate, ChatMessageRepository, ChatSess use nvisy_postgres::types::ChatRole; use tokio_util::sync::CancellationToken; -use crate::extract::{Authorized, Json, Path, Query, UseChat, ValidateJson}; +use crate::extract::{Authorized, Json, Path, Query, ValidateJson, markers}; use crate::handler::request::{ ChatSessionPathParams, CreateChatSession, CursorPagination, SendChatMessage, }; -use crate::handler::response::{ChatMessage, ChatSession, ChatSessionsPage, ErrorResponse}; -use crate::handler::{Error, Result}; -use crate::response::SseResponse; +use crate::handler::response::{ChatMessage, ChatSession, ChatSessionsPage}; +use crate::response::{Error, ErrorResponse, Result, SseResponse}; use crate::service::{ChatService, ServiceState, TurnLocation}; /// Tracing target for chat operations. @@ -45,7 +44,7 @@ const MAX_REPLY_BYTES: usize = 96 * 1024; #[tracing::instrument(skip_all, fields(account_id = %authz.account_id, workspace_id = %authz.workspace.id))] async fn create_session( State(pg_client): State, - authz: Authorized, + authz: Authorized, ValidateJson(request): ValidateJson, ) -> Result<(StatusCode, Json)> { let account_id = authz.account_id; @@ -76,7 +75,7 @@ fn create_session_docs(op: TransformOperation) -> TransformOperation { #[tracing::instrument(skip_all, fields(account_id = %authz.account_id, workspace_id = %authz.workspace.id))] async fn list_sessions( State(pg_client): State, - authz: Authorized, + authz: Authorized, Query(pagination): Query, ) -> Result<(StatusCode, Json)> { let workspace = authz.workspace; @@ -103,7 +102,7 @@ fn list_sessions_docs(op: TransformOperation) -> TransformOperation { async fn list_messages( State(pg_client): State, State(chat): State, - authz: Authorized, + authz: Authorized, Path(path_params): Path, ) -> Result<(StatusCode, Json>)> { let workspace = authz.workspace; @@ -136,7 +135,7 @@ fn list_messages_docs(op: TransformOperation) -> TransformOperation { #[tracing::instrument(skip_all, fields(account_id = %authz.account_id, workspace_id = %authz.workspace.id, session_id = %path_params.session_id))] async fn delete_session( State(pg_client): State, - authz: Authorized, + authz: Authorized, Path(path_params): Path, ) -> Result { let workspace = authz.workspace; @@ -175,7 +174,7 @@ async fn send_message( State(pg_client): State, State(chat): State, State(shutdown): State, - authz: Authorized, + authz: Authorized, Path(path_params): Path, ValidateJson(request): ValidateJson, ) -> Result> { diff --git a/crates/nvisy-server/src/handler/connection_oauth.rs b/crates/nvisy-server/src/handler/connection_oauth.rs index e1bc387a..a5933994 100644 --- a/crates/nvisy-server/src/handler/connection_oauth.rs +++ b/crates/nvisy-server/src/handler/connection_oauth.rs @@ -36,16 +36,12 @@ use nvisy_postgres::{AsyncConnection, PgClient}; use serde::{Deserialize, Serialize}; use uuid::Uuid; -use crate::extract::{ - Authorized, Json, ManageConnections, Path, Query, SecurityContext, ValidateJson, -}; +use crate::extract::{Authorized, Json, Path, Query, SecurityContext, ValidateJson, markers}; use crate::handler::request::{OAuthCallbackQuery, OAuthStartPathParams, StartFileServiceOAuth}; -use crate::handler::response::ErrorResponse; -use crate::handler::{Error, ErrorKind, Result}; -use crate::response::connection_result_redirect; +use crate::response::{Error, ErrorKind, ErrorResponse, Result, connection_result_redirect}; use crate::service::{ - ConnectionConfig, ConnectionRef, CryptoService, EventEmitter, EventOrigin, FileServiceRedirect, - ServiceState, WorkspaceEvent, + ConnectionConfig, ConnectionCreated, CryptoService, EventEmitter, EventOrigin, + FileServiceRedirect, ServiceState, WorkspaceEvent, }; /// Tracing target for connection OAuth operations. @@ -104,7 +100,7 @@ pub struct OAuthStartResponse { async fn start_oauth( State(nats): State, State(cloud): State, - authz: Authorized, + authz: Authorized, Path(path_params): Path, ValidateJson(request): ValidateJson, ) -> Result<(StatusCode, Json)> { @@ -258,7 +254,7 @@ async fn complete_callback( account_id: flow.account_id, security, }, - WorkspaceEvent::ConnectionCreated(ConnectionRef { + WorkspaceEvent::ConnectionCreated(ConnectionCreated { connection_id: connection.id, connection_name: connection.display_name.clone(), }), diff --git a/crates/nvisy-server/src/handler/connection_syncs.rs b/crates/nvisy-server/src/handler/connection_syncs.rs index ef38b9a0..a60d6a28 100644 --- a/crates/nvisy-server/src/handler/connection_syncs.rs +++ b/crates/nvisy-server/src/handler/connection_syncs.rs @@ -26,16 +26,14 @@ use nvisy_postgres::types::{ use nvisy_postgres::{PgClient, PgConn}; use uuid::Uuid; -use crate::extract::{ - Authorized, Json, Path, Query, RunConnectionSyncs, ValidateJson, ViewConnections, -}; +use crate::extract::{Authorized, Json, Path, Query, ValidateJson, markers}; use crate::handler::request::{ ConnectionPathParams, ConnectionSyncPathParams, CursorPagination, ExportFiles, ImportFiles, WorkspaceSyncsQuery, }; -use crate::handler::response::{ConnectionSync, ConnectionSyncsPage, ErrorResponse, Page}; +use crate::handler::response::{ConnectionSync, ConnectionSyncsPage, Page}; use crate::handler::utility::resolve_account_ref; -use crate::handler::{Error, ErrorKind, Result}; +use crate::response::{Error, ErrorKind, ErrorResponse, Result}; use crate::service::{ ConnectionConfig, ConnectionSyncService, CryptoService, ServiceState, SourceEntry, TransferKind, TransferRequest, @@ -62,7 +60,7 @@ async fn sync_connection( State(pg_client): State, State(crypto): State, State(connection_sync): State, - authz: Authorized, + authz: Authorized, Path(path_params): Path, ) -> Result<(StatusCode, Json)> { tracing::debug!(target: TRACING_TARGET, "Triggering connection sync"); @@ -160,7 +158,7 @@ async fn import_files( State(pg_client): State, State(crypto): State, State(connection_sync): State, - authz: Authorized, + authz: Authorized, Path(path_params): Path, ValidateJson(request): ValidateJson, ) -> Result<(StatusCode, Json)> { @@ -247,7 +245,7 @@ async fn export_files( State(pg_client): State, State(crypto): State, State(connection_sync): State, - authz: Authorized, + authz: Authorized, Path(path_params): Path, ValidateJson(request): ValidateJson, ) -> Result<(StatusCode, Json)> { @@ -358,7 +356,7 @@ async fn open_run_and_transfer( )] async fn list_connection_syncs( State(pg_client): State, - authz: Authorized, + authz: Authorized, Path(path_params): Path, Query(pagination): Query, ) -> Result<(StatusCode, Json)> { @@ -403,7 +401,7 @@ fn list_connection_syncs_docs(op: TransformOperation) -> TransformOperation { )] async fn list_workspace_syncs( State(pg_client): State, - authz: Authorized, + authz: Authorized, Query(pagination): Query, Query(query): Query, ) -> Result<(StatusCode, Json)> { @@ -452,7 +450,7 @@ fn list_workspace_syncs_docs(op: TransformOperation) -> TransformOperation { )] async fn read_connection_sync( State(pg_client): State, - authz: Authorized, + authz: Authorized, Path(path_params): Path, ) -> Result<(StatusCode, Json)> { tracing::debug!(target: TRACING_TARGET, "Reading connection sync"); @@ -505,7 +503,7 @@ fn read_connection_sync_docs(op: TransformOperation) -> TransformOperation { async fn cancel_connection_sync( State(pg_client): State, State(connection_sync): State, - authz: Authorized, + authz: Authorized, Path(path_params): Path, ) -> Result<(StatusCode, Json)> { tracing::debug!(target: TRACING_TARGET, "Cancelling connection sync"); diff --git a/crates/nvisy-server/src/handler/connections.rs b/crates/nvisy-server/src/handler/connections.rs index c24e8332..d216f303 100644 --- a/crates/nvisy-server/src/handler/connections.rs +++ b/crates/nvisy-server/src/handler/connections.rs @@ -34,22 +34,18 @@ use nvisy_postgres::types::{ConnectionId, WithAccountRef}; use nvisy_postgres::{AsyncConnection, PgClient, PgConn}; use uuid::Uuid; -use crate::extract::{ - Authorized, Json, ManageConnections, Path, Query, RunConnectionSyncs, SecurityContext, - ValidateJson, ViewConnections, -}; +use crate::extract::{Authorized, Json, Path, Query, SecurityContext, ValidateJson, markers}; use crate::handler::request::{ ConnectionPathParams, ConnectionsQuery, CreateConnection, CursorPagination, PickerTokenRequest, SyncScheduleInput, UpdateConnection, }; -use crate::handler::response::{ - Connection, ConnectionVerification, ConnectionsPage, ErrorResponse, PickerToken, -}; +use crate::handler::response::{Connection, ConnectionVerification, ConnectionsPage, PickerToken}; use crate::handler::utility::resolve_account_ref; -use crate::handler::{Error, ErrorKind, Result}; +use crate::response::{Error, ErrorKind, ErrorResponse, Result}; use crate::service::{ - ConnectionConfig, ConnectionRef, CryptoService, EventEmitter, EventOrigin, ExternalObjectStore, - ServiceState, StandardCronSchedule, WorkspaceEvent, persist_refreshed_tokens, + ConnectionConfig, ConnectionCreated, ConnectionDeleted, ConnectionUpdated, CryptoService, + EventEmitter, EventOrigin, ExternalObjectStore, ServiceState, StandardCronSchedule, + WorkspaceEvent, persist_refreshed_tokens, }; /// Tracing target for workspace connection operations. @@ -70,7 +66,7 @@ async fn create_connection( State(pg_client): State, State(crypto): State, State(endpoint_policy): State, - authz: Authorized, + authz: Authorized, security: SecurityContext, ValidateJson(request): ValidateJson, ) -> Result<(StatusCode, Json)> { @@ -140,7 +136,7 @@ async fn create_connection( account_id, security: &security, }, - WorkspaceEvent::ConnectionCreated(ConnectionRef { + WorkspaceEvent::ConnectionCreated(ConnectionCreated { connection_id: connection.id, connection_name: connection.display_name.clone(), }), @@ -199,7 +195,7 @@ fn create_connection_docs(op: TransformOperation) -> TransformOperation { )] async fn list_connections( State(pg_client): State, - authz: Authorized, + authz: Authorized, Query(pagination): Query, Query(query): Query, ) -> Result<(StatusCode, Json)> { @@ -278,7 +274,7 @@ fn list_connections_docs(op: TransformOperation) -> TransformOperation { )] async fn read_connection( State(pg_client): State, - authz: Authorized, + authz: Authorized, Path(path_params): Path, ) -> Result<(StatusCode, Json)> { tracing::debug!(target: TRACING_TARGET, "Reading workspace connection"); @@ -330,7 +326,7 @@ async fn update_connection( State(pg_client): State, State(crypto): State, State(endpoint_policy): State, - authz: Authorized, + authz: Authorized, Path(path_params): Path, security: SecurityContext, ValidateJson(request): ValidateJson, @@ -445,7 +441,7 @@ async fn update_connection( account_id, security: &security, }, - WorkspaceEvent::ConnectionUpdated(ConnectionRef { + WorkspaceEvent::ConnectionUpdated(ConnectionUpdated { connection_id, connection_name, }), @@ -498,7 +494,7 @@ fn update_connection_docs(op: TransformOperation) -> TransformOperation { )] async fn delete_connection( State(pg_client): State, - authz: Authorized, + authz: Authorized, Path(path_params): Path, security: SecurityContext, ) -> Result { @@ -523,7 +519,7 @@ async fn delete_connection( account_id, security: &security, }, - WorkspaceEvent::ConnectionDeleted(ConnectionRef { + WorkspaceEvent::ConnectionDeleted(ConnectionDeleted { connection_id: existing.id, connection_name: existing.display_name.clone(), }), @@ -567,7 +563,7 @@ async fn verify_connection( State(crypto): State, State(object): State, State(cloud): State, - authz: Authorized, + authz: Authorized, Path(path_params): Path, ) -> Result<(StatusCode, Json)> { tracing::debug!(target: TRACING_TARGET, "Verifying workspace connection"); @@ -677,7 +673,7 @@ async fn mint_picker_token( State(pg_client): State, State(crypto): State, State(cloud): State, - authz: Authorized, + authz: Authorized, Path(path_params): Path, // Optional body: a picker that names a resource per `authenticate` command // (OneDrive) sends `{ resource }`; single-token pickers send no body. diff --git a/crates/nvisy-server/src/handler/detection_audits.rs b/crates/nvisy-server/src/handler/detection_audits.rs index 41ba9634..2dae801c 100644 --- a/crates/nvisy-server/src/handler/detection_audits.rs +++ b/crates/nvisy-server/src/handler/detection_audits.rs @@ -19,12 +19,10 @@ use elide_pipeline::{ArtifactSet, Audit}; use nvisy_postgres::PgClient; use super::detections::find_detection; -use crate::extract::{Authorized, DownloadAudit, DownloadOriginalFiles, Json, Path, Query}; +use crate::extract::{Authorized, Json, Path, Query, markers}; use crate::handler::request::{DetectionPathParams, ExportFormat, ExportQuery}; -use crate::handler::response::ErrorResponse; use crate::handler::utility::DownloadDocs; -use crate::handler::{Error, ErrorKind, Result}; -use crate::response::attachment_headers; +use crate::response::{Error, ErrorKind, ErrorResponse, Result, attachment_headers}; use crate::service::{EngineService, RunBlobStore, ServiceState}; /// Tracing target for detection audit operations. @@ -46,7 +44,7 @@ async fn get_detection_analysis( State(pg_client): State, State(blob): State, State(engine): State, - authz: Authorized, + authz: Authorized, Path(path_params): Path, ) -> Result<(StatusCode, Json)> { tracing::debug!(target: TRACING_TARGET, "Getting detection analysis"); @@ -105,7 +103,7 @@ async fn get_detection_intermediates( State(pg_client): State, State(blob): State, State(engine): State, - authz: Authorized, + authz: Authorized, Path(path_params): Path, ) -> Result<(StatusCode, Json)> { tracing::debug!(target: TRACING_TARGET, "Getting detection intermediates"); @@ -168,7 +166,7 @@ async fn download_detection_audit( State(pg_client): State, State(blob): State, State(engine): State, - authz: Authorized, + authz: Authorized, Path(path_params): Path, Query(query): Query, ) -> Result<(StatusCode, HeaderMap, Body)> { diff --git a/crates/nvisy-server/src/handler/detections.rs b/crates/nvisy-server/src/handler/detections.rs index 72e4b9a8..16dcc688 100644 --- a/crates/nvisy-server/src/handler/detections.rs +++ b/crates/nvisy-server/src/handler/detections.rs @@ -26,20 +26,19 @@ use nvisy_postgres::{AsyncConnection, PgClient, PgConn}; use uuid::Uuid; use crate::extract::{ - Authorized, IdempotencyKey, Json, Path, Query, RunDetections, RunRedactions, SecurityContext, - ValidateJson, ViewDetections, + Authorized, IdempotencyKey, Json, Path, Query, SecurityContext, ValidateJson, markers, }; use crate::handler::request::{ CreateDetection, CursorPagination, DetectionPathParams, PipelineDefinition, PipelineDetectionsQuery, PipelinePathParams, RedactDetection, WorkspaceDetectionsQuery, }; -use crate::handler::response::{Detection, DetectionsPage, ErrorResponse, RedactionResult}; +use crate::handler::response::{Detection, DetectionsPage, RedactionResult}; use crate::handler::utility::resolve_account_ref; -use crate::handler::{Error, ErrorKind, Result}; -use crate::response::SseResponse; +use crate::response::{Error, ErrorKind, ErrorResponse, Result, SseResponse}; use crate::service::{ - CryptoService, DetectionJob, DetectionQueue, DetectionRef, DetectionStatusEvent, EngineService, - EventEmitter, EventOrigin, RunBlobStore, ServiceState, WorkspaceEvent, resolve_policies, + CryptoService, DetectionJob, DetectionQueue, DetectionStarted, DetectionStatusEvent, + EngineService, EventEmitter, EventOrigin, RedactionCreated, RunBlobStore, ServiceState, + WorkspaceEvent, resolve_policies, }; /// Tracing target for detection operations. @@ -61,7 +60,7 @@ const TRACING_TARGET: &str = "nvisy_server::handler::detections"; async fn create_detection( State(pg_client): State, State(detection): State, - authz: Authorized, + authz: Authorized, Path(path_params): Path, IdempotencyKey(idempotency_key): IdempotencyKey, security: SecurityContext, @@ -158,7 +157,7 @@ async fn create_detection( account_id: authz.account_id, security: &security, }, - WorkspaceEvent::DetectionStarted(DetectionRef { + WorkspaceEvent::DetectionStarted(DetectionStarted { detection_id: detection_row.id, pipeline_slug: pipeline.slug.clone(), }), @@ -248,7 +247,7 @@ fn create_detection_docs(op: TransformOperation) -> TransformOperation { )] async fn list_pipeline_detections( State(pg_client): State, - authz: Authorized, + authz: Authorized, Path(path_params): Path, Query(pagination): Query, Query(query): Query, @@ -310,7 +309,7 @@ fn list_pipeline_detections_docs(op: TransformOperation) -> TransformOperation { )] async fn list_workspace_detections( State(pg_client): State, - authz: Authorized, + authz: Authorized, Query(pagination): Query, Query(query): Query, ) -> Result<(StatusCode, Json)> { @@ -369,7 +368,7 @@ fn list_workspace_detections_docs(op: TransformOperation) -> TransformOperation )] async fn get_detection( State(pg_client): State, - authz: Authorized, + authz: Authorized, Path(path_params): Path, ) -> Result<(StatusCode, Json)> { tracing::debug!(target: TRACING_TARGET, "Getting detection"); @@ -432,7 +431,7 @@ fn get_detection_docs(op: TransformOperation) -> TransformOperation { async fn stream_detection_events( State(pg_client): State, State(detection): State, - authz: Authorized, + authz: Authorized, Path(path_params): Path, ) -> Result> { tracing::debug!(target: TRACING_TARGET, "Opening detection status stream"); @@ -580,7 +579,7 @@ async fn redact_detection( State(blob): State, State(crypto): State, State(engine): State, - authz: Authorized, + authz: Authorized, Path(path_params): Path, security: SecurityContext, Json(request): Json, @@ -706,15 +705,13 @@ async fn redact_detection( account_id: authz.account_id, security: &security, }, - WorkspaceEvent::RedactionCreated { - detection: DetectionRef { - detection_id: detection.id, - pipeline_slug: pipeline.slug.clone(), - }, + WorkspaceEvent::RedactionCreated(RedactionCreated { + detection_id: detection.id, + pipeline_slug: pipeline.slug.clone(), redaction_id: redaction.id, input_file_name: Some(file.display_name.clone()), notify: detection.account_id, - }, + }), ) .await?; Ok::<_, Error>(redaction) diff --git a/crates/nvisy-server/src/handler/files.rs b/crates/nvisy-server/src/handler/files.rs index 64756401..da67f3e4 100644 --- a/crates/nvisy-server/src/handler/files.rs +++ b/crates/nvisy-server/src/handler/files.rs @@ -23,21 +23,20 @@ use tokio_util::io::{ReaderStream, StreamReader}; use uuid::Uuid; use crate::extract::{ - AuthState, Authorized, DeleteFiles, Json, Multipart, Path, Permission, Query, SecurityContext, - UpdateFiles, UploadFiles, ValidateJson, ViewFiles, WorkspaceContext, + AuthState, Authorized, Json, Multipart, Path, Permission, Query, SecurityContext, ValidateJson, + WorkspaceContext, markers, }; use crate::handler::request::{ CursorPagination, DeleteFiles as DeleteFilesRequest, ListFiles, UpdateFile, WorkspaceFilePathParams, }; -use crate::handler::response::{self, ErrorResponse, File, Files, FilesPage}; +use crate::handler::response::{self, File, Files, FilesPage}; use crate::handler::utility::{DownloadDocs, resolve_account_ref}; -use crate::handler::{Error, ErrorKind, Result}; use crate::middleware::UploadConfig; -use crate::response::attachment_headers; +use crate::response::{Error, ErrorKind, ErrorResponse, Result, attachment_headers}; use crate::service::{ - CryptoService, EngineService, EventEmitter, EventOrigin, FileRef, HashingReader, LimitedReader, - RunBlobStore, ServiceState, WorkspaceEvent, + CryptoService, EngineService, EventEmitter, EventOrigin, FileCreated, FileDeleted, FileUpdated, + HashingReader, LimitedReader, RunBlobStore, ServiceState, WorkspaceEvent, }; /// Tracing target for workspace file operations. @@ -73,7 +72,7 @@ async fn find_file_with_creator( async fn list_files( State(pg_client): State, State(engine): State, - authz: Authorized, + authz: Authorized, Query(files_query): Query, Query(cursor_pagination): Query, ) -> Result<(StatusCode, Json)> { @@ -303,7 +302,7 @@ async fn upload_file( State(crypto): State, State(engine): State, State(upload): State, - authz: Authorized, + authz: Authorized, security: SecurityContext, mut multipart: Multipart, ) -> Result<(StatusCode, Json)> { @@ -367,13 +366,11 @@ async fn upload_file( let record = conn.create_workspace_file(file.record.clone()).await?; conn.emit_event( origin, - WorkspaceEvent::FileCreated { - file: FileRef { - file_id: record.id, - file_name: record.display_name.clone(), - }, + WorkspaceEvent::FileCreated(FileCreated { + file_id: record.id, + file_name: record.display_name.clone(), file_size_bytes: record.file_size_bytes, - }, + }), ) .await?; created.push(record); @@ -427,7 +424,7 @@ fn upload_file_docs(op: TransformOperation) -> TransformOperation { )] async fn read_file( State(pg_client): State, - authz: Authorized, + authz: Authorized, Path(path_params): Path, ) -> Result<(StatusCode, Json)> { tracing::debug!(target: TRACING_TARGET, "Reading file metadata"); @@ -469,7 +466,7 @@ fn read_file_docs(op: TransformOperation) -> TransformOperation { )] async fn update_file( State(pg_client): State, - authz: Authorized, + authz: Authorized, Path(path_params): Path, security: SecurityContext, ValidateJson(request): ValidateJson, @@ -500,7 +497,7 @@ async fn update_file( account_id: authz.account_id, security: &security, }, - WorkspaceEvent::FileUpdated(FileRef { + WorkspaceEvent::FileUpdated(FileUpdated { file_id: path_params.file_id, file_name: updated_file.display_name.clone(), }), @@ -668,7 +665,7 @@ fn download_file_docs(op: TransformOperation) -> TransformOperation { async fn delete_file( State(pg_client): State, State(blob): State, - authz: Authorized, + authz: Authorized, Path(path_params): Path, security: SecurityContext, ) -> Result { @@ -690,7 +687,7 @@ async fn delete_file( account_id: authz.account_id, security: &security, }, - WorkspaceEvent::FileDeleted(FileRef { + WorkspaceEvent::FileDeleted(FileDeleted { file_id: path_params.file_id, file_name: file.display_name.clone(), }), @@ -738,7 +735,7 @@ fn delete_file_docs(op: TransformOperation) -> TransformOperation { async fn bulk_delete_files( State(pg_client): State, State(blob): State, - authz: Authorized, + authz: Authorized, security: SecurityContext, ValidateJson(request): ValidateJson, ) -> Result<(StatusCode, Json)> { @@ -769,7 +766,7 @@ async fn bulk_delete_files( account_id: authz.account_id, security: &security, }, - WorkspaceEvent::FileDeleted(FileRef { + WorkspaceEvent::FileDeleted(FileDeleted { file_id: file.id, file_name: file.display_name.clone(), }), diff --git a/crates/nvisy-server/src/handler/identities.rs b/crates/nvisy-server/src/handler/identities.rs index a8f38b9c..942f9fb3 100644 --- a/crates/nvisy-server/src/handler/identities.rs +++ b/crates/nvisy-server/src/handler/identities.rs @@ -31,9 +31,9 @@ use nvisy_postgres::{AsyncConnection, PgClient, PgConn}; use super::consume_reauth_proof; use crate::extract::{AuthState, Json, Path, ValidateJson}; use crate::handler::request::{IdentityPathParams, SetPassword}; -use crate::handler::response::{AccountIdentities, ErrorResponse}; +use crate::handler::response::AccountIdentities; use crate::handler::utility::build_password_user_inputs; -use crate::handler::{Error, ErrorKind, Result}; +use crate::response::{Error, ErrorKind, ErrorResponse, Result}; use crate::service::{PasswordService, ServiceState}; /// Tracing target for identity operations. diff --git a/crates/nvisy-server/src/handler/invites.rs b/crates/nvisy-server/src/handler/invites.rs index 0aa5293c..ac668d13 100644 --- a/crates/nvisy-server/src/handler/invites.rs +++ b/crates/nvisy-server/src/handler/invites.rs @@ -9,34 +9,28 @@ use aide::axum::ApiRouter; use aide::transform::TransformOperation; use axum::extract::State; use axum::http::StatusCode; -use nvisy_postgres::model::{ - Account, NewAccountNotification, NewWorkspaceMember, WorkspaceInvite, WorkspaceMember, -}; +use nvisy_postgres::model::{Account, NewWorkspaceMember, WorkspaceInvite, WorkspaceMember}; use nvisy_postgres::query::{ - AccountNotificationRepository, AccountRepository, WorkspaceInviteRepository, - WorkspaceMemberRepository, WorkspaceRepository, -}; -use nvisy_postgres::types::{ - Handle, MemberInvitedParams, MemberJoinedParams, NotificationPayload, WorkspaceRole, + AccountRepository, WorkspaceInviteRepository, WorkspaceMemberRepository, WorkspaceRepository, }; use nvisy_postgres::{AsyncConnection, Error as PgError, PgClient, PgConn}; use uuid::Uuid; use crate::extract::{ - AuthState, Authorized, InviteMembers, Json, Path, Query, SecurityContext, ValidateJson, - ViewMembers, WorkspaceContext, + AuthState, Authorized, Json, Path, Query, SecurityContext, ValidateJson, WorkspaceContext, + markers, }; use crate::handler::request::{ CreateInvite, CursorPagination, GenerateInviteCode, InviteCodePathParams, InvitePathParams, ListInvites, ReplyInvite, }; use crate::handler::response::{ - ErrorResponse, Invite, InviteCode, InvitePreview, InviteSent, InvitesPage, Member, + Invite, InviteCode, InvitePreview, InviteSent, InvitesPage, Member, }; -use crate::handler::{Error, ErrorKind, Result}; +use crate::response::{Error, ErrorKind, ErrorResponse, Result}; use crate::service::{ - EventEmitter, EventOrigin, InviteRef, MemberRef, NotificationEmitter, ServiceState, - WorkspaceEvent, + EventEmitter, EventOrigin, InviteAccepted, InviteCanceled, InviteCreated, InviteDeclined, + MemberAdded, ServiceState, WorkspaceEvent, }; /// Tracing target for workspace invite operations. @@ -44,14 +38,13 @@ const TRACING_TARGET: &str = "nvisy_server::handler::invites"; /// Outcome of [`create_invite`]. /// -/// The invitee must already be a platform account, since the only delivery -/// this server performs is an in-app notification. An email that maps to no +/// The invitee must already be a platform account. An email that maps to no /// account produces [`InviteOutcome::UnknownEmail`] and no invite row is /// created — the caller reports success either way so the response cannot be /// used to probe whether an account exists. #[must_use] pub enum InviteOutcome { - /// The invite (and its notification) were created for an existing account. + /// The invite was created for an existing account. /// /// Boxed so this variant does not dominate the enum's size over the empty /// [`InviteOutcome::UnknownEmail`]. @@ -75,8 +68,8 @@ pub struct CreatedInvite { /// /// Assumes the caller has already authorized `InviteMembers` on the workspace. /// Rejects an email that already belongs to a member or has a pending invite. -/// If the email resolves to an account, the invite and an in-app notification -/// are created together in one transaction and returned as +/// If the email resolves to an account, the invite is created and its +/// `invite.created` event recorded in one transaction, returned as /// [`InviteOutcome::Created`]; otherwise [`InviteOutcome::UnknownEmail`] is /// returned without creating anything. /// @@ -86,7 +79,6 @@ pub struct CreatedInvite { pub async fn create_invite( conn: &mut PgConn, workspace_id: Uuid, - workspace_slug: &Handle, actor_id: Uuid, security: &SecurityContext, request: &CreateInvite, @@ -116,32 +108,18 @@ pub async fn create_invite( } let new_invite = request.to_model(workspace_id, actor_id); - let account_id = account.id; let invite = conn .transaction(async |conn| { let invite = conn.create_workspace_invite(new_invite).await?; - let (notify_type, params) = NotificationPayload::MemberInvited(MemberInvitedParams { - workspace_slug: workspace_slug.clone(), - invited_by: None, - }) - .into_stored(); - conn.create_account_notification(NewAccountNotification { - account_id, - notify_type, - params, - expires_at: None, - }) - .await?; - conn.emit_event( EventOrigin { workspace_id, account_id: actor_id, security, }, - WorkspaceEvent::InviteCreated(InviteRef { + WorkspaceEvent::InviteCreated(InviteCreated { invite_id: invite.id, email: Some(request.invitee_email.clone()), }), @@ -160,11 +138,10 @@ pub async fn create_invite( /// Creates a new workspace invitation. /// -/// Invites an existing platform user to the workspace and delivers an in-app -/// notification. This server sends no email; if the address does not belong to -/// a known account, the request still succeeds but nothing is created, so the -/// response cannot reveal whether an account exists. Requires `InviteMembers` -/// permission. +/// Invites an existing platform user to the workspace. This server sends no +/// email; if the address does not belong to a known account, the request still +/// succeeds but nothing is created, so the response cannot reveal whether an +/// account exists. Requires `InviteMembers` permission. #[tracing::instrument( skip_all, fields( @@ -175,7 +152,7 @@ pub async fn create_invite( )] async fn send_invite( State(pg_client): State, - authz: Authorized, + authz: Authorized, security: SecurityContext, ValidateJson(request): ValidateJson, ) -> Result<(StatusCode, Json)> { @@ -185,16 +162,7 @@ async fn send_invite( let workspace = authz.workspace; let mut conn = pg_client.get_connection().await?; - match create_invite( - &mut conn, - workspace.id, - &workspace.slug, - account_id, - &security, - &request, - ) - .await? - { + match create_invite(&mut conn, workspace.id, account_id, &security, &request).await? { InviteOutcome::Created(created) => { tracing::info!( target: TRACING_TARGET, @@ -213,10 +181,9 @@ async fn send_invite( fn send_invite_docs(op: TransformOperation) -> TransformOperation { op.summary("Send invitation") .description( - "Invites an existing platform user to the workspace and delivers an in-app \ - notification. No email is sent by this server. The response is identical whether \ - or not the address belongs to a known account, so it cannot be used to determine \ - whether an account exists.", + "Invites an existing platform user to the workspace. No email is sent by this \ + server. The response is identical whether or not the address belongs to a known \ + account, so it cannot be used to determine whether an account exists.", ) .response::<200, Json>() .response::<400, Json>() @@ -238,7 +205,7 @@ fn send_invite_docs(op: TransformOperation) -> TransformOperation { )] async fn list_invites( State(pg_client): State, - authz: Authorized, + authz: Authorized, Query(query): Query, Query(pagination): Query, ) -> Result<(StatusCode, Json)> { @@ -292,7 +259,7 @@ fn list_invites_docs(op: TransformOperation) -> TransformOperation { )] async fn cancel_invite( State(pg_client): State, - authz: Authorized, + authz: Authorized, security: SecurityContext, Path(path_params): Path, ) -> Result { @@ -316,7 +283,7 @@ async fn cancel_invite( account_id, security: &security, }, - WorkspaceEvent::InviteCanceled(InviteRef { + WorkspaceEvent::InviteCanceled(InviteCanceled { invite_id: invite.id, email: invite.invitee_email, }), @@ -356,7 +323,6 @@ fn cancel_invite_docs(op: TransformOperation) -> TransformOperation { )] async fn reply_to_invite( State(pg_client): State, - State(notification_emitter): State, auth_state: AuthState, WorkspaceContext(workspace): WorkspaceContext, security: SecurityContext, @@ -382,24 +348,9 @@ async fn reply_to_invite( tracing::info!(target: TRACING_TARGET, "Invitation accepted"); - // Notify the workspace's owners and admins that a new member joined, - // excluding the joiner themselves (best-effort). - let payload = NotificationPayload::MemberJoined(MemberJoinedParams { - workspace_slug: workspace.slug.clone(), - member_username: account.username.clone(), - }); - if let Err(err) = notification_emitter - .notify_workspace_roles( - workspace.id, - &[WorkspaceRole::Owner, WorkspaceRole::Admin], - Some(auth_state.account_id), - payload, - ) - .await - { - tracing::warn!(target: TRACING_TARGET, error = %err, "Failed to create member-joined notifications"); - } - + // The member.joined notification to owners and admins is raised by the + // MemberAdded event that accept_invite_as_member emits, through the + // drainer. let member = Member::from_model(workspace_member, account); Ok((StatusCode::CREATED, Json(Some(member)))) @@ -415,7 +366,7 @@ async fn reply_to_invite( account_id: auth_state.account_id, security: &security, }, - WorkspaceEvent::InviteDeclined(InviteRef { + WorkspaceEvent::InviteDeclined(InviteDeclined { invite_id: invite.id, email: invite.invitee_email.clone(), }), @@ -460,7 +411,7 @@ fn reply_to_invite_docs(op: TransformOperation) -> TransformOperation { )] async fn generate_invite_code( State(pg_client): State, - authz: Authorized, + authz: Authorized, ValidateJson(request): ValidateJson, ) -> Result<(StatusCode, Json)> { tracing::info!(target: TRACING_TARGET, "Generating invite code"); @@ -615,7 +566,7 @@ async fn reply_to_invite_code( account_id: auth_state.account_id, security: &security, }, - WorkspaceEvent::InviteDeclined(InviteRef { + WorkspaceEvent::InviteDeclined(InviteDeclined { invite_id: invite.id, email: invite.invitee_email.clone(), }), @@ -688,6 +639,14 @@ async fn accept_invite_as_member( .await? .ok_or_else(|| PgError::Unexpected("Member not found after insert".into()))?; + // The workspace slug names the joined workspace in the member.joined + // notification that `MemberAdded` fans out to owners and admins. + let workspace_slug = conn + .find_workspace_by_id(workspace_id) + .await? + .ok_or_else(|| PgError::Unexpected("Workspace not found for invite".into()))? + .slug; + let origin = EventOrigin { workspace_id, account_id, @@ -695,14 +654,15 @@ async fn accept_invite_as_member( }; conn.emit_event( origin, - WorkspaceEvent::InviteAccepted(InviteRef { invite_id, email }), + WorkspaceEvent::InviteAccepted(InviteAccepted { invite_id, email }), ) .await?; conn.emit_event( origin, - WorkspaceEvent::MemberAdded(MemberRef { + WorkspaceEvent::MemberAdded(MemberAdded { member_id: account_id, member_username: account.username.clone(), + workspace_slug, }), ) .await?; diff --git a/crates/nvisy-server/src/handler/members.rs b/crates/nvisy-server/src/handler/members.rs index e43dd458..491c9a90 100644 --- a/crates/nvisy-server/src/handler/members.rs +++ b/crates/nvisy-server/src/handler/members.rs @@ -15,13 +15,15 @@ use nvisy_postgres::{AsyncConnection, PgClient, PgConn}; use uuid::Uuid; use crate::extract::{ - AuthState, Authorized, Json, ManageRoles, Path, Query, RemoveMembers, SecurityContext, - ValidateJson, ViewMembers, WorkspaceContext, + AuthState, Authorized, Json, Path, Query, SecurityContext, ValidateJson, WorkspaceContext, + markers, }; use crate::handler::request::{CursorPagination, ListMembers, MemberPathParams, UpdateMember}; -use crate::handler::response::{ErrorResponse, Member, MembersPage, Page}; -use crate::handler::{Error, ErrorKind, Result}; -use crate::service::{EventEmitter, EventOrigin, MemberRef, ServiceState, WorkspaceEvent}; +use crate::handler::response::{Member, MembersPage, Page}; +use crate::response::{Error, ErrorKind, ErrorResponse, Result}; +use crate::service::{ + EventEmitter, EventOrigin, MemberDeleted, MemberUpdated, ServiceState, WorkspaceEvent, +}; /// Tracing target for workspace member operations. const TRACING_TARGET: &str = "nvisy_server::handler::members"; @@ -39,7 +41,7 @@ const TRACING_TARGET: &str = "nvisy_server::handler::members"; )] async fn list_members( State(pg_client): State, - authz: Authorized, + authz: Authorized, Query(query): Query, Query(pagination): Query, ) -> Result<(StatusCode, Json)> { @@ -92,7 +94,7 @@ fn list_members_docs(op: TransformOperation) -> TransformOperation { )] async fn get_member( State(pg_client): State, - authz: Authorized, + authz: Authorized, Path(path_params): Path, ) -> Result<(StatusCode, Json)> { tracing::debug!(target: TRACING_TARGET, "Retrieving workspace member details"); @@ -148,7 +150,7 @@ fn get_member_docs(op: TransformOperation) -> TransformOperation { )] async fn delete_member( State(pg_client): State, - authz: Authorized, + authz: Authorized, Path(path_params): Path, security: SecurityContext, ) -> Result { @@ -192,7 +194,7 @@ async fn delete_member( account_id, security: &security, }, - WorkspaceEvent::MemberDeleted(MemberRef { + WorkspaceEvent::MemberDeleted(MemberDeleted { member_id: member_account_id, member_username: path_params.username.clone(), }), @@ -235,7 +237,7 @@ fn delete_member_docs(op: TransformOperation) -> TransformOperation { )] async fn update_member( State(pg_client): State, - authz: Authorized, + authz: Authorized, Path(path_params): Path, security: SecurityContext, ValidateJson(request): ValidateJson, @@ -281,7 +283,7 @@ async fn update_member( account_id, security: &security, }, - WorkspaceEvent::MemberUpdated(MemberRef { + WorkspaceEvent::MemberUpdated(MemberUpdated { member_id: member_account_id, member_username: path_params.username.clone(), }), @@ -367,7 +369,7 @@ async fn leave_workspace( account_id: auth_state.account_id, security: &security, }, - WorkspaceEvent::MemberDeleted(MemberRef { + WorkspaceEvent::MemberDeleted(MemberDeleted { member_id: auth_state.account_id, member_username: account.username.clone(), }), diff --git a/crates/nvisy-server/src/handler/mod.rs b/crates/nvisy-server/src/handler/mod.rs index 5fa193b5..bb7ac798 100644 --- a/crates/nvisy-server/src/handler/mod.rs +++ b/crates/nvisy-server/src/handler/mod.rs @@ -6,6 +6,7 @@ mod accounts; mod activities; mod analytics; +mod assignments; mod auth_oidc; mod authentication; @@ -18,7 +19,6 @@ mod connection_syncs; mod connections; mod detection_audits; mod detections; -mod error; mod files; mod identities; mod invites; @@ -41,11 +41,11 @@ use axum::extract::FromRef; use axum::http::{Method, Uri}; use axum::middleware::{from_fn, from_fn_with_state}; use axum::response::{IntoResponse, Response}; -pub use error::{Error, ErrorKind, Result}; pub use invites::{CreatedInvite, InviteOutcome, create_invite}; pub use utility::CustomRoutes; use crate::middleware::{csrf_protect, require_authentication, slide_session}; +use crate::response::ErrorKind; use crate::service::ServiceState; /// Tracing target for unmatched-route fallbacks. @@ -80,6 +80,7 @@ fn private_routes(service_state: ServiceState) -> ApiRouter { .merge(activities::routes()) .merge(analytics::routes()) .merge(members::routes()) + .merge(assignments::routes()) .merge(connections::routes()) .merge(providers::routes()) .merge(connection_oauth::private_routes()) diff --git a/crates/nvisy-server/src/handler/monitors.rs b/crates/nvisy-server/src/handler/monitors.rs index 4350d852..e287724e 100644 --- a/crates/nvisy-server/src/handler/monitors.rs +++ b/crates/nvisy-server/src/handler/monitors.rs @@ -12,7 +12,7 @@ use nvisy_core::health::HealthStatus; use super::response::Health; use crate::extract::{Json, OptionalAuth, Version}; -use crate::handler::Result; +use crate::response::Result; use crate::service::{HealthCache, ServiceState}; /// Tracing target for monitor operations. @@ -37,7 +37,6 @@ const TRACING_TARGET: &str = "nvisy_server::handler::monitors"; skip_all, fields( authenticated = auth_state.is_some(), - is_admin = auth_state.as_ref().map(|a| a.is_admin).unwrap_or(false), account_id = auth_state.as_ref().map(|a| a.account_id.to_string()), ) )] @@ -47,14 +46,12 @@ async fn health_status( version: Version, ) -> Result<(StatusCode, Json)> { let is_authenticated = auth_state.is_some(); - let is_admin = auth_state.as_ref().is_some_and(|auth| auth.is_admin); let account_id = auth_state.as_ref().map(|auth| auth.account_id); tracing::debug!( target: TRACING_TARGET, ?account_id, is_authenticated, - is_admin, version = %version, "Health status check requested" ); diff --git a/crates/nvisy-server/src/handler/notifications.rs b/crates/nvisy-server/src/handler/notifications.rs index 744a5c8b..004172d6 100644 --- a/crates/nvisy-server/src/handler/notifications.rs +++ b/crates/nvisy-server/src/handler/notifications.rs @@ -19,11 +19,8 @@ use uuid::Uuid; use crate::extract::{AuthState, Json, Path, Query}; use crate::handler::request::{CursorPagination, NotificationPathParams}; -use crate::handler::response::{ - ErrorResponse, MarkedReadStatus, Notification, NotificationsPage, UnreadStatus, -}; -use crate::handler::{Error, Result}; -use crate::response::SseResponse; +use crate::handler::response::{MarkedReadStatus, Notification, NotificationsPage, UnreadStatus}; +use crate::response::{Error, ErrorResponse, Result, SseResponse}; use crate::service::{NotificationEmitter, ServiceState, UnreadCountEvent}; /// Tracing target for notification operations. diff --git a/crates/nvisy-server/src/handler/pipelines.rs b/crates/nvisy-server/src/handler/pipelines.rs index a0c648df..1718c412 100644 --- a/crates/nvisy-server/src/handler/pipelines.rs +++ b/crates/nvisy-server/src/handler/pipelines.rs @@ -16,18 +16,18 @@ use nvisy_postgres::types::{FileKind, Handle, RetentionScope, WithAccountRef}; use nvisy_postgres::{AsyncConnection, PgClient, PgConn, PgConnection, Result as PgResult}; use uuid::Uuid; -use crate::extract::{ - Authorized, CreatePipelines, DeletePipelines, Json, Path, Query, SecurityContext, - UpdatePipelines, ValidateJson, ViewPipelines, -}; +use crate::extract::{Authorized, Json, Path, Query, SecurityContext, ValidateJson, markers}; use crate::handler::request::{ CreatePipeline, CursorPagination, PipelineFilter, PipelinePathParams, PipelineReferences, UpdatePipeline, }; -use crate::handler::response::{AccountRef, ErrorResponse, Page, Pipeline, PipelineSummary}; +use crate::handler::response::{AccountRef, Page, Pipeline, PipelineSummary}; use crate::handler::utility::resolve_account_ref; -use crate::handler::{Error, ErrorKind, Result}; -use crate::service::{EventEmitter, EventOrigin, PipelineRef, ServiceState, WorkspaceEvent}; +use crate::response::{Error, ErrorKind, ErrorResponse, Result}; +use crate::service::{ + EventEmitter, EventOrigin, PipelineCreated, PipelineDeleted, PipelineUpdated, ServiceState, + WorkspaceEvent, +}; /// Tracing target for pipeline operations. const TRACING_TARGET: &str = "nvisy_server::handler::pipelines"; @@ -45,7 +45,7 @@ const TRACING_TARGET: &str = "nvisy_server::handler::pipelines"; )] async fn create_pipeline( State(pg_client): State, - authz: Authorized, + authz: Authorized, security: SecurityContext, ValidateJson(request): ValidateJson, ) -> Result<(StatusCode, Json)> { @@ -71,7 +71,7 @@ async fn create_pipeline( account_id, security: &security, }, - WorkspaceEvent::PipelineCreated(PipelineRef { + WorkspaceEvent::PipelineCreated(PipelineCreated { pipeline_id: pipeline.id, pipeline_slug: pipeline.slug.clone(), }), @@ -120,7 +120,7 @@ fn create_pipeline_docs(op: TransformOperation) -> TransformOperation { )] async fn list_pipelines( State(pg_client): State, - authz: Authorized, + authz: Authorized, Query(pagination): Query, Query(filter): Query, ) -> Result<(StatusCode, Json>)> { @@ -170,7 +170,7 @@ fn list_pipelines_docs(op: TransformOperation) -> TransformOperation { )] async fn get_pipeline( State(pg_client): State, - authz: Authorized, + authz: Authorized, Path(path_params): Path, ) -> Result<(StatusCode, Json)> { tracing::debug!(target: TRACING_TARGET, "Getting pipeline"); @@ -214,7 +214,7 @@ fn get_pipeline_docs(op: TransformOperation) -> TransformOperation { )] async fn update_pipeline( State(pg_client): State, - authz: Authorized, + authz: Authorized, Path(path_params): Path, security: SecurityContext, ValidateJson(request): ValidateJson, @@ -291,7 +291,7 @@ async fn update_pipeline( account_id, security: &security, }, - WorkspaceEvent::PipelineUpdated(PipelineRef { + WorkspaceEvent::PipelineUpdated(PipelineUpdated { pipeline_id, pipeline_slug: pipeline.slug.clone(), }), @@ -340,7 +340,7 @@ fn update_pipeline_docs(op: TransformOperation) -> TransformOperation { )] async fn delete_pipeline( State(pg_client): State, - authz: Authorized, + authz: Authorized, Path(path_params): Path, security: SecurityContext, ) -> Result { @@ -367,7 +367,7 @@ async fn delete_pipeline( account_id, security: &security, }, - WorkspaceEvent::PipelineDeleted(PipelineRef { + WorkspaceEvent::PipelineDeleted(PipelineDeleted { pipeline_id, pipeline_slug, }), diff --git a/crates/nvisy-server/src/handler/policies.rs b/crates/nvisy-server/src/handler/policies.rs index a1ddeba9..40016324 100644 --- a/crates/nvisy-server/src/handler/policies.rs +++ b/crates/nvisy-server/src/handler/policies.rs @@ -17,15 +17,14 @@ use nvisy_postgres::types::WithAccountRef; use nvisy_postgres::{AsyncConnection, PgClient, PgConn}; use uuid::Uuid; -use crate::extract::{ - Authorized, Json, ManagePolicies, Path, Query, SecurityContext, ValidateJson, ViewPolicies, -}; +use crate::extract::{Authorized, Json, Path, Query, SecurityContext, ValidateJson, markers}; use crate::handler::request::{CreatePolicy, CursorPagination, PolicyPathParams, UpdatePolicy}; -use crate::handler::response::{ErrorResponse, PoliciesPage, Policy, PolicySummary}; +use crate::handler::response::{PoliciesPage, Policy, PolicySummary}; use crate::handler::utility::resolve_account_ref; -use crate::handler::{Error, Result}; +use crate::response::{Error, ErrorResponse, Result}; use crate::service::{ - CryptoService, EventEmitter, EventOrigin, PolicyRef, ServiceState, WorkspaceEvent, + CryptoService, EventEmitter, EventOrigin, PolicyCreated, PolicyDeleted, PolicyUpdated, + ServiceState, WorkspaceEvent, }; /// Tracing target for workspace policy operations. @@ -46,7 +45,7 @@ const TRACING_TARGET: &str = "nvisy_server::handler::policies"; async fn create_policy( State(pg_client): State, State(crypto): State, - authz: Authorized, + authz: Authorized, security: SecurityContext, ValidateJson(request): ValidateJson, ) -> Result<(StatusCode, Json)> { @@ -89,7 +88,7 @@ async fn create_policy( account_id, security: &security, }, - WorkspaceEvent::PolicyCreated(PolicyRef { + WorkspaceEvent::PolicyCreated(PolicyCreated { policy_id: policy.id, policy_slug: policy.slug.clone(), }), @@ -128,7 +127,7 @@ fn create_policy_docs(op: TransformOperation) -> TransformOperation { )] async fn list_policies( State(pg_client): State, - authz: Authorized, + authz: Authorized, Query(pagination): Query, ) -> Result<(StatusCode, Json)> { tracing::debug!(target: TRACING_TARGET, "Listing workspace policies"); @@ -175,7 +174,7 @@ fn list_policies_docs(op: TransformOperation) -> TransformOperation { async fn read_policy( State(pg_client): State, State(crypto): State, - authz: Authorized, + authz: Authorized, Path(path_params): Path, ) -> Result<(StatusCode, Json)> { tracing::debug!(target: TRACING_TARGET, "Reading workspace policy"); @@ -222,7 +221,7 @@ fn read_policy_docs(op: TransformOperation) -> TransformOperation { async fn update_policy( State(pg_client): State, State(crypto): State, - authz: Authorized, + authz: Authorized, Path(path_params): Path, security: SecurityContext, ValidateJson(request): ValidateJson, @@ -271,7 +270,7 @@ async fn update_policy( account_id, security: &security, }, - WorkspaceEvent::PolicyUpdated(PolicyRef { + WorkspaceEvent::PolicyUpdated(PolicyUpdated { policy_id, policy_slug, }), @@ -311,7 +310,7 @@ fn update_policy_docs(op: TransformOperation) -> TransformOperation { )] async fn delete_policy( State(pg_client): State, - authz: Authorized, + authz: Authorized, Path(path_params): Path, security: SecurityContext, ) -> Result { @@ -338,7 +337,7 @@ async fn delete_policy( account_id, security: &security, }, - WorkspaceEvent::PolicyDeleted(PolicyRef { + WorkspaceEvent::PolicyDeleted(PolicyDeleted { policy_id, policy_slug, }), diff --git a/crates/nvisy-server/src/handler/providers.rs b/crates/nvisy-server/src/handler/providers.rs index bf46d194..8faf224f 100644 --- a/crates/nvisy-server/src/handler/providers.rs +++ b/crates/nvisy-server/src/handler/providers.rs @@ -17,18 +17,16 @@ use nvisy_postgres::types::ProviderId; use nvisy_postgres::{AsyncConnection, PgClient, PgConn}; use uuid::Uuid; -use crate::extract::{ - Authorized, Json, ManageProviders, Path, Query, SecurityContext, ValidateJson, ViewProviders, -}; +use crate::extract::{Authorized, Json, Path, Query, SecurityContext, ValidateJson, markers}; use crate::handler::request::{ CreateProvider, CursorPagination, ProviderPathParams, ProvidersQuery, UpdateProvider, }; -use crate::handler::response::{ConnectionVerification, ErrorResponse, Provider, ProvidersPage}; +use crate::handler::response::{ConnectionVerification, Provider, ProvidersPage}; use crate::handler::utility::resolve_account_ref; -use crate::handler::{Error, ErrorKind, Result}; +use crate::response::{Error, ErrorKind, ErrorResponse, Result}; use crate::service::{ - CryptoService, EventEmitter, EventOrigin, ProviderConfig, ProviderRef, ServiceState, - WorkspaceEvent, + CryptoService, EventEmitter, EventOrigin, ProviderConfig, ProviderCreated, ProviderDeleted, + ProviderUpdated, ServiceState, WorkspaceEvent, }; /// Tracing target for workspace provider operations. @@ -49,7 +47,7 @@ async fn create_provider( State(pg_client): State, State(crypto): State, State(endpoint_policy): State, - authz: Authorized, + authz: Authorized, security: SecurityContext, ValidateJson(request): ValidateJson, ) -> Result<(StatusCode, Json)> { @@ -91,7 +89,7 @@ async fn create_provider( account_id, security: &security, }, - WorkspaceEvent::ProviderCreated(ProviderRef { + WorkspaceEvent::ProviderCreated(ProviderCreated { provider_id: created.id, provider_name: created.display_name.clone(), }), @@ -142,7 +140,7 @@ fn create_provider_docs(op: TransformOperation) -> TransformOperation { )] async fn list_providers( State(pg_client): State, - authz: Authorized, + authz: Authorized, Query(pagination): Query, Query(query): Query, ) -> Result<(StatusCode, Json)> { @@ -194,7 +192,7 @@ fn list_providers_docs(op: TransformOperation) -> TransformOperation { )] async fn read_provider( State(pg_client): State, - authz: Authorized, + authz: Authorized, Path(path_params): Path, ) -> Result<(StatusCode, Json)> { tracing::debug!(target: TRACING_TARGET, "Reading workspace provider"); @@ -240,7 +238,7 @@ async fn update_provider( State(pg_client): State, State(crypto): State, State(endpoint_policy): State, - authz: Authorized, + authz: Authorized, Path(path_params): Path, security: SecurityContext, ValidateJson(request): ValidateJson, @@ -306,7 +304,7 @@ async fn update_provider( account_id, security: &security, }, - WorkspaceEvent::ProviderUpdated(ProviderRef { + WorkspaceEvent::ProviderUpdated(ProviderUpdated { provider_id, provider_name, }), @@ -353,7 +351,7 @@ fn update_provider_docs(op: TransformOperation) -> TransformOperation { )] async fn delete_provider( State(pg_client): State, - authz: Authorized, + authz: Authorized, Path(path_params): Path, security: SecurityContext, ) -> Result { @@ -375,7 +373,7 @@ async fn delete_provider( account_id, security: &security, }, - WorkspaceEvent::ProviderDeleted(ProviderRef { + WorkspaceEvent::ProviderDeleted(ProviderDeleted { provider_id: existing.id, provider_name: existing.display_name.clone(), }), @@ -417,7 +415,7 @@ fn delete_provider_docs(op: TransformOperation) -> TransformOperation { async fn verify_provider( State(pg_client): State, State(crypto): State, - authz: Authorized, + authz: Authorized, Path(path_params): Path, ) -> Result<(StatusCode, Json)> { tracing::debug!(target: TRACING_TARGET, "Verifying workspace provider"); diff --git a/crates/nvisy-server/src/handler/redactions.rs b/crates/nvisy-server/src/handler/redactions.rs index d2b568a1..07c0186d 100644 --- a/crates/nvisy-server/src/handler/redactions.rs +++ b/crates/nvisy-server/src/handler/redactions.rs @@ -15,11 +15,12 @@ use nvisy_postgres::{PgClient, PgConn}; use uuid::Uuid; use super::detections::find_detection; -use crate::extract::{Authorized, DownloadAudit, Json, Path, Query, ViewDetections}; +use crate::extract::{Authorized, Json, Path, Query, markers}; +use crate::handler::ServiceState; use crate::handler::request::{CursorPagination, DetectionPathParams, RedactionPathParams}; -use crate::handler::response::{ErrorResponse, RedactionResult, RedactionsPage}; +use crate::handler::response::{RedactionResult, RedactionsPage}; use crate::handler::utility::resolve_account_ref; -use crate::handler::{ErrorKind, Result, ServiceState}; +use crate::response::{ErrorKind, ErrorResponse, Result}; use crate::service::{EngineService, RunBlobStore}; /// Tracing target for redaction operations. @@ -36,7 +37,7 @@ const TRACING_TARGET: &str = "nvisy_server::handler::redactions"; )] async fn list_detection_redactions( State(pg_client): State, - authz: Authorized, + authz: Authorized, Path(path_params): Path, Query(pagination): Query, ) -> Result<(StatusCode, Json)> { @@ -97,7 +98,7 @@ async fn get_redaction_review( State(pg_client): State, State(blob): State, State(engine): State, - authz: Authorized, + authz: Authorized, Path(path_params): Path, ) -> Result<(StatusCode, Json)> { tracing::debug!(target: TRACING_TARGET, "Getting redaction review audit"); diff --git a/crates/nvisy-server/src/handler/request/activities.rs b/crates/nvisy-server/src/handler/request/activities.rs index dc49f9c8..2a967b0a 100644 --- a/crates/nvisy-server/src/handler/request/activities.rs +++ b/crates/nvisy-server/src/handler/request/activities.rs @@ -6,8 +6,8 @@ use schemars::JsonSchema; use serde::Deserialize; use uuid::Uuid; -use crate::handler::Result; use crate::handler::request::{DateWindow, ExportFormat, ResolvedWindow}; +use crate::response::Result; /// Most rows a single export returns. A hard ceiling so one request cannot /// materialize an unbounded result; a truncated export says so in its response. diff --git a/crates/nvisy-server/src/handler/request/assignments.rs b/crates/nvisy-server/src/handler/request/assignments.rs new file mode 100644 index 00000000..2f43aa7b --- /dev/null +++ b/crates/nvisy-server/src/handler/request/assignments.rs @@ -0,0 +1,72 @@ +//! Assignment request types (assign a reviewer, change status, filter). + +use garde::Validate; +use nvisy_postgres::types::{AssignmentFilter, AssignmentStatus, Handle}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +/// Path parameters addressing one assignment by its opaque id. +#[must_use] +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct AssignmentPathParams { + /// Unique identifier of the assignment. + pub assignment_id: Uuid, +} + +/// Request payload to assign a file to a reviewer. +/// +/// A file may be assigned to several reviewers at once; assigning the same +/// reviewer twice is a no-op. Requires `AssignTasks`. +#[must_use] +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Validate)] +#[serde(rename_all = "camelCase")] +#[garde(allow_unvalidated)] +pub struct CreateAssignment { + /// Handle of the workspace member to assign the file to. + pub assignee: Handle, +} + +/// Request payload to change an assignment's review status. +/// +/// Allowed for the assignee (their own review status) or a member with +/// `AssignTasks`. +#[must_use] +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Validate)] +#[serde(rename_all = "camelCase")] +#[garde(allow_unvalidated)] +pub struct UpdateAssignment { + /// The new review status. + pub status: AssignmentStatus, +} + +/// Query parameters for listing a workspace's assignments. +/// +/// Every field is an optional filter; unset fields impose no constraint. The +/// special assignee value `me` resolves to the caller's own account and is +/// handled by the handler, not carried here. +#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct WorkspaceAssignmentsQuery { + /// Filter by the reviewer the file is assigned to (a member handle, or the + /// literal `me` for the caller). + pub assignee: Option, + /// Filter by review status. + pub status: Option, + /// Filter by the file under review. + pub file_id: Option, +} + +impl WorkspaceAssignmentsQuery { + /// Builds the repository filter, given the already-resolved assignee account + /// id (the handler resolves `me` / a handle to an id, or `None` for no + /// assignee filter). + pub fn into_filter(self, assignee_account_id: Option) -> AssignmentFilter { + AssignmentFilter { + assignee_account_id, + status: self.status, + file_id: self.file_id, + } + } +} diff --git a/crates/nvisy-server/src/handler/request/mod.rs b/crates/nvisy-server/src/handler/request/mod.rs index 7cada41c..bb4c6e3d 100644 --- a/crates/nvisy-server/src/handler/request/mod.rs +++ b/crates/nvisy-server/src/handler/request/mod.rs @@ -2,6 +2,7 @@ mod accounts; mod activities; +mod assignments; mod authentications; mod chat; mod connection_syncs; @@ -24,6 +25,7 @@ mod workspaces; pub use accounts::*; pub use activities::*; +pub use assignments::*; pub use authentications::*; pub use chat::*; pub use connection_syncs::*; diff --git a/crates/nvisy-server/src/handler/request/tokens.rs b/crates/nvisy-server/src/handler/request/tokens.rs index 0c11f593..038dd184 100644 --- a/crates/nvisy-server/src/handler/request/tokens.rs +++ b/crates/nvisy-server/src/handler/request/tokens.rs @@ -13,7 +13,7 @@ use serde::{Deserialize, Serialize}; use uuid::Uuid; use crate::extract::SecurityContext; -use crate::handler::Result; +use crate::response::Result; /// Expiration options for API tokens. #[must_use] @@ -95,7 +95,7 @@ impl CreateApiToken { ) -> Result { let sanitized_name = self.display_name.trim().to_string(); if sanitized_name.is_empty() { - return Err(crate::handler::ErrorKind::BadRequest + return Err(crate::response::ErrorKind::BadRequest .with_resource("api_token") .with_message("Token name cannot be empty or whitespace only")); } diff --git a/crates/nvisy-server/src/handler/request/webhooks.rs b/crates/nvisy-server/src/handler/request/webhooks.rs index 10bdeb43..e9b2a86f 100644 --- a/crates/nvisy-server/src/handler/request/webhooks.rs +++ b/crates/nvisy-server/src/handler/request/webhooks.rs @@ -14,7 +14,7 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use uuid::Uuid; -use crate::handler::{ErrorKind, Result}; +use crate::response::{ErrorKind, Result}; /// Request payload for creating a new workspace webhook. #[must_use] diff --git a/crates/nvisy-server/src/handler/request/windows.rs b/crates/nvisy-server/src/handler/request/windows.rs index ba7e50c3..a1b4dfe6 100644 --- a/crates/nvisy-server/src/handler/request/windows.rs +++ b/crates/nvisy-server/src/handler/request/windows.rs @@ -12,7 +12,7 @@ use jiff::{ToSpan, Zoned}; use schemars::JsonSchema; use serde::Deserialize; -use crate::handler::{Error, ErrorKind, Result}; +use crate::response::{Error, ErrorKind, Result}; /// Longest window, as a count of inclusive calendar days. Ranges are materialized /// per day (a gap-filled series, an exported row set), so the span is capped to diff --git a/crates/nvisy-server/src/handler/request/workspaces.rs b/crates/nvisy-server/src/handler/request/workspaces.rs index 90fa6967..d01535c4 100644 --- a/crates/nvisy-server/src/handler/request/workspaces.rs +++ b/crates/nvisy-server/src/handler/request/workspaces.rs @@ -14,7 +14,7 @@ use serde::{Deserialize, Serialize}; use uuid::Uuid; use crate::extract::validators::{validate_non_blank, validate_non_blank_opt}; -use crate::handler::{ErrorKind, Result}; +use crate::response::{ErrorKind, Result}; /// Request payload for creating a new workspace. /// diff --git a/crates/nvisy-server/src/handler/response/accounts.rs b/crates/nvisy-server/src/handler/response/accounts.rs index 4f091fb4..350ea918 100644 --- a/crates/nvisy-server/src/handler/response/accounts.rs +++ b/crates/nvisy-server/src/handler/response/accounts.rs @@ -15,8 +15,6 @@ pub struct Account { pub username: Handle, /// Whether the account email has been verified. pub is_activated: bool, - /// Whether the account has administrator privileges. - pub is_admin: bool, /// Whether the account is currently suspended. pub is_suspended: bool, @@ -40,7 +38,6 @@ impl Account { Self { username: account.username, is_activated: account.is_verified, - is_admin: account.is_admin, is_suspended: account.is_suspended, display_name: account.display_name, diff --git a/crates/nvisy-server/src/handler/response/assignments.rs b/crates/nvisy-server/src/handler/response/assignments.rs new file mode 100644 index 00000000..869e9dfc --- /dev/null +++ b/crates/nvisy-server/src/handler/response/assignments.rs @@ -0,0 +1,58 @@ +//! Assignment response types. + +use jiff::Timestamp; +use nvisy_postgres::model::WorkspaceAssignment as AssignmentModel; +use nvisy_postgres::types::AssignmentStatus; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use super::{AccountRef, Page}; + +/// Response type for a file review assignment. +/// +/// A file may be assigned to several reviewers at once (like GitHub assignees); +/// each assignment is its own resource with its own review status. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct Assignment { + /// Unique identifier of the assignment. + pub id: Uuid, + /// File under review. + pub file_id: Uuid, + /// Display name of the file under review, for showing the assignment without + /// a separate file lookup. `None` if the file was removed (e.g. by retention). + #[serde(skip_serializing_if = "Option::is_none")] + pub file_name: Option, + /// Reviewer the file is assigned to. + pub assignee: AccountRef, + /// The reviewer's current review status for this file. + pub status: AssignmentStatus, + /// When the assignment was created. + pub created_at: Timestamp, + /// When the assignment was last updated. + pub updated_at: Timestamp, +} + +/// Paginated response for assignments. +pub type AssignmentsPage = Page; + +impl Assignment { + /// Creates an assignment response from the database model, the resolved + /// reviewer reference, and the file display name. + pub fn from_model( + assignment: AssignmentModel, + assignee: AccountRef, + file_name: Option, + ) -> Self { + Self { + id: assignment.id, + file_id: assignment.file_id, + file_name, + assignee, + status: assignment.status, + created_at: assignment.created_at.into(), + updated_at: assignment.updated_at.into(), + } + } +} diff --git a/crates/nvisy-server/src/handler/response/chat.rs b/crates/nvisy-server/src/handler/response/chat.rs index d7542950..1e209a3a 100644 --- a/crates/nvisy-server/src/handler/response/chat.rs +++ b/crates/nvisy-server/src/handler/response/chat.rs @@ -8,7 +8,7 @@ use serde::{Deserialize, Serialize}; use uuid::Uuid; use super::Page; -use crate::handler::Result; +use crate::response::Result; use crate::service::ChatService; /// A chat session. diff --git a/crates/nvisy-server/src/handler/response/mod.rs b/crates/nvisy-server/src/handler/response/mod.rs index f30514fe..2e7acc02 100644 --- a/crates/nvisy-server/src/handler/response/mod.rs +++ b/crates/nvisy-server/src/handler/response/mod.rs @@ -8,13 +8,13 @@ mod account_ref; mod accounts; mod activities; mod analytics; +mod assignments; mod authentications; mod catalog; mod chat; mod connection_syncs; mod connections; mod detections; -mod errors; mod files; mod identities; mod invites; @@ -33,13 +33,13 @@ pub use account_ref::*; pub use accounts::*; pub use activities::*; pub use analytics::*; +pub use assignments::*; pub use authentications::*; pub use catalog::*; pub use chat::*; pub use connection_syncs::*; pub use connections::*; pub use detections::*; -pub use errors::*; pub use files::*; pub use identities::*; pub use invites::*; diff --git a/crates/nvisy-server/src/handler/response/policies.rs b/crates/nvisy-server/src/handler/response/policies.rs index ceb2aac9..fb7ac093 100644 --- a/crates/nvisy-server/src/handler/response/policies.rs +++ b/crates/nvisy-server/src/handler/response/policies.rs @@ -89,7 +89,7 @@ impl Policy { workspace_slug: Handle, created_by: AccountRef, crypto: &CryptoService, - ) -> crate::handler::Result { + ) -> crate::response::Result { let definition = crypto.decrypt_json::(policy.workspace_id, &policy.definition)?; diff --git a/crates/nvisy-server/src/handler/tokens.rs b/crates/nvisy-server/src/handler/tokens.rs index f50b984e..17242750 100644 --- a/crates/nvisy-server/src/handler/tokens.rs +++ b/crates/nvisy-server/src/handler/tokens.rs @@ -15,9 +15,9 @@ use nvisy_postgres::{PgClient, PgConn}; use uuid::Uuid; use super::request::{CreateApiToken, CursorPagination, TokenPathParams, UpdateApiToken}; -use super::response::{ApiToken, ApiTokenWithJWT, ApiTokensPage, ErrorResponse}; +use super::response::{ApiToken, ApiTokenWithJWT, ApiTokensPage}; use crate::extract::{AuthState, Json, Path, Query, SecurityContext, ValidateJson}; -use crate::handler::{ErrorKind, Result}; +use crate::response::{ErrorKind, ErrorResponse, Result}; use crate::service::{AuthIssuer, ServiceState}; /// Tracing target for API token operations. diff --git a/crates/nvisy-server/src/handler/utility/accounts.rs b/crates/nvisy-server/src/handler/utility/accounts.rs index 08849f64..e48d33ce 100644 --- a/crates/nvisy-server/src/handler/utility/accounts.rs +++ b/crates/nvisy-server/src/handler/utility/accounts.rs @@ -10,7 +10,7 @@ use nvisy_postgres::types::Handle; use uuid::Uuid; use crate::handler::response::AccountRef; -use crate::handler::{ErrorKind, Result}; +use crate::response::{ErrorKind, Result}; /// The outcome of resolving an actor filter (a username supplied by the client) /// against the accounts table. diff --git a/crates/nvisy-server/src/handler/webhooks.rs b/crates/nvisy-server/src/handler/webhooks.rs index 3b982932..a19fe101 100644 --- a/crates/nvisy-server/src/handler/webhooks.rs +++ b/crates/nvisy-server/src/handler/webhooks.rs @@ -19,21 +19,17 @@ use nvisy_webhook::provider::{WebhookContext, WebhookRequest}; use url::Url; use uuid::Uuid; -use crate::extract::{ - Authorized, CreateWebhooks, DeleteWebhooks, Json, Path, Query, SecurityContext, TestWebhooks, - UpdateWebhooks, ValidateJson, ViewWebhooks, -}; +use crate::extract::{Authorized, Json, Path, Query, SecurityContext, ValidateJson, markers}; use crate::handler::request::{ CreateWebhook, CursorPagination, TestWebhook, UpdateWebhook as UpdateWebhookRequest, WebhookPathParams, }; -use crate::handler::response::{ - ErrorResponse, Webhook, WebhookCreated, WebhookResult, WebhooksPage, -}; +use crate::handler::response::{Webhook, WebhookCreated, WebhookResult, WebhooksPage}; use crate::handler::utility::resolve_account_ref; -use crate::handler::{Error, ErrorKind, Result}; +use crate::response::{Error, ErrorKind, ErrorResponse, Result}; use crate::service::{ - CryptoService, EventEmitter, EventOrigin, ServiceState, WebhookRef, WorkspaceEvent, + CryptoService, EventEmitter, EventOrigin, ServiceState, WebhookCreated as WebhookCreatedEvent, + WebhookDeleted as WebhookDeletedEvent, WebhookUpdated as WebhookUpdatedEvent, WorkspaceEvent, }; /// Tracing target for workspace webhook operations. @@ -52,7 +48,7 @@ const TRACING_TARGET: &str = "nvisy_server::handler::webhooks"; async fn create_webhook( State(pg_client): State, State(crypto): State, - authz: Authorized, + authz: Authorized, security: SecurityContext, ValidateJson(request): ValidateJson, ) -> Result<(StatusCode, Json)> { @@ -82,7 +78,7 @@ async fn create_webhook( account_id, security: &security, }, - WorkspaceEvent::WebhookCreated(WebhookRef { + WorkspaceEvent::WebhookCreated(WebhookCreatedEvent { webhook_id: webhook.id, webhook_name: webhook.display_name.clone(), }), @@ -138,7 +134,7 @@ fn create_webhook_docs(op: TransformOperation) -> TransformOperation { )] async fn list_webhooks( State(pg_client): State, - authz: Authorized, + authz: Authorized, Query(pagination): Query, ) -> Result<(StatusCode, Json)> { tracing::debug!(target: TRACING_TARGET, "Listing workspace webhooks"); @@ -185,7 +181,7 @@ fn list_webhooks_docs(op: TransformOperation) -> TransformOperation { )] async fn read_webhook( State(pg_client): State, - authz: Authorized, + authz: Authorized, Path(path_params): Path, ) -> Result<(StatusCode, Json)> { tracing::debug!(target: TRACING_TARGET, "Reading workspace webhook"); @@ -229,7 +225,7 @@ fn read_webhook_docs(op: TransformOperation) -> TransformOperation { )] async fn update_webhook( State(pg_client): State, - authz: Authorized, + authz: Authorized, Path(path_params): Path, security: SecurityContext, ValidateJson(request): ValidateJson, @@ -267,7 +263,7 @@ async fn update_webhook( account_id, security: &security, }, - WorkspaceEvent::WebhookUpdated(WebhookRef { + WorkspaceEvent::WebhookUpdated(WebhookUpdatedEvent { webhook_id: existing.id, webhook_name, }), @@ -314,7 +310,7 @@ fn update_webhook_docs(op: TransformOperation) -> TransformOperation { )] async fn delete_webhook( State(pg_client): State, - authz: Authorized, + authz: Authorized, Path(path_params): Path, security: SecurityContext, ) -> Result { @@ -338,7 +334,7 @@ async fn delete_webhook( account_id, security: &security, }, - WorkspaceEvent::WebhookDeleted(WebhookRef { + WorkspaceEvent::WebhookDeleted(WebhookDeletedEvent { webhook_id: existing.id, webhook_name: existing.display_name.clone(), }), @@ -378,7 +374,7 @@ async fn test_webhook( State(pg_client): State, State(crypto): State, State(webhook_service): State, - authz: Authorized, + authz: Authorized, Path(path_params): Path, ValidateJson(request): ValidateJson, ) -> Result<(StatusCode, Json)> { diff --git a/crates/nvisy-server/src/handler/workspaces.rs b/crates/nvisy-server/src/handler/workspaces.rs index 6caf329a..510ba634 100644 --- a/crates/nvisy-server/src/handler/workspaces.rs +++ b/crates/nvisy-server/src/handler/workspaces.rs @@ -17,21 +17,19 @@ use nvisy_postgres::{AsyncConnection, PgClient, PgConn}; use uuid::Uuid; use crate::extract::{ - AuthState, Authorized, AvatarUpload, DeleteWorkspace, Json, Query, SecurityContext, - UpdateWorkspace as UpdateWorkspacePerm, ValidateJson, ViewWorkspace, WorkspaceContext, + AuthState, Authorized, AvatarUpload, Json, Query, SecurityContext, ValidateJson, + WorkspaceContext, markers, }; use crate::handler::request::{ CreateWorkspace, CursorPagination, UpdateNotificationSettings, UpdateWorkspace, }; -use crate::handler::response::{ - AccountRef, ErrorResponse, NotificationSettings, Page, Workspace, WorkspacesPage, -}; +use crate::handler::response::{AccountRef, NotificationSettings, Page, Workspace, WorkspacesPage}; use crate::handler::utility::resolve_account_ref; -use crate::handler::{Error, ErrorKind, Result}; use crate::middleware::UploadConfig; +use crate::response::{Error, ErrorKind, ErrorResponse, Result}; use crate::service::{ AvatarService, EventEmitter, EventOrigin, MAX_AVATAR_UPLOAD_BYTES, ServiceState, - WorkspaceEvent, WorkspaceRef, + WorkspaceCreated, WorkspaceDeleted, WorkspaceEvent, WorkspaceUpdated, }; /// Tracing target for workspace operations. @@ -96,7 +94,7 @@ async fn create_workspace( account_id: creator_id, security: &security, }, - WorkspaceEvent::WorkspaceCreated(WorkspaceRef { + WorkspaceEvent::WorkspaceCreated(WorkspaceCreated { workspace_id: workspace.id, workspace_slug: workspace.slug.clone(), }), @@ -187,7 +185,7 @@ fn list_workspaces_docs(op: TransformOperation) -> TransformOperation { async fn read_workspace( State(pg_client): State, State(upload): State, - authz: Authorized, + authz: Authorized, ) -> Result<(StatusCode, Json)> { let workspace = authz.workspace; let member = authz.member; @@ -198,10 +196,7 @@ async fn read_workspace( tracing::info!(target: TRACING_TARGET, "Workspace read"); let hard = upload.max_file_bytes(); - let response = match member { - Some(member) => Workspace::from_model_with_membership(workspace, member, creator, hard), - None => Workspace::from_model(workspace, creator, hard), - }; + let response = Workspace::from_model_with_membership(workspace, member, creator, hard); Ok((StatusCode::OK, Json(response))) } @@ -227,7 +222,7 @@ fn read_workspace_docs(op: TransformOperation) -> TransformOperation { async fn update_workspace( State(pg_client): State, State(upload): State, - authz: Authorized, + authz: Authorized, security: SecurityContext, ValidateJson(request): ValidateJson, ) -> Result<(StatusCode, Json)> { @@ -261,7 +256,7 @@ async fn update_workspace( account_id, security: &security, }, - WorkspaceEvent::WorkspaceUpdated(WorkspaceRef { + WorkspaceEvent::WorkspaceUpdated(WorkspaceUpdated { workspace_id: updated.id, workspace_slug: updated.slug.clone(), }), @@ -276,10 +271,7 @@ async fn update_workspace( tracing::info!(target: TRACING_TARGET, "Workspace updated"); let hard = upload.max_file_bytes(); - let response = match member { - Some(member) => Workspace::from_model_with_membership(updated, member, creator, hard), - None => Workspace::from_model(updated, creator, hard), - }; + let response = Workspace::from_model_with_membership(updated, member, creator, hard); Ok((StatusCode::OK, Json(response))) } @@ -308,7 +300,7 @@ fn update_workspace_docs(op: TransformOperation) -> TransformOperation { )] async fn delete_workspace( State(pg_client): State, - authz: Authorized, + authz: Authorized, security: SecurityContext, ) -> Result { tracing::debug!(target: TRACING_TARGET, "Deleting workspace"); @@ -327,7 +319,7 @@ async fn delete_workspace( account_id, security: &security, }, - WorkspaceEvent::WorkspaceDeleted(WorkspaceRef { + WorkspaceEvent::WorkspaceDeleted(WorkspaceDeleted { workspace_id: workspace.id, workspace_slug: workspace.slug.clone(), }), @@ -458,7 +450,7 @@ async fn find_workspace_creator(conn: &mut PgConn, slug: &str) -> Result, - authz: Authorized, + authz: Authorized, AvatarUpload(bytes): AvatarUpload, ) -> Result { tracing::debug!(target: TRACING_TARGET, "Uploading workspace avatar"); @@ -487,7 +479,7 @@ fn upload_workspace_avatar_docs(op: TransformOperation) -> TransformOperation { #[tracing::instrument(skip_all, fields(account_id = %authz.account_id, workspace_id = %authz.workspace.id))] async fn delete_workspace_avatar( State(avatar): State, - authz: Authorized, + authz: Authorized, ) -> Result { tracing::debug!(target: TRACING_TARGET, "Deleting workspace avatar"); diff --git a/crates/nvisy-server/src/middleware/auth/csrf.rs b/crates/nvisy-server/src/middleware/auth/csrf.rs index 4b44e356..d185d0a9 100644 --- a/crates/nvisy-server/src/middleware/auth/csrf.rs +++ b/crates/nvisy-server/src/middleware/auth/csrf.rs @@ -8,7 +8,7 @@ use axum_extra::extract::CookieJar; use super::TRACING_TARGET; use crate::extract::{AuthTransport, CSRF_COOKIE_NAME, CSRF_HEADER_NAME, SessionToken}; -use crate::handler::{ErrorKind, Result}; +use crate::response::{ErrorKind, Result}; /// Enforces CSRF protection on cookie-authenticated, state-changing requests /// (the double-submit-cookie check). diff --git a/crates/nvisy-server/src/middleware/auth/session.rs b/crates/nvisy-server/src/middleware/auth/session.rs index 04d486a0..ec19741e 100644 --- a/crates/nvisy-server/src/middleware/auth/session.rs +++ b/crates/nvisy-server/src/middleware/auth/session.rs @@ -15,7 +15,7 @@ use nvisy_postgres::types::session::SlidingWindow; use super::TRACING_TARGET; use crate::extract::AuthState; -use crate::handler::Result; +use crate::response::Result; /// Requires a valid session to proceed with the request. /// diff --git a/crates/nvisy-server/src/middleware/recovery.rs b/crates/nvisy-server/src/middleware/recovery.rs index cbf02b2e..21c59f53 100644 --- a/crates/nvisy-server/src/middleware/recovery.rs +++ b/crates/nvisy-server/src/middleware/recovery.rs @@ -16,7 +16,7 @@ use tower::timeout::TimeoutLayer; use tower::{BoxError, ServiceBuilder}; use tower_http::catch_panic::CatchPanicLayer; -use crate::handler::{Error, ErrorKind}; +use crate::response::{Error, ErrorKind}; /// Tracing target for error recovery. const TRACING_TARGET_ERROR: &str = "nvisy_server::recovery::error"; diff --git a/crates/nvisy-server/src/handler/error/crypto_error.rs b/crates/nvisy-server/src/response/error/crypto_error.rs similarity index 100% rename from crates/nvisy-server/src/handler/error/crypto_error.rs rename to crates/nvisy-server/src/response/error/crypto_error.rs diff --git a/crates/nvisy-server/src/handler/error/engine_error.rs b/crates/nvisy-server/src/response/error/engine_error.rs similarity index 100% rename from crates/nvisy-server/src/handler/error/engine_error.rs rename to crates/nvisy-server/src/response/error/engine_error.rs diff --git a/crates/nvisy-server/src/handler/response/errors.rs b/crates/nvisy-server/src/response/error/error_response.rs similarity index 91% rename from crates/nvisy-server/src/handler/response/errors.rs rename to crates/nvisy-server/src/response/error/error_response.rs index 86b17733..e7255d06 100644 --- a/crates/nvisy-server/src/handler/response/errors.rs +++ b/crates/nvisy-server/src/response/error/error_response.rs @@ -7,9 +7,9 @@ use schemars::JsonSchema; use serde::Serialize; /// The serialized shape of an HTTP error: the inert wire/OpenAPI-schema view -/// that [`Error`](crate::handler::Error) renders to at the response boundary. +/// that [`Error`](crate::response::Error) renders to at the response boundary. /// -/// It carries no builder logic — [`Error`](crate::handler::Error) is the type +/// It carries no builder logic — [`Error`](crate::response::Error) is the type /// handlers construct and thread through `Result`, and it builds an /// `ErrorResponse` directly in its `IntoResponse` impl. `context` and `status` /// are not part of the JSON body (`context` is logged, `status` sets the HTTP @@ -39,7 +39,7 @@ impl<'a> ErrorResponse<'a> { /// status), with no per-occurrence resource or context. /// /// This is the building block for - /// [`ErrorKind::response`](crate::handler::ErrorKind::response), the single + /// [`ErrorKind::response`](crate::response::ErrorKind::response), the single /// source of truth for each kind's name, status, and message. #[inline] pub const fn new(name: &'a str, message: &'a str, status: StatusCode) -> Self { @@ -56,7 +56,7 @@ impl<'a> ErrorResponse<'a> { impl Default for ErrorResponse<'_> { #[inline] fn default() -> Self { - crate::handler::ErrorKind::InternalServerError.response() + crate::response::ErrorKind::InternalServerError.response() } } @@ -79,7 +79,7 @@ mod tests { use axum::http::StatusCode; use super::ErrorResponse; - use crate::handler::ErrorKind; + use crate::response::ErrorKind; #[test] fn a_kinds_response_carries_its_defaults() { diff --git a/crates/nvisy-server/src/handler/error/file_service_error.rs b/crates/nvisy-server/src/response/error/file_service_error.rs similarity index 94% rename from crates/nvisy-server/src/handler/error/file_service_error.rs rename to crates/nvisy-server/src/response/error/file_service_error.rs index 6dfdab9b..ad9fcf06 100644 --- a/crates/nvisy-server/src/handler/error/file_service_error.rs +++ b/crates/nvisy-server/src/response/error/file_service_error.rs @@ -22,7 +22,7 @@ impl<'a> From for HttpError<'a> { FileServiceErrorKind::BadRequest => ErrorKind::BadRequest .with_message("Cloud file provider rejected the request") .with_context(message), - FileServiceErrorKind::Connection => ErrorKind::BadRequest + FileServiceErrorKind::Connection => ErrorKind::ServiceUnavailable .with_message("Could not connect to the cloud file provider") .with_context(message), FileServiceErrorKind::Runtime => ErrorKind::InternalServerError diff --git a/crates/nvisy-server/src/handler/error/http_error.rs b/crates/nvisy-server/src/response/error/http_error.rs similarity index 99% rename from crates/nvisy-server/src/handler/error/http_error.rs rename to crates/nvisy-server/src/response/error/http_error.rs index 365f9928..ccb68946 100644 --- a/crates/nvisy-server/src/handler/error/http_error.rs +++ b/crates/nvisy-server/src/response/error/http_error.rs @@ -13,7 +13,7 @@ use axum::http::StatusCode; use axum::response::{IntoResponse, Response}; use strum::EnumIter; -use crate::handler::response::ErrorResponse; +use super::ErrorResponse; /// The error type for HTTP handlers in the server. /// diff --git a/crates/nvisy-server/src/handler/error/inference_error.rs b/crates/nvisy-server/src/response/error/inference_error.rs similarity index 100% rename from crates/nvisy-server/src/handler/error/inference_error.rs rename to crates/nvisy-server/src/response/error/inference_error.rs diff --git a/crates/nvisy-server/src/handler/error/mod.rs b/crates/nvisy-server/src/response/error/mod.rs similarity index 53% rename from crates/nvisy-server/src/handler/error/mod.rs rename to crates/nvisy-server/src/response/error/mod.rs index cafeabd4..93951d8d 100644 --- a/crates/nvisy-server/src/handler/error/mod.rs +++ b/crates/nvisy-server/src/response/error/mod.rs @@ -1,7 +1,10 @@ -//! [`Error`], [`ErrorKind`] and [`Result`]. +//! The HTTP error response: [`Error`], [`ErrorKind`], [`Result`], and the +//! serialized [`ErrorResponse`] body, plus the `From` conversions that turn each +//! infrastructure error into an [`Error`] at the request boundary. mod crypto_error; mod engine_error; +mod error_response; mod file_service_error; mod http_error; mod inference_error; @@ -17,4 +20,5 @@ mod pg_workspace; mod s3_error; mod webhook_error; +pub use error_response::ErrorResponse; pub use http_error::{Error, ErrorKind, Result}; diff --git a/crates/nvisy-server/src/handler/error/nats_error.rs b/crates/nvisy-server/src/response/error/nats_error.rs similarity index 91% rename from crates/nvisy-server/src/handler/error/nats_error.rs rename to crates/nvisy-server/src/response/error/nats_error.rs index 9b9e9ec0..236a5a40 100644 --- a/crates/nvisy-server/src/handler/error/nats_error.rs +++ b/crates/nvisy-server/src/response/error/nats_error.rs @@ -8,8 +8,9 @@ use super::http_error::{Error as HttpError, ErrorKind}; impl<'a> From for HttpError<'a> { fn from(nats_error: nvisy_nats::Error) -> Self { match nats_error { - // Connection and network errors -> Service Unavailable or Internal Server Error - nvisy_nats::Error::Connection(_) => ErrorKind::InternalServerError + // Connection and network errors -> Service Unavailable (transient, + // retryable; the messaging backend is momentarily unreachable). + nvisy_nats::Error::Connection(_) => ErrorKind::ServiceUnavailable .with_message("Service temporarily unavailable") .with_context("Unable to connect to messaging service"), @@ -21,12 +22,15 @@ impl<'a> From for HttpError<'a> { .with_message("Message delivery failed") .with_context(format!("Failed to deliver message to {}", subject)), - // Data validation and serialization errors -> Bad Request - nvisy_nats::Error::Serialization(_) => ErrorKind::BadRequest + // Serialization is an internal encode/decode fault, not a client + // input error: the caller cannot influence how we frame NATS payloads. + nvisy_nats::Error::Serialization(_) => ErrorKind::InternalServerError .with_message("Invalid request or response data format") .with_context("Failed to serialize data for storage"), - nvisy_nats::Error::InvalidConfig { .. } => ErrorKind::BadRequest + // A bad messaging configuration is a server-side fault, not a client + // error. + nvisy_nats::Error::InvalidConfig { .. } => ErrorKind::InternalServerError .with_message("Invalid configuration") .with_context("Service configuration is invalid"), @@ -116,7 +120,7 @@ mod tests { let nats_err = nvisy_nats::Error::Serialization(json_err); let http_err: HttpError = nats_err.into(); - assert_eq!(http_err.kind(), ErrorKind::BadRequest); + assert_eq!(http_err.kind(), ErrorKind::InternalServerError); assert!( http_err .message @@ -161,7 +165,7 @@ mod tests { let nats_err = nvisy_nats::Error::invalid_config("missing server URL"); let http_err: HttpError = nats_err.into(); - assert_eq!(http_err.kind(), ErrorKind::BadRequest); + assert_eq!(http_err.kind(), ErrorKind::InternalServerError); assert!( http_err .context diff --git a/crates/nvisy-server/src/handler/error/object_error.rs b/crates/nvisy-server/src/response/error/object_error.rs similarity index 94% rename from crates/nvisy-server/src/handler/error/object_error.rs rename to crates/nvisy-server/src/response/error/object_error.rs index 1e969b57..9a4069e5 100644 --- a/crates/nvisy-server/src/handler/error/object_error.rs +++ b/crates/nvisy-server/src/response/error/object_error.rs @@ -22,7 +22,7 @@ impl<'a> From for HttpError<'a> { ObjectErrorKind::AlreadyExists => ErrorKind::Conflict .with_message("Object already exists") .with_context(message), - ObjectErrorKind::Connection => ErrorKind::BadRequest + ObjectErrorKind::Connection => ErrorKind::ServiceUnavailable .with_message("Could not connect to the object store") .with_context(message), _ => ErrorKind::InternalServerError diff --git a/crates/nvisy-server/src/handler/error/oidc_error.rs b/crates/nvisy-server/src/response/error/oidc_error.rs similarity index 100% rename from crates/nvisy-server/src/handler/error/oidc_error.rs rename to crates/nvisy-server/src/response/error/oidc_error.rs diff --git a/crates/nvisy-server/src/handler/error/pg_account.rs b/crates/nvisy-server/src/response/error/pg_account.rs similarity index 98% rename from crates/nvisy-server/src/handler/error/pg_account.rs rename to crates/nvisy-server/src/response/error/pg_account.rs index d00cb8f3..73fcafe3 100644 --- a/crates/nvisy-server/src/handler/error/pg_account.rs +++ b/crates/nvisy-server/src/response/error/pg_account.rs @@ -5,7 +5,7 @@ use nvisy_postgres::types::{ AccountNotificationConstraints, }; -use crate::handler::{Error, ErrorKind}; +use super::{Error, ErrorKind}; impl From for Error<'static> { fn from(c: AccountConstraints) -> Self { diff --git a/crates/nvisy-server/src/handler/error/pg_chat.rs b/crates/nvisy-server/src/response/error/pg_chat.rs similarity index 96% rename from crates/nvisy-server/src/handler/error/pg_chat.rs rename to crates/nvisy-server/src/response/error/pg_chat.rs index 9da07173..668dcd91 100644 --- a/crates/nvisy-server/src/handler/error/pg_chat.rs +++ b/crates/nvisy-server/src/response/error/pg_chat.rs @@ -2,7 +2,7 @@ use nvisy_postgres::types::{ChatMessageConstraints, ChatSessionConstraints}; -use crate::handler::{Error, ErrorKind}; +use super::{Error, ErrorKind}; impl From for Error<'static> { fn from(c: ChatSessionConstraints) -> Self { diff --git a/crates/nvisy-server/src/handler/error/pg_document.rs b/crates/nvisy-server/src/response/error/pg_document.rs similarity index 97% rename from crates/nvisy-server/src/handler/error/pg_document.rs rename to crates/nvisy-server/src/response/error/pg_document.rs index c5c94b4f..f5b14871 100644 --- a/crates/nvisy-server/src/handler/error/pg_document.rs +++ b/crates/nvisy-server/src/response/error/pg_document.rs @@ -2,7 +2,7 @@ use nvisy_postgres::types::WorkspaceFileConstraints; -use crate::handler::{Error, ErrorKind}; +use super::{Error, ErrorKind}; impl From for Error<'static> { fn from(c: WorkspaceFileConstraints) -> Self { diff --git a/crates/nvisy-server/src/handler/error/pg_error.rs b/crates/nvisy-server/src/response/error/pg_error.rs similarity index 98% rename from crates/nvisy-server/src/handler/error/pg_error.rs rename to crates/nvisy-server/src/response/error/pg_error.rs index c9f5ba4b..74a08e2d 100644 --- a/crates/nvisy-server/src/handler/error/pg_error.rs +++ b/crates/nvisy-server/src/response/error/pg_error.rs @@ -9,7 +9,7 @@ use nvisy_postgres::types::ConstraintViolation; use nvisy_postgres::{Error as PgError, TimeoutType}; -use crate::handler::{Error, ErrorKind}; +use super::{Error, ErrorKind}; /// Tracing target for account operations. const TRACING_TARGET: &str = "nvisy_server::postgres_constraints"; @@ -29,6 +29,7 @@ impl From for Error<'static> { ConstraintViolation::WorkspaceActivityLog(c) => c.into(), ConstraintViolation::WorkspaceWebhook(c) => c.into(), ConstraintViolation::WorkspaceFile(c) => c.into(), + ConstraintViolation::WorkspaceAssignment(c) => c.into(), ConstraintViolation::WorkspacePipeline(c) => c.into(), ConstraintViolation::WorkspaceDetection(c) => c.into(), ConstraintViolation::WorkspacePipelineReference(c) => c.into(), diff --git a/crates/nvisy-server/src/handler/error/pg_pipeline.rs b/crates/nvisy-server/src/response/error/pg_pipeline.rs similarity index 99% rename from crates/nvisy-server/src/handler/error/pg_pipeline.rs rename to crates/nvisy-server/src/response/error/pg_pipeline.rs index 4d6fe8e9..1f7035e9 100644 --- a/crates/nvisy-server/src/handler/error/pg_pipeline.rs +++ b/crates/nvisy-server/src/response/error/pg_pipeline.rs @@ -6,7 +6,7 @@ use nvisy_postgres::types::{ WorkspacePipelineReferenceConstraints, WorkspacePolicyConstraints, }; -use crate::handler::{Error, ErrorKind}; +use super::{Error, ErrorKind}; impl From for Error<'static> { fn from(c: WorkspacePipelineConstraints) -> Self { diff --git a/crates/nvisy-server/src/handler/error/pg_workspace.rs b/crates/nvisy-server/src/response/error/pg_workspace.rs similarity index 87% rename from crates/nvisy-server/src/handler/error/pg_workspace.rs rename to crates/nvisy-server/src/response/error/pg_workspace.rs index 9e0609b6..0513841f 100644 --- a/crates/nvisy-server/src/handler/error/pg_workspace.rs +++ b/crates/nvisy-server/src/response/error/pg_workspace.rs @@ -1,11 +1,11 @@ //! Workspace-related constraint violation error handlers. use nvisy_postgres::types::{ - WorkspaceActivitiesConstraints, WorkspaceConstraints, WorkspaceInviteConstraints, - WorkspaceMemberConstraints, WorkspaceWebhookConstraints, + WorkspaceActivitiesConstraints, WorkspaceAssignmentConstraints, WorkspaceConstraints, + WorkspaceInviteConstraints, WorkspaceMemberConstraints, WorkspaceWebhookConstraints, }; -use crate::handler::{Error, ErrorKind}; +use super::{Error, ErrorKind}; impl From for Error<'static> { fn from(c: WorkspaceConstraints) -> Self { @@ -49,6 +49,18 @@ impl From for Error<'static> { } } +impl From for Error<'static> { + fn from(c: WorkspaceAssignmentConstraints) -> Self { + let error = match c { + WorkspaceAssignmentConstraints::FileAssigneeUnique => { + ErrorKind::Conflict.with_message("This reviewer is already assigned to the file") + } + }; + + error.with_resource("workspace_assignment") + } +} + impl From for Error<'static> { fn from(c: WorkspaceInviteConstraints) -> Self { let error = match c { diff --git a/crates/nvisy-server/src/handler/error/s3_error.rs b/crates/nvisy-server/src/response/error/s3_error.rs similarity index 100% rename from crates/nvisy-server/src/handler/error/s3_error.rs rename to crates/nvisy-server/src/response/error/s3_error.rs diff --git a/crates/nvisy-server/src/handler/error/webhook_error.rs b/crates/nvisy-server/src/response/error/webhook_error.rs similarity index 97% rename from crates/nvisy-server/src/handler/error/webhook_error.rs rename to crates/nvisy-server/src/response/error/webhook_error.rs index e935038c..ca5ebf9e 100644 --- a/crates/nvisy-server/src/handler/error/webhook_error.rs +++ b/crates/nvisy-server/src/response/error/webhook_error.rs @@ -58,7 +58,7 @@ impl From for HttpError<'static> { .with_message("Invalid webhook endpoint") .with_context(message.to_string()), - WebhookErrorKind::Configuration => ErrorKind::BadRequest + WebhookErrorKind::Configuration => ErrorKind::InternalServerError .with_message("Invalid webhook configuration") .with_context(message.to_string()), diff --git a/crates/nvisy-server/src/response/mod.rs b/crates/nvisy-server/src/response/mod.rs index 08e0dde4..6ede4133 100644 --- a/crates/nvisy-server/src/response/mod.rs +++ b/crates/nvisy-server/src/response/mod.rs @@ -8,11 +8,13 @@ mod auth; mod avatar_image; mod download; +mod error; mod redirect; mod sse; pub use auth::{ClearedSession, CookieConfig, WebSession}; pub use avatar_image::AvatarImage; pub use download::attachment_headers; +pub use error::{Error, ErrorKind, ErrorResponse, Result}; pub(crate) use redirect::{RedirectResult, connection_result_redirect}; pub use sse::SseResponse; diff --git a/crates/nvisy-server/src/response/redirect.rs b/crates/nvisy-server/src/response/redirect.rs index 1a5fd37b..59dd6c59 100644 --- a/crates/nvisy-server/src/response/redirect.rs +++ b/crates/nvisy-server/src/response/redirect.rs @@ -8,7 +8,7 @@ use axum::http::StatusCode; use axum::response::{IntoResponse, Redirect, Response}; -use crate::handler::ErrorKind; +use crate::response::ErrorKind; /// Tracing target for frontend-redirect construction. const TRACING_TARGET: &str = "nvisy_server::response::redirect"; diff --git a/crates/nvisy-server/src/service/account_provisioner.rs b/crates/nvisy-server/src/service/account_provisioner.rs index b79b1605..3bef3367 100644 --- a/crates/nvisy-server/src/service/account_provisioner.rs +++ b/crates/nvisy-server/src/service/account_provisioner.rs @@ -13,7 +13,7 @@ use nvisy_postgres::types::{HANDLE_MAX_LENGTH, Handle, IdentityProvider}; use nvisy_postgres::{AsyncConnection, Error as PgError, PgConn}; use uuid::Uuid; -use crate::handler::{ErrorKind, Result}; +use crate::response::{ErrorKind, Result}; use crate::service::OidcIdentity; /// Tracing target for account provisioning. diff --git a/crates/nvisy-server/src/service/auth_issuer.rs b/crates/nvisy-server/src/service/auth_issuer.rs index 2d4342ee..b4d73e0c 100644 --- a/crates/nvisy-server/src/service/auth_issuer.rs +++ b/crates/nvisy-server/src/service/auth_issuer.rs @@ -19,7 +19,7 @@ use nvisy_postgres::query::AccountApiTokenRepository; use nvisy_postgres::types::{ApiTokenType, session}; use crate::extract::{AuthClaims, SecurityContext}; -use crate::handler::Result; +use crate::response::Result; use crate::service::{SessionKeys, UserAgentParser}; /// Tracing target for token issuance. diff --git a/crates/nvisy-server/src/service/avatar.rs b/crates/nvisy-server/src/service/avatar.rs index 54335fb2..6c3cc06d 100644 --- a/crates/nvisy-server/src/service/avatar.rs +++ b/crates/nvisy-server/src/service/avatar.rs @@ -17,7 +17,7 @@ use nvisy_s3::{AccountAvatarKey, GetObject, WorkspaceAvatarKey}; use sha2::{Digest, Sha256}; use uuid::Uuid; -use crate::handler::{ErrorKind, Result}; +use crate::response::{ErrorKind, Result}; use crate::service::Infra; /// The content type of every stored avatar. diff --git a/crates/nvisy-server/src/service/chat.rs b/crates/nvisy-server/src/service/chat.rs index 2e7b1a66..6bf5db82 100644 --- a/crates/nvisy-server/src/service/chat.rs +++ b/crates/nvisy-server/src/service/chat.rs @@ -14,7 +14,7 @@ use nvisy_postgres::query::{ use nvisy_postgres::types::{ChatRole, ProviderType}; use uuid::Uuid; -use crate::handler::{ErrorKind, Result}; +use crate::response::{ErrorKind, Result}; use crate::service::{Infra, ProviderConfig}; /// Where in a conversation a turn happens: the workspace and session it belongs diff --git a/crates/nvisy-server/src/service/detection/drainer.rs b/crates/nvisy-server/src/service/detection/drainer.rs index 50504681..faea9b49 100644 --- a/crates/nvisy-server/src/service/detection/drainer.rs +++ b/crates/nvisy-server/src/service/detection/drainer.rs @@ -17,7 +17,7 @@ use tokio_util::sync::CancellationToken; use super::coordinator::DetectionCoordinator; use super::job::DetectionJob; use super::service::DetectionQueue; -use crate::handler::{Error, Result}; +use crate::response::{Error, Result}; use crate::service::{Infra, Worker}; /// Tracing target for the detection-job drainer. diff --git a/crates/nvisy-server/src/service/detection/service.rs b/crates/nvisy-server/src/service/detection/service.rs index aa8165b1..5838e80b 100644 --- a/crates/nvisy-server/src/service/detection/service.rs +++ b/crates/nvisy-server/src/service/detection/service.rs @@ -12,7 +12,7 @@ use uuid::Uuid; use super::coordinator::DetectionCoordinator; use super::job::{DetectionJob, DetectionStatusEvent, DetectionStream, detection_subject}; -use crate::handler::Result; +use crate::response::Result; use crate::service::Infra; /// Enqueues detection jobs and broadcasts detection-status changes. diff --git a/crates/nvisy-server/src/service/detection/support.rs b/crates/nvisy-server/src/service/detection/support.rs index 0fbc8b3b..80608731 100644 --- a/crates/nvisy-server/src/service/detection/support.rs +++ b/crates/nvisy-server/src/service/detection/support.rs @@ -11,8 +11,8 @@ use uuid::Uuid; use super::service::DetectionQueue; use crate::extract::SecurityContext; -use crate::handler::Result; -use crate::service::{CryptoService, DetectionRef, EventEmitter, EventOrigin, WorkspaceEvent}; +use crate::response::Result; +use crate::service::{CryptoService, DetectionFailed, EventEmitter, EventOrigin, WorkspaceEvent}; /// Tracing target for shared detection operations. const TRACING_TARGET: &str = "nvisy_server::service::detection"; @@ -202,15 +202,13 @@ pub(crate) async fn fail_detection( account_id: triggered_by, security: &SecurityContext::default(), }, - WorkspaceEvent::DetectionFailed { - detection: DetectionRef { - detection_id, - pipeline_slug, - }, + WorkspaceEvent::DetectionFailed(DetectionFailed { + detection_id, + pipeline_slug, input_file_name: None, error: Some(reason.to_owned()), notify: triggered_by, - }, + }), ) .await { diff --git a/crates/nvisy-server/src/service/detection/worker.rs b/crates/nvisy-server/src/service/detection/worker.rs index cce00829..9deb4683 100644 --- a/crates/nvisy-server/src/service/detection/worker.rs +++ b/crates/nvisy-server/src/service/detection/worker.rs @@ -30,9 +30,9 @@ use super::support::{ }; use crate::extract::SecurityContext; use crate::handler::request::PipelineDefinition; -use crate::handler::{ErrorKind, Result}; +use crate::response::{ErrorKind, Result}; use crate::service::{ - DetectionRef, EngineService, EventOrigin, Infra, RunBlobStore, Worker, WorkspaceEvent, + DetectionCompleted, EngineService, EventOrigin, Infra, RunBlobStore, Worker, WorkspaceEvent, event_outbox_row, }; @@ -489,14 +489,12 @@ impl DetectionWorker { // transaction is `PgError`-typed for its rollback sentinel, and insert it // alongside the finalize so the `Complete` event commits atomically with // the detection. - let completed_event = WorkspaceEvent::DetectionCompleted { - detection: DetectionRef { - detection_id: detection.id, - pipeline_slug: pipeline.slug.clone(), - }, + let completed_event = WorkspaceEvent::DetectionCompleted(DetectionCompleted { + detection_id: detection.id, + pipeline_slug: pipeline.slug.clone(), input_file_name: Some(file.display_name.clone()), notify: detection.account_id, - }; + }); let outbox_row = event_outbox_row( EventOrigin { workspace_id: job.workspace_id, diff --git a/crates/nvisy-server/src/service/event/drainer.rs b/crates/nvisy-server/src/service/event/drainer.rs index aebae26d..bf439738 100644 --- a/crates/nvisy-server/src/service/event/drainer.rs +++ b/crates/nvisy-server/src/service/event/drainer.rs @@ -19,24 +19,13 @@ use std::time::Duration; use nvisy_postgres::model::{EventOutbox, NewWorkspaceActivity}; use nvisy_postgres::query::{EventOutboxRepository, WorkspaceActivityRepository}; -use nvisy_postgres::types::{ - ActivityPayload, ConnectionActivityParams, ConnectionId, ConnectionSyncCompletedParams, - ConnectionSyncFailedParams, DetectionActivityParams, DetectionCompletedParams, - DetectionFailedParams, DetectionId, FileActivityParams, Handle, InviteActivityParams, Json, - MemberActivityParams, NotificationPayload, PipelineActivityParams, PolicyActivityParams, - ProviderActivityParams, ProviderId, RedactionActivityParams, RedactionCreatedParams, - RedactionId, WebhookActivityParams, WebhookEvent, WebhookId, WorkspaceActivityParams, -}; +use nvisy_postgres::types::Json; use nvisy_postgres::{AsyncConnection, PgConn}; -use serde_json::Value; use tokio_util::sync::CancellationToken; use uuid::Uuid; -use crate::handler::{Error, Result}; -use crate::service::event::{ - ConnectionRef, DetectionRef, FileRef, InviteRef, MemberRef, PolicyRef, ProviderRef, WebhookRef, - WorkspaceEvent, WorkspaceRef, -}; +use crate::response::{Error, Result}; +use crate::service::event::{Notification, NotifyTarget, WorkspaceEvent}; use crate::service::{Infra, NotificationEmitter, WebhookEmitter, Worker}; /// Tracing target for the outbox drainer. @@ -232,7 +221,7 @@ impl EventOutboxDrainer { tracing::error!(target: TRACING_TARGET, error = %err, id = %row.id, "Failed to decode outbox event"); })?; - let activity = activity_of(&event); + let activity = event.activity(); let activity_row = NewWorkspaceActivity { workspace_id: row.workspace_id, account_id: row.account_id, @@ -259,25 +248,47 @@ impl EventOutboxDrainer { let actor = row.account_id; // Webhook — only the events the webhook vocabulary carries. - if let Some((webhook_event, data)) = webhook_of(event) { - let resource_id = resource_id_of(event); + if let Some(delivery) = event.webhook() { + let resource_id = event.resource_id(); if let Err(err) = self .webhook - .emit(workspace_id, webhook_event, resource_id, Some(actor), data) + .emit( + workspace_id, + delivery.event, + resource_id, + Some(actor), + delivery.body, + ) .await { tracing::warn!(target: TRACING_TARGET, error = %err, %workspace_id, "Failed to emit webhook event"); } } - // Notification — only the events that raise one. - if let Some((recipient, payload)) = notification_of(event.clone()) - && let Err(err) = self + // Notifications — an event may raise several, each to its own audience. + for notification in event.clone().notification() { + self.dispatch_notification(workspace_id, notification).await; + } + } + + /// Delivers one notification to its target audience, honoring recipient + /// preferences. Best-effort: a failure is logged, never propagated. + async fn dispatch_notification(&self, workspace_id: Uuid, notification: Notification) { + let Notification { target, payload } = notification; + let result = match target { + NotifyTarget::Account(recipient) => self .notification .notify_account(workspace_id, recipient, payload) .await - { - tracing::warn!(target: TRACING_TARGET, error = %err, %workspace_id, %recipient, "Failed to notify"); + .map(|_delivered| ()), + NotifyTarget::Roles { roles, exclude } => self + .notification + .notify_workspace_roles(workspace_id, &roles, exclude, payload) + .await + .map(|_count| ()), + }; + if let Err(err) = result { + tracing::warn!(target: TRACING_TARGET, error = %err, %workspace_id, "Failed to deliver notification"); } } } @@ -292,309 +303,3 @@ fn retry_backoff(attempts: i32) -> i64 { .saturating_mul(steps) .min(RETRY_BACKOFF_MAX_SECS) } - -/// The activity-log payload for an event. Total: every event is recorded. -fn activity_of(event: &WorkspaceEvent) -> ActivityPayload { - use WorkspaceEvent as E; - - let workspace = |w: &WorkspaceRef| WorkspaceActivityParams { - workspace_slug: w.workspace_slug.clone(), - }; - let member = |m: &MemberRef| MemberActivityParams { - member_username: m.member_username.clone(), - }; - let invite = |i: &InviteRef| InviteActivityParams { - invite_id: i.invite_id, - email: i.email.clone(), - }; - let connection = |c: &ConnectionRef| ConnectionActivityParams { - connection_id: ConnectionId::from_uuid(c.connection_id), - connection_name: c.connection_name.clone(), - }; - let connection_sync = |connection_id: Uuid, connection_name: &str| ConnectionActivityParams { - connection_id: ConnectionId::from_uuid(connection_id), - connection_name: connection_name.to_owned(), - }; - let provider = |p: &ProviderRef| ProviderActivityParams { - provider_id: ProviderId::from_uuid(p.provider_id), - provider_name: p.provider_name.clone(), - }; - let webhook = |w: &WebhookRef| WebhookActivityParams { - webhook_id: WebhookId::from_uuid(w.webhook_id), - webhook_name: w.webhook_name.clone(), - }; - let file_params = |file: &FileRef| FileActivityParams { - file_id: file.file_id, - file_name: file.file_name.clone(), - }; - let pipeline = |pipeline_slug: &Handle| PipelineActivityParams { - pipeline_slug: pipeline_slug.clone(), - }; - let detection = |detection: &DetectionRef| DetectionActivityParams { - pipeline_slug: detection.pipeline_slug.clone(), - detection_id: DetectionId::from_uuid(detection.detection_id), - }; - let redaction = |detection: &DetectionRef, redaction_id: Uuid| RedactionActivityParams { - pipeline_slug: detection.pipeline_slug.clone(), - redaction_id: RedactionId::from_uuid(redaction_id), - }; - let policy = |p: &PolicyRef| PolicyActivityParams { - policy_id: p.policy_id, - policy_slug: p.policy_slug.clone(), - }; - - match event { - E::WorkspaceCreated(w) => ActivityPayload::WorkspaceCreated(workspace(w)), - E::WorkspaceUpdated(w) => ActivityPayload::WorkspaceUpdated(workspace(w)), - E::WorkspaceDeleted(w) => ActivityPayload::WorkspaceDeleted(workspace(w)), - E::MemberAdded(m) => ActivityPayload::MemberAdded(member(m)), - E::MemberUpdated(m) => ActivityPayload::MemberUpdated(member(m)), - E::MemberDeleted(m) => ActivityPayload::MemberDeleted(member(m)), - E::InviteCreated(i) => ActivityPayload::InviteCreated(invite(i)), - E::InviteAccepted(i) => ActivityPayload::InviteAccepted(invite(i)), - E::InviteDeclined(i) => ActivityPayload::InviteDeclined(invite(i)), - E::InviteCanceled(i) => ActivityPayload::InviteCanceled(invite(i)), - E::ConnectionCreated(c) => ActivityPayload::ConnectionCreated(connection(c)), - E::ConnectionUpdated(c) => ActivityPayload::ConnectionUpdated(connection(c)), - E::ConnectionDeleted(c) => ActivityPayload::ConnectionDeleted(connection(c)), - E::ConnectionSyncStarted(c) => ActivityPayload::ConnectionSyncStarted(connection(c)), - E::ConnectionSyncCompleted { - connection_id, - connection_name, - .. - } => ActivityPayload::ConnectionSyncCompleted(connection_sync( - *connection_id, - connection_name, - )), - E::ConnectionSyncFailed { - connection_id, - connection_name, - .. - } => { - ActivityPayload::ConnectionSyncFailed(connection_sync(*connection_id, connection_name)) - } - E::ProviderCreated(p) => ActivityPayload::ProviderCreated(provider(p)), - E::ProviderUpdated(p) => ActivityPayload::ProviderUpdated(provider(p)), - E::ProviderDeleted(p) => ActivityPayload::ProviderDeleted(provider(p)), - E::WebhookCreated(w) => ActivityPayload::WebhookCreated(webhook(w)), - E::WebhookUpdated(w) => ActivityPayload::WebhookUpdated(webhook(w)), - E::WebhookDeleted(w) => ActivityPayload::WebhookDeleted(webhook(w)), - E::FileCreated { file, .. } => ActivityPayload::FileCreated(file_params(file)), - E::FileUpdated(f) => ActivityPayload::FileUpdated(file_params(f)), - E::FileDeleted(f) => ActivityPayload::FileDeleted(file_params(f)), - E::PipelineCreated(p) => ActivityPayload::PipelineCreated(pipeline(&p.pipeline_slug)), - E::PipelineUpdated(p) => ActivityPayload::PipelineUpdated(pipeline(&p.pipeline_slug)), - E::PipelineDeleted(p) => ActivityPayload::PipelineDeleted(pipeline(&p.pipeline_slug)), - E::DetectionStarted(d) => ActivityPayload::DetectionStarted(detection(d)), - E::DetectionCompleted { detection: d, .. } => { - ActivityPayload::DetectionCompleted(detection(d)) - } - E::DetectionFailed { detection: d, .. } => ActivityPayload::DetectionFailed(detection(d)), - E::RedactionCreated { - detection: d, - redaction_id, - .. - } => ActivityPayload::RedactionCreated(redaction(d, *redaction_id)), - E::PolicyCreated(p) => ActivityPayload::PolicyCreated(policy(p)), - E::PolicyUpdated(p) => ActivityPayload::PolicyUpdated(policy(p)), - E::PolicyDeleted(p) => ActivityPayload::PolicyDeleted(policy(p)), - } -} - -/// The webhook event (and any extra body) for an event, or `None` for events the -/// webhook vocabulary does not carry (workspace lifecycle, invites, webhook CRUD). -fn webhook_of(event: &WorkspaceEvent) -> Option<(WebhookEvent, Option)> { - use WorkspaceEvent as E; - let webhook = match event { - E::MemberAdded(..) => (WebhookEvent::MemberAdded, None), - E::MemberUpdated(..) => (WebhookEvent::MemberUpdated, None), - E::MemberDeleted(..) => (WebhookEvent::MemberDeleted, None), - E::ConnectionCreated(..) => (WebhookEvent::ConnectionCreated, None), - E::ConnectionUpdated(..) => (WebhookEvent::ConnectionUpdated, None), - E::ConnectionDeleted(..) => (WebhookEvent::ConnectionDeleted, None), - E::ConnectionSyncStarted(..) => (WebhookEvent::ConnectionSyncStarted, None), - E::ConnectionSyncCompleted { .. } => (WebhookEvent::ConnectionSyncCompleted, None), - E::ConnectionSyncFailed { .. } => (WebhookEvent::ConnectionSyncFailed, None), - E::ProviderCreated(..) => (WebhookEvent::ProviderCreated, None), - E::ProviderUpdated(..) => (WebhookEvent::ProviderUpdated, None), - E::ProviderDeleted(..) => (WebhookEvent::ProviderDeleted, None), - E::FileCreated { - file, - file_size_bytes, - } => ( - WebhookEvent::FileCreated, - Some( - serde_json::json!({ "displayName": file.file_name, "fileSizeBytes": file_size_bytes }), - ), - ), - E::FileUpdated(f) => ( - WebhookEvent::FileUpdated, - Some(serde_json::json!({ "displayName": f.file_name })), - ), - E::FileDeleted(f) => ( - WebhookEvent::FileDeleted, - Some(serde_json::json!({ "displayName": f.file_name })), - ), - E::PipelineCreated(..) => (WebhookEvent::PipelineCreated, None), - E::PipelineUpdated(..) => (WebhookEvent::PipelineUpdated, None), - E::PipelineDeleted(..) => (WebhookEvent::PipelineDeleted, None), - E::DetectionStarted(..) => (WebhookEvent::DetectionStarted, None), - E::DetectionCompleted { .. } => (WebhookEvent::DetectionCompleted, None), - E::DetectionFailed { .. } => (WebhookEvent::DetectionFailed, None), - E::RedactionCreated { .. } => (WebhookEvent::RedactionCreated, None), - E::PolicyCreated(..) => (WebhookEvent::PolicyCreated, None), - E::PolicyUpdated(..) => (WebhookEvent::PolicyUpdated, None), - E::PolicyDeleted(..) => (WebhookEvent::PolicyDeleted, None), - E::WorkspaceCreated(..) - | E::WorkspaceUpdated(..) - | E::WorkspaceDeleted(..) - | E::InviteCreated(..) - | E::InviteAccepted(..) - | E::InviteDeclined(..) - | E::InviteCanceled(..) - | E::WebhookCreated(..) - | E::WebhookUpdated(..) - | E::WebhookDeleted(..) => return None, - }; - Some(webhook) -} - -/// The in-app notification for an event — recipient and payload — or `None` for -/// events that raise none. Consumes the event, moving its facts into the payload. -fn notification_of(event: WorkspaceEvent) -> Option<(Uuid, NotificationPayload)> { - use WorkspaceEvent as E; - match event { - E::ConnectionSyncCompleted { - connection_id, - connection_name, - records_synced, - notify, - } => notify.map(|to| { - ( - to, - NotificationPayload::ConnectionSyncCompleted(ConnectionSyncCompletedParams { - connection_id: ConnectionId::from_uuid(connection_id), - connection_name, - records_synced, - }), - ) - }), - E::ConnectionSyncFailed { - connection_id, - connection_name, - error, - notify, - } => notify.map(|to| { - ( - to, - NotificationPayload::ConnectionSyncFailed(ConnectionSyncFailedParams { - connection_id: ConnectionId::from_uuid(connection_id), - connection_name, - error, - }), - ) - }), - E::DetectionCompleted { - detection, - input_file_name, - notify, - } => Some(( - notify, - NotificationPayload::DetectionCompleted(DetectionCompletedParams { - detection_id: DetectionId::from_uuid(detection.detection_id), - pipeline_slug: detection.pipeline_slug, - input_file_name, - }), - )), - E::RedactionCreated { - detection, - redaction_id, - input_file_name, - notify, - } => Some(( - notify, - NotificationPayload::RedactionCreated(RedactionCreatedParams { - redaction_id: RedactionId::from_uuid(redaction_id), - detection_id: DetectionId::from_uuid(detection.detection_id), - pipeline_slug: detection.pipeline_slug, - input_file_name, - }), - )), - E::DetectionFailed { - detection, - input_file_name, - error, - notify, - } => Some(( - notify, - NotificationPayload::DetectionFailed(DetectionFailedParams { - detection_id: DetectionId::from_uuid(detection.detection_id), - pipeline_slug: detection.pipeline_slug, - input_file_name, - error, - }), - )), - // Events that raise no in-app notification. Listed explicitly (no wildcard) - // so a new event forces a deliberate notify / no-notify decision here. - E::WorkspaceCreated(_) - | E::WorkspaceUpdated(_) - | E::WorkspaceDeleted(_) - | E::MemberAdded(_) - | E::MemberUpdated(_) - | E::MemberDeleted(_) - | E::InviteCreated(_) - | E::InviteAccepted(_) - | E::InviteDeclined(_) - | E::InviteCanceled(_) - | E::ConnectionCreated(_) - | E::ConnectionUpdated(_) - | E::ConnectionDeleted(_) - | E::ConnectionSyncStarted(_) - | E::ProviderCreated(_) - | E::ProviderUpdated(_) - | E::ProviderDeleted(_) - | E::WebhookCreated(_) - | E::WebhookUpdated(_) - | E::WebhookDeleted(_) - | E::FileCreated { .. } - | E::FileUpdated(_) - | E::FileDeleted(_) - | E::PipelineCreated(_) - | E::PipelineUpdated(_) - | E::PipelineDeleted(_) - | E::DetectionStarted(_) - | E::PolicyCreated(_) - | E::PolicyUpdated(_) - | E::PolicyDeleted(_) => None, - } -} - -/// The affected resource's id, for the webhook payload. Every event carries its -/// resource's id, so consumers always receive a real identifier. -fn resource_id_of(event: &WorkspaceEvent) -> Uuid { - use WorkspaceEvent as E; - match event { - E::WorkspaceCreated(w) | E::WorkspaceUpdated(w) | E::WorkspaceDeleted(w) => w.workspace_id, - E::MemberAdded(m) | E::MemberUpdated(m) | E::MemberDeleted(m) => m.member_id, - E::InviteCreated(i) - | E::InviteAccepted(i) - | E::InviteDeclined(i) - | E::InviteCanceled(i) => i.invite_id, - E::ConnectionCreated(c) - | E::ConnectionUpdated(c) - | E::ConnectionDeleted(c) - | E::ConnectionSyncStarted(c) => c.connection_id, - E::ConnectionSyncCompleted { connection_id, .. } - | E::ConnectionSyncFailed { connection_id, .. } => *connection_id, - E::ProviderCreated(p) | E::ProviderUpdated(p) | E::ProviderDeleted(p) => p.provider_id, - E::WebhookCreated(w) | E::WebhookUpdated(w) | E::WebhookDeleted(w) => w.webhook_id, - E::FileCreated { file, .. } => file.file_id, - E::FileUpdated(f) | E::FileDeleted(f) => f.file_id, - E::PipelineCreated(p) | E::PipelineUpdated(p) | E::PipelineDeleted(p) => p.pipeline_id, - E::DetectionStarted(d) => d.detection_id, - E::DetectionCompleted { detection, .. } - | E::DetectionFailed { detection, .. } - | E::RedactionCreated { detection, .. } => detection.detection_id, - E::PolicyCreated(p) | E::PolicyUpdated(p) | E::PolicyDeleted(p) => p.policy_id, - } -} diff --git a/crates/nvisy-server/src/service/event/emitter.rs b/crates/nvisy-server/src/service/event/emitter.rs index 58ad03b1..c9e38acc 100644 --- a/crates/nvisy-server/src/service/event/emitter.rs +++ b/crates/nvisy-server/src/service/event/emitter.rs @@ -15,7 +15,7 @@ use nvisy_postgres::PgConn; use nvisy_postgres::model::NewEventOutbox; use nvisy_postgres::query::EventOutboxRepository; -use crate::handler::{ErrorKind, Result}; +use crate::response::{ErrorKind, Result}; use crate::service::event::{EventOrigin, WorkspaceEvent}; /// Builds the outbox row for an event and its origin. diff --git a/crates/nvisy-server/src/service/event/kind.rs b/crates/nvisy-server/src/service/event/kind.rs new file mode 100644 index 00000000..b0017a93 --- /dev/null +++ b/crates/nvisy-server/src/service/event/kind.rs @@ -0,0 +1,103 @@ +//! The event-projection contract: how one event becomes its three sink payloads. +//! +//! Every workspace event is a plain struct that owns its facts once and +//! implements [`EventKind`]. The trait is the single place an event decides how +//! it projects onto the activity log, the webhook stream, and notifications, so +//! the drainer just calls the trait rather than matching on a giant enum. The +//! sink payload types ([`ActivityPayload`], [`NotificationPayload`], +//! [`WebhookEvent`]) are the stored/wire formats and live in `nvisy-postgres`; +//! an event's job is to build them. + +use nvisy_postgres::types::{ActivityPayload, NotificationPayload, WebhookEvent, WorkspaceRole}; +use uuid::Uuid; + +/// A webhook delivery an event raises: which webhook event fired, and an optional +/// JSON body carrying the event's display fields. +/// +/// The body is built from a typed, `camelCase` struct (never an ad-hoc map), so +/// the delivered shape is defined in one place per event and cannot drift. +pub struct WebhookDelivery { + /// The webhook event that fired. + pub event: WebhookEvent, + /// Extra display fields for subscribers, or `None` when the event carries no + /// body beyond its envelope. + pub body: Option, +} + +/// Who an in-app notification is delivered to. +/// +/// Named (not a bare [`Uuid`]) so a call site can't confuse the recipient with +/// any of the other ids an event carries, and so a single event can fan out to a +/// role-based audience as easily as to one account. +pub enum NotifyTarget { + /// One specific account, subject to its own notification preferences. + Account(Uuid), + /// Every member holding one of `roles`, optionally excluding one account + /// (e.g. the actor, who need not be told of their own action). + Roles { + /// The roles whose holders receive the notification. + roles: Vec, + /// An account to skip, if any. + exclude: Option, + }, +} + +/// An in-app notification an event raises: its audience and payload. +pub struct Notification { + /// Who receives the notification. + pub target: NotifyTarget, + /// The stored notification payload. + pub payload: NotificationPayload, +} + +impl Notification { + /// A single-recipient notification list: one entry addressed to `recipient` + /// when it is `Some`, or empty when it is `None` (e.g. an actor acting on + /// their own resource, who is not notified). Collapses the common + /// `notify.map(...).into_iter().collect()` at an event's notification site. + pub fn to_account(recipient: Option, payload: NotificationPayload) -> Vec { + recipient + .map(|recipient| Self { + target: NotifyTarget::Account(recipient), + payload, + }) + .into_iter() + .collect() + } +} + +/// How one workspace event projects onto its sinks. +/// +/// Implemented once per event struct. The event owns its data; each method +/// derives a sink payload from that data, so the field set is declared a single +/// time rather than re-typed per sink. `TAG` is the one place the event's stable +/// wire tag lives; the `workspace_events!` macro checks it against the enum's +/// serde rename. +pub trait EventKind { + /// The stable dotted wire tag for this event (e.g. `file.assigned`). The + /// outbox envelope's `type` field, asserted equal to the enum variant's serde + /// rename by a generated test. + const TAG: &'static str; + + /// The affected resource's id, for the webhook envelope. + fn resource_id(&self) -> Uuid; + + /// The activity-log payload. Every event is recorded, so this is total. + fn activity(&self) -> ActivityPayload; + + /// The webhook delivery this event raises, or `None` when the webhook + /// vocabulary does not carry it (workspace lifecycle, invites, webhook CRUD). + fn webhook(&self) -> Option { + None + } + + /// The in-app notifications this event raises — an empty `Vec` when it raises + /// none, one entry for a single-recipient event, or several to fan out to + /// distinct audiences. Consumes the event, moving its facts into the payloads. + fn notification(self) -> Vec + where + Self: Sized, + { + Vec::new() + } +} diff --git a/crates/nvisy-server/src/service/event/macros.rs b/crates/nvisy-server/src/service/event/macros.rs new file mode 100644 index 00000000..192d24c2 --- /dev/null +++ b/crates/nvisy-server/src/service/event/macros.rs @@ -0,0 +1,193 @@ +//! The `workspace_events!` macro: one table binds every event struct into the +//! outbox envelope and its trait dispatch. + +/// Generates the [`WorkspaceEvent`](super::WorkspaceEvent) outbox envelope from a +/// table of `Variant => "wire.tag"` entries. +/// +/// Each variant wraps the like-named event struct (which must implement +/// [`EventKind`](super::EventKind)). The macro expands to: +/// +/// 1. the `WorkspaceEvent` enum, `#[serde(tag = "type", content = "data")]`, one +/// newtype variant per entry carrying its serde `rename`; +/// 2. an inherent impl forwarding `tag`, `resource_id`, `activity`, `webhook`, +/// and `notification` to the wrapped struct's [`EventKind`](super::EventKind); +/// 3. a test asserting each variant's serde rename equals the wrapped struct's +/// [`EventKind::TAG`](super::EventKind::TAG), so the tag lives once (in the +/// table) and the two can never drift. +/// +/// The tag string appears once per event, in the table. +macro_rules! workspace_events { + ($( $variant:ident => $tag:literal ),+ $(,)?) => { + /// A workspace event: the raw facts of one action, as written to the + /// transactional outbox and later projected onto its sinks by the drainer. + /// + /// The wire format is pinned: variants are tagged by an explicit, stable + /// `type` string (not the Rust identifier), with the event's fields under + /// `data`. An outbox row written by one build is decoded by a later one, + /// so a variant's tag must never change. + #[derive(Debug, Clone, ::serde::Serialize, ::serde::Deserialize)] + #[serde(tag = "type", content = "data")] + pub enum WorkspaceEvent { + $( + #[serde(rename = $tag)] + $variant($variant), + )+ + } + + impl WorkspaceEvent { + /// The event's stable wire tag (its `type` in the outbox envelope). + pub fn tag(&self) -> &'static str { + match self { + $( Self::$variant(_) => <$variant as $crate::service::event::EventKind>::TAG, )+ + } + } + + /// The affected resource's id, for the webhook envelope. + pub fn resource_id(&self) -> ::uuid::Uuid { + match self { + $( Self::$variant(e) => $crate::service::event::EventKind::resource_id(e), )+ + } + } + + /// The activity-log payload. Total: every event is recorded. + pub fn activity(&self) -> ::nvisy_postgres::types::ActivityPayload { + match self { + $( Self::$variant(e) => $crate::service::event::EventKind::activity(e), )+ + } + } + + /// The webhook delivery this event raises, or `None` for events the + /// webhook vocabulary does not carry. + pub fn webhook(&self) -> Option<$crate::service::event::WebhookDelivery> { + match self { + $( Self::$variant(e) => $crate::service::event::EventKind::webhook(e), )+ + } + } + + /// The in-app notifications this event raises (empty when none). + /// Consumes the event, moving its facts into the payloads. + pub fn notification(self) -> Vec<$crate::service::event::Notification> { + match self { + $( Self::$variant(e) => $crate::service::event::EventKind::notification(e), )+ + } + } + } + + #[cfg(test)] + mod generated_tag_tests { + use super::*; + + /// The serde rename on each variant must equal the wrapped struct's + /// `EventKind::TAG`, so the outbox `type` and the event's own tag agree. + #[test] + fn variant_rename_matches_event_tag() { + $( + assert_eq!( + variant_tag(stringify!($variant)), + <$variant as $crate::service::event::EventKind>::TAG, + concat!("tag mismatch for ", stringify!($variant)), + ); + )+ + } + + /// Reads the serde `rename` for a variant by finding its table entry. + /// Kept in lockstep with the enum by the same macro expansion. + fn variant_tag(variant: &str) -> &'static str { + match variant { + $( stringify!($variant) => $tag, )+ + other => panic!("unknown variant {other}"), + } + } + } + }; +} + +pub(crate) use workspace_events; + +/// Generates a family of CRUD-style event structs that share a field set and +/// differ only by action (created / updated / deleted / …). +/// +/// Each action `Foo` in the family expands to a `pub struct Foo { }` and +/// its [`EventKind`](super::EventKind) impl, where: +/// - `TAG` is the given per-action wire tag; +/// - `resource_id()` returns the named `id` field; +/// - `activity()` is `ActivityPayload::Foo()` — the payload +/// variant name matches the struct name, and the params are built by the shared +/// `activity` expression (bound to `$this`, the struct value); +/// - `webhook()` is `Some(WebhookEvent::Foo)` with no body when the family is +/// declared `webhook`, and the default (none) otherwise. +/// +/// Families whose actions carry a payload body, a notification, or per-action +/// fields are written out by hand instead — this macro is only for the plain, +/// uniform CRUD families. +macro_rules! crud_events { + ( + fields $fields:tt + id = $id:ident; + activity($this:ident) = $activity:expr; + webhook = $webhook:tt; + $( + $(#[doc = $action_doc:literal])* + $action:ident => $tag:literal + ),+ $(,)? + ) => { + $( + $crate::service::event::macros::crud_events! { + @one + $(#[doc = $action_doc])* + $action => $tag, + fields $fields, + id = $id, + activity($this) = $activity, + webhook = $webhook, + } + )+ + }; + + // One action struct + its EventKind impl. The field group is pasted verbatim, + // so the per-action loop above never repeats over the fields itself. + ( + @one + $(#[doc = $action_doc:literal])* + $action:ident => $tag:literal, + fields { $( $field:ident : $field_ty:ty ),+ $(,)? }, + id = $id:ident, + activity($this:ident) = $activity:expr, + webhook = $webhook:tt, + ) => { + $(#[doc = $action_doc])* + #[derive(Debug, Clone, ::serde::Serialize, ::serde::Deserialize)] + pub struct $action { + $( pub $field : $field_ty ),+ + } + + impl $crate::service::event::EventKind for $action { + const TAG: &'static str = $tag; + + fn resource_id(&self) -> ::uuid::Uuid { + self.$id + } + + fn activity(&self) -> ::nvisy_postgres::types::ActivityPayload { + let $this = self; + ::nvisy_postgres::types::ActivityPayload::$action($activity) + } + + $crate::service::event::macros::crud_events!(@webhook $action, $webhook); + } + }; + + // `webhook = yes` -> emit a bodyless webhook using the like-named variant. + (@webhook $action:ident, yes) => { + fn webhook(&self) -> Option<$crate::service::event::WebhookDelivery> { + Some($crate::service::event::WebhookDelivery { + event: ::nvisy_postgres::types::WebhookEvent::$action, + body: None, + }) + } + }; + // `webhook = no` -> keep the trait default (no webhook). + (@webhook $action:ident, no) => {}; +} + +pub(crate) use crud_events; diff --git a/crates/nvisy-server/src/service/event/mod.rs b/crates/nvisy-server/src/service/event/mod.rs index 6f826bc9..839dc8d8 100644 --- a/crates/nvisy-server/src/service/event/mod.rs +++ b/crates/nvisy-server/src/service/event/mod.rs @@ -11,6 +11,8 @@ mod drainer; mod emitter; +mod kind; +mod macros; mod workspace_event; use uuid::Uuid; @@ -18,9 +20,16 @@ use uuid::Uuid; use crate::extract::SecurityContext; pub use crate::service::event::drainer::EventOutboxDrainer; pub use crate::service::event::emitter::{EventEmitter, event_outbox_row}; +pub use crate::service::event::kind::{EventKind, Notification, NotifyTarget, WebhookDelivery}; pub use crate::service::event::workspace_event::{ - ConnectionRef, DetectionRef, FileRef, InviteRef, MemberRef, PipelineRef, PolicyRef, - ProviderRef, WebhookRef, WorkspaceEvent, WorkspaceRef, + AssignmentStatusChanged, ConnectionCreated, ConnectionDeleted, ConnectionSyncCompleted, + ConnectionSyncFailed, ConnectionSyncStarted, ConnectionUpdated, DetectionCompleted, + DetectionFailed, DetectionStarted, FileAssigned, FileCreated, FileDeleted, FileUnassigned, + FileUpdated, InviteAccepted, InviteCanceled, InviteCreated, InviteDeclined, MemberAdded, + MemberDeleted, MemberUpdated, PipelineCreated, PipelineDeleted, PipelineUpdated, PolicyCreated, + PolicyDeleted, PolicyUpdated, ProviderCreated, ProviderDeleted, ProviderUpdated, + RedactionCreated, WebhookCreated, WebhookDeleted, WebhookUpdated, WorkspaceCreated, + WorkspaceDeleted, WorkspaceEvent, WorkspaceUpdated, }; /// Who raised an event and where. diff --git a/crates/nvisy-server/src/service/event/workspace_event.rs b/crates/nvisy-server/src/service/event/workspace_event.rs index 2f2b5fa1..f83f4435 100644 --- a/crates/nvisy-server/src/service/event/workspace_event.rs +++ b/crates/nvisy-server/src/service/event/workspace_event.rs @@ -1,221 +1,788 @@ -//! The workspace-event vocabulary: what happened, as raw domain facts. +//! The workspace-event vocabulary: one struct per event, each the single source +//! of truth for its facts and its projection onto the three sinks. //! -//! A [`WorkspaceEvent`] carries only the facts of an action — no knowledge of the -//! sinks it feeds. The drainer projects each event onto the activity log, the -//! webhook stream, and notifications. Facts are owned and serializable so an -//! event is persisted to the outbox and drained later. +//! Each event is a plain, serializable struct that owns its fields once and +//! implements [`EventKind`]: the trait builds the activity, webhook, and +//! notification payloads from those fields, so a field set is declared a single +//! time rather than re-typed per sink. The [`workspace_events!`] macro binds the +//! structs into the [`WorkspaceEvent`] outbox envelope and its dispatch. //! -//! Variants that share a field-set carry it as one of the small `*Ref` structs -//! below the enum, so the shape is written once and the variants stay uniform. +//! The wire format is pinned: an outbox row written by one build is decoded by a +//! later one, so a variant's tag and an event struct's field names must not +//! change. -use nvisy_postgres::types::Handle; +use nvisy_postgres::types::{ + ActivityPayload, AssignmentActivityParams, AssignmentStatus, ConnectionActivityParams, + ConnectionId, ConnectionSyncCompletedParams, ConnectionSyncFailedParams, + DetectionActivityParams, DetectionCompletedParams, DetectionFailedParams, DetectionId, + FileActivityParams, FileAssignedParams, FileUnassignedParams, Handle, InviteActivityParams, + MemberActivityParams, MemberJoinedParams, NotificationPayload, PipelineActivityParams, + PolicyActivityParams, ProviderActivityParams, ProviderId, RedactionActivityParams, + RedactionCreatedParams, RedactionId, WebhookActivityParams, WebhookEvent, WebhookId, + WorkspaceActivityParams, WorkspaceRole, +}; use serde::{Deserialize, Serialize}; use uuid::Uuid; -/// A workspace event, as the raw domain facts. -/// -/// The wire format is pinned: variants are tagged by an explicit, stable `type` -/// string (not the Rust identifier), and every variant's body is a single `*Ref` -/// payload under `data`. An outbox row written by one build is decoded by a -/// later one, so a Rust-side rename must never change the stored JSON. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "type", content = "data")] -pub enum WorkspaceEvent { - // Workspace - #[serde(rename = "workspace.created")] - WorkspaceCreated(WorkspaceRef), - #[serde(rename = "workspace.updated")] - WorkspaceUpdated(WorkspaceRef), - #[serde(rename = "workspace.deleted")] - WorkspaceDeleted(WorkspaceRef), - - // Members - #[serde(rename = "member.added")] - MemberAdded(MemberRef), - #[serde(rename = "member.updated")] - MemberUpdated(MemberRef), - #[serde(rename = "member.deleted")] - MemberDeleted(MemberRef), - - // Invites - #[serde(rename = "invite.created")] - InviteCreated(InviteRef), - #[serde(rename = "invite.accepted")] - InviteAccepted(InviteRef), - #[serde(rename = "invite.declined")] - InviteDeclined(InviteRef), - #[serde(rename = "invite.canceled")] - InviteCanceled(InviteRef), - - // Connections - #[serde(rename = "connection.created")] - ConnectionCreated(ConnectionRef), - #[serde(rename = "connection.updated")] - ConnectionUpdated(ConnectionRef), - #[serde(rename = "connection.deleted")] - ConnectionDeleted(ConnectionRef), - #[serde(rename = "connection.sync.started")] - ConnectionSyncStarted(ConnectionRef), - #[serde(rename = "connection.sync.completed")] - ConnectionSyncCompleted { - connection_id: Uuid, - connection_name: String, - records_synced: Option, - notify: Option, - }, - #[serde(rename = "connection.sync.failed")] - ConnectionSyncFailed { - connection_id: Uuid, - connection_name: String, - error: Option, - notify: Option, - }, - - // Providers - #[serde(rename = "provider.created")] - ProviderCreated(ProviderRef), - #[serde(rename = "provider.updated")] - ProviderUpdated(ProviderRef), - #[serde(rename = "provider.deleted")] - ProviderDeleted(ProviderRef), - - // Webhooks - #[serde(rename = "webhook.created")] - WebhookCreated(WebhookRef), - #[serde(rename = "webhook.updated")] - WebhookUpdated(WebhookRef), - #[serde(rename = "webhook.deleted")] - WebhookDeleted(WebhookRef), - - // Files - #[serde(rename = "file.created")] - FileCreated { - #[serde(flatten)] - file: FileRef, - file_size_bytes: i64, - }, - #[serde(rename = "file.updated")] - FileUpdated(FileRef), - #[serde(rename = "file.deleted")] - FileDeleted(FileRef), - - // Pipelines - #[serde(rename = "pipeline.created")] - PipelineCreated(PipelineRef), - #[serde(rename = "pipeline.updated")] - PipelineUpdated(PipelineRef), - #[serde(rename = "pipeline.deleted")] - PipelineDeleted(PipelineRef), - - // Detections - #[serde(rename = "pipeline.detection.started")] - DetectionStarted(DetectionRef), - #[serde(rename = "pipeline.detection.completed")] - DetectionCompleted { - #[serde(flatten)] - detection: DetectionRef, - input_file_name: Option, - notify: Uuid, - }, - #[serde(rename = "pipeline.detection.failed")] - DetectionFailed { - #[serde(flatten)] - detection: DetectionRef, - input_file_name: Option, - error: Option, - notify: Uuid, - }, - - // Redactions - #[serde(rename = "pipeline.redaction.created")] - RedactionCreated { - #[serde(flatten)] - detection: DetectionRef, - /// The redaction that was produced (its own id, distinct from the - /// detection's — a detection can produce many redactions). - redaction_id: Uuid, - input_file_name: Option, - notify: Uuid, - }, - - // Policies - #[serde(rename = "policy.created")] - PolicyCreated(PolicyRef), - #[serde(rename = "policy.updated")] - PolicyUpdated(PolicyRef), - #[serde(rename = "policy.deleted")] - PolicyDeleted(PolicyRef), -} - -/// A workspace and its slug. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct WorkspaceRef { - pub workspace_id: Uuid, - pub workspace_slug: Handle, +use crate::service::event::kind::{EventKind, Notification, NotifyTarget, WebhookDelivery}; +use crate::service::event::macros::{crud_events, workspace_events}; + +workspace_events! { + WorkspaceCreated => "workspace.created", + WorkspaceUpdated => "workspace.updated", + WorkspaceDeleted => "workspace.deleted", + + MemberAdded => "member.added", + MemberUpdated => "member.updated", + MemberDeleted => "member.deleted", + + InviteCreated => "invite.created", + InviteAccepted => "invite.accepted", + InviteDeclined => "invite.declined", + InviteCanceled => "invite.canceled", + + ConnectionCreated => "connection.created", + ConnectionUpdated => "connection.updated", + ConnectionDeleted => "connection.deleted", + ConnectionSyncStarted => "connection.sync.started", + ConnectionSyncCompleted => "connection.sync.completed", + ConnectionSyncFailed => "connection.sync.failed", + + ProviderCreated => "provider.created", + ProviderUpdated => "provider.updated", + ProviderDeleted => "provider.deleted", + + WebhookCreated => "webhook.created", + WebhookUpdated => "webhook.updated", + WebhookDeleted => "webhook.deleted", + + FileCreated => "file.created", + FileUpdated => "file.updated", + FileDeleted => "file.deleted", + + FileAssigned => "file.assigned", + FileUnassigned => "file.unassigned", + AssignmentStatusChanged => "file.assignment.updated", + + PipelineCreated => "pipeline.created", + PipelineUpdated => "pipeline.updated", + PipelineDeleted => "pipeline.deleted", + + DetectionStarted => "pipeline.detection.started", + DetectionCompleted => "pipeline.detection.completed", + DetectionFailed => "pipeline.detection.failed", + + RedactionCreated => "pipeline.redaction.created", + + PolicyCreated => "policy.created", + PolicyUpdated => "policy.updated", + PolicyDeleted => "policy.deleted", +} + +/// The webhook body for a file event: just the file's display name. +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct FileWebhookBody<'a> { + display_name: &'a str, +} + +/// The webhook body for `file.created`: the display name plus the byte size. +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct FileCreatedWebhookBody<'a> { + display_name: &'a str, + file_size_bytes: i64, } -/// A member and their username. +/// The webhook body for an assignment event: the assignee and status, plus the +/// file name when it is still known (omitted if the file was removed). +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct AssignmentWebhookBody<'a> { + #[serde(skip_serializing_if = "Option::is_none")] + display_name: Option<&'a str>, + assignee: &'a Handle, + status: AssignmentStatus, +} + +/// Serializes a webhook body to JSON, failing closed to `None` (no body) rather +/// than dropping the whole delivery if serialization ever fails. +fn webhook_body(body: &T) -> Option { + serde_json::to_value(body).ok() +} + +// Workspace lifecycle events. +crud_events! { + fields { workspace_id: Uuid, workspace_slug: Handle } + id = workspace_id; + activity(this) = WorkspaceActivityParams { workspace_slug: this.workspace_slug.clone() }; + webhook = no; + + /// A workspace was created. + WorkspaceCreated => "workspace.created", + /// A workspace was updated. + WorkspaceUpdated => "workspace.updated", + /// A workspace was deleted. + WorkspaceDeleted => "workspace.deleted", +} + +/// A member was added / updated / removed. +/// +/// `MemberAdded` also raises the `member.joined` in-app notification to the +/// workspace's owners and admins (excluding the joiner), so it carries the +/// workspace slug and the joiner's account id the notification needs. #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct MemberRef { +pub struct MemberAdded { pub member_id: Uuid, pub member_username: Handle, + pub workspace_slug: Handle, +} + +impl EventKind for MemberAdded { + const TAG: &'static str = "member.added"; + + fn resource_id(&self) -> Uuid { + self.member_id + } + + fn activity(&self) -> ActivityPayload { + ActivityPayload::MemberAdded(MemberActivityParams { + member_username: self.member_username.clone(), + }) + } + + fn webhook(&self) -> Option { + Some(WebhookDelivery { + event: WebhookEvent::MemberAdded, + body: None, + }) + } + + fn notification(self) -> Vec { + // Tell the workspace's owners and admins that someone joined, skipping the + // joiner themselves. + vec![Notification { + target: NotifyTarget::Roles { + roles: vec![WorkspaceRole::Owner, WorkspaceRole::Admin], + exclude: Some(self.member_id), + }, + payload: NotificationPayload::MemberJoined(MemberJoinedParams { + workspace_slug: self.workspace_slug, + member_username: self.member_username, + }), + }] + } +} + +// Member update/removal. (MemberAdded is hand-written above: it also raises the +// member.joined notification and carries the fields that needs.) +crud_events! { + fields { member_id: Uuid, member_username: Handle } + id = member_id; + activity(this) = MemberActivityParams { member_username: this.member_username.clone() }; + webhook = yes; + + /// A member's role or notification preferences were updated. + MemberUpdated => "member.updated", + /// A member was removed from the workspace. + MemberDeleted => "member.deleted", +} + +// Invite lifecycle events. The invitee's email is recorded when the invite +// carried one; `None` keeps an absent address distinct from a blank one. No +// webhook — the invite flow is internal to the workspace. +crud_events! { + fields { invite_id: Uuid, email: Option } + id = invite_id; + activity(this) = InviteActivityParams { invite_id: this.invite_id, email: this.email.clone() }; + webhook = no; + + /// An invitation was created. + InviteCreated => "invite.created", + /// An invitation was accepted. + InviteAccepted => "invite.accepted", + /// An invitation was declined. + InviteDeclined => "invite.declined", + /// An invitation was canceled. + InviteCanceled => "invite.canceled", +} + +// Connection lifecycle + sync-started. (The sync completed/failed events are +// hand-written below: they notify the triggering account and carry extra fields.) +crud_events! { + fields { connection_id: Uuid, connection_name: String } + id = connection_id; + activity(this) = connection_activity(this.connection_id, &this.connection_name); + webhook = yes; + + /// A connection was created. + ConnectionCreated => "connection.created", + /// A connection was updated. + ConnectionUpdated => "connection.updated", + /// A connection was deleted. + ConnectionDeleted => "connection.deleted", + /// A connection's sync started. + ConnectionSyncStarted => "connection.sync.started", } -/// An invitation and the address it was sent to. +/// A connection sync completed. Notifies the triggering account, when known. #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct InviteRef { - pub invite_id: Uuid, - /// The invitee's email, when the invitation recorded one. `None` when the - /// invite carried no address, so an absent address stays distinct from a - /// blank one. - pub email: Option, +pub struct ConnectionSyncCompleted { + pub connection_id: Uuid, + pub connection_name: String, + pub records_synced: Option, + pub notify: Option, +} + +impl EventKind for ConnectionSyncCompleted { + const TAG: &'static str = "connection.sync.completed"; + + fn resource_id(&self) -> Uuid { + self.connection_id + } + + fn activity(&self) -> ActivityPayload { + ActivityPayload::ConnectionSyncCompleted(connection_activity( + self.connection_id, + &self.connection_name, + )) + } + + fn webhook(&self) -> Option { + Some(WebhookDelivery { + event: WebhookEvent::ConnectionSyncCompleted, + body: None, + }) + } + + fn notification(self) -> Vec { + Notification::to_account( + self.notify, + NotificationPayload::ConnectionSyncCompleted(ConnectionSyncCompletedParams { + connection_id: ConnectionId::from_uuid(self.connection_id), + connection_name: self.connection_name, + records_synced: self.records_synced, + }), + ) + } } -/// A connection and its display name. +/// A connection sync failed. Notifies the triggering account, when known. #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ConnectionRef { +pub struct ConnectionSyncFailed { pub connection_id: Uuid, pub connection_name: String, + pub error: Option, + pub notify: Option, +} + +impl EventKind for ConnectionSyncFailed { + const TAG: &'static str = "connection.sync.failed"; + + fn resource_id(&self) -> Uuid { + self.connection_id + } + + fn activity(&self) -> ActivityPayload { + ActivityPayload::ConnectionSyncFailed(connection_activity( + self.connection_id, + &self.connection_name, + )) + } + + fn webhook(&self) -> Option { + Some(WebhookDelivery { + event: WebhookEvent::ConnectionSyncFailed, + body: None, + }) + } + + fn notification(self) -> Vec { + Notification::to_account( + self.notify, + NotificationPayload::ConnectionSyncFailed(ConnectionSyncFailedParams { + connection_id: ConnectionId::from_uuid(self.connection_id), + connection_name: self.connection_name, + error: self.error, + }), + ) + } } -/// A provider and its display name. +/// Builds the shared connection activity params. +fn connection_activity(connection_id: Uuid, connection_name: &str) -> ConnectionActivityParams { + ConnectionActivityParams { + connection_id: ConnectionId::from_uuid(connection_id), + connection_name: connection_name.to_owned(), + } +} + +// Provider lifecycle events. +crud_events! { + fields { provider_id: Uuid, provider_name: String } + id = provider_id; + activity(this) = provider_activity(this.provider_id, &this.provider_name); + webhook = yes; + + /// A provider was created. + ProviderCreated => "provider.created", + /// A provider was updated. + ProviderUpdated => "provider.updated", + /// A provider was deleted. + ProviderDeleted => "provider.deleted", +} + +/// Builds the shared provider activity params. +fn provider_activity(provider_id: Uuid, provider_name: &str) -> ProviderActivityParams { + ProviderActivityParams { + provider_id: ProviderId::from_uuid(provider_id), + provider_name: provider_name.to_owned(), + } +} + +// Webhook lifecycle events. Webhook CRUD does not itself fire a webhook (it is +// recorded only in the activity log). +crud_events! { + fields { webhook_id: Uuid, webhook_name: String } + id = webhook_id; + activity(this) = webhook_activity(this.webhook_id, &this.webhook_name); + webhook = no; + + /// A webhook was created. + WebhookCreated => "webhook.created", + /// A webhook was updated. + WebhookUpdated => "webhook.updated", + /// A webhook was deleted. + WebhookDeleted => "webhook.deleted", +} + +/// Builds the shared webhook activity params. +fn webhook_activity(webhook_id: Uuid, webhook_name: &str) -> WebhookActivityParams { + WebhookActivityParams { + webhook_id: WebhookId::from_uuid(webhook_id), + webhook_name: webhook_name.to_owned(), + } +} + +/// A file was created. Carries its byte size for the webhook body. #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ProviderRef { - pub provider_id: Uuid, - pub provider_name: String, +pub struct FileCreated { + pub file_id: Uuid, + pub file_name: String, + pub file_size_bytes: i64, +} + +impl EventKind for FileCreated { + const TAG: &'static str = "file.created"; + + fn resource_id(&self) -> Uuid { + self.file_id + } + + fn activity(&self) -> ActivityPayload { + ActivityPayload::FileCreated(file_activity(self.file_id, &self.file_name)) + } + + fn webhook(&self) -> Option { + Some(WebhookDelivery { + event: WebhookEvent::FileCreated, + body: webhook_body(&FileCreatedWebhookBody { + display_name: &self.file_name, + file_size_bytes: self.file_size_bytes, + }), + }) + } } -/// A webhook and its display name. #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct WebhookRef { - pub webhook_id: Uuid, - pub webhook_name: String, +pub struct FileUpdated { + pub file_id: Uuid, + pub file_name: String, +} + +impl EventKind for FileUpdated { + const TAG: &'static str = "file.updated"; + + fn resource_id(&self) -> Uuid { + self.file_id + } + + fn activity(&self) -> ActivityPayload { + ActivityPayload::FileUpdated(file_activity(self.file_id, &self.file_name)) + } + + fn webhook(&self) -> Option { + Some(WebhookDelivery { + event: WebhookEvent::FileUpdated, + body: webhook_body(&FileWebhookBody { + display_name: &self.file_name, + }), + }) + } } -/// A file and its display name. #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct FileRef { +pub struct FileDeleted { pub file_id: Uuid, pub file_name: String, } -/// A pipeline and its slug. +impl EventKind for FileDeleted { + const TAG: &'static str = "file.deleted"; + + fn resource_id(&self) -> Uuid { + self.file_id + } + + fn activity(&self) -> ActivityPayload { + ActivityPayload::FileDeleted(file_activity(self.file_id, &self.file_name)) + } + + fn webhook(&self) -> Option { + Some(WebhookDelivery { + event: WebhookEvent::FileDeleted, + body: webhook_body(&FileWebhookBody { + display_name: &self.file_name, + }), + }) + } +} + +/// Builds the shared file activity params. +fn file_activity(file_id: Uuid, file_name: &str) -> FileActivityParams { + FileActivityParams { + file_id, + file_name: file_name.to_owned(), + } +} + +/// A file was assigned to a reviewer. A file always exists at assign time, so its +/// name is present. Notifies the reviewer unless they assigned themselves. #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct PipelineRef { - pub pipeline_id: Uuid, +pub struct FileAssigned { + pub assignment_id: Uuid, + pub file_id: Uuid, + pub file_name: String, + pub assignee_username: Handle, + pub status: AssignmentStatus, + pub notify: Option, +} + +impl EventKind for FileAssigned { + const TAG: &'static str = "file.assigned"; + + fn resource_id(&self) -> Uuid { + self.assignment_id + } + + fn activity(&self) -> ActivityPayload { + ActivityPayload::FileAssigned(AssignmentActivityParams { + assignment_id: self.assignment_id, + file_name: Some(self.file_name.clone()), + assignee_username: self.assignee_username.clone(), + status: self.status, + }) + } + + fn webhook(&self) -> Option { + Some(WebhookDelivery { + event: WebhookEvent::FileAssigned, + body: webhook_body(&AssignmentWebhookBody { + display_name: Some(&self.file_name), + assignee: &self.assignee_username, + status: self.status, + }), + }) + } + + fn notification(self) -> Vec { + Notification::to_account( + self.notify, + NotificationPayload::FileAssigned(FileAssignedParams { + assignment_id: self.assignment_id, + file_id: self.file_id, + file_name: self.file_name, + }), + ) + } +} + +/// A reviewer was unassigned from a file. The assignment can outlive its file +/// (removed by retention), so the name is optional. Notifies the former reviewer +/// unless they unassigned themselves. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FileUnassigned { + pub assignment_id: Uuid, + pub file_id: Uuid, + pub file_name: Option, + pub assignee_username: Handle, + pub status: AssignmentStatus, + pub notify: Option, +} + +impl EventKind for FileUnassigned { + const TAG: &'static str = "file.unassigned"; + + fn resource_id(&self) -> Uuid { + self.assignment_id + } + + fn activity(&self) -> ActivityPayload { + ActivityPayload::FileUnassigned(AssignmentActivityParams { + assignment_id: self.assignment_id, + file_name: self.file_name.clone(), + assignee_username: self.assignee_username.clone(), + status: self.status, + }) + } + + fn webhook(&self) -> Option { + Some(WebhookDelivery { + event: WebhookEvent::FileUnassigned, + body: webhook_body(&AssignmentWebhookBody { + display_name: self.file_name.as_deref(), + assignee: &self.assignee_username, + status: self.status, + }), + }) + } + + fn notification(self) -> Vec { + Notification::to_account( + self.notify, + NotificationPayload::FileUnassigned(FileUnassignedParams { + file_id: self.file_id, + file_name: self.file_name, + }), + ) + } +} + +/// A file assignment's review status changed. The file may since have been +/// removed, so its name is optional. Raises no notification. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AssignmentStatusChanged { + pub assignment_id: Uuid, + pub file_id: Uuid, + pub file_name: Option, + pub assignee_username: Handle, + pub status: AssignmentStatus, +} + +impl EventKind for AssignmentStatusChanged { + const TAG: &'static str = "file.assignment.updated"; + + fn resource_id(&self) -> Uuid { + self.assignment_id + } + + fn activity(&self) -> ActivityPayload { + ActivityPayload::AssignmentStatusChanged(AssignmentActivityParams { + assignment_id: self.assignment_id, + file_name: self.file_name.clone(), + assignee_username: self.assignee_username.clone(), + status: self.status, + }) + } + + fn webhook(&self) -> Option { + Some(WebhookDelivery { + event: WebhookEvent::AssignmentStatusChanged, + body: webhook_body(&AssignmentWebhookBody { + display_name: self.file_name.as_deref(), + assignee: &self.assignee_username, + status: self.status, + }), + }) + } +} + +// Pipeline lifecycle events. (Detection/redaction run events are hand-written +// below: they carry run-specific fields and notifications.) +crud_events! { + fields { pipeline_id: Uuid, pipeline_slug: Handle } + id = pipeline_id; + activity(this) = PipelineActivityParams { pipeline_slug: this.pipeline_slug.clone() }; + webhook = yes; + + /// A pipeline was created. + PipelineCreated => "pipeline.created", + /// A pipeline was updated. + PipelineUpdated => "pipeline.updated", + /// A pipeline was deleted. + PipelineDeleted => "pipeline.deleted", +} + +// A detection started: a plain activity + webhook event, no notification. (The +// completed/failed events below notify the triggering account.) +crud_events! { + fields { detection_id: Uuid, pipeline_slug: Handle } + id = detection_id; + activity(this) = detection_activity(this.detection_id, &this.pipeline_slug); + webhook = yes; + + /// A detection was started. + DetectionStarted => "pipeline.detection.started", +} + +/// A detection finished analysis. Notifies the triggering account. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DetectionCompleted { + pub detection_id: Uuid, pub pipeline_slug: Handle, + pub input_file_name: Option, + pub notify: Uuid, } -/// A detection and its pipeline's slug. +impl EventKind for DetectionCompleted { + const TAG: &'static str = "pipeline.detection.completed"; + + fn resource_id(&self) -> Uuid { + self.detection_id + } + + fn activity(&self) -> ActivityPayload { + ActivityPayload::DetectionCompleted(detection_activity( + self.detection_id, + &self.pipeline_slug, + )) + } + + fn webhook(&self) -> Option { + Some(WebhookDelivery { + event: WebhookEvent::DetectionCompleted, + body: None, + }) + } + + fn notification(self) -> Vec { + vec![Notification { + target: NotifyTarget::Account(self.notify), + payload: NotificationPayload::DetectionCompleted(DetectionCompletedParams { + detection_id: DetectionId::from_uuid(self.detection_id), + pipeline_slug: self.pipeline_slug, + input_file_name: self.input_file_name, + }), + }] + } +} + +/// A detection failed. Notifies the triggering account. #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct DetectionRef { +pub struct DetectionFailed { pub detection_id: Uuid, pub pipeline_slug: Handle, + pub input_file_name: Option, + pub error: Option, + pub notify: Uuid, +} + +impl EventKind for DetectionFailed { + const TAG: &'static str = "pipeline.detection.failed"; + + fn resource_id(&self) -> Uuid { + self.detection_id + } + + fn activity(&self) -> ActivityPayload { + ActivityPayload::DetectionFailed(detection_activity(self.detection_id, &self.pipeline_slug)) + } + + fn webhook(&self) -> Option { + Some(WebhookDelivery { + event: WebhookEvent::DetectionFailed, + body: None, + }) + } + + fn notification(self) -> Vec { + vec![Notification { + target: NotifyTarget::Account(self.notify), + payload: NotificationPayload::DetectionFailed(DetectionFailedParams { + detection_id: DetectionId::from_uuid(self.detection_id), + pipeline_slug: self.pipeline_slug, + input_file_name: self.input_file_name, + error: self.error, + }), + }] + } +} + +/// Builds the shared detection activity params. +fn detection_activity(detection_id: Uuid, pipeline_slug: &Handle) -> DetectionActivityParams { + DetectionActivityParams { + pipeline_slug: pipeline_slug.clone(), + detection_id: DetectionId::from_uuid(detection_id), + } } -/// A policy and its slug. +/// A redaction was created from a detection. Notifies the triggering account. #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct PolicyRef { - pub policy_id: Uuid, - pub policy_slug: Handle, +pub struct RedactionCreated { + pub detection_id: Uuid, + pub pipeline_slug: Handle, + /// The redaction that was produced (its own id, distinct from the detection's + /// — a detection can produce many redactions). + pub redaction_id: Uuid, + pub input_file_name: Option, + pub notify: Uuid, +} + +impl EventKind for RedactionCreated { + const TAG: &'static str = "pipeline.redaction.created"; + + fn resource_id(&self) -> Uuid { + // The affected resource is the redaction produced, not the detection it + // came from, this matches the activity log's object id for the event. + self.redaction_id + } + + fn activity(&self) -> ActivityPayload { + ActivityPayload::RedactionCreated(RedactionActivityParams { + pipeline_slug: self.pipeline_slug.clone(), + redaction_id: RedactionId::from_uuid(self.redaction_id), + }) + } + + fn webhook(&self) -> Option { + Some(WebhookDelivery { + event: WebhookEvent::RedactionCreated, + body: None, + }) + } + + fn notification(self) -> Vec { + vec![Notification { + target: NotifyTarget::Account(self.notify), + payload: NotificationPayload::RedactionCreated(RedactionCreatedParams { + redaction_id: RedactionId::from_uuid(self.redaction_id), + detection_id: DetectionId::from_uuid(self.detection_id), + pipeline_slug: self.pipeline_slug, + input_file_name: self.input_file_name, + }), + }] + } +} + +// Policy lifecycle events. +crud_events! { + fields { policy_id: Uuid, policy_slug: Handle } + id = policy_id; + activity(this) = policy_activity(this.policy_id, &this.policy_slug); + webhook = yes; + + /// A policy was created. + PolicyCreated => "policy.created", + /// A policy was updated. + PolicyUpdated => "policy.updated", + /// A policy was deleted. + PolicyDeleted => "policy.deleted", +} + +/// Builds the shared policy activity params. +fn policy_activity(policy_id: Uuid, policy_slug: &Handle) -> PolicyActivityParams { + PolicyActivityParams { + policy_id, + policy_slug: policy_slug.clone(), + } } diff --git a/crates/nvisy-server/src/service/file_reaper.rs b/crates/nvisy-server/src/service/file_reaper.rs index 78beb813..614cab60 100644 --- a/crates/nvisy-server/src/service/file_reaper.rs +++ b/crates/nvisy-server/src/service/file_reaper.rs @@ -17,7 +17,7 @@ use std::time::Duration; use nvisy_postgres::query::{ExpiredFileRef, WorkspaceFileRepository}; use tokio_util::sync::CancellationToken; -use crate::handler::Result; +use crate::response::Result; use crate::service::{Infra, PurgeOutcome, RunBlobStore, Worker}; /// Tracing target for the file reaper. diff --git a/crates/nvisy-server/src/service/integration/connection_config.rs b/crates/nvisy-server/src/service/integration/connection_config.rs index d7dd5fa1..fe014c48 100644 --- a/crates/nvisy-server/src/service/integration/connection_config.rs +++ b/crates/nvisy-server/src/service/integration/connection_config.rs @@ -14,7 +14,7 @@ use nvisy_postgres::types::ConnectionType; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -use crate::handler::{ErrorKind, Result}; +use crate::response::{ErrorKind, Result}; /// A fully-typed transfer-connection configuration. /// diff --git a/crates/nvisy-server/src/service/integration/connector.rs b/crates/nvisy-server/src/service/integration/connector.rs index ddf52b4d..d0989835 100644 --- a/crates/nvisy-server/src/service/integration/connector.rs +++ b/crates/nvisy-server/src/service/integration/connector.rs @@ -12,7 +12,7 @@ use nvisy_file_service::oauth::OAuthTokens; use nvisy_postgres::model::WorkspaceConnection; use super::file_source::{FileServiceSource, FileSource, ObjectStoreSource}; -use crate::handler::{ErrorKind, Result}; +use crate::response::{ErrorKind, Result}; use crate::service::{ConnectionConfig, ExternalObjectStore, Infra, persist_refreshed_tokens}; /// Tracing target for connection sync operations. diff --git a/crates/nvisy-server/src/service/integration/export.rs b/crates/nvisy-server/src/service/integration/export.rs index 4dcbdc17..83ce6631 100644 --- a/crates/nvisy-server/src/service/integration/export.rs +++ b/crates/nvisy-server/src/service/integration/export.rs @@ -20,7 +20,7 @@ use uuid::Uuid; use super::connector::Connector; use super::file_source::{ByteStream, FileSource, FileUpload}; use super::naming::{export_key, mime_from_extension}; -use crate::handler::{ErrorKind, Result}; +use crate::response::{ErrorKind, Result}; use crate::service::{ConnectionConfig, Infra}; /// Tracing target for connection sync operations. diff --git a/crates/nvisy-server/src/service/integration/file_source.rs b/crates/nvisy-server/src/service/integration/file_source.rs index 0ace66c2..12e66fad 100644 --- a/crates/nvisy-server/src/service/integration/file_source.rs +++ b/crates/nvisy-server/src/service/integration/file_source.rs @@ -18,7 +18,7 @@ use nvisy_file_service::client::FileServiceClient; use nvisy_object_store::Error as ObjectError; use nvisy_object_store::client::ObjectStoreClient; -use crate::handler::Result; +use crate::response::Result; /// One entry to transfer, addressed by the provider-specific `key` the transfer /// methods accept. diff --git a/crates/nvisy-server/src/service/integration/import.rs b/crates/nvisy-server/src/service/integration/import.rs index b11eca06..8c687a15 100644 --- a/crates/nvisy-server/src/service/integration/import.rs +++ b/crates/nvisy-server/src/service/integration/import.rs @@ -23,7 +23,7 @@ use uuid::Uuid; use super::connector::Connector; use super::file_source::{FileSource, SourceEntry}; use super::naming::{object_basename, object_extension}; -use crate::handler::Result; +use crate::response::Result; use crate::service::{ConnectionConfig, HashingReader, Infra, Measurements}; /// Tracing target for connection sync operations. diff --git a/crates/nvisy-server/src/service/integration/persist_oauth.rs b/crates/nvisy-server/src/service/integration/persist_oauth.rs index 7a12ae45..64d4a3b2 100644 --- a/crates/nvisy-server/src/service/integration/persist_oauth.rs +++ b/crates/nvisy-server/src/service/integration/persist_oauth.rs @@ -6,7 +6,7 @@ use nvisy_postgres::query::WorkspaceConnectionRepository; use nvisy_postgres::{AsyncConnection, PgConn}; use uuid::Uuid; -use crate::handler::Result; +use crate::response::Result; use crate::service::{ConnectionConfig, CryptoService}; /// Persists refreshed OAuth tokens, merging them onto the connection's *current* @@ -47,7 +47,7 @@ pub async fn persist_refreshed_tokens( }; conn.update_workspace_connection(connection_id, update) .await?; - Ok::<_, crate::handler::Error>(()) + Ok::<_, crate::response::Error>(()) }) .await?; Ok(()) diff --git a/crates/nvisy-server/src/service/integration/provider_config.rs b/crates/nvisy-server/src/service/integration/provider_config.rs index 9129e032..2d333781 100644 --- a/crates/nvisy-server/src/service/integration/provider_config.rs +++ b/crates/nvisy-server/src/service/integration/provider_config.rs @@ -13,7 +13,7 @@ use nvisy_postgres::types::ProviderType; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -use crate::handler::{ErrorKind, Result}; +use crate::response::{ErrorKind, Result}; /// A fully-typed inference-provider configuration. /// diff --git a/crates/nvisy-server/src/service/integration/service.rs b/crates/nvisy-server/src/service/integration/service.rs index f22de58f..41f1d7a7 100644 --- a/crates/nvisy-server/src/service/integration/service.rs +++ b/crates/nvisy-server/src/service/integration/service.rs @@ -26,10 +26,10 @@ use super::export::Exporter; use super::file_source::SourceEntry; use super::import::Importer; use crate::extract::SecurityContext; -use crate::handler::{ErrorKind, Result}; +use crate::response::{ErrorKind, Result}; use crate::service::{ - ConnectionConfig, ConnectionRef, EventEmitter, EventOrigin, ExternalObjectStore, Infra, - WorkspaceEvent, + ConnectionConfig, ConnectionSyncCompleted, ConnectionSyncFailed, ConnectionSyncStarted, + EventEmitter, EventOrigin, ExternalObjectStore, Infra, WorkspaceEvent, }; /// Tracing target for connection sync operations. @@ -140,7 +140,7 @@ impl ConnectionSyncService { account_id: new_run.account_id, security: &SecurityContext::default(), }; - let started = WorkspaceEvent::ConnectionSyncStarted(ConnectionRef { + let started = WorkspaceEvent::ConnectionSyncStarted(ConnectionSyncStarted { connection_id: connection.id, connection_name: connection.display_name.clone(), }); @@ -148,7 +148,7 @@ impl ConnectionSyncService { .transaction(async |conn| { let run = conn.create_workspace_connection_sync(new_run).await?; conn.emit_event(origin, started).await?; - Ok::<_, crate::handler::Error>(run) + Ok::<_, crate::response::Error>(run) }) .await?; Ok(run) @@ -288,22 +288,24 @@ impl ConnectionSyncService { // Build the terminal event before `result` is consumed. `notify` targets // the account the run is attributed to (the origin's). let event = match &result { - Ok(records_synced) => WorkspaceEvent::ConnectionSyncCompleted { - connection_id, - connection_name: connection_name.to_owned(), - records_synced: Some(*records_synced as i64), - notify: Some(origin.account_id), - }, + Ok(records_synced) => { + WorkspaceEvent::ConnectionSyncCompleted(ConnectionSyncCompleted { + connection_id, + connection_name: connection_name.to_owned(), + records_synced: Some(*records_synced as i64), + notify: Some(origin.account_id), + }) + } Err(err) => { // Log the full error (may include backend URLs/details) but record // only the safe summary; the stored message is exposed to clients. tracing::warn!(target: TRACING_TARGET, %run_id, error = %err, "Sync failed"); - WorkspaceEvent::ConnectionSyncFailed { + WorkspaceEvent::ConnectionSyncFailed(ConnectionSyncFailed { connection_id, connection_name: connection_name.to_owned(), error: Some(err.message.as_deref().unwrap_or("Sync failed").to_owned()), notify: Some(origin.account_id), - } + }) } }; @@ -327,8 +329,8 @@ impl ConnectionSyncService { .is_some(), Err(_) => { let safe_message = match &event { - WorkspaceEvent::ConnectionSyncFailed { error, .. } => { - error.clone().unwrap_or_else(|| "Sync failed".to_owned()) + WorkspaceEvent::ConnectionSyncFailed(e) => { + e.error.clone().unwrap_or_else(|| "Sync failed".to_owned()) } _ => "Sync failed".to_owned(), }; @@ -340,7 +342,7 @@ impl ConnectionSyncService { if transitioned { conn.emit_event(origin, event).await?; } - Ok::<_, crate::handler::Error>(transitioned) + Ok::<_, crate::response::Error>(transitioned) }) .await; diff --git a/crates/nvisy-server/src/service/integration/worker.rs b/crates/nvisy-server/src/service/integration/worker.rs index 29f1a563..ec02449d 100644 --- a/crates/nvisy-server/src/service/integration/worker.rs +++ b/crates/nvisy-server/src/service/integration/worker.rs @@ -32,7 +32,7 @@ use tokio_util::sync::CancellationToken; use uuid::Uuid; use super::{ConnectionSyncService, StandardCronSchedule, TransferKind, TransferRequest}; -use crate::handler::{ErrorKind, Result}; +use crate::response::{ErrorKind, Result}; use crate::service::{ConnectionConfig, Infra, Worker}; /// Tracing target for the connection sync worker. diff --git a/crates/nvisy-server/src/service/mod.rs b/crates/nvisy-server/src/service/mod.rs index 2c3f8b40..85e7f7f8 100644 --- a/crates/nvisy-server/src/service/mod.rs +++ b/crates/nvisy-server/src/service/mod.rs @@ -49,9 +49,15 @@ pub use crate::service::detection::{ }; pub use crate::service::engine::{EngineConfig, EngineService, UnknownFormatToken}; pub use crate::service::event::{ - ConnectionRef, DetectionRef, EventEmitter, EventOrigin, EventOutboxDrainer, FileRef, InviteRef, - MemberRef, PipelineRef, PolicyRef, ProviderRef, WebhookRef, WorkspaceEvent, WorkspaceRef, - event_outbox_row, + AssignmentStatusChanged, ConnectionCreated, ConnectionDeleted, ConnectionSyncCompleted, + ConnectionSyncFailed, ConnectionSyncStarted, ConnectionUpdated, DetectionCompleted, + DetectionFailed, DetectionStarted, EventEmitter, EventKind, EventOrigin, EventOutboxDrainer, + FileAssigned, FileCreated, FileDeleted, FileUnassigned, FileUpdated, InviteAccepted, + InviteCanceled, InviteCreated, InviteDeclined, MemberAdded, MemberDeleted, MemberUpdated, + Notification, NotifyTarget, PipelineCreated, PipelineDeleted, PipelineUpdated, PolicyCreated, + PolicyDeleted, PolicyUpdated, ProviderCreated, ProviderDeleted, ProviderUpdated, + RedactionCreated, WebhookCreated, WebhookDeleted, WebhookDelivery, WebhookUpdated, + WorkspaceCreated, WorkspaceDeleted, WorkspaceEvent, WorkspaceUpdated, event_outbox_row, }; pub use crate::service::file_reaper::FileReaper; pub use crate::service::health::{HealthCache, HealthConfig}; diff --git a/crates/nvisy-server/src/service/notification.rs b/crates/nvisy-server/src/service/notification.rs index 7f757fd5..ef5ffcdb 100644 --- a/crates/nvisy-server/src/service/notification.rs +++ b/crates/nvisy-server/src/service/notification.rs @@ -60,7 +60,7 @@ impl NotificationEmitter { pub async fn subscribe_unread( &self, account_id: Uuid, - ) -> crate::handler::Result> { + ) -> crate::response::Result> { let stream = self .infra .nats @@ -105,32 +105,6 @@ impl NotificationEmitter { } } - /// Notifies a single account unconditionally, without a workspace membership - /// or preference check. - /// - /// For events that target someone who is not (yet) a workspace member — e.g. - /// `member:invited`, where the recipient is being invited *to* the workspace. - /// Best-effort: callers log-and-continue on error. - pub async fn notify_account_direct( - &self, - account_id: Uuid, - payload: NotificationPayload, - ) -> Result<()> { - let (event, params) = payload.into_stored(); - let mut conn = self.infra.postgres.get_connection().await?; - conn.create_account_notification(NewAccountNotification { - account_id, - notify_type: event, - params, - expires_at: None, - }) - .await?; - tracing::debug!(target: TRACING_TARGET, %account_id, event = %event, "Notification created"); - - self.broadcast_unread(&mut conn, account_id).await; - Ok(()) - } - /// Notifies a single account of an event, honoring the recipient's in-app /// notification preferences within `workspace_id`. /// diff --git a/crates/nvisy-server/src/service/password/hasher.rs b/crates/nvisy-server/src/service/password/hasher.rs index bc9991af..c24c93b8 100644 --- a/crates/nvisy-server/src/service/password/hasher.rs +++ b/crates/nvisy-server/src/service/password/hasher.rs @@ -9,7 +9,7 @@ use argon2::password_hash::{Error as ArgonError, PasswordHasher as _, PasswordVe use argon2::{Argon2, PasswordHash}; use rand::distr::Alphanumeric; -use crate::handler::{ErrorKind, Result}; +use crate::response::{ErrorKind, Result}; /// Tracing target for password hashing operations. const TRACING_TARGET: &str = "nvisy_server::password_hasher"; diff --git a/crates/nvisy-server/src/service/password/service.rs b/crates/nvisy-server/src/service/password/service.rs index 9726acc1..5008959b 100644 --- a/crates/nvisy-server/src/service/password/service.rs +++ b/crates/nvisy-server/src/service/password/service.rs @@ -7,7 +7,7 @@ use super::hasher::PasswordHasher; use super::strength::PasswordStrength; -use crate::handler::Result; +use crate::response::Result; /// Password strength validation and Argon2id hashing. #[derive(Debug, Clone, Default)] diff --git a/crates/nvisy-server/src/service/password/strength.rs b/crates/nvisy-server/src/service/password/strength.rs index 7f40de1e..555475df 100644 --- a/crates/nvisy-server/src/service/password/strength.rs +++ b/crates/nvisy-server/src/service/password/strength.rs @@ -9,7 +9,7 @@ use zxcvbn::feedback::Feedback; use zxcvbn::time_estimates::CrackTimeSeconds; use zxcvbn::zxcvbn; -use crate::handler::{ErrorKind, Result}; +use crate::response::{ErrorKind, Result}; /// Tracing target for password strength operations. const TRACING_TARGET: &str = "nvisy_server::password_strength"; diff --git a/crates/nvisy-server/src/service/run_blob_store.rs b/crates/nvisy-server/src/service/run_blob_store.rs index 11136ae8..c4942978 100644 --- a/crates/nvisy-server/src/service/run_blob_store.rs +++ b/crates/nvisy-server/src/service/run_blob_store.rs @@ -26,7 +26,7 @@ use sha2::{Digest, Sha256}; use tokio::io::AsyncReadExt; use uuid::Uuid; -use crate::handler::{Error, ErrorKind, Result}; +use crate::response::{Error, ErrorKind, Result}; use crate::service::Infra; /// Tracing target for blob-store operations. diff --git a/migrations/2025-05-21-121131_accounts/up.sql b/migrations/2025-05-21-121131_accounts/up.sql index cbdcd134..a4a9ab62 100644 --- a/migrations/2025-05-21-121131_accounts/up.sql +++ b/migrations/2025-05-21-121131_accounts/up.sql @@ -25,8 +25,7 @@ CREATE TABLE accounts ( -- Primary identifier id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - -- Status and permissions - is_admin BOOLEAN NOT NULL DEFAULT FALSE, + -- Status is_verified BOOLEAN NOT NULL DEFAULT FALSE, is_suspended BOOLEAN NOT NULL DEFAULT FALSE, @@ -64,10 +63,7 @@ CREATE TABLE accounts ( CONSTRAINT accounts_updated_after_created CHECK (updated_at >= created_at), CONSTRAINT accounts_deleted_after_created CHECK (deleted_at IS NULL OR deleted_at >= created_at), CONSTRAINT accounts_deleted_after_updated CHECK (deleted_at IS NULL OR deleted_at >= updated_at), - CONSTRAINT accounts_password_changed_after_created CHECK (password_changed_at IS NULL OR password_changed_at >= created_at), - - -- An admin account cannot be suspended. - CONSTRAINT accounts_suspended_not_admin CHECK (NOT (is_suspended AND is_admin)) + CONSTRAINT accounts_password_changed_after_created CHECK (password_changed_at IS NULL OR password_changed_at >= created_at) ); -- Keep updated_at current on every write. @@ -82,11 +78,6 @@ CREATE UNIQUE INDEX accounts_username_unique_idx ON accounts (lower(username)) WHERE deleted_at IS NULL; --- Admins, for the admin listing. -CREATE INDEX accounts_admin_users_idx - ON accounts (id, display_name) - WHERE is_admin = TRUE AND deleted_at IS NULL; - -- Fuzzy name/email search over live accounts. CREATE INDEX accounts_display_name_trgm_idx ON accounts USING gin (display_name gin_trgm_ops) @@ -98,7 +89,6 @@ CREATE INDEX accounts_email_address_trgm_idx COMMENT ON TABLE accounts IS 'Account identities, with preferences and security tracking.'; COMMENT ON COLUMN accounts.id IS 'Unique account identifier'; -COMMENT ON COLUMN accounts.is_admin IS 'Administrative privileges across the whole system'; COMMENT ON COLUMN accounts.is_verified IS 'Whether the account has confirmed its email'; COMMENT ON COLUMN accounts.is_suspended IS 'Whether account access is temporarily disabled'; COMMENT ON COLUMN accounts.username IS 'Public handle, unique across accounts (3-32 chars, lowercase, dash-separated)'; diff --git a/migrations/2025-05-21-121132_notifications/up.sql b/migrations/2025-05-21-121132_notifications/up.sql index a2c0c56e..e831409d 100644 --- a/migrations/2025-05-21-121132_notifications/up.sql +++ b/migrations/2025-05-21-121132_notifications/up.sql @@ -3,17 +3,11 @@ -- client renders copy from the event type and its typed params. -- Type of a notification event: what happened that the account is told about. -CREATE TYPE NOTIFICATION_EVENT AS ENUM ( - 'member.invited', -- User was invited to a workspace - 'member.joined', -- A new member joined a workspace - - 'connection.sync.completed', -- A connection sync completed - 'connection.sync.failed', -- A connection sync failed - - 'pipeline.detection.completed', -- A detection finished analysis, ready to redact - 'pipeline.redaction.created', -- A redaction was created (redacted output produced) - 'pipeline.detection.failed' -- A detection failed -); +-- Created empty here (the account_notifications column below needs the type to +-- exist); every value is added by the migration that introduces the object it +-- describes — members in the workspaces migration, connections/detections/ +-- redactions/assignments in theirs — via ALTER TYPE ... ADD VALUE. +CREATE TYPE NOTIFICATION_EVENT AS ENUM (); COMMENT ON TYPE NOTIFICATION_EVENT IS 'Type of a notification event delivered to an account.'; diff --git a/migrations/2025-05-21-222840_workspaces/up.sql b/migrations/2025-05-21-222840_workspaces/up.sql index 5b93edb5..478ded95 100644 --- a/migrations/2025-05-21-222840_workspaces/up.sql +++ b/migrations/2025-05-21-222840_workspaces/up.sql @@ -99,6 +99,11 @@ CREATE TYPE WORKSPACE_ROLE AS ENUM ( COMMENT ON TYPE WORKSPACE_ROLE IS 'Access role of a workspace member: owner, admin, editor, or reviewer.'; +-- Membership is where "a member joined" first becomes meaningful, so the +-- notification value for it is added here (the type is created empty by the +-- notifications migration). +ALTER TYPE NOTIFICATION_EVENT ADD VALUE IF NOT EXISTS 'member.joined'; + -- Workspace members table: an account's membership in a workspace. CREATE TABLE workspace_members ( -- Primary key (composite) @@ -111,14 +116,12 @@ CREATE TABLE workspace_members ( -- Notification preferences notify_via_email BOOLEAN NOT NULL DEFAULT FALSE, - -- In-app defaults to every event so members are notified out of the box; - -- a member narrows the set by replacing it. Keep this list in sync with the - -- NOTIFICATION_EVENT enum when it changes. Email stays opt-in (empty). - notification_events_app NOTIFICATION_EVENT[] NOT NULL DEFAULT ARRAY[ - 'member.invited', 'member.joined', - 'connection.sync.completed', 'connection.sync.failed', - 'pipeline.detection.completed', 'pipeline.redaction.created', 'pipeline.detection.failed' - ]::NOTIFICATION_EVENT[], + -- 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 '{}', notification_events_email NOTIFICATION_EVENT[] NOT NULL DEFAULT '{}', -- Audit tracking diff --git a/migrations/2025-05-21-222841_activities/up.sql b/migrations/2025-05-21-222841_activities/up.sql index bd43ea11..823d5c1d 100644 --- a/migrations/2025-05-21-222841_activities/up.sql +++ b/migrations/2025-05-21-222841_activities/up.sql @@ -3,6 +3,11 @@ -- and its typed params; the client renders the copy from those. -- Type of activity recorded in a workspace audit log. +-- Only the values for objects that exist by this migration are declared here. +-- Each later feature (webhooks, connections, providers, files, policies, +-- pipelines, detections, redactions, assignments) adds its own values via +-- ALTER TYPE ... ADD VALUE in its own migration, so a value is introduced by +-- the migration that introduces the object it describes. CREATE TYPE ACTIVITY_TYPE AS ENUM ( -- Workspace activities 'workspace.created', @@ -18,44 +23,7 @@ CREATE TYPE ACTIVITY_TYPE AS ENUM ( 'invite.created', 'invite.accepted', 'invite.declined', - 'invite.canceled', - - -- Connection activities - 'connection.created', - 'connection.updated', - 'connection.deleted', - 'connection.sync.started', - 'connection.sync.completed', - 'connection.sync.failed', - - -- Provider activities - 'provider.created', - 'provider.updated', - 'provider.deleted', - - -- Webhook activities - 'webhook.created', - 'webhook.updated', - 'webhook.deleted', - - -- File activities - 'file.created', - 'file.updated', - 'file.deleted', - - -- Pipeline, detection, and redaction activities - 'pipeline.created', - 'pipeline.updated', - 'pipeline.deleted', - 'pipeline.detection.started', - 'pipeline.detection.completed', - 'pipeline.detection.failed', - 'pipeline.redaction.created', - - -- Policy activities - 'policy.created', - 'policy.updated', - 'policy.deleted' + 'invite.canceled' ); COMMENT ON TYPE ACTIVITY_TYPE IS 'Type of activity performed in a workspace, for audit logging.'; diff --git a/migrations/2025-05-21-222842_webhooks/up.sql b/migrations/2025-05-21-222842_webhooks/up.sql index 6a976975..84957bbd 100644 --- a/migrations/2025-05-21-222842_webhooks/up.sql +++ b/migrations/2025-05-21-222842_webhooks/up.sql @@ -13,43 +13,15 @@ COMMENT ON TYPE WEBHOOK_STATUS IS 'Defines the operational status of workspace webhooks.'; -- Event types a webhook can subscribe to, grouped by resource. +-- Only the values for objects that exist by this migration are declared here. +-- Each later feature (connections, providers, files, policies, pipelines, +-- detections, redactions, assignments) adds its own values via +-- ALTER TYPE ... ADD VALUE in its own migration. CREATE TYPE WEBHOOK_EVENT AS ENUM ( - -- File events - 'file.created', - 'file.updated', - 'file.deleted', - -- Member events 'member.added', 'member.deleted', - 'member.updated', - - -- Connection events - 'connection.created', - 'connection.updated', - 'connection.deleted', - 'connection.sync.started', - 'connection.sync.completed', - 'connection.sync.failed', - - -- Provider events - 'provider.created', - 'provider.updated', - 'provider.deleted', - - -- Pipeline, detection, and redaction events - 'pipeline.created', - 'pipeline.updated', - 'pipeline.deleted', - 'pipeline.detection.started', - 'pipeline.detection.completed', - 'pipeline.detection.failed', - 'pipeline.redaction.created', - - -- Policy events - 'policy.created', - 'policy.updated', - 'policy.deleted' + 'member.updated' ); COMMENT ON TYPE WEBHOOK_EVENT IS @@ -144,3 +116,9 @@ COMMENT ON COLUMN workspace_webhooks.created_by IS 'Account that created the web COMMENT ON COLUMN workspace_webhooks.created_at IS 'Webhook creation timestamp'; COMMENT ON COLUMN workspace_webhooks.updated_at IS 'Timestamp when webhook was last modified'; COMMENT ON COLUMN workspace_webhooks.deleted_at IS 'Soft-deletion timestamp; NULL means live'; + +-- Webhook lifecycle is recorded in the activity log (webhooks do not fire on +-- their own management, so no WEBHOOK_EVENT values here). +ALTER TYPE ACTIVITY_TYPE ADD VALUE IF NOT EXISTS 'webhook.created'; +ALTER TYPE ACTIVITY_TYPE ADD VALUE IF NOT EXISTS 'webhook.updated'; +ALTER TYPE ACTIVITY_TYPE ADD VALUE IF NOT EXISTS 'webhook.deleted'; diff --git a/migrations/2025-05-27-011852_files/up.sql b/migrations/2025-05-27-011852_files/up.sql index 83883c98..568ec8f9 100644 --- a/migrations/2025-05-27-011852_files/up.sql +++ b/migrations/2025-05-27-011852_files/up.sql @@ -155,3 +155,12 @@ COMMENT ON COLUMN workspace_files.updated_at IS 'Last modification timestamp'; COMMENT ON COLUMN workspace_files.deleted_at IS 'Soft-deletion timestamp; NULL means live'; COMMENT ON COLUMN workspace_files.expires_at IS 'Data-retention expiry (NULL = keep indefinitely)'; COMMENT ON COLUMN workspace_files.purged_at IS 'When the backing object was reclaimed; NULL on a deleted row means purge still pending'; + +-- File lifecycle events feed the activity log and webhooks. +ALTER TYPE ACTIVITY_TYPE ADD VALUE IF NOT EXISTS 'file.created'; +ALTER TYPE ACTIVITY_TYPE ADD VALUE IF NOT EXISTS 'file.updated'; +ALTER TYPE ACTIVITY_TYPE ADD VALUE IF NOT EXISTS 'file.deleted'; + +ALTER TYPE WEBHOOK_EVENT ADD VALUE IF NOT EXISTS 'file.created'; +ALTER TYPE WEBHOOK_EVENT ADD VALUE IF NOT EXISTS 'file.updated'; +ALTER TYPE WEBHOOK_EVENT ADD VALUE IF NOT EXISTS 'file.deleted'; diff --git a/migrations/2026-01-19-045012_connections/up.sql b/migrations/2026-01-19-045012_connections/up.sql index 64f4c543..9c92dd10 100644 --- a/migrations/2026-01-19-045012_connections/up.sql +++ b/migrations/2026-01-19-045012_connections/up.sql @@ -299,3 +299,22 @@ COMMENT ON COLUMN workspace_file_exports.file_id IS 'The workspace file that was COMMENT ON COLUMN workspace_file_exports.connection_id IS 'Connection the file was exported to'; COMMENT ON COLUMN workspace_file_exports.remote_key IS 'Remote key the file was written to on the provider'; COMMENT ON COLUMN workspace_file_exports.exported_at IS 'When the file was exported'; + +-- Connection lifecycle and sync events feed the activity log, webhooks, and (for +-- sync completion/failure) in-app notifications. +ALTER TYPE ACTIVITY_TYPE ADD VALUE IF NOT EXISTS 'connection.created'; +ALTER TYPE ACTIVITY_TYPE ADD VALUE IF NOT EXISTS 'connection.updated'; +ALTER TYPE ACTIVITY_TYPE ADD VALUE IF NOT EXISTS 'connection.deleted'; +ALTER TYPE ACTIVITY_TYPE ADD VALUE IF NOT EXISTS 'connection.sync.started'; +ALTER TYPE ACTIVITY_TYPE ADD VALUE IF NOT EXISTS 'connection.sync.completed'; +ALTER TYPE ACTIVITY_TYPE ADD VALUE IF NOT EXISTS 'connection.sync.failed'; + +ALTER TYPE WEBHOOK_EVENT ADD VALUE IF NOT EXISTS 'connection.created'; +ALTER TYPE WEBHOOK_EVENT ADD VALUE IF NOT EXISTS 'connection.updated'; +ALTER TYPE WEBHOOK_EVENT ADD VALUE IF NOT EXISTS 'connection.deleted'; +ALTER TYPE WEBHOOK_EVENT ADD VALUE IF NOT EXISTS 'connection.sync.started'; +ALTER TYPE WEBHOOK_EVENT ADD VALUE IF NOT EXISTS 'connection.sync.completed'; +ALTER TYPE WEBHOOK_EVENT ADD VALUE IF NOT EXISTS 'connection.sync.failed'; + +ALTER TYPE NOTIFICATION_EVENT ADD VALUE IF NOT EXISTS 'connection.sync.completed'; +ALTER TYPE NOTIFICATION_EVENT ADD VALUE IF NOT EXISTS 'connection.sync.failed'; diff --git a/migrations/2026-01-19-045013_providers/up.sql b/migrations/2026-01-19-045013_providers/up.sql index 760ee72b..f8aedd23 100644 --- a/migrations/2026-01-19-045013_providers/up.sql +++ b/migrations/2026-01-19-045013_providers/up.sql @@ -101,3 +101,12 @@ COMMENT ON COLUMN workspace_providers.metadata IS 'Non-encrypted metadata for fi COMMENT ON COLUMN workspace_providers.created_at IS 'Provider creation timestamp'; COMMENT ON COLUMN workspace_providers.updated_at IS 'Last modification timestamp'; COMMENT ON COLUMN workspace_providers.deleted_at IS 'Soft-deletion timestamp; NULL means live'; + +-- Provider lifecycle events feed the activity log and webhooks. +ALTER TYPE ACTIVITY_TYPE ADD VALUE IF NOT EXISTS 'provider.created'; +ALTER TYPE ACTIVITY_TYPE ADD VALUE IF NOT EXISTS 'provider.updated'; +ALTER TYPE ACTIVITY_TYPE ADD VALUE IF NOT EXISTS 'provider.deleted'; + +ALTER TYPE WEBHOOK_EVENT ADD VALUE IF NOT EXISTS 'provider.created'; +ALTER TYPE WEBHOOK_EVENT ADD VALUE IF NOT EXISTS 'provider.updated'; +ALTER TYPE WEBHOOK_EVENT ADD VALUE IF NOT EXISTS 'provider.deleted'; diff --git a/migrations/2026-01-19-045014_policies/up.sql b/migrations/2026-01-19-045014_policies/up.sql index 1dcb42f5..2d9aeae0 100644 --- a/migrations/2026-01-19-045014_policies/up.sql +++ b/migrations/2026-01-19-045014_policies/up.sql @@ -80,3 +80,12 @@ COMMENT ON COLUMN workspace_policies.metadata IS 'Metadata for filtering/display COMMENT ON COLUMN workspace_policies.created_at IS 'Creation timestamp'; COMMENT ON COLUMN workspace_policies.updated_at IS 'Last modification timestamp'; COMMENT ON COLUMN workspace_policies.deleted_at IS 'Soft-deletion timestamp; NULL means live'; + +-- Policy lifecycle events feed the activity log and webhooks. +ALTER TYPE ACTIVITY_TYPE ADD VALUE IF NOT EXISTS 'policy.created'; +ALTER TYPE ACTIVITY_TYPE ADD VALUE IF NOT EXISTS 'policy.updated'; +ALTER TYPE ACTIVITY_TYPE ADD VALUE IF NOT EXISTS 'policy.deleted'; + +ALTER TYPE WEBHOOK_EVENT ADD VALUE IF NOT EXISTS 'policy.created'; +ALTER TYPE WEBHOOK_EVENT ADD VALUE IF NOT EXISTS 'policy.updated'; +ALTER TYPE WEBHOOK_EVENT ADD VALUE IF NOT EXISTS 'policy.deleted'; diff --git a/migrations/2026-01-19-045015_pipelines/up.sql b/migrations/2026-01-19-045015_pipelines/up.sql index 35312489..ea0c673a 100644 --- a/migrations/2026-01-19-045015_pipelines/up.sql +++ b/migrations/2026-01-19-045015_pipelines/up.sql @@ -127,3 +127,13 @@ COMMENT ON TABLE workspace_pipeline_policies IS 'Policies a pipeline applies at COMMENT ON COLUMN workspace_pipeline_policies.workspace_id IS 'Workspace shared by the pipeline and policy'; COMMENT ON COLUMN workspace_pipeline_policies.pipeline_id IS 'Pipeline that applies the policy'; COMMENT ON COLUMN workspace_pipeline_policies.policy_id IS 'Policy applied by the pipeline'; + +-- Pipeline lifecycle events feed the activity log and webhooks. The detection +-- and redaction run events are added by their own later migrations. +ALTER TYPE ACTIVITY_TYPE ADD VALUE IF NOT EXISTS 'pipeline.created'; +ALTER TYPE ACTIVITY_TYPE ADD VALUE IF NOT EXISTS 'pipeline.updated'; +ALTER TYPE ACTIVITY_TYPE ADD VALUE IF NOT EXISTS 'pipeline.deleted'; + +ALTER TYPE WEBHOOK_EVENT ADD VALUE IF NOT EXISTS 'pipeline.created'; +ALTER TYPE WEBHOOK_EVENT ADD VALUE IF NOT EXISTS 'pipeline.updated'; +ALTER TYPE WEBHOOK_EVENT ADD VALUE IF NOT EXISTS 'pipeline.deleted'; diff --git a/migrations/2026-01-19-045016_detections/up.sql b/migrations/2026-01-19-045016_detections/up.sql index 62392012..ccf654cd 100644 --- a/migrations/2026-01-19-045016_detections/up.sql +++ b/migrations/2026-01-19-045016_detections/up.sql @@ -254,3 +254,16 @@ COMMENT ON COLUMN workspace_detection_jobs.attempts IS 'Number of publish attemp COMMENT ON COLUMN workspace_detection_jobs.next_attempt_at IS 'Earliest time the row may next be claimed; advanced by a backoff after each failed attempt'; COMMENT ON COLUMN workspace_detection_jobs.created_at IS 'Timestamp when the job was queued'; COMMENT ON COLUMN workspace_detection_jobs.resolved_at IS 'When a terminal (processed or failed) row was resolved by an operator; NULL until then. A manual affordance for inspecting the outbox after the fact'; + +-- Detection run events feed the activity log, webhooks, and (for terminal +-- completion/failure) in-app notifications. +ALTER TYPE ACTIVITY_TYPE ADD VALUE IF NOT EXISTS 'pipeline.detection.started'; +ALTER TYPE ACTIVITY_TYPE ADD VALUE IF NOT EXISTS 'pipeline.detection.completed'; +ALTER TYPE ACTIVITY_TYPE ADD VALUE IF NOT EXISTS 'pipeline.detection.failed'; + +ALTER TYPE WEBHOOK_EVENT ADD VALUE IF NOT EXISTS 'pipeline.detection.started'; +ALTER TYPE WEBHOOK_EVENT ADD VALUE IF NOT EXISTS 'pipeline.detection.completed'; +ALTER TYPE WEBHOOK_EVENT ADD VALUE IF NOT EXISTS 'pipeline.detection.failed'; + +ALTER TYPE NOTIFICATION_EVENT ADD VALUE IF NOT EXISTS 'pipeline.detection.completed'; +ALTER TYPE NOTIFICATION_EVENT ADD VALUE IF NOT EXISTS 'pipeline.detection.failed'; diff --git a/migrations/2026-01-19-045017_redactions/up.sql b/migrations/2026-01-19-045017_redactions/up.sql index 113bd14a..74ed6b06 100644 --- a/migrations/2026-01-19-045017_redactions/up.sql +++ b/migrations/2026-01-19-045017_redactions/up.sql @@ -44,3 +44,8 @@ COMMENT ON COLUMN workspace_redactions.account_id IS 'Account that requested the COMMENT ON COLUMN workspace_redactions.review_file_id IS 'Review audit (file_kind=review) recording the applied edits and redaction outcome'; COMMENT ON COLUMN workspace_redactions.output_file_id IS 'Redacted document this redaction produced'; COMMENT ON COLUMN workspace_redactions.created_at IS 'When the redaction was created'; + +-- Redaction creation feeds the activity log, webhooks, and in-app notifications. +ALTER TYPE ACTIVITY_TYPE ADD VALUE IF NOT EXISTS 'pipeline.redaction.created'; +ALTER TYPE WEBHOOK_EVENT ADD VALUE IF NOT EXISTS 'pipeline.redaction.created'; +ALTER TYPE NOTIFICATION_EVENT ADD VALUE IF NOT EXISTS 'pipeline.redaction.created'; diff --git a/migrations/2026-09-10-013351_assignments/down.sql b/migrations/2026-09-10-013351_assignments/down.sql new file mode 100644 index 00000000..69b6f5fb --- /dev/null +++ b/migrations/2026-09-10-013351_assignments/down.sql @@ -0,0 +1,11 @@ +-- Revert the assignments table. +-- Objects are dropped in reverse order of creation. + +DROP TABLE IF EXISTS workspace_assignments; + +DROP TYPE IF EXISTS ASSIGNMENT_STATUS; + +-- The `file.assigned` / `file.unassigned` / `file.assignment.updated` labels added +-- to ACTIVITY_TYPE, WEBHOOK_EVENT, and NOTIFICATION_EVENT are intentionally left +-- in place: Postgres has no ALTER TYPE ... DROP VALUE, and the surviving labels +-- are inert once nothing references them. diff --git a/migrations/2026-09-10-013351_assignments/up.sql b/migrations/2026-09-10-013351_assignments/up.sql new file mode 100644 index 00000000..a74569a1 --- /dev/null +++ b/migrations/2026-09-10-013351_assignments/up.sql @@ -0,0 +1,102 @@ +-- Assignments: distribute redaction-review work over a file to workspace +-- members. A file can be assigned to several reviewers at once (like GitHub +-- assignees), each with their own review status, so an assignment is a +-- first-class row keyed per (file, reviewer) rather than a column on the file. + +-- The review status of one reviewer's assignment on a file. This is the human +-- review-workflow axis, independent of a detection's execution status +-- (DETECTION_STATUS), which is driven by the analysis worker. +CREATE TYPE ASSIGNMENT_STATUS AS ENUM ( + 'assigned', -- Assigned to the reviewer; not yet started + 'in_review', -- The reviewer has started reviewing + 'done' -- The reviewer has finished their review +); + +COMMENT ON TYPE ASSIGNMENT_STATUS IS 'Review-workflow status of one reviewer''s assignment on a file: assigned, in_review, or done.'; + +-- Assignments table: one reviewer's assignment of one file. +CREATE TABLE workspace_assignments ( + -- Primary identifier + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- References. The workspace is denormalized onto the row (rather than reached + -- through the file) so the common "my assignments across the workspace" query + -- is a single indexed scan with no join to workspace_files. + workspace_id UUID NOT NULL REFERENCES workspaces (id) ON DELETE CASCADE, + file_id UUID NOT NULL, + + -- The two accounts an assignment relates to, each a distinct role. + -- assignee: the reviewer. If their account is removed, the assignment goes + -- with it (CASCADE). + -- assigned: who created the assignment, kept for the audit trail. SET NULL + -- rather than CASCADE so the assigner leaving does not delete a + -- live assignment; null then means "assigner gone". + assignee_account_id UUID NOT NULL REFERENCES accounts (id) ON DELETE CASCADE, + assigned_account_id UUID DEFAULT NULL REFERENCES accounts (id) ON DELETE SET NULL, + + -- The reviewer's current review status for this file. + status ASSIGNMENT_STATUS NOT NULL DEFAULT 'assigned', + + -- Timestamps + created_at TIMESTAMPTZ NOT NULL DEFAULT current_timestamp, + updated_at TIMESTAMPTZ NOT NULL DEFAULT current_timestamp, + CONSTRAINT workspace_assignments_updated_after_created CHECK (updated_at >= created_at), + + -- The file is referenced with its workspace, against + -- workspace_files (workspace_id, id), so the denormalized workspace_id must + -- match the file's own — a file from another workspace cannot be stored. A + -- whole-workspace teardown, or removing the file, cascades its assignments + -- away together. + CONSTRAINT workspace_assignments_file_fkey FOREIGN KEY (workspace_id, file_id) + REFERENCES workspace_files (workspace_id, id) ON DELETE CASCADE, + + -- A reviewer is assigned a given file at most once; assigning again is a + -- conflict, and reassignment is remove-then-add, not a second row. + CONSTRAINT workspace_assignments_file_assignee_key UNIQUE (file_id, assignee_account_id) +); + +-- A reviewer's assignments across the workspace, newest first ("my work"). +CREATE INDEX workspace_assignments_assignee_idx + ON workspace_assignments (assignee_account_id, created_at DESC); + +-- A file's reviewer list ("who is assigned to this file"). +CREATE INDEX workspace_assignments_file_idx + ON workspace_assignments (file_id, created_at DESC); + +-- Workspace-scoped listing/board, filterable by status, newest first. +CREATE INDEX workspace_assignments_workspace_idx + ON workspace_assignments (workspace_id, created_at DESC); + +-- Auto-maintain updated_at on writes (no soft-delete column here). +SELECT setup_updated_at_no_soft_delete('workspace_assignments'); + +COMMENT ON TABLE workspace_assignments IS 'Per-reviewer assignment of a file for redaction review; a file may have several.'; +COMMENT ON COLUMN workspace_assignments.id IS 'Unique assignment identifier'; +COMMENT ON COLUMN workspace_assignments.workspace_id IS 'Denormalized workspace scope for fast per-workspace assignment queries'; +COMMENT ON COLUMN workspace_assignments.file_id IS 'File under review'; +COMMENT ON COLUMN workspace_assignments.assignee_account_id IS 'Reviewer the file is assigned to'; +COMMENT ON COLUMN workspace_assignments.assigned_account_id IS 'Account that created the assignment, for the audit trail; null if that account was removed'; +COMMENT ON COLUMN workspace_assignments.status IS 'The reviewer''s review status for this file'; +COMMENT ON COLUMN workspace_assignments.created_at IS 'When the assignment was created'; +COMMENT ON COLUMN workspace_assignments.updated_at IS 'When the assignment was last updated'; + +-- Assignment lifecycle events feed the three event sinks, so their type strings +-- are added to each sink's enum. Postgres 12+ permits ALTER TYPE ... ADD VALUE +-- inside a transaction as long as the value is not used in the same one; this +-- migration only adds the labels (no rows use them yet), so it stays +-- transactional like the others. +-- +-- Activity log records all three (assigned, unassigned, status changed). +ALTER TYPE ACTIVITY_TYPE ADD VALUE IF NOT EXISTS 'file.assigned'; +ALTER TYPE ACTIVITY_TYPE ADD VALUE IF NOT EXISTS 'file.unassigned'; +ALTER TYPE ACTIVITY_TYPE ADD VALUE IF NOT EXISTS 'file.assignment.updated'; + +-- Webhooks carry all three. +ALTER TYPE WEBHOOK_EVENT ADD VALUE IF NOT EXISTS 'file.assigned'; +ALTER TYPE WEBHOOK_EVENT ADD VALUE IF NOT EXISTS 'file.unassigned'; +ALTER TYPE WEBHOOK_EVENT ADD VALUE IF NOT EXISTS 'file.assignment.updated'; + +-- In-app notifications go to the reviewer on assign and unassign; a status +-- change raises no notification, so it is not added here. +ALTER TYPE NOTIFICATION_EVENT ADD VALUE IF NOT EXISTS 'file.assigned'; +ALTER TYPE NOTIFICATION_EVENT ADD VALUE IF NOT EXISTS 'file.unassigned';