From a191696e523432d091f83b583fe4d4e8c278dc82 Mon Sep 17 00:00:00 2001 From: Oleh Martsokha Date: Thu, 10 Sep 2026 03:09:17 +0200 Subject: [PATCH 1/6] Add file review assignments; gut global admin; relayer refactors Task assignment (the headline feature): - New workspace_assignments table + ASSIGNMENT_STATUS enum. A file can be assigned to many reviewers at once (GitHub-assignees model), each with an independent review status; UNIQUE(file_id, assignee_account_id). - nvisy-postgres: AssignmentStatus enum, WorkspaceAssignment model, WorkspaceAssignmentRepository (idempotent create via a typed conflict outcome, targeted (file,assignee) lookup, cursor listing, status update, delete), AssignmentFilter, a constraint mapping to a 409. - Two permissions: ViewAssignments (Reviewer), AssignTasks (Editor). - Three events (FileAssigned, FileUnassigned, AssignmentStatusChanged) wired end to end through the drainer's activity/webhook/notification sinks. Assign and unassign notify the reviewer; a self-action raises no notification; a status change raises none. - handler/assignments.rs: five routes (create/list under files, workspace list with assignee=me|handle/status/file filters, status patch with split authz, delete), request/response DTOs, workspace-member validation. Migration hygiene: the ACTIVITY_TYPE / WEBHOOK_EVENT / NOTIFICATION_EVENT enums pre-declared values for features that shipped later. Moved each value to the feature migration that introduces its object via ALTER TYPE ADD VALUE, and switched workspace_members.notification_events_app to default '{}' (the service already treats empty as "all events"), removing the forward reference. Remove the global-admin (is_admin) concept: the is_admin column, JWT claim and its per-request forgery re-check, the authorize_workspace bypass, the Authorized extractor's Option (now a plain WorkspaceMember), the monitors log field, and the account DTO field. An operator is a workspace member like anyone else. Move the HTTP error surface to its rightful home: Error / ErrorKind / ErrorResponse and every From conversion now live in src/response/error/ (the IntoResponse module) rather than handler/error/ and handler/response/errors.rs. All references repointed to crate::response; no compatibility shims. Extract cleanup: the generated Authorized

permission markers are namespaced under a `markers` module (Authorized) instead of a flat glob; deleted the unused local TypedHeader wrapper. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_018bKk1YEG4tZ69jzYVQvQL8 --- crates/nvisy-postgres/src/model/account.rs | 9 +- crates/nvisy-postgres/src/model/mod.rs | 4 + .../src/model/workspace_assignment.rs | 81 +++ crates/nvisy-postgres/src/query/mod.rs | 4 + .../src/query/workspace_assignment.rs | 543 ++++++++++++++++++ crates/nvisy-postgres/src/schema.rs | 24 +- .../src/types/constraint/assignments.rs | 11 + .../src/types/constraint/mod.rs | 8 + .../src/types/enums/activity_type.rs | 6 + .../src/types/enums/assignment_status.rs | 46 ++ crates/nvisy-postgres/src/types/enums/mod.rs | 4 + .../src/types/enums/notification_event.rs | 4 + .../src/types/enums/webhook_event.rs | 15 +- .../src/types/filtering/assignments.rs | 25 + .../nvisy-postgres/src/types/filtering/mod.rs | 2 + .../src/types/json/activity_params.rs | 37 ++ crates/nvisy-postgres/src/types/json/mod.rs | 10 +- .../src/types/json/notification_params.rs | 35 ++ crates/nvisy-postgres/src/types/mod.rs | 33 +- .../src/extract/auth/auth_state.rs | 150 +---- .../src/extract/auth/authorized.rs | 37 +- .../src/extract/auth/jwt_claims.rs | 7 +- crates/nvisy-server/src/extract/auth/mod.rs | 2 +- .../src/extract/auth/optional_auth.rs | 2 +- .../src/extract/auth/permission.rs | 8 + .../src/extract/auth/session_token.rs | 2 +- .../nvisy-server/src/extract/avatar_upload.rs | 2 +- .../src/extract/idempotency_key.rs | 4 +- crates/nvisy-server/src/extract/mod.rs | 9 +- .../src/extract/reject/form_with_rej.rs | 2 +- .../src/extract/reject/json_with_rej.rs | 2 +- .../src/extract/reject/mutlipart_with_rej.rs | 2 +- .../src/extract/reject/path_with_rej.rs | 2 +- .../src/extract/reject/query_with_rej.rs | 2 +- .../nvisy-server/src/extract/typed_header.rs | 39 -- .../src/extract/valid/validated_json.rs | 2 +- .../src/extract/workspace_context.rs | 2 +- crates/nvisy-server/src/handler/accounts.rs | 4 +- crates/nvisy-server/src/handler/activities.rs | 12 +- crates/nvisy-server/src/handler/analytics.rs | 11 +- .../nvisy-server/src/handler/assignments.rs | 513 +++++++++++++++++ crates/nvisy-server/src/handler/auth_oidc.rs | 5 +- .../src/handler/authentication.rs | 4 +- crates/nvisy-server/src/handler/avatars.rs | 4 +- crates/nvisy-server/src/handler/catalog.rs | 3 +- crates/nvisy-server/src/handler/chat.rs | 17 +- .../src/handler/connection_oauth.rs | 10 +- .../src/handler/connection_syncs.rs | 22 +- .../nvisy-server/src/handler/connections.rs | 25 +- .../src/handler/detection_audits.rs | 12 +- crates/nvisy-server/src/handler/detections.rs | 20 +- crates/nvisy-server/src/handler/files.rs | 21 +- crates/nvisy-server/src/handler/identities.rs | 4 +- crates/nvisy-server/src/handler/invites.rs | 16 +- crates/nvisy-server/src/handler/members.rs | 16 +- crates/nvisy-server/src/handler/mod.rs | 5 +- crates/nvisy-server/src/handler/monitors.rs | 5 +- .../nvisy-server/src/handler/notifications.rs | 7 +- crates/nvisy-server/src/handler/pipelines.rs | 19 +- crates/nvisy-server/src/handler/policies.rs | 18 +- crates/nvisy-server/src/handler/providers.rs | 20 +- crates/nvisy-server/src/handler/redactions.rs | 11 +- .../src/handler/request/activities.rs | 2 +- .../src/handler/request/assignments.rs | 72 +++ .../nvisy-server/src/handler/request/mod.rs | 2 + .../src/handler/request/tokens.rs | 4 +- .../src/handler/request/webhooks.rs | 2 +- .../src/handler/request/windows.rs | 2 +- .../src/handler/request/workspaces.rs | 2 +- .../src/handler/response/accounts.rs | 3 - .../src/handler/response/assignments.rs | 58 ++ .../nvisy-server/src/handler/response/chat.rs | 2 +- .../nvisy-server/src/handler/response/mod.rs | 4 +- .../src/handler/response/policies.rs | 2 +- crates/nvisy-server/src/handler/tokens.rs | 4 +- .../src/handler/utility/accounts.rs | 2 +- crates/nvisy-server/src/handler/webhooks.rs | 23 +- crates/nvisy-server/src/handler/workspaces.rs | 30 +- .../nvisy-server/src/middleware/auth/csrf.rs | 2 +- .../src/middleware/auth/session.rs | 2 +- .../nvisy-server/src/middleware/recovery.rs | 2 +- .../error/crypto_error.rs | 0 .../error/engine_error.rs | 0 .../error/error_response.rs} | 10 +- .../error/file_service_error.rs | 0 .../{handler => response}/error/http_error.rs | 2 +- .../error/inference_error.rs | 0 .../src/{handler => response}/error/mod.rs | 6 +- .../{handler => response}/error/nats_error.rs | 0 .../error/object_error.rs | 0 .../{handler => response}/error/oidc_error.rs | 0 .../{handler => response}/error/pg_account.rs | 2 +- .../{handler => response}/error/pg_chat.rs | 2 +- .../error/pg_document.rs | 2 +- .../{handler => response}/error/pg_error.rs | 3 +- .../error/pg_pipeline.rs | 2 +- .../error/pg_workspace.rs | 18 +- .../{handler => response}/error/s3_error.rs | 0 .../error/webhook_error.rs | 0 crates/nvisy-server/src/response/mod.rs | 2 + crates/nvisy-server/src/response/redirect.rs | 2 +- .../src/service/account_provisioner.rs | 2 +- .../nvisy-server/src/service/auth_issuer.rs | 2 +- crates/nvisy-server/src/service/avatar.rs | 2 +- crates/nvisy-server/src/service/chat.rs | 2 +- .../src/service/detection/drainer.rs | 2 +- .../src/service/detection/service.rs | 2 +- .../src/service/detection/support.rs | 2 +- .../src/service/detection/worker.rs | 2 +- .../nvisy-server/src/service/event/drainer.rs | 74 ++- .../nvisy-server/src/service/event/emitter.rs | 2 +- crates/nvisy-server/src/service/event/mod.rs | 4 +- .../src/service/event/workspace_event.rs | 29 + .../nvisy-server/src/service/file_reaper.rs | 2 +- .../service/integration/connection_config.rs | 2 +- .../src/service/integration/connector.rs | 2 +- .../src/service/integration/export.rs | 2 +- .../src/service/integration/file_source.rs | 2 +- .../src/service/integration/import.rs | 2 +- .../src/service/integration/persist_oauth.rs | 4 +- .../service/integration/provider_config.rs | 2 +- .../src/service/integration/service.rs | 6 +- .../src/service/integration/worker.rs | 2 +- crates/nvisy-server/src/service/mod.rs | 6 +- .../nvisy-server/src/service/notification.rs | 2 +- .../src/service/password/hasher.rs | 2 +- .../src/service/password/service.rs | 2 +- .../src/service/password/strength.rs | 2 +- .../src/service/run_blob_store.rs | 2 +- migrations/2025-05-21-121131_accounts/up.sql | 14 +- .../2025-05-21-121132_notifications/up.sql | 13 +- .../2025-05-21-222840_workspaces/up.sql | 14 +- .../2025-05-21-222841_activities/up.sql | 44 +- migrations/2025-05-21-222842_webhooks/up.sql | 44 +- migrations/2025-05-27-011852_files/up.sql | 9 + .../2026-01-19-045012_connections/up.sql | 19 + migrations/2026-01-19-045013_providers/up.sql | 9 + migrations/2026-01-19-045014_policies/up.sql | 9 + migrations/2026-01-19-045015_pipelines/up.sql | 10 + .../2026-01-19-045016_detections/up.sql | 13 + .../2026-01-19-045017_redactions/up.sql | 5 + .../2026-09-10-013351_assignments/down.sql | 11 + .../2026-09-10-013351_assignments/up.sql | 96 ++++ 143 files changed, 2117 insertions(+), 612 deletions(-) create mode 100644 crates/nvisy-postgres/src/model/workspace_assignment.rs create mode 100644 crates/nvisy-postgres/src/query/workspace_assignment.rs create mode 100644 crates/nvisy-postgres/src/types/constraint/assignments.rs create mode 100644 crates/nvisy-postgres/src/types/enums/assignment_status.rs create mode 100644 crates/nvisy-postgres/src/types/filtering/assignments.rs delete mode 100644 crates/nvisy-server/src/extract/typed_header.rs create mode 100644 crates/nvisy-server/src/handler/assignments.rs create mode 100644 crates/nvisy-server/src/handler/request/assignments.rs create mode 100644 crates/nvisy-server/src/handler/response/assignments.rs rename crates/nvisy-server/src/{handler => response}/error/crypto_error.rs (100%) rename crates/nvisy-server/src/{handler => response}/error/engine_error.rs (100%) rename crates/nvisy-server/src/{handler/response/errors.rs => response/error/error_response.rs} (91%) rename crates/nvisy-server/src/{handler => response}/error/file_service_error.rs (100%) rename crates/nvisy-server/src/{handler => response}/error/http_error.rs (99%) rename crates/nvisy-server/src/{handler => response}/error/inference_error.rs (100%) rename crates/nvisy-server/src/{handler => response}/error/mod.rs (53%) rename crates/nvisy-server/src/{handler => response}/error/nats_error.rs (100%) rename crates/nvisy-server/src/{handler => response}/error/object_error.rs (100%) rename crates/nvisy-server/src/{handler => response}/error/oidc_error.rs (100%) rename crates/nvisy-server/src/{handler => response}/error/pg_account.rs (98%) rename crates/nvisy-server/src/{handler => response}/error/pg_chat.rs (96%) rename crates/nvisy-server/src/{handler => response}/error/pg_document.rs (97%) rename crates/nvisy-server/src/{handler => response}/error/pg_error.rs (98%) rename crates/nvisy-server/src/{handler => response}/error/pg_pipeline.rs (99%) rename crates/nvisy-server/src/{handler => response}/error/pg_workspace.rs (87%) rename crates/nvisy-server/src/{handler => response}/error/s3_error.rs (100%) rename crates/nvisy-server/src/{handler => response}/error/webhook_error.rs (100%) create mode 100644 migrations/2026-09-10-013351_assignments/down.sql create mode 100644 migrations/2026-09-10-013351_assignments/up.sql 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..50f48028 --- /dev/null +++ b/crates/nvisy-postgres/src/query/workspace_assignment.rs @@ -0,0 +1,543 @@ +//! 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}; + + 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..c1e4262b 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,8 @@ 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 -> workspace_files (file_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 +605,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..36378541 100644 --- a/crates/nvisy-postgres/src/types/enums/notification_event.rs +++ b/crates/nvisy-postgres/src/types/enums/notification_event.rs @@ -24,5 +24,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..3bde612a 100644 --- a/crates/nvisy-postgres/src/types/json/activity_params.rs +++ b/crates/nvisy-postgres/src/types/json/activity_params.rs @@ -89,6 +89,20 @@ 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. + pub file_name: String, + /// Username of the reviewer the file is assigned to. + pub assignee_username: Handle, +} + /// Params of a pipeline activity (`pipeline.*`, non-run). #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] @@ -223,6 +237,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 +310,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 +358,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 +407,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 +455,10 @@ impl ActivityPayload { | ActivityPayload::FileUpdated(p) | ActivityPayload::FileDeleted(p) => Some(p.file_name.clone()), + ActivityPayload::FileAssigned(p) + | ActivityPayload::FileUnassigned(p) + | ActivityPayload::AssignmentStatusChanged(p) => Some(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..c24fb540 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, MemberInvitedParams, + 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..aaaa6624 100644 --- a/crates/nvisy-postgres/src/types/json/notification_params.rs +++ b/crates/nvisy-postgres/src/types/json/notification_params.rs @@ -6,6 +6,7 @@ //! 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}; @@ -108,6 +109,30 @@ 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. + pub file_name: String, +} + /// 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). /// @@ -145,6 +170,14 @@ 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 { @@ -160,6 +193,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..a7ed102c 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, MemberInvitedParams, 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..170d5291 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, } } } @@ -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..133f0c22 --- /dev/null +++ b/crates/nvisy-server/src/handler/assignments.rs @@ -0,0 +1,513 @@ +//! 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, WorkspaceAssignment, +}; +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::{ + AssignmentRef, EventEmitter, EventOrigin, FileRef, 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 { + assignment: assignment_ref( + &assignment, + &file.display_name, + &request.assignee, + ), + // 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. + let file_name = conn + .find_file_in_workspace(workspace.id, assignment.file_id) + .await? + .map(|f| f.display_name) + .unwrap_or_default(); + 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(assignment_ref( + &updated, + &file_name, + &assignee_handle, + )), + ) + .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, + Some(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) + .unwrap_or_default(); + 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 { + assignment: assignment_ref(&assignment, &file_name, &assignee_handle), + // 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, + } +} + +/// Builds an [`AssignmentRef`] from an assignment row, its file name, and the +/// assignee's handle. +fn assignment_ref( + assignment: &WorkspaceAssignment, + file_name: &str, + assignee_username: &Handle, +) -> AssignmentRef { + AssignmentRef { + assignment_id: assignment.id, + file: FileRef { + file_id: assignment.file_id, + file_name: file_name.to_owned(), + }, + assignee_username: assignee_username.clone(), + } +} + +/// 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..bb01709f 100644 --- a/crates/nvisy-server/src/handler/connection_oauth.rs +++ b/crates/nvisy-server/src/handler/connection_oauth.rs @@ -36,13 +36,9 @@ 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, @@ -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)> { 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..783318b1 100644 --- a/crates/nvisy-server/src/handler/connections.rs +++ b/crates/nvisy-server/src/handler/connections.rs @@ -34,19 +34,14 @@ 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, @@ -70,7 +65,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)> { @@ -199,7 +194,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 +273,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 +325,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, @@ -498,7 +493,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 { @@ -567,7 +562,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 +672,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..cba72945 100644 --- a/crates/nvisy-server/src/handler/detections.rs +++ b/crates/nvisy-server/src/handler/detections.rs @@ -26,17 +26,15 @@ 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, @@ -61,7 +59,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, @@ -248,7 +246,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 +308,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 +367,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 +430,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 +578,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, diff --git a/crates/nvisy-server/src/handler/files.rs b/crates/nvisy-server/src/handler/files.rs index 64756401..3d0dd64c 100644 --- a/crates/nvisy-server/src/handler/files.rs +++ b/crates/nvisy-server/src/handler/files.rs @@ -23,18 +23,17 @@ 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, @@ -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)> { @@ -427,7 +426,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 +468,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, @@ -668,7 +667,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 { @@ -738,7 +737,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)> { 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..215a79d2 100644 --- a/crates/nvisy-server/src/handler/invites.rs +++ b/crates/nvisy-server/src/handler/invites.rs @@ -23,17 +23,17 @@ 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, @@ -175,7 +175,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)> { @@ -238,7 +238,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 +292,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 { @@ -460,7 +460,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"); diff --git a/crates/nvisy-server/src/handler/members.rs b/crates/nvisy-server/src/handler/members.rs index e43dd458..69c84739 100644 --- a/crates/nvisy-server/src/handler/members.rs +++ b/crates/nvisy-server/src/handler/members.rs @@ -15,12 +15,12 @@ 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::handler::response::{Member, MembersPage, Page}; +use crate::response::{Error, ErrorKind, ErrorResponse, Result}; use crate::service::{EventEmitter, EventOrigin, MemberRef, ServiceState, WorkspaceEvent}; /// Tracing target for workspace member operations. @@ -39,7 +39,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 +92,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 +148,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 { @@ -235,7 +235,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, 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..deda5e4e 100644 --- a/crates/nvisy-server/src/handler/pipelines.rs +++ b/crates/nvisy-server/src/handler/pipelines.rs @@ -16,17 +16,14 @@ 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::response::{Error, ErrorKind, ErrorResponse, Result}; use crate::service::{EventEmitter, EventOrigin, PipelineRef, ServiceState, WorkspaceEvent}; /// Tracing target for pipeline operations. @@ -45,7 +42,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)> { @@ -120,7 +117,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 +167,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 +211,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, @@ -340,7 +337,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 { diff --git a/crates/nvisy-server/src/handler/policies.rs b/crates/nvisy-server/src/handler/policies.rs index a1ddeba9..56bbe512 100644 --- a/crates/nvisy-server/src/handler/policies.rs +++ b/crates/nvisy-server/src/handler/policies.rs @@ -17,13 +17,11 @@ 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, }; @@ -46,7 +44,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)> { @@ -128,7 +126,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 +173,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 +220,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, @@ -311,7 +309,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 { diff --git a/crates/nvisy-server/src/handler/providers.rs b/crates/nvisy-server/src/handler/providers.rs index bf46d194..c27b7322 100644 --- a/crates/nvisy-server/src/handler/providers.rs +++ b/crates/nvisy-server/src/handler/providers.rs @@ -17,15 +17,13 @@ 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, @@ -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)> { @@ -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, @@ -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 { @@ -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..e059171b 100644 --- a/crates/nvisy-server/src/handler/webhooks.rs +++ b/crates/nvisy-server/src/handler/webhooks.rs @@ -19,19 +19,14 @@ 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, }; @@ -52,7 +47,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)> { @@ -138,7 +133,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 +180,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 +224,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, @@ -314,7 +309,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 { @@ -378,7 +373,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..340bace8 100644 --- a/crates/nvisy-server/src/handler/workspaces.rs +++ b/crates/nvisy-server/src/handler/workspaces.rs @@ -17,18 +17,16 @@ 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, @@ -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)> { @@ -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"); @@ -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 100% rename from crates/nvisy-server/src/handler/error/file_service_error.rs rename to crates/nvisy-server/src/response/error/file_service_error.rs 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 100% rename from crates/nvisy-server/src/handler/error/nats_error.rs rename to crates/nvisy-server/src/response/error/nats_error.rs diff --git a/crates/nvisy-server/src/handler/error/object_error.rs b/crates/nvisy-server/src/response/error/object_error.rs similarity index 100% rename from crates/nvisy-server/src/handler/error/object_error.rs rename to crates/nvisy-server/src/response/error/object_error.rs 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 100% rename from crates/nvisy-server/src/handler/error/webhook_error.rs rename to crates/nvisy-server/src/response/error/webhook_error.rs 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..6a6d0cbb 100644 --- a/crates/nvisy-server/src/service/detection/support.rs +++ b/crates/nvisy-server/src/service/detection/support.rs @@ -11,7 +11,7 @@ use uuid::Uuid; use super::service::DetectionQueue; use crate::extract::SecurityContext; -use crate::handler::Result; +use crate::response::Result; use crate::service::{CryptoService, DetectionRef, EventEmitter, EventOrigin, WorkspaceEvent}; /// Tracing target for shared detection operations. diff --git a/crates/nvisy-server/src/service/detection/worker.rs b/crates/nvisy-server/src/service/detection/worker.rs index cce00829..455031df 100644 --- a/crates/nvisy-server/src/service/detection/worker.rs +++ b/crates/nvisy-server/src/service/detection/worker.rs @@ -30,7 +30,7 @@ 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, event_outbox_row, diff --git a/crates/nvisy-server/src/service/event/drainer.rs b/crates/nvisy-server/src/service/event/drainer.rs index aebae26d..9af97ce1 100644 --- a/crates/nvisy-server/src/service/event/drainer.rs +++ b/crates/nvisy-server/src/service/event/drainer.rs @@ -20,9 +20,10 @@ 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, + ActivityPayload, AssignmentActivityParams, ConnectionActivityParams, ConnectionId, + ConnectionSyncCompletedParams, ConnectionSyncFailedParams, DetectionActivityParams, + DetectionCompletedParams, DetectionFailedParams, DetectionId, FileActivityParams, + FileAssignedParams, FileUnassignedParams, Handle, InviteActivityParams, Json, MemberActivityParams, NotificationPayload, PipelineActivityParams, PolicyActivityParams, ProviderActivityParams, ProviderId, RedactionActivityParams, RedactionCreatedParams, RedactionId, WebhookActivityParams, WebhookEvent, WebhookId, WorkspaceActivityParams, @@ -32,10 +33,10 @@ use serde_json::Value; use tokio_util::sync::CancellationToken; use uuid::Uuid; -use crate::handler::{Error, Result}; +use crate::response::{Error, Result}; use crate::service::event::{ - ConnectionRef, DetectionRef, FileRef, InviteRef, MemberRef, PolicyRef, ProviderRef, WebhookRef, - WorkspaceEvent, WorkspaceRef, + AssignmentRef, ConnectionRef, DetectionRef, FileRef, InviteRef, MemberRef, PolicyRef, + ProviderRef, WebhookRef, WorkspaceEvent, WorkspaceRef, }; use crate::service::{Infra, NotificationEmitter, WebhookEmitter, Worker}; @@ -327,6 +328,11 @@ fn activity_of(event: &WorkspaceEvent) -> ActivityPayload { file_id: file.file_id, file_name: file.file_name.clone(), }; + let assignment_params = |a: &AssignmentRef| AssignmentActivityParams { + assignment_id: a.assignment_id, + file_name: a.file.file_name.clone(), + assignee_username: a.assignee_username.clone(), + }; let pipeline = |pipeline_slug: &Handle| PipelineActivityParams { pipeline_slug: pipeline_slug.clone(), }; @@ -382,6 +388,15 @@ fn activity_of(event: &WorkspaceEvent) -> ActivityPayload { 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::FileAssigned { assignment, .. } => { + ActivityPayload::FileAssigned(assignment_params(assignment)) + } + E::FileUnassigned { assignment, .. } => { + ActivityPayload::FileUnassigned(assignment_params(assignment)) + } + E::AssignmentStatusChanged(a) => { + ActivityPayload::AssignmentStatusChanged(assignment_params(a)) + } 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)), @@ -435,6 +450,27 @@ fn webhook_of(event: &WorkspaceEvent) -> Option<(WebhookEvent, Option)> { WebhookEvent::FileDeleted, Some(serde_json::json!({ "displayName": f.file_name })), ), + E::FileAssigned { assignment, .. } => ( + WebhookEvent::FileAssigned, + Some(serde_json::json!({ + "displayName": assignment.file.file_name, + "assignee": assignment.assignee_username, + })), + ), + E::FileUnassigned { assignment, .. } => ( + WebhookEvent::FileUnassigned, + Some(serde_json::json!({ + "displayName": assignment.file.file_name, + "assignee": assignment.assignee_username, + })), + ), + E::AssignmentStatusChanged(assignment) => ( + WebhookEvent::AssignmentStatusChanged, + Some(serde_json::json!({ + "displayName": assignment.file.file_name, + "assignee": assignment.assignee_username, + })), + ), E::PipelineCreated(..) => (WebhookEvent::PipelineCreated, None), E::PipelineUpdated(..) => (WebhookEvent::PipelineUpdated, None), E::PipelineDeleted(..) => (WebhookEvent::PipelineDeleted, None), @@ -534,9 +570,29 @@ fn notification_of(event: WorkspaceEvent) -> Option<(Uuid, NotificationPayload)> error, }), )), + E::FileAssigned { assignment, notify } => notify.map(|to| { + ( + to, + NotificationPayload::FileAssigned(FileAssignedParams { + assignment_id: assignment.assignment_id, + file_id: assignment.file.file_id, + file_name: assignment.file.file_name, + }), + ) + }), + E::FileUnassigned { assignment, notify } => notify.map(|to| { + ( + to, + NotificationPayload::FileUnassigned(FileUnassignedParams { + file_id: assignment.file.file_id, + file_name: assignment.file.file_name, + }), + ) + }), // 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::AssignmentStatusChanged(_) + | E::WorkspaceCreated(_) | E::WorkspaceUpdated(_) | E::WorkspaceDeleted(_) | E::MemberAdded(_) @@ -590,6 +646,10 @@ fn resource_id_of(event: &WorkspaceEvent) -> Uuid { 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::FileAssigned { assignment, .. } | E::FileUnassigned { assignment, .. } => { + assignment.assignment_id + } + E::AssignmentStatusChanged(a) => a.assignment_id, E::PipelineCreated(p) | E::PipelineUpdated(p) | E::PipelineDeleted(p) => p.pipeline_id, E::DetectionStarted(d) => d.detection_id, E::DetectionCompleted { detection, .. } 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/mod.rs b/crates/nvisy-server/src/service/event/mod.rs index 6f826bc9..d006895c 100644 --- a/crates/nvisy-server/src/service/event/mod.rs +++ b/crates/nvisy-server/src/service/event/mod.rs @@ -19,8 +19,8 @@ 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::workspace_event::{ - ConnectionRef, DetectionRef, FileRef, InviteRef, MemberRef, PipelineRef, PolicyRef, - ProviderRef, WebhookRef, WorkspaceEvent, WorkspaceRef, + AssignmentRef, ConnectionRef, DetectionRef, FileRef, InviteRef, MemberRef, PipelineRef, + PolicyRef, ProviderRef, WebhookRef, WorkspaceEvent, WorkspaceRef, }; /// 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..c2106117 100644 --- a/crates/nvisy-server/src/service/event/workspace_event.rs +++ b/crates/nvisy-server/src/service/event/workspace_event.rs @@ -99,6 +99,26 @@ pub enum WorkspaceEvent { #[serde(rename = "file.deleted")] FileDeleted(FileRef), + // Assignments + #[serde(rename = "file.assigned")] + FileAssigned { + #[serde(flatten)] + assignment: AssignmentRef, + /// The reviewer to notify in-app. `None` when the actor assigned the file + /// to themselves, so a self-assignment raises no notification. + notify: Option, + }, + #[serde(rename = "file.unassigned")] + FileUnassigned { + #[serde(flatten)] + assignment: AssignmentRef, + /// The former reviewer to notify in-app. `None` when the actor unassigned + /// themselves. + notify: Option, + }, + #[serde(rename = "file.assignment.updated")] + AssignmentStatusChanged(AssignmentRef), + // Pipelines #[serde(rename = "pipeline.created")] PipelineCreated(PipelineRef), @@ -199,6 +219,15 @@ pub struct FileRef { pub file_name: String, } +/// An assignment, the file it covers, and the reviewer it is assigned to. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AssignmentRef { + pub assignment_id: Uuid, + #[serde(flatten)] + pub file: FileRef, + pub assignee_username: Handle, +} + /// A pipeline and its slug. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PipelineRef { 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..7a285727 100644 --- a/crates/nvisy-server/src/service/integration/service.rs +++ b/crates/nvisy-server/src/service/integration/service.rs @@ -26,7 +26,7 @@ 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, @@ -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) @@ -340,7 +340,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..ef0e1423 100644 --- a/crates/nvisy-server/src/service/mod.rs +++ b/crates/nvisy-server/src/service/mod.rs @@ -49,9 +49,9 @@ 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, + AssignmentRef, ConnectionRef, DetectionRef, EventEmitter, EventOrigin, EventOutboxDrainer, + FileRef, InviteRef, MemberRef, PipelineRef, PolicyRef, ProviderRef, WebhookRef, WorkspaceEvent, + WorkspaceRef, 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..3abc782a 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 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..6ecff0a5 100644 --- a/migrations/2025-05-21-121132_notifications/up.sql +++ b/migrations/2025-05-21-121132_notifications/up.sql @@ -3,16 +3,13 @@ -- client renders copy from the event type and its typed params. -- Type of a notification event: what happened that the account is told about. +-- Only the baseline member values are declared here. Each later feature +-- (connections, detections, redactions, assignments) adds its own notification +-- 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 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 + 'member.joined' -- A new member joined a workspace ); 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..1a50266d 100644 --- a/migrations/2025-05-21-222840_workspaces/up.sql +++ b/migrations/2025-05-21-222840_workspaces/up.sql @@ -111,14 +111,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..7649923b 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 'webhook.created'; +ALTER TYPE ACTIVITY_TYPE ADD VALUE 'webhook.updated'; +ALTER TYPE ACTIVITY_TYPE ADD VALUE 'webhook.deleted'; diff --git a/migrations/2025-05-27-011852_files/up.sql b/migrations/2025-05-27-011852_files/up.sql index 83883c98..abd03e2d 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 'file.created'; +ALTER TYPE ACTIVITY_TYPE ADD VALUE 'file.updated'; +ALTER TYPE ACTIVITY_TYPE ADD VALUE 'file.deleted'; + +ALTER TYPE WEBHOOK_EVENT ADD VALUE 'file.created'; +ALTER TYPE WEBHOOK_EVENT ADD VALUE 'file.updated'; +ALTER TYPE WEBHOOK_EVENT ADD VALUE 'file.deleted'; diff --git a/migrations/2026-01-19-045012_connections/up.sql b/migrations/2026-01-19-045012_connections/up.sql index 64f4c543..e6bc1d12 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 'connection.created'; +ALTER TYPE ACTIVITY_TYPE ADD VALUE 'connection.updated'; +ALTER TYPE ACTIVITY_TYPE ADD VALUE 'connection.deleted'; +ALTER TYPE ACTIVITY_TYPE ADD VALUE 'connection.sync.started'; +ALTER TYPE ACTIVITY_TYPE ADD VALUE 'connection.sync.completed'; +ALTER TYPE ACTIVITY_TYPE ADD VALUE 'connection.sync.failed'; + +ALTER TYPE WEBHOOK_EVENT ADD VALUE 'connection.created'; +ALTER TYPE WEBHOOK_EVENT ADD VALUE 'connection.updated'; +ALTER TYPE WEBHOOK_EVENT ADD VALUE 'connection.deleted'; +ALTER TYPE WEBHOOK_EVENT ADD VALUE 'connection.sync.started'; +ALTER TYPE WEBHOOK_EVENT ADD VALUE 'connection.sync.completed'; +ALTER TYPE WEBHOOK_EVENT ADD VALUE 'connection.sync.failed'; + +ALTER TYPE NOTIFICATION_EVENT ADD VALUE 'connection.sync.completed'; +ALTER TYPE NOTIFICATION_EVENT ADD VALUE '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..a54e4fd5 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 'provider.created'; +ALTER TYPE ACTIVITY_TYPE ADD VALUE 'provider.updated'; +ALTER TYPE ACTIVITY_TYPE ADD VALUE 'provider.deleted'; + +ALTER TYPE WEBHOOK_EVENT ADD VALUE 'provider.created'; +ALTER TYPE WEBHOOK_EVENT ADD VALUE 'provider.updated'; +ALTER TYPE WEBHOOK_EVENT ADD VALUE 'provider.deleted'; diff --git a/migrations/2026-01-19-045014_policies/up.sql b/migrations/2026-01-19-045014_policies/up.sql index 1dcb42f5..b17797a3 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 'policy.created'; +ALTER TYPE ACTIVITY_TYPE ADD VALUE 'policy.updated'; +ALTER TYPE ACTIVITY_TYPE ADD VALUE 'policy.deleted'; + +ALTER TYPE WEBHOOK_EVENT ADD VALUE 'policy.created'; +ALTER TYPE WEBHOOK_EVENT ADD VALUE 'policy.updated'; +ALTER TYPE WEBHOOK_EVENT ADD VALUE 'policy.deleted'; diff --git a/migrations/2026-01-19-045015_pipelines/up.sql b/migrations/2026-01-19-045015_pipelines/up.sql index 35312489..a09d72ca 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 'pipeline.created'; +ALTER TYPE ACTIVITY_TYPE ADD VALUE 'pipeline.updated'; +ALTER TYPE ACTIVITY_TYPE ADD VALUE 'pipeline.deleted'; + +ALTER TYPE WEBHOOK_EVENT ADD VALUE 'pipeline.created'; +ALTER TYPE WEBHOOK_EVENT ADD VALUE 'pipeline.updated'; +ALTER TYPE WEBHOOK_EVENT ADD VALUE 'pipeline.deleted'; diff --git a/migrations/2026-01-19-045016_detections/up.sql b/migrations/2026-01-19-045016_detections/up.sql index 62392012..af891c3c 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 'pipeline.detection.started'; +ALTER TYPE ACTIVITY_TYPE ADD VALUE 'pipeline.detection.completed'; +ALTER TYPE ACTIVITY_TYPE ADD VALUE 'pipeline.detection.failed'; + +ALTER TYPE WEBHOOK_EVENT ADD VALUE 'pipeline.detection.started'; +ALTER TYPE WEBHOOK_EVENT ADD VALUE 'pipeline.detection.completed'; +ALTER TYPE WEBHOOK_EVENT ADD VALUE 'pipeline.detection.failed'; + +ALTER TYPE NOTIFICATION_EVENT ADD VALUE 'pipeline.detection.completed'; +ALTER TYPE NOTIFICATION_EVENT ADD VALUE '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..7a9f7a7e 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 'pipeline.redaction.created'; +ALTER TYPE WEBHOOK_EVENT ADD VALUE 'pipeline.redaction.created'; +ALTER TYPE NOTIFICATION_EVENT ADD VALUE '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..6e354476 --- /dev/null +++ b/migrations/2026-09-10-013351_assignments/up.sql @@ -0,0 +1,96 @@ +-- 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. Both the workspace + -- and the file cascade: a whole-workspace teardown removes files and their + -- assignments away together, and removing a file removes its assignments. + workspace_id UUID NOT NULL REFERENCES workspaces (id) ON DELETE CASCADE, + file_id UUID NOT NULL REFERENCES workspace_files (id) ON DELETE CASCADE, + + -- 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), + + -- 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 'file.assigned'; +ALTER TYPE ACTIVITY_TYPE ADD VALUE 'file.unassigned'; +ALTER TYPE ACTIVITY_TYPE ADD VALUE 'file.assignment.updated'; + +-- Webhooks carry all three. +ALTER TYPE WEBHOOK_EVENT ADD VALUE 'file.assigned'; +ALTER TYPE WEBHOOK_EVENT ADD VALUE 'file.unassigned'; +ALTER TYPE WEBHOOK_EVENT ADD VALUE '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 'file.assigned'; +ALTER TYPE NOTIFICATION_EVENT ADD VALUE 'file.unassigned'; From e96a8c34816e6580399e120674cd93dba301a880 Mon Sep 17 00:00:00 2001 From: Oleh Martsokha Date: Thu, 10 Sep 2026 04:05:57 +0200 Subject: [PATCH 2/6] Address review: fix required-claim regression, add status to event, harden migrations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - jwt_claims: drop "adm" from set_required_spec_claims. Removing the is_admin claim without this left every newly issued token failing validation (the spec still required "adm") — a total auth-breakage regression. Now fixed. - assignments: composite FK (workspace_id, file_id) -> workspace_files (workspace_id, id) ON DELETE CASCADE, so a file from another workspace can no longer be stored against the denormalized workspace_id (matches the workspace_pipeline_policies pattern). - events: AssignmentRef now carries the review status, threaded into the activity params and all three assignment webhook bodies, so a consumer of file.assignment.updated can see the new status. - update_assignment: keep file_name as Option through to the response (a removed file reads as null, not blank); default to empty only for the event. - migrations: ALTER TYPE ... ADD VALUE now uses IF NOT EXISTS across all enum extensions, so a re-run is a no-op. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_018bKk1YEG4tZ69jzYVQvQL8 --- crates/nvisy-postgres/src/schema.rs | 1 - .../src/types/json/activity_params.rs | 6 ++-- .../src/extract/auth/jwt_claims.rs | 2 +- .../nvisy-server/src/handler/assignments.rs | 14 ++++---- .../nvisy-server/src/service/event/drainer.rs | 4 +++ .../src/service/event/workspace_event.rs | 6 ++-- migrations/2025-05-21-222842_webhooks/up.sql | 6 ++-- migrations/2025-05-27-011852_files/up.sql | 12 +++---- .../2026-01-19-045012_connections/up.sql | 32 +++++++++---------- migrations/2026-01-19-045013_providers/up.sql | 12 +++---- migrations/2026-01-19-045014_policies/up.sql | 12 +++---- migrations/2026-01-19-045015_pipelines/up.sql | 12 +++---- .../2026-01-19-045016_detections/up.sql | 16 +++++----- .../2026-01-19-045017_redactions/up.sql | 6 ++-- .../2026-09-10-013351_assignments/up.sql | 30 ++++++++++------- 15 files changed, 91 insertions(+), 80 deletions(-) diff --git a/crates/nvisy-postgres/src/schema.rs b/crates/nvisy-postgres/src/schema.rs index c1e4262b..37a26a46 100644 --- a/crates/nvisy-postgres/src/schema.rs +++ b/crates/nvisy-postgres/src/schema.rs @@ -564,7 +564,6 @@ 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 -> workspace_files (file_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)); diff --git a/crates/nvisy-postgres/src/types/json/activity_params.rs b/crates/nvisy-postgres/src/types/json/activity_params.rs index 3bde612a..e5a2904d 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.*`). @@ -101,6 +101,8 @@ pub struct AssignmentActivityParams { pub file_name: String, /// 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). diff --git a/crates/nvisy-server/src/extract/auth/jwt_claims.rs b/crates/nvisy-server/src/extract/auth/jwt_claims.rs index 170d5291..802f6b1e 100644 --- a/crates/nvisy-server/src/extract/auth/jwt_claims.rs +++ b/crates/nvisy-server/src/extract/auth/jwt_claims.rs @@ -209,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, diff --git a/crates/nvisy-server/src/handler/assignments.rs b/crates/nvisy-server/src/handler/assignments.rs index 133f0c22..8dd3fb6f 100644 --- a/crates/nvisy-server/src/handler/assignments.rs +++ b/crates/nvisy-server/src/handler/assignments.rs @@ -301,11 +301,12 @@ async fn update_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) - .unwrap_or_default(); + .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(); @@ -324,7 +325,7 @@ async fn update_assignment( workspace_origin(workspace.id, authz.account_id, &security), WorkspaceEvent::AssignmentStatusChanged(assignment_ref( &updated, - &file_name, + file_name.as_deref().unwrap_or_default(), &assignee_handle, )), ) @@ -337,11 +338,7 @@ async fn update_assignment( Ok(( StatusCode::OK, - Json(Assignment::from_model( - updated, - assignee_ref, - Some(file_name), - )), + Json(Assignment::from_model(updated, assignee_ref, file_name)), )) } @@ -477,6 +474,7 @@ fn assignment_ref( file_name: file_name.to_owned(), }, assignee_username: assignee_username.clone(), + status: assignment.status, } } diff --git a/crates/nvisy-server/src/service/event/drainer.rs b/crates/nvisy-server/src/service/event/drainer.rs index 9af97ce1..92d1eddc 100644 --- a/crates/nvisy-server/src/service/event/drainer.rs +++ b/crates/nvisy-server/src/service/event/drainer.rs @@ -332,6 +332,7 @@ fn activity_of(event: &WorkspaceEvent) -> ActivityPayload { assignment_id: a.assignment_id, file_name: a.file.file_name.clone(), assignee_username: a.assignee_username.clone(), + status: a.status, }; let pipeline = |pipeline_slug: &Handle| PipelineActivityParams { pipeline_slug: pipeline_slug.clone(), @@ -455,6 +456,7 @@ fn webhook_of(event: &WorkspaceEvent) -> Option<(WebhookEvent, Option)> { Some(serde_json::json!({ "displayName": assignment.file.file_name, "assignee": assignment.assignee_username, + "status": assignment.status, })), ), E::FileUnassigned { assignment, .. } => ( @@ -462,6 +464,7 @@ fn webhook_of(event: &WorkspaceEvent) -> Option<(WebhookEvent, Option)> { Some(serde_json::json!({ "displayName": assignment.file.file_name, "assignee": assignment.assignee_username, + "status": assignment.status, })), ), E::AssignmentStatusChanged(assignment) => ( @@ -469,6 +472,7 @@ fn webhook_of(event: &WorkspaceEvent) -> Option<(WebhookEvent, Option)> { Some(serde_json::json!({ "displayName": assignment.file.file_name, "assignee": assignment.assignee_username, + "status": assignment.status, })), ), E::PipelineCreated(..) => (WebhookEvent::PipelineCreated, None), diff --git a/crates/nvisy-server/src/service/event/workspace_event.rs b/crates/nvisy-server/src/service/event/workspace_event.rs index c2106117..4c2e76a4 100644 --- a/crates/nvisy-server/src/service/event/workspace_event.rs +++ b/crates/nvisy-server/src/service/event/workspace_event.rs @@ -8,7 +8,7 @@ //! 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. -use nvisy_postgres::types::Handle; +use nvisy_postgres::types::{AssignmentStatus, Handle}; use serde::{Deserialize, Serialize}; use uuid::Uuid; @@ -219,13 +219,15 @@ pub struct FileRef { pub file_name: String, } -/// An assignment, the file it covers, and the reviewer it is assigned to. +/// An assignment, the file it covers, the reviewer it is assigned to, and its +/// current review status. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AssignmentRef { pub assignment_id: Uuid, #[serde(flatten)] pub file: FileRef, pub assignee_username: Handle, + pub status: AssignmentStatus, } /// A pipeline and its slug. diff --git a/migrations/2025-05-21-222842_webhooks/up.sql b/migrations/2025-05-21-222842_webhooks/up.sql index 7649923b..84957bbd 100644 --- a/migrations/2025-05-21-222842_webhooks/up.sql +++ b/migrations/2025-05-21-222842_webhooks/up.sql @@ -119,6 +119,6 @@ COMMENT ON COLUMN workspace_webhooks.deleted_at IS 'Soft-deletion timestamp; NUL -- 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 'webhook.created'; -ALTER TYPE ACTIVITY_TYPE ADD VALUE 'webhook.updated'; -ALTER TYPE ACTIVITY_TYPE ADD VALUE 'webhook.deleted'; +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 abd03e2d..568ec8f9 100644 --- a/migrations/2025-05-27-011852_files/up.sql +++ b/migrations/2025-05-27-011852_files/up.sql @@ -157,10 +157,10 @@ COMMENT ON COLUMN workspace_files.expires_at IS 'Data-retention expiry (NULL = k 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 'file.created'; -ALTER TYPE ACTIVITY_TYPE ADD VALUE 'file.updated'; -ALTER TYPE ACTIVITY_TYPE ADD VALUE 'file.deleted'; +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 'file.created'; -ALTER TYPE WEBHOOK_EVENT ADD VALUE 'file.updated'; -ALTER TYPE WEBHOOK_EVENT ADD VALUE '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 e6bc1d12..9c92dd10 100644 --- a/migrations/2026-01-19-045012_connections/up.sql +++ b/migrations/2026-01-19-045012_connections/up.sql @@ -302,19 +302,19 @@ COMMENT ON COLUMN workspace_file_exports.exported_at IS 'When the file was expor -- Connection lifecycle and sync events feed the activity log, webhooks, and (for -- sync completion/failure) in-app notifications. -ALTER TYPE ACTIVITY_TYPE ADD VALUE 'connection.created'; -ALTER TYPE ACTIVITY_TYPE ADD VALUE 'connection.updated'; -ALTER TYPE ACTIVITY_TYPE ADD VALUE 'connection.deleted'; -ALTER TYPE ACTIVITY_TYPE ADD VALUE 'connection.sync.started'; -ALTER TYPE ACTIVITY_TYPE ADD VALUE 'connection.sync.completed'; -ALTER TYPE ACTIVITY_TYPE ADD VALUE 'connection.sync.failed'; - -ALTER TYPE WEBHOOK_EVENT ADD VALUE 'connection.created'; -ALTER TYPE WEBHOOK_EVENT ADD VALUE 'connection.updated'; -ALTER TYPE WEBHOOK_EVENT ADD VALUE 'connection.deleted'; -ALTER TYPE WEBHOOK_EVENT ADD VALUE 'connection.sync.started'; -ALTER TYPE WEBHOOK_EVENT ADD VALUE 'connection.sync.completed'; -ALTER TYPE WEBHOOK_EVENT ADD VALUE 'connection.sync.failed'; - -ALTER TYPE NOTIFICATION_EVENT ADD VALUE 'connection.sync.completed'; -ALTER TYPE NOTIFICATION_EVENT ADD VALUE 'connection.sync.failed'; +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 a54e4fd5..f8aedd23 100644 --- a/migrations/2026-01-19-045013_providers/up.sql +++ b/migrations/2026-01-19-045013_providers/up.sql @@ -103,10 +103,10 @@ 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 'provider.created'; -ALTER TYPE ACTIVITY_TYPE ADD VALUE 'provider.updated'; -ALTER TYPE ACTIVITY_TYPE ADD VALUE 'provider.deleted'; +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 'provider.created'; -ALTER TYPE WEBHOOK_EVENT ADD VALUE 'provider.updated'; -ALTER TYPE WEBHOOK_EVENT ADD VALUE '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 b17797a3..2d9aeae0 100644 --- a/migrations/2026-01-19-045014_policies/up.sql +++ b/migrations/2026-01-19-045014_policies/up.sql @@ -82,10 +82,10 @@ 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 'policy.created'; -ALTER TYPE ACTIVITY_TYPE ADD VALUE 'policy.updated'; -ALTER TYPE ACTIVITY_TYPE ADD VALUE 'policy.deleted'; +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 'policy.created'; -ALTER TYPE WEBHOOK_EVENT ADD VALUE 'policy.updated'; -ALTER TYPE WEBHOOK_EVENT ADD VALUE '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 a09d72ca..ea0c673a 100644 --- a/migrations/2026-01-19-045015_pipelines/up.sql +++ b/migrations/2026-01-19-045015_pipelines/up.sql @@ -130,10 +130,10 @@ COMMENT ON COLUMN workspace_pipeline_policies.policy_id IS 'Policy applied by th -- 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 'pipeline.created'; -ALTER TYPE ACTIVITY_TYPE ADD VALUE 'pipeline.updated'; -ALTER TYPE ACTIVITY_TYPE ADD VALUE 'pipeline.deleted'; +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 'pipeline.created'; -ALTER TYPE WEBHOOK_EVENT ADD VALUE 'pipeline.updated'; -ALTER TYPE WEBHOOK_EVENT ADD VALUE '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 af891c3c..ccf654cd 100644 --- a/migrations/2026-01-19-045016_detections/up.sql +++ b/migrations/2026-01-19-045016_detections/up.sql @@ -257,13 +257,13 @@ COMMENT ON COLUMN workspace_detection_jobs.resolved_at IS 'When a terminal (proc -- Detection run events feed the activity log, webhooks, and (for terminal -- completion/failure) in-app notifications. -ALTER TYPE ACTIVITY_TYPE ADD VALUE 'pipeline.detection.started'; -ALTER TYPE ACTIVITY_TYPE ADD VALUE 'pipeline.detection.completed'; -ALTER TYPE ACTIVITY_TYPE ADD VALUE 'pipeline.detection.failed'; +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 'pipeline.detection.started'; -ALTER TYPE WEBHOOK_EVENT ADD VALUE 'pipeline.detection.completed'; -ALTER TYPE WEBHOOK_EVENT ADD VALUE '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 'pipeline.detection.completed'; -ALTER TYPE NOTIFICATION_EVENT ADD VALUE '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 7a9f7a7e..74ed6b06 100644 --- a/migrations/2026-01-19-045017_redactions/up.sql +++ b/migrations/2026-01-19-045017_redactions/up.sql @@ -46,6 +46,6 @@ COMMENT ON COLUMN workspace_redactions.output_file_id IS 'Redacted document this 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 'pipeline.redaction.created'; -ALTER TYPE WEBHOOK_EVENT ADD VALUE 'pipeline.redaction.created'; -ALTER TYPE NOTIFICATION_EVENT ADD VALUE 'pipeline.redaction.created'; +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/up.sql b/migrations/2026-09-10-013351_assignments/up.sql index 6e354476..a74569a1 100644 --- a/migrations/2026-09-10-013351_assignments/up.sql +++ b/migrations/2026-09-10-013351_assignments/up.sql @@ -21,11 +21,9 @@ CREATE TABLE workspace_assignments ( -- 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. Both the workspace - -- and the file cascade: a whole-workspace teardown removes files and their - -- assignments away together, and removing a file removes its assignments. + -- 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 REFERENCES workspace_files (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 @@ -44,6 +42,14 @@ CREATE TABLE workspace_assignments ( 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) @@ -81,16 +87,16 @@ COMMENT ON COLUMN workspace_assignments.updated_at IS 'When the assignment was l -- transactional like the others. -- -- Activity log records all three (assigned, unassigned, status changed). -ALTER TYPE ACTIVITY_TYPE ADD VALUE 'file.assigned'; -ALTER TYPE ACTIVITY_TYPE ADD VALUE 'file.unassigned'; -ALTER TYPE ACTIVITY_TYPE ADD VALUE 'file.assignment.updated'; +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 'file.assigned'; -ALTER TYPE WEBHOOK_EVENT ADD VALUE 'file.unassigned'; -ALTER TYPE WEBHOOK_EVENT ADD VALUE 'file.assignment.updated'; +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 'file.assigned'; -ALTER TYPE NOTIFICATION_EVENT ADD VALUE 'file.unassigned'; +ALTER TYPE NOTIFICATION_EVENT ADD VALUE IF NOT EXISTS 'file.assigned'; +ALTER TYPE NOTIFICATION_EVENT ADD VALUE IF NOT EXISTS 'file.unassigned'; From 74d607ceceee514155a008f844cc611470907b5d Mon Sep 17 00:00:00 2001 From: Oleh Martsokha Date: Thu, 10 Sep 2026 09:37:59 +0200 Subject: [PATCH 3/6] Address review: HTTP error mappings and omit missing unassign file name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - file_service/object errors: Connection failures map to ServiceUnavailable (503) rather than BadRequest (400) — an unreachable backend is not a client error. - nats errors: Connection -> ServiceUnavailable (503), Serialization and InvalidConfig -> InternalServerError (500). A network blip is transient and retryable; an encode fault or a bad broker config is a server-side fault, not a client 400. Conversion tests updated to match. - FileUnassignedParams.file_name is now Option, omitted when serializing, so an unassign notification for a file removed by retention omits the name instead of carrying a blank string. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_018bKk1YEG4tZ69jzYVQvQL8 --- .../src/types/json/notification_params.rs | 6 ++++-- .../src/response/error/file_service_error.rs | 2 +- .../src/response/error/nats_error.rs | 18 +++++++++++------- .../src/response/error/object_error.rs | 2 +- .../nvisy-server/src/service/event/drainer.rs | 8 ++++++-- 5 files changed, 23 insertions(+), 13 deletions(-) diff --git a/crates/nvisy-postgres/src/types/json/notification_params.rs b/crates/nvisy-postgres/src/types/json/notification_params.rs index aaaa6624..24b56981 100644 --- a/crates/nvisy-postgres/src/types/json/notification_params.rs +++ b/crates/nvisy-postgres/src/types/json/notification_params.rs @@ -129,8 +129,10 @@ pub struct FileAssignedParams { 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. - pub file_name: String, + /// 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 diff --git a/crates/nvisy-server/src/response/error/file_service_error.rs b/crates/nvisy-server/src/response/error/file_service_error.rs index 6dfdab9b..ad9fcf06 100644 --- a/crates/nvisy-server/src/response/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/response/error/nats_error.rs b/crates/nvisy-server/src/response/error/nats_error.rs index 9b9e9ec0..236a5a40 100644 --- a/crates/nvisy-server/src/response/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/response/error/object_error.rs b/crates/nvisy-server/src/response/error/object_error.rs index 1e969b57..9a4069e5 100644 --- a/crates/nvisy-server/src/response/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/service/event/drainer.rs b/crates/nvisy-server/src/service/event/drainer.rs index 92d1eddc..80b8e1bd 100644 --- a/crates/nvisy-server/src/service/event/drainer.rs +++ b/crates/nvisy-server/src/service/event/drainer.rs @@ -585,11 +585,15 @@ fn notification_of(event: WorkspaceEvent) -> Option<(Uuid, NotificationPayload)> ) }), E::FileUnassigned { assignment, notify } => notify.map(|to| { + let file = assignment.file; ( to, NotificationPayload::FileUnassigned(FileUnassignedParams { - file_id: assignment.file.file_id, - file_name: assignment.file.file_name, + file_id: file.file_id, + // The handler encodes a removed file as an empty name; surface + // that as `None` so the notification omits it rather than + // showing a blank. + file_name: (!file.file_name.is_empty()).then_some(file.file_name), }), ) }), From c61d5a8bf7f89b8451842be4d6468fea57e8844f Mon Sep 17 00:00:00 2001 From: Oleh Martsokha Date: Fri, 11 Sep 2026 00:10:41 +0200 Subject: [PATCH 4/6] Address review: webhook Configuration is a server fault; guard empty assignment update - webhook_error: WebhookErrorKind::Configuration maps to InternalServerError (500) rather than BadRequest (400); a bad webhook configuration is a server-side fault, not client input. - update_workspace_assignment rejects an all-None changeset up front instead of letting Diesel emit an empty SET that Postgres rejects as a syntax error. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_018bKk1YEG4tZ69jzYVQvQL8 --- crates/nvisy-postgres/src/query/workspace_assignment.rs | 9 +++++++++ crates/nvisy-server/src/response/error/webhook_error.rs | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/crates/nvisy-postgres/src/query/workspace_assignment.rs b/crates/nvisy-postgres/src/query/workspace_assignment.rs index 50f48028..c07db316 100644 --- a/crates/nvisy-postgres/src/query/workspace_assignment.rs +++ b/crates/nvisy-postgres/src/query/workspace_assignment.rs @@ -314,6 +314,15 @@ impl WorkspaceAssignmentRepository for PgConnection { ) -> 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()) diff --git a/crates/nvisy-server/src/response/error/webhook_error.rs b/crates/nvisy-server/src/response/error/webhook_error.rs index e935038c..ca5ebf9e 100644 --- a/crates/nvisy-server/src/response/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()), From 07276fa1a3362f0642f111ffccecad621fbdc539 Mon Sep 17 00:00:00 2001 From: Oleh Martsokha Date: Fri, 11 Sep 2026 02:09:17 +0200 Subject: [PATCH 5/6] Redesign the event system: events as the single source of truth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each workspace event is now one struct that owns its fields once and implements an `EventKind` trait projecting it onto the three sinks, replacing the parallel `*Ref` structs and the drainer's four 37-arm match functions. - service/event/kind.rs: the `EventKind` trait (TAG, resource_id, activity, webhook, notification) plus `WebhookDelivery`, `Notification`, and `NotifyTarget::{Account, Roles}` — a named notification target so the recipient is never a bare Uuid and an event can fan out to a role audience. - service/event/macros.rs: `workspace_events!` binds every event struct into the `WorkspaceEvent` outbox envelope + trait dispatch from one table (the tag lives once, checked against each struct's TAG by a generated test); `crud_events!` generates the uniform lifecycle families (workspace/member/invite/connection/ provider/webhook/pipeline/policy/detection-started) from one declaration each. - service/event/workspace_event.rs: 40 per-event structs. Events that carry a notification or a typed webhook body stay explicit; the rest collapse into `crud_events!`. Webhook bodies are typed structs, not ad-hoc json!. `notify` is uniformly `Option`. - drainer.rs: the four match functions are gone; it calls the trait and fans `NotifyTarget` to notify_account / notify_workspace_roles. Notifications now all flow through the outbox: member.joined is emitted by MemberAdded (role broadcast to owners/admins, excluding the joiner). member.invited is dropped — a pending-invites view covers it — removing its enum value, params, and the direct emission in invites.rs. Enum-placement consistency: NOTIFICATION_EVENT is created empty in the notifications migration and gains member.joined in the workspaces migration (where members are introduced), matching how every other event value is added by its object's migration. AssignmentActivityParams.file_name is now Option (omitted when the file was removed) instead of a required String defaulted to "", so the activity log records "gone" honestly — matching the webhook body and notification. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_018bKk1YEG4tZ69jzYVQvQL8 --- .../src/types/enums/notification_event.rs | 2 - .../src/types/json/activity_params.rs | 8 +- crates/nvisy-postgres/src/types/json/mod.rs | 4 +- .../src/types/json/notification_params.rs | 17 - crates/nvisy-postgres/src/types/mod.rs | 10 +- .../nvisy-server/src/handler/assignments.rs | 64 +- .../src/handler/connection_oauth.rs | 6 +- .../nvisy-server/src/handler/connections.rs | 11 +- crates/nvisy-server/src/handler/detections.rs | 17 +- crates/nvisy-server/src/handler/files.rs | 20 +- crates/nvisy-server/src/handler/invites.rs | 108 +- crates/nvisy-server/src/handler/members.rs | 10 +- crates/nvisy-server/src/handler/pipelines.rs | 11 +- crates/nvisy-server/src/handler/policies.rs | 9 +- crates/nvisy-server/src/handler/providers.rs | 10 +- crates/nvisy-server/src/handler/webhooks.rs | 9 +- crates/nvisy-server/src/handler/workspaces.rs | 8 +- .../src/service/detection/support.rs | 12 +- .../src/service/detection/worker.rs | 12 +- .../nvisy-server/src/service/event/drainer.rs | 429 +------- crates/nvisy-server/src/service/event/kind.rs | 103 ++ .../nvisy-server/src/service/event/macros.rs | 193 ++++ crates/nvisy-server/src/service/event/mod.rs | 13 +- .../src/service/event/workspace_event.rs | 940 ++++++++++++++---- .../src/service/integration/service.rs | 28 +- crates/nvisy-server/src/service/mod.rs | 12 +- .../nvisy-server/src/service/notification.rs | 26 - .../2025-05-21-121132_notifications/up.sql | 13 +- .../2025-05-21-222840_workspaces/up.sql | 5 + 29 files changed, 1250 insertions(+), 860 deletions(-) create mode 100644 crates/nvisy-server/src/service/event/kind.rs create mode 100644 crates/nvisy-server/src/service/event/macros.rs diff --git a/crates/nvisy-postgres/src/types/enums/notification_event.rs b/crates/nvisy-postgres/src/types/enums/notification_event.rs index 36378541..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. diff --git a/crates/nvisy-postgres/src/types/json/activity_params.rs b/crates/nvisy-postgres/src/types/json/activity_params.rs index e5a2904d..532d6e36 100644 --- a/crates/nvisy-postgres/src/types/json/activity_params.rs +++ b/crates/nvisy-postgres/src/types/json/activity_params.rs @@ -97,8 +97,10 @@ pub struct FileActivityParams { pub struct AssignmentActivityParams { /// Id of the assignment. pub assignment_id: Uuid, - /// Display name of the file under review. - pub file_name: String, + /// 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. @@ -459,7 +461,7 @@ impl ActivityPayload { ActivityPayload::FileAssigned(p) | ActivityPayload::FileUnassigned(p) - | ActivityPayload::AssignmentStatusChanged(p) => Some(p.file_name.clone()), + | ActivityPayload::AssignmentStatusChanged(p) => p.file_name.clone(), ActivityPayload::PipelineCreated(p) | ActivityPayload::PipelineUpdated(p) diff --git a/crates/nvisy-postgres/src/types/json/mod.rs b/crates/nvisy-postgres/src/types/json/mod.rs index c24fb540..0a9da15f 100644 --- a/crates/nvisy-postgres/src/types/json/mod.rs +++ b/crates/nvisy-postgres/src/types/json/mod.rs @@ -23,8 +23,8 @@ pub use activity_params::{ pub use detection_metadata::DetectionMetadata; pub use notification_params::{ ConnectionSyncCompletedParams, ConnectionSyncFailedParams, DetectionCompletedParams, - DetectionFailedParams, FileAssignedParams, FileUnassignedParams, 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 24b56981..a612fee8 100644 --- a/crates/nvisy-postgres/src/types/json/notification_params.rs +++ b/crates/nvisy-postgres/src/types/json/notification_params.rs @@ -11,18 +11,6 @@ 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))] @@ -145,10 +133,6 @@ pub struct FileUnassignedParams { #[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), @@ -186,7 +170,6 @@ 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 diff --git a/crates/nvisy-postgres/src/types/mod.rs b/crates/nvisy-postgres/src/types/mod.rs index a7ed102c..d71c878f 100644 --- a/crates/nvisy-postgres/src/types/mod.rs +++ b/crates/nvisy-postgres/src/types/mod.rs @@ -32,11 +32,11 @@ pub use json::{ ConnectionSyncCompletedParams, ConnectionSyncFailedParams, DetectionActivityParams, DetectionCompletedParams, DetectionFailedParams, DetectionMetadata, FileActivityParams, FileAssignedParams, FileUnassignedParams, InvalidHeader, InviteActivityParams, Json, - MemberActivityParams, MemberInvitedParams, MemberJoinedParams, NotificationPayload, - PipelineActivityParams, PipelineMetadata, PolicyActivityParams, ProviderActivityParams, - RasterPolicy, RedactionActivityParams, RedactionCreatedParams, Retention, RetentionOverride, - RetentionScope, RetentionSettings, WebhookActivityParams, WebhookHeaders, - WorkspaceActivityParams, WorkspaceMetadata, WorkspaceSettings, + 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/handler/assignments.rs b/crates/nvisy-server/src/handler/assignments.rs index 8dd3fb6f..83a75d27 100644 --- a/crates/nvisy-server/src/handler/assignments.rs +++ b/crates/nvisy-server/src/handler/assignments.rs @@ -10,9 +10,7 @@ use aide::axum::ApiRouter; use aide::transform::TransformOperation; use axum::extract::State; use axum::http::StatusCode; -use nvisy_postgres::model::{ - NewWorkspaceAssignment, UpdateWorkspaceAssignment, WorkspaceAssignment, -}; +use nvisy_postgres::model::{NewWorkspaceAssignment, UpdateWorkspaceAssignment}; use nvisy_postgres::query::{ AccountRepository, CreateAssignmentOutcome, WorkspaceAssignmentRepository, WorkspaceFileRepository, WorkspaceMemberRepository, @@ -32,7 +30,8 @@ use crate::handler::response::{Assignment, AssignmentsPage}; use crate::handler::utility::resolve_account_ref; use crate::response::{Error, ErrorKind, ErrorResponse, Result}; use crate::service::{ - AssignmentRef, EventEmitter, EventOrigin, FileRef, ServiceState, WorkspaceEvent, + AssignmentStatusChanged, EventEmitter, EventOrigin, FileAssigned, FileUnassigned, ServiceState, + WorkspaceEvent, }; /// Tracing target for assignment operations. @@ -94,16 +93,16 @@ async fn create_assignment( emit_assignment_event( conn, workspace_origin(workspace.id, authz.account_id, &security), - WorkspaceEvent::FileAssigned { - assignment: assignment_ref( - &assignment, - &file.display_name, - &request.assignee, - ), + 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)) @@ -323,11 +322,13 @@ async fn update_assignment( emit_assignment_event( conn, workspace_origin(workspace.id, authz.account_id, &security), - WorkspaceEvent::AssignmentStatusChanged(assignment_ref( - &updated, - file_name.as_deref().unwrap_or_default(), - &assignee_handle, - )), + 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) @@ -386,8 +387,7 @@ async fn delete_assignment( let file_name = conn .find_file_in_workspace(workspace.id, assignment.file_id) .await? - .map(|f| f.display_name) - .unwrap_or_default(); + .map(|f| f.display_name); let assignee_handle = resolve_account_ref(&mut conn, assignment.assignee_account_id) .await? .username; @@ -397,11 +397,15 @@ async fn delete_assignment( emit_assignment_event( conn, workspace_origin(workspace.id, authz.account_id, &security), - WorkspaceEvent::FileUnassigned { - assignment: assignment_ref(&assignment, &file_name, &assignee_handle), + 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>(()) @@ -460,24 +464,6 @@ fn workspace_origin<'a>( } } -/// Builds an [`AssignmentRef`] from an assignment row, its file name, and the -/// assignee's handle. -fn assignment_ref( - assignment: &WorkspaceAssignment, - file_name: &str, - assignee_username: &Handle, -) -> AssignmentRef { - AssignmentRef { - assignment_id: assignment.id, - file: FileRef { - file_id: assignment.file_id, - file_name: file_name.to_owned(), - }, - assignee_username: assignee_username.clone(), - status: assignment.status, - } -} - /// Emits one assignment event onto the outbox. async fn emit_assignment_event( conn: &mut PgConn, diff --git a/crates/nvisy-server/src/handler/connection_oauth.rs b/crates/nvisy-server/src/handler/connection_oauth.rs index bb01709f..a5933994 100644 --- a/crates/nvisy-server/src/handler/connection_oauth.rs +++ b/crates/nvisy-server/src/handler/connection_oauth.rs @@ -40,8 +40,8 @@ use crate::extract::{Authorized, Json, Path, Query, SecurityContext, ValidateJso use crate::handler::request::{OAuthCallbackQuery, OAuthStartPathParams, StartFileServiceOAuth}; 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. @@ -254,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/connections.rs b/crates/nvisy-server/src/handler/connections.rs index 783318b1..d216f303 100644 --- a/crates/nvisy-server/src/handler/connections.rs +++ b/crates/nvisy-server/src/handler/connections.rs @@ -43,8 +43,9 @@ use crate::handler::response::{Connection, ConnectionVerification, ConnectionsPa use crate::handler::utility::resolve_account_ref; 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. @@ -135,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(), }), @@ -440,7 +441,7 @@ async fn update_connection( account_id, security: &security, }, - WorkspaceEvent::ConnectionUpdated(ConnectionRef { + WorkspaceEvent::ConnectionUpdated(ConnectionUpdated { connection_id, connection_name, }), @@ -518,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(), }), diff --git a/crates/nvisy-server/src/handler/detections.rs b/crates/nvisy-server/src/handler/detections.rs index cba72945..16dcc688 100644 --- a/crates/nvisy-server/src/handler/detections.rs +++ b/crates/nvisy-server/src/handler/detections.rs @@ -36,8 +36,9 @@ use crate::handler::response::{Detection, DetectionsPage, RedactionResult}; use crate::handler::utility::resolve_account_ref; 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. @@ -156,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(), }), @@ -704,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 3d0dd64c..da67f3e4 100644 --- a/crates/nvisy-server/src/handler/files.rs +++ b/crates/nvisy-server/src/handler/files.rs @@ -35,8 +35,8 @@ use crate::handler::utility::{DownloadDocs, resolve_account_ref}; use crate::middleware::UploadConfig; 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. @@ -366,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); @@ -499,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(), }), @@ -689,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(), }), @@ -768,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/invites.rs b/crates/nvisy-server/src/handler/invites.rs index 215a79d2..ac668d13 100644 --- a/crates/nvisy-server/src/handler/invites.rs +++ b/crates/nvisy-server/src/handler/invites.rs @@ -9,15 +9,9 @@ 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; @@ -35,8 +29,8 @@ use crate::handler::response::{ }; 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( @@ -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>() @@ -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(), }), @@ -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 69c84739..491c9a90 100644 --- a/crates/nvisy-server/src/handler/members.rs +++ b/crates/nvisy-server/src/handler/members.rs @@ -21,7 +21,9 @@ use crate::extract::{ use crate::handler::request::{CursorPagination, ListMembers, MemberPathParams, UpdateMember}; use crate::handler::response::{Member, MembersPage, Page}; use crate::response::{Error, ErrorKind, ErrorResponse, Result}; -use crate::service::{EventEmitter, EventOrigin, MemberRef, ServiceState, WorkspaceEvent}; +use crate::service::{ + EventEmitter, EventOrigin, MemberDeleted, MemberUpdated, ServiceState, WorkspaceEvent, +}; /// Tracing target for workspace member operations. const TRACING_TARGET: &str = "nvisy_server::handler::members"; @@ -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(), }), @@ -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/pipelines.rs b/crates/nvisy-server/src/handler/pipelines.rs index deda5e4e..1718c412 100644 --- a/crates/nvisy-server/src/handler/pipelines.rs +++ b/crates/nvisy-server/src/handler/pipelines.rs @@ -24,7 +24,10 @@ use crate::handler::request::{ use crate::handler::response::{AccountRef, Page, Pipeline, PipelineSummary}; use crate::handler::utility::resolve_account_ref; use crate::response::{Error, ErrorKind, ErrorResponse, Result}; -use crate::service::{EventEmitter, EventOrigin, PipelineRef, ServiceState, WorkspaceEvent}; +use crate::service::{ + EventEmitter, EventOrigin, PipelineCreated, PipelineDeleted, PipelineUpdated, ServiceState, + WorkspaceEvent, +}; /// Tracing target for pipeline operations. const TRACING_TARGET: &str = "nvisy_server::handler::pipelines"; @@ -68,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(), }), @@ -288,7 +291,7 @@ async fn update_pipeline( account_id, security: &security, }, - WorkspaceEvent::PipelineUpdated(PipelineRef { + WorkspaceEvent::PipelineUpdated(PipelineUpdated { pipeline_id, pipeline_slug: pipeline.slug.clone(), }), @@ -364,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 56bbe512..40016324 100644 --- a/crates/nvisy-server/src/handler/policies.rs +++ b/crates/nvisy-server/src/handler/policies.rs @@ -23,7 +23,8 @@ use crate::handler::response::{PoliciesPage, Policy, PolicySummary}; use crate::handler::utility::resolve_account_ref; 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. @@ -87,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(), }), @@ -269,7 +270,7 @@ async fn update_policy( account_id, security: &security, }, - WorkspaceEvent::PolicyUpdated(PolicyRef { + WorkspaceEvent::PolicyUpdated(PolicyUpdated { policy_id, policy_slug, }), @@ -336,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 c27b7322..8faf224f 100644 --- a/crates/nvisy-server/src/handler/providers.rs +++ b/crates/nvisy-server/src/handler/providers.rs @@ -25,8 +25,8 @@ use crate::handler::response::{ConnectionVerification, Provider, ProvidersPage}; use crate::handler::utility::resolve_account_ref; 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. @@ -89,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(), }), @@ -304,7 +304,7 @@ async fn update_provider( account_id, security: &security, }, - WorkspaceEvent::ProviderUpdated(ProviderRef { + WorkspaceEvent::ProviderUpdated(ProviderUpdated { provider_id, provider_name, }), @@ -373,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(), }), diff --git a/crates/nvisy-server/src/handler/webhooks.rs b/crates/nvisy-server/src/handler/webhooks.rs index e059171b..a19fe101 100644 --- a/crates/nvisy-server/src/handler/webhooks.rs +++ b/crates/nvisy-server/src/handler/webhooks.rs @@ -28,7 +28,8 @@ use crate::handler::response::{Webhook, WebhookCreated, WebhookResult, WebhooksP use crate::handler::utility::resolve_account_ref; 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. @@ -77,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(), }), @@ -262,7 +263,7 @@ async fn update_webhook( account_id, security: &security, }, - WorkspaceEvent::WebhookUpdated(WebhookRef { + WorkspaceEvent::WebhookUpdated(WebhookUpdatedEvent { webhook_id: existing.id, webhook_name, }), @@ -333,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(), }), diff --git a/crates/nvisy-server/src/handler/workspaces.rs b/crates/nvisy-server/src/handler/workspaces.rs index 340bace8..510ba634 100644 --- a/crates/nvisy-server/src/handler/workspaces.rs +++ b/crates/nvisy-server/src/handler/workspaces.rs @@ -29,7 +29,7 @@ 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. @@ -94,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(), }), @@ -256,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(), }), @@ -319,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(), }), diff --git a/crates/nvisy-server/src/service/detection/support.rs b/crates/nvisy-server/src/service/detection/support.rs index 6a6d0cbb..80608731 100644 --- a/crates/nvisy-server/src/service/detection/support.rs +++ b/crates/nvisy-server/src/service/detection/support.rs @@ -12,7 +12,7 @@ use uuid::Uuid; use super::service::DetectionQueue; use crate::extract::SecurityContext; use crate::response::Result; -use crate::service::{CryptoService, DetectionRef, EventEmitter, EventOrigin, WorkspaceEvent}; +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 455031df..9deb4683 100644 --- a/crates/nvisy-server/src/service/detection/worker.rs +++ b/crates/nvisy-server/src/service/detection/worker.rs @@ -32,7 +32,7 @@ use crate::extract::SecurityContext; use crate::handler::request::PipelineDefinition; 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 80b8e1bd..bf439738 100644 --- a/crates/nvisy-server/src/service/event/drainer.rs +++ b/crates/nvisy-server/src/service/event/drainer.rs @@ -19,25 +19,13 @@ use std::time::Duration; use nvisy_postgres::model::{EventOutbox, NewWorkspaceActivity}; use nvisy_postgres::query::{EventOutboxRepository, WorkspaceActivityRepository}; -use nvisy_postgres::types::{ - ActivityPayload, AssignmentActivityParams, ConnectionActivityParams, ConnectionId, - ConnectionSyncCompletedParams, ConnectionSyncFailedParams, DetectionActivityParams, - DetectionCompletedParams, DetectionFailedParams, DetectionId, FileActivityParams, - FileAssignedParams, FileUnassignedParams, 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::response::{Error, Result}; -use crate::service::event::{ - AssignmentRef, ConnectionRef, DetectionRef, FileRef, InviteRef, MemberRef, PolicyRef, - ProviderRef, WebhookRef, WorkspaceEvent, WorkspaceRef, -}; +use crate::service::event::{Notification, NotifyTarget, WorkspaceEvent}; use crate::service::{Infra, NotificationEmitter, WebhookEmitter, Worker}; /// Tracing target for the outbox drainer. @@ -233,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, @@ -260,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"); } } } @@ -293,376 +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 assignment_params = |a: &AssignmentRef| AssignmentActivityParams { - assignment_id: a.assignment_id, - file_name: a.file.file_name.clone(), - assignee_username: a.assignee_username.clone(), - status: a.status, - }; - 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::FileAssigned { assignment, .. } => { - ActivityPayload::FileAssigned(assignment_params(assignment)) - } - E::FileUnassigned { assignment, .. } => { - ActivityPayload::FileUnassigned(assignment_params(assignment)) - } - E::AssignmentStatusChanged(a) => { - ActivityPayload::AssignmentStatusChanged(assignment_params(a)) - } - 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::FileAssigned { assignment, .. } => ( - WebhookEvent::FileAssigned, - Some(serde_json::json!({ - "displayName": assignment.file.file_name, - "assignee": assignment.assignee_username, - "status": assignment.status, - })), - ), - E::FileUnassigned { assignment, .. } => ( - WebhookEvent::FileUnassigned, - Some(serde_json::json!({ - "displayName": assignment.file.file_name, - "assignee": assignment.assignee_username, - "status": assignment.status, - })), - ), - E::AssignmentStatusChanged(assignment) => ( - WebhookEvent::AssignmentStatusChanged, - Some(serde_json::json!({ - "displayName": assignment.file.file_name, - "assignee": assignment.assignee_username, - "status": assignment.status, - })), - ), - 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, - }), - )), - E::FileAssigned { assignment, notify } => notify.map(|to| { - ( - to, - NotificationPayload::FileAssigned(FileAssignedParams { - assignment_id: assignment.assignment_id, - file_id: assignment.file.file_id, - file_name: assignment.file.file_name, - }), - ) - }), - E::FileUnassigned { assignment, notify } => notify.map(|to| { - let file = assignment.file; - ( - to, - NotificationPayload::FileUnassigned(FileUnassignedParams { - file_id: file.file_id, - // The handler encodes a removed file as an empty name; surface - // that as `None` so the notification omits it rather than - // showing a blank. - file_name: (!file.file_name.is_empty()).then_some(file.file_name), - }), - ) - }), - // Events that raise no in-app notification. Listed explicitly (no wildcard) - // so a new event forces a deliberate notify / no-notify decision here. - E::AssignmentStatusChanged(_) - | 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::FileAssigned { assignment, .. } | E::FileUnassigned { assignment, .. } => { - assignment.assignment_id - } - E::AssignmentStatusChanged(a) => a.assignment_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/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 d006895c..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::{ - AssignmentRef, 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 4c2e76a4..06318baa 100644 --- a/crates/nvisy-server/src/service/event/workspace_event.rs +++ b/crates/nvisy-server/src/service/event/workspace_event.rs @@ -1,252 +1,786 @@ -//! 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::{AssignmentStatus, 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), - - // Assignments - #[serde(rename = "file.assigned")] - FileAssigned { - #[serde(flatten)] - assignment: AssignmentRef, - /// The reviewer to notify in-app. `None` when the actor assigned the file - /// to themselves, so a self-assignment raises no notification. - notify: Option, - }, - #[serde(rename = "file.unassigned")] - FileUnassigned { - #[serde(flatten)] - assignment: AssignmentRef, - /// The former reviewer to notify in-app. `None` when the actor unassigned - /// themselves. - notify: Option, - }, - #[serde(rename = "file.assignment.updated")] - AssignmentStatusChanged(AssignmentRef), - - // 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, + }), + }] + } } -/// An invitation and the address it was sent to. +// 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", +} + +/// 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, + }), + ) + } +} + +/// 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 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 provider and its display name. #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ProviderRef { - pub provider_id: Uuid, - pub provider_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 webhook and its display name. #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct WebhookRef { - pub webhook_id: Uuid, - pub webhook_name: String, +pub struct FileDeleted { + pub file_id: Uuid, + pub file_name: String, } -/// A file and its display name. +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 FileRef { +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, } -/// An assignment, the file it covers, the reviewer it is assigned to, and its -/// current review status. +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 AssignmentRef { +pub struct FileUnassigned { pub assignment_id: Uuid, - #[serde(flatten)] - pub file: FileRef, + 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 pipeline and its slug. +/// 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 PipelineRef { - pub pipeline_id: Uuid, +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, +} + +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 and its pipeline's slug. +/// 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, } -/// A policy and its slug. +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 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 { + self.detection_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/integration/service.rs b/crates/nvisy-server/src/service/integration/service.rs index 7a285727..41f1d7a7 100644 --- a/crates/nvisy-server/src/service/integration/service.rs +++ b/crates/nvisy-server/src/service/integration/service.rs @@ -28,8 +28,8 @@ use super::import::Importer; use crate::extract::SecurityContext; 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(), }); @@ -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(), }; diff --git a/crates/nvisy-server/src/service/mod.rs b/crates/nvisy-server/src/service/mod.rs index ef0e1423..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::{ - AssignmentRef, 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 3abc782a..ef5ffcdb 100644 --- a/crates/nvisy-server/src/service/notification.rs +++ b/crates/nvisy-server/src/service/notification.rs @@ -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/migrations/2025-05-21-121132_notifications/up.sql b/migrations/2025-05-21-121132_notifications/up.sql index 6ecff0a5..e831409d 100644 --- a/migrations/2025-05-21-121132_notifications/up.sql +++ b/migrations/2025-05-21-121132_notifications/up.sql @@ -3,14 +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. --- Only the baseline member values are declared here. Each later feature --- (connections, detections, redactions, assignments) adds its own notification --- 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 NOTIFICATION_EVENT AS ENUM ( - 'member.invited', -- User was invited to a workspace - 'member.joined' -- A new member joined a workspace -); +-- 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 1a50266d..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) From aa651cfcd07f63454c01fb393d8477edf0413b94 Mon Sep 17 00:00:00 2001 From: Oleh Martsokha Date: Fri, 11 Sep 2026 03:14:06 +0200 Subject: [PATCH 6/6] Address review: redaction.created webhook resource id is the redaction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RedactionCreated::resource_id() returned the detection id, so the pipeline.redaction.created webhook pointed at the detection while the activity log's object id used the redaction id — the two disagreed for the same event. Return redaction_id, the actual affected resource. (Pre-existing behavior the event redesign had carried over faithfully.) Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_018bKk1YEG4tZ69jzYVQvQL8 --- crates/nvisy-server/src/service/event/workspace_event.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/nvisy-server/src/service/event/workspace_event.rs b/crates/nvisy-server/src/service/event/workspace_event.rs index 06318baa..f83f4435 100644 --- a/crates/nvisy-server/src/service/event/workspace_event.rs +++ b/crates/nvisy-server/src/service/event/workspace_event.rs @@ -732,7 +732,9 @@ impl EventKind for RedactionCreated { const TAG: &'static str = "pipeline.redaction.created"; fn resource_id(&self) -> Uuid { - self.detection_id + // 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 {