From f4b90407aeba7ec895a0f7df560fa47823734c0f Mon Sep 17 00:00:00 2001 From: Oleh Martsokha Date: Fri, 11 Sep 2026 05:52:35 +0200 Subject: [PATCH 1/5] Add comments: threaded, resolvable, @-mentioned, multimodal anchors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Team-collaboration comments on files under review (Redactable-parity, extended to our multimodal documents). Model + storage: - workspace_comments table: authored by a member on a file (composite FK to workspace_files), one-level replies via a self-referential parent_id, an optional modality-tagged anchor (JSONB), resolve columns, soft-delete. - WorkspaceCommentRepository: create, validated create_reply (typed ReplyParentError), list-by-file, cursor workspace listing with a CommentFilter (file/author/resolved), edit body, resolve/reopen, soft-delete. Plus a batched find_member_ids_by_usernames for mention resolution. Anchors — optional and multimodal: - A comment can be document-level (no anchor) or pinned to a location. The CommentAnchor is a `modality`-tagged enum over the engine's own location types (elide_pipeline text/image/audio/tabular), so a comment anchors to exactly what a detection/redaction does — a page region, a time span, a text span, a cell. The engine's location types carry no modality discriminator, so the tag here supplies one; stored as JSON in the modality-agnostic anchor column. Permissions (Reviewer tier): ViewComments, Comment, ResolveComments. Events (through the outbox): comment.created (activity + webhook + a comment.mentioned notification per @-mentioned member), comment.resolved (activity + webhook), comment.deleted (activity). @-mention parsing is hand-rolled (no regex dep) and ignores email-embedded @. Endpoints under /workspaces/{slug}: POST/GET files/{fileId}/comments/, GET comments/, PATCH/DELETE comments/{id}/, POST/DELETE comments/{id}/resolve/. Edit and delete are author-only; resolve/reopen apply to a thread's top-level comment; re-resolve is idempotent (preserves the original resolver/timestamp). Bumps elide-pipeline to expose the modality location types through its facade. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_018bKk1YEG4tZ69jzYVQvQL8 --- Cargo.lock | 116 +++- crates/nvisy-postgres/src/model/mod.rs | 2 + .../src/model/workspace_comment.rs | 95 +++ crates/nvisy-postgres/src/query/mod.rs | 2 + .../src/query/workspace_comment.rs | 530 +++++++++++++++ .../src/query/workspace_member.rs | 40 +- crates/nvisy-postgres/src/schema.rs | 21 + .../src/types/constraint/comments.rs | 14 + .../src/types/constraint/mod.rs | 6 + .../src/types/enums/activity_type.rs | 6 + .../src/types/enums/notification_event.rs | 2 + .../src/types/enums/webhook_event.rs | 5 + .../src/types/filtering/comments.rs | 24 + .../nvisy-postgres/src/types/filtering/mod.rs | 2 + .../src/types/json/activity_params.rs | 38 +- crates/nvisy-postgres/src/types/json/mod.rs | 14 +- .../src/types/json/notification_params.rs | 18 + crates/nvisy-postgres/src/types/mod.rs | 31 +- .../src/extract/auth/authorized.rs | 3 + .../src/extract/auth/permission.rs | 11 + crates/nvisy-server/src/handler/comments.rs | 640 ++++++++++++++++++ crates/nvisy-server/src/handler/mod.rs | 2 + .../src/handler/request/comments.rs | 135 ++++ .../nvisy-server/src/handler/request/mod.rs | 2 + .../src/handler/response/comments.rs | 71 ++ .../nvisy-server/src/handler/response/mod.rs | 2 + .../src/response/error/pg_error.rs | 1 + .../src/response/error/pg_workspace.rs | 19 +- crates/nvisy-server/src/service/event/mod.rs | 16 +- .../src/service/event/workspace_event.rs | 120 +++- crates/nvisy-server/src/service/mod.rs | 19 +- .../2026-09-11-040235_comments/down.sql | 9 + migrations/2026-09-11-040235_comments/up.sql | 113 ++++ 33 files changed, 2047 insertions(+), 82 deletions(-) create mode 100644 crates/nvisy-postgres/src/model/workspace_comment.rs create mode 100644 crates/nvisy-postgres/src/query/workspace_comment.rs create mode 100644 crates/nvisy-postgres/src/types/constraint/comments.rs create mode 100644 crates/nvisy-postgres/src/types/filtering/comments.rs create mode 100644 crates/nvisy-server/src/handler/comments.rs create mode 100644 crates/nvisy-server/src/handler/request/comments.rs create mode 100644 crates/nvisy-server/src/handler/response/comments.rs create mode 100644 migrations/2026-09-11-040235_comments/down.sql create mode 100644 migrations/2026-09-11-040235_comments/up.sql diff --git a/Cargo.lock b/Cargo.lock index f977316f..1cf280a5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2140,6 +2140,21 @@ dependencies = [ "libc", ] +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + [[package]] name = "crc-fast" version = "1.10.0" @@ -3500,7 +3515,7 @@ checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" [[package]] name = "elide" version = "0.1.0" -source = "git+https://github.com/nvisycom/elide?branch=main#0eb6f34fd879b8e2b12ab19ae49aff022d0e5c18" +source = "git+https://github.com/nvisycom/elide?branch=main#d691f3b31cac7667864edb8eee5425731a8102d3" dependencies = [ "async-trait", "elide-codec", @@ -3521,7 +3536,7 @@ dependencies = [ [[package]] name = "elide-bentoml" version = "0.1.0" -source = "git+https://github.com/nvisycom/elide-provider?branch=main#11c1c2301f737bf0e7ab46bc6ff9a0355f1c04e1" +source = "git+https://github.com/nvisycom/elide-provider?branch=main#1cc51805baa927d225cce95a6f6c4e58e7a7bd34" dependencies = [ "async-trait", "base64 0.23.1", @@ -3538,18 +3553,18 @@ dependencies = [ [[package]] name = "elide-codec" version = "0.1.0" -source = "git+https://github.com/nvisycom/elide?branch=main#0eb6f34fd879b8e2b12ab19ae49aff022d0e5c18" +source = "git+https://github.com/nvisycom/elide?branch=main#d691f3b31cac7667864edb8eee5425731a8102d3" dependencies = [ "async-trait", "bytes", "csv", "elide-core", + "elide-image", "elide-office", "elide-pdf", "hex", "hound", "image", - "imageproc", "quick-xml 0.42.0", "serde_json", "sha2 0.11.0", @@ -3560,7 +3575,7 @@ dependencies = [ [[package]] name = "elide-context" version = "0.1.0" -source = "git+https://github.com/nvisycom/elide?branch=main#0eb6f34fd879b8e2b12ab19ae49aff022d0e5c18" +source = "git+https://github.com/nvisycom/elide?branch=main#d691f3b31cac7667864edb8eee5425731a8102d3" dependencies = [ "async-trait", "elide-core", @@ -3571,7 +3586,7 @@ dependencies = [ [[package]] name = "elide-core" version = "0.1.0" -source = "git+https://github.com/nvisycom/elide?branch=main#0eb6f34fd879b8e2b12ab19ae49aff022d0e5c18" +source = "git+https://github.com/nvisycom/elide?branch=main#d691f3b31cac7667864edb8eee5425731a8102d3" dependencies = [ "async-trait", "blake3", @@ -3590,7 +3605,7 @@ dependencies = [ [[package]] name = "elide-detection" version = "0.1.0" -source = "git+https://github.com/nvisycom/elide?branch=main#0eb6f34fd879b8e2b12ab19ae49aff022d0e5c18" +source = "git+https://github.com/nvisycom/elide?branch=main#d691f3b31cac7667864edb8eee5425731a8102d3" dependencies = [ "elide-core", "futures", @@ -3602,7 +3617,7 @@ dependencies = [ [[package]] name = "elide-engine" version = "0.1.0" -source = "git+https://github.com/nvisycom/elide?branch=main#0eb6f34fd879b8e2b12ab19ae49aff022d0e5c18" +source = "git+https://github.com/nvisycom/elide?branch=main#d691f3b31cac7667864edb8eee5425731a8102d3" dependencies = [ "bytes", "elide-codec", @@ -3621,7 +3636,7 @@ dependencies = [ [[package]] name = "elide-export" version = "0.1.0" -source = "git+https://github.com/nvisycom/elide-runtime?branch=main#a23b0458db7a6cb0031110cee85a764605594526" +source = "git+https://github.com/nvisycom/elide-runtime?branch=main#82ea417d0b92c3799cc2dec885c922db9f78d5a7" dependencies = [ "csv", "elide", @@ -3633,18 +3648,19 @@ dependencies = [ [[package]] name = "elide-fake" version = "0.1.0" -source = "git+https://github.com/nvisycom/elide?branch=main#0eb6f34fd879b8e2b12ab19ae49aff022d0e5c18" +source = "git+https://github.com/nvisycom/elide?branch=main#d691f3b31cac7667864edb8eee5425731a8102d3" dependencies = [ "async-trait", "elide-core", "fake", + "getrandom 0.4.3", "uuid", ] [[package]] name = "elide-governance" version = "0.1.0" -source = "git+https://github.com/nvisycom/elide-runtime?branch=main#a23b0458db7a6cb0031110cee85a764605594526" +source = "git+https://github.com/nvisycom/elide-runtime?branch=main#82ea417d0b92c3799cc2dec885c922db9f78d5a7" dependencies = [ "elide-core", "elide-operator", @@ -3655,10 +3671,23 @@ dependencies = [ "uuid", ] +[[package]] +name = "elide-image" +version = "0.1.0" +source = "git+https://github.com/nvisycom/elide?branch=main#d691f3b31cac7667864edb8eee5425731a8102d3" +dependencies = [ + "async-trait", + "bytes", + "elide-core", + "image", + "imageproc", + "little_exif", +] + [[package]] name = "elide-lingua" version = "0.1.0" -source = "git+https://github.com/nvisycom/elide?branch=main#0eb6f34fd879b8e2b12ab19ae49aff022d0e5c18" +source = "git+https://github.com/nvisycom/elide?branch=main#d691f3b31cac7667864edb8eee5425731a8102d3" dependencies = [ "async-trait", "elide-core", @@ -3669,7 +3698,7 @@ dependencies = [ [[package]] name = "elide-llm" version = "0.1.0" -source = "git+https://github.com/nvisycom/elide?branch=main#0eb6f34fd879b8e2b12ab19ae49aff022d0e5c18" +source = "git+https://github.com/nvisycom/elide?branch=main#d691f3b31cac7667864edb8eee5425731a8102d3" dependencies = [ "async-trait", "derive_builder", @@ -3690,7 +3719,7 @@ dependencies = [ [[package]] name = "elide-ner" version = "0.1.0" -source = "git+https://github.com/nvisycom/elide?branch=main#0eb6f34fd879b8e2b12ab19ae49aff022d0e5c18" +source = "git+https://github.com/nvisycom/elide?branch=main#d691f3b31cac7667864edb8eee5425731a8102d3" dependencies = [ "async-trait", "derive_builder", @@ -3704,7 +3733,7 @@ dependencies = [ [[package]] name = "elide-ocr" version = "0.1.0" -source = "git+https://github.com/nvisycom/elide?branch=main#0eb6f34fd879b8e2b12ab19ae49aff022d0e5c18" +source = "git+https://github.com/nvisycom/elide?branch=main#d691f3b31cac7667864edb8eee5425731a8102d3" dependencies = [ "async-trait", "derive_builder", @@ -3716,7 +3745,7 @@ dependencies = [ [[package]] name = "elide-office" version = "0.1.0" -source = "git+https://github.com/nvisycom/elide?branch=main#0eb6f34fd879b8e2b12ab19ae49aff022d0e5c18" +source = "git+https://github.com/nvisycom/elide?branch=main#d691f3b31cac7667864edb8eee5425731a8102d3" dependencies = [ "bytes", "hipstr", @@ -3728,7 +3757,7 @@ dependencies = [ [[package]] name = "elide-operator" version = "0.1.0" -source = "git+https://github.com/nvisycom/elide?branch=main#0eb6f34fd879b8e2b12ab19ae49aff022d0e5c18" +source = "git+https://github.com/nvisycom/elide?branch=main#d691f3b31cac7667864edb8eee5425731a8102d3" dependencies = [ "aes-gcm", "async-trait", @@ -3750,7 +3779,7 @@ dependencies = [ [[package]] name = "elide-pattern" version = "0.1.0" -source = "git+https://github.com/nvisycom/elide?branch=main#0eb6f34fd879b8e2b12ab19ae49aff022d0e5c18" +source = "git+https://github.com/nvisycom/elide?branch=main#d691f3b31cac7667864edb8eee5425731a8102d3" dependencies = [ "aho-corasick", "async-trait", @@ -3770,7 +3799,7 @@ dependencies = [ [[package]] name = "elide-pdf" version = "0.1.0" -source = "git+https://github.com/nvisycom/elide?branch=main#0eb6f34fd879b8e2b12ab19ae49aff022d0e5c18" +source = "git+https://github.com/nvisycom/elide?branch=main#d691f3b31cac7667864edb8eee5425731a8102d3" dependencies = [ "bytes", "hipstr", @@ -3782,7 +3811,7 @@ dependencies = [ [[package]] name = "elide-pipeline" version = "0.1.0" -source = "git+https://github.com/nvisycom/elide-runtime?branch=main#a23b0458db7a6cb0031110cee85a764605594526" +source = "git+https://github.com/nvisycom/elide-runtime?branch=main#82ea417d0b92c3799cc2dec885c922db9f78d5a7" dependencies = [ "bytes", "elide", @@ -3803,7 +3832,7 @@ dependencies = [ [[package]] name = "elide-provider" version = "0.1.0" -source = "git+https://github.com/nvisycom/elide-runtime?branch=main#a23b0458db7a6cb0031110cee85a764605594526" +source = "git+https://github.com/nvisycom/elide-runtime?branch=main#82ea417d0b92c3799cc2dec885c922db9f78d5a7" dependencies = [ "elide", "elide-bentoml", @@ -3817,7 +3846,7 @@ dependencies = [ [[package]] name = "elide-redaction" version = "0.1.0" -source = "git+https://github.com/nvisycom/elide?branch=main#0eb6f34fd879b8e2b12ab19ae49aff022d0e5c18" +source = "git+https://github.com/nvisycom/elide?branch=main#d691f3b31cac7667864edb8eee5425731a8102d3" dependencies = [ "elide-core", "hipstr", @@ -3827,7 +3856,7 @@ dependencies = [ [[package]] name = "elide-review" version = "0.1.0" -source = "git+https://github.com/nvisycom/elide-runtime?branch=main#a23b0458db7a6cb0031110cee85a764605594526" +source = "git+https://github.com/nvisycom/elide-runtime?branch=main#82ea417d0b92c3799cc2dec885c922db9f78d5a7" dependencies = [ "elide", "elide-governance", @@ -3839,7 +3868,7 @@ dependencies = [ [[package]] name = "elide-stt" version = "0.1.0" -source = "git+https://github.com/nvisycom/elide?branch=main#0eb6f34fd879b8e2b12ab19ae49aff022d0e5c18" +source = "git+https://github.com/nvisycom/elide?branch=main#d691f3b31cac7667864edb8eee5425731a8102d3" dependencies = [ "async-trait", "derive_builder", @@ -3851,7 +3880,7 @@ dependencies = [ [[package]] name = "elide-template" version = "0.1.0" -source = "git+https://github.com/nvisycom/elide-runtime?branch=main#a23b0458db7a6cb0031110cee85a764605594526" +source = "git+https://github.com/nvisycom/elide-runtime?branch=main#82ea417d0b92c3799cc2dec885c922db9f78d5a7" dependencies = [ "elide-core", "elide-governance", @@ -5388,10 +5417,12 @@ dependencies = [ "jiff-core", "jiff-static", "jiff-tzdb-platform", + "js-sys", "log", "portable-atomic", "portable-atomic-util", "serde_core", + "wasm-bindgen", "windows-link", ] @@ -6335,6 +6366,20 @@ version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" +[[package]] +name = "little_exif" +version = "0.6.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21eeb58b22d31be8dc5c625004fcd4b9b385cd3c05df575f523bcca382c51122" +dependencies = [ + "brotli", + "crc", + "log", + "miniz_oxide 0.8.9", + "paste", + "quick-xml 0.37.5", +] + [[package]] name = "lock_api" version = "0.4.14" @@ -7217,7 +7262,7 @@ version = "5.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d" dependencies = [ - "base64 0.22.1", + "base64 0.21.7", "chrono", "getrandom 0.2.17", "http 1.5.0", @@ -7995,8 +8040,8 @@ version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "03da047801ff44bb6a4d407d4860c05fd70bb81714e6b2f3812603d5b145b042" dependencies = [ - "heck 0.5.0", - "itertools 0.14.0", + "heck 0.4.1", + "itertools 0.10.5", "log", "multimap", "petgraph", @@ -8015,7 +8060,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" dependencies = [ "anyhow", - "itertools 0.14.0", + "itertools 0.10.5", "proc-macro2", "quote", "syn 2.0.119", @@ -8094,6 +8139,15 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" +[[package]] +name = "quick-xml" +version = "0.37.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" +dependencies = [ + "memchr", +] + [[package]] name = "quick-xml" version = "0.41.0" @@ -9629,7 +9683,7 @@ version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c1c97747dbf44bb1ca44a561ece23508e99cb592e862f22222dcf42f51d1e451" dependencies = [ - "heck 0.5.0", + "heck 0.4.1", "proc-macro2", "quote", "syn 2.0.119", @@ -9641,7 +9695,7 @@ version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "287f59010008f0d7cf5e3b03196d666c1acc46c8d3e9cf34c28a1a7157601e72" dependencies = [ - "heck 0.5.0", + "heck 0.4.1", "proc-macro2", "quote", "syn 2.0.119", diff --git a/crates/nvisy-postgres/src/model/mod.rs b/crates/nvisy-postgres/src/model/mod.rs index eff366de..f83677b0 100644 --- a/crates/nvisy-postgres/src/model/mod.rs +++ b/crates/nvisy-postgres/src/model/mod.rs @@ -14,6 +14,7 @@ mod pipeline_reference; mod workspace; mod workspace_activity; mod workspace_assignment; +mod workspace_comment; mod workspace_connection; mod workspace_connection_schedule; mod workspace_connection_sync; @@ -49,6 +50,7 @@ pub use workspace_activity::{NewWorkspaceActivity, WorkspaceActivity}; pub use workspace_assignment::{ NewWorkspaceAssignment, UpdateWorkspaceAssignment, WorkspaceAssignment, }; +pub use workspace_comment::{NewWorkspaceComment, UpdateWorkspaceComment, WorkspaceComment}; pub use workspace_connection::{ NewWorkspaceConnection, UpdateWorkspaceConnection, WorkspaceConnection, }; diff --git a/crates/nvisy-postgres/src/model/workspace_comment.rs b/crates/nvisy-postgres/src/model/workspace_comment.rs new file mode 100644 index 00000000..d2445653 --- /dev/null +++ b/crates/nvisy-postgres/src/model/workspace_comment.rs @@ -0,0 +1,95 @@ +//! Workspace comment model for PostgreSQL database operations. + +use diesel::prelude::*; +use jiff_diesel::Timestamp; +use serde_json::Value; +use uuid::Uuid; + +use crate::schema::workspace_comments; + +/// A comment on a file under review. +/// +/// A comment is authored by a workspace member, optionally anchored to a location +/// within the file (a modality-tagged [`anchor`](Self::anchor)), optionally a +/// one-level reply to another comment ([`parent_id`](Self::parent_id)), and can +/// be resolved to close its thread. +#[derive(Debug, Clone, PartialEq, Queryable, Selectable)] +#[diesel(table_name = workspace_comments)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct WorkspaceComment { + /// Unique comment identifier. + pub id: Uuid, + /// Workspace this comment belongs to (denormalized for fast per-workspace + /// queries). + pub workspace_id: Uuid, + /// File the comment is on. + pub file_id: Uuid, + /// Account that wrote the comment. + pub author_account_id: Uuid, + /// Parent comment for a one-level reply; `None` for a top-level comment. + pub parent_id: Option, + /// The comment text. + pub body: String, + /// Optional modality-tagged location within the file the comment is pinned to. + /// `None` for a file-level comment. Stored as the anchor's typed JSON; the + /// handler layer decodes it into the typed anchor. + pub anchor: Option, + /// When the thread was resolved; `None` while open. + pub resolved_at: Option, + /// Account that resolved the thread, for the audit trail. `None` if open (or + /// if that account was since removed). + pub resolved_by: Option, + /// When the comment was created. + pub created_at: Timestamp, + /// When the comment was last updated. + pub updated_at: Timestamp, + /// When the comment was soft-deleted; `None` means live. + pub deleted_at: Option, +} + +/// Data for creating a new workspace comment. +#[derive(Debug, Default, Clone, Insertable)] +#[diesel(table_name = workspace_comments)] +#[diesel(check_for_backend(diesel::pg::Pg))] +#[must_use] +pub struct NewWorkspaceComment { + /// Workspace ID (required). + pub workspace_id: Uuid, + /// File ID (required). + pub file_id: Uuid, + /// Author account ID (required). + pub author_account_id: Uuid, + /// Parent comment for a reply; `None` for a top-level comment. + pub parent_id: Option, + /// The comment text (required). + pub body: String, + /// Optional anchor JSON. + pub anchor: Option, +} + +impl NewWorkspaceComment { + /// A minimal top-level comment on `file_id`, for tests. + #[cfg(any(feature = "test_util", test))] + pub fn test(workspace_id: Uuid, file_id: Uuid, author_account_id: Uuid) -> Self { + Self { + workspace_id, + file_id, + author_account_id, + body: "A test comment.".to_owned(), + ..Default::default() + } + } +} + +/// Data for updating a workspace comment's body. +/// +/// Only the body is editable. Resolution and soft-delete are separate repository +/// operations (they set their own audited timestamp columns). +#[derive(Debug, Clone, Default, AsChangeset)] +#[diesel(table_name = workspace_comments)] +#[diesel(check_for_backend(diesel::pg::Pg))] +#[must_use] +pub struct UpdateWorkspaceComment { + /// The new comment text. + pub body: Option, +} diff --git a/crates/nvisy-postgres/src/query/mod.rs b/crates/nvisy-postgres/src/query/mod.rs index 75725a6e..0a62572b 100644 --- a/crates/nvisy-postgres/src/query/mod.rs +++ b/crates/nvisy-postgres/src/query/mod.rs @@ -26,6 +26,7 @@ mod search; mod workspace; mod workspace_activity; mod workspace_assignment; +mod workspace_comment; mod workspace_connection; mod workspace_connection_schedule; mod workspace_connection_sync; @@ -57,6 +58,7 @@ pub use workspace_activity::{ActivityFilter, WorkspaceActivityRepository}; pub use workspace_assignment::{ AssignmentListRow, CreateAssignmentOutcome, WorkspaceAssignmentRepository, }; +pub use workspace_comment::{ReplyParentError, WorkspaceCommentRepository}; 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_comment.rs b/crates/nvisy-postgres/src/query/workspace_comment.rs new file mode 100644 index 00000000..6f7e400d --- /dev/null +++ b/crates/nvisy-postgres/src/query/workspace_comment.rs @@ -0,0 +1,530 @@ +//! Workspace comments repository for threaded discussion on files. + +use std::future::Future; + +use diesel::dsl::now; +use diesel::prelude::*; +use diesel_async::RunQueryDsl; +use uuid::Uuid; + +use crate::model::{NewWorkspaceComment, UpdateWorkspaceComment, WorkspaceComment}; +use crate::types::{AccountRefRow, CommentFilter, CursorPage, CursorPagination, WithAccountRef}; +use crate::{Error, PgConnection, Result, schema}; + +/// The result of a [`create_comment`](WorkspaceCommentRepository::create_comment) +/// call whose parent reference is invalid. +/// +/// Returned instead of a raw FK error so the handler can map a bad reply target +/// to a clear client error. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ReplyParentError { + /// The named parent does not exist in this workspace (or is deleted). + NotFound, + /// The named parent is on a different file than the reply. + FileMismatch, + /// The named parent is itself a reply; threads are only one level deep. + NotTopLevel, +} + +/// Repository for workspace comment database operations. +/// +/// Comments are threaded one level deep (a top-level comment and its direct +/// replies) and scoped to a file. Resolution and soft-delete are their own +/// audited operations. +pub trait WorkspaceCommentRepository { + /// Creates a top-level comment. Does not validate a parent (see + /// [`create_reply`](Self::create_reply) for replies). + fn create_comment( + &mut self, + new_comment: NewWorkspaceComment, + ) -> impl Future> + Send; + + /// Creates a reply to `parent_id`, validating that the parent exists in the + /// same workspace and file and is itself top-level. Returns the offending + /// [`ReplyParentError`] otherwise. + fn create_reply( + &mut self, + new_comment: NewWorkspaceComment, + ) -> impl Future>> + Send; + + /// Finds a live comment by id within a specific workspace. + fn find_comment_in_workspace( + &mut self, + workspace_id: Uuid, + comment_id: Uuid, + ) -> impl Future>> + Send; + + /// Lists a file's live comments, oldest first (a thread reads top to bottom), + /// each paired with the author's account reference. + fn list_file_comments( + &mut self, + workspace_id: Uuid, + file_id: Uuid, + ) -> impl Future>>> + Send; + + /// Lists a workspace's live comments with cursor pagination, each paired with + /// the author's account reference. + fn cursor_list_workspace_comments( + &mut self, + workspace_id: Uuid, + pagination: CursorPagination, + filter: &CommentFilter, + ) -> impl Future>>> + Send; + + /// Updates a comment's body. + fn update_comment_body( + &mut self, + comment_id: Uuid, + updates: UpdateWorkspaceComment, + ) -> impl Future> + Send; + + /// Resolves a comment thread, recording who resolved it. A no-op timestamp + /// change if already resolved. + fn resolve_comment( + &mut self, + comment_id: Uuid, + resolved_by: Uuid, + ) -> impl Future> + Send; + + /// Reopens a resolved comment thread (clears the resolution). + fn reopen_comment( + &mut self, + comment_id: Uuid, + ) -> impl Future> + Send; + + /// Soft-deletes a comment. + fn delete_comment(&mut self, comment_id: Uuid) -> impl Future> + Send; +} + +impl WorkspaceCommentRepository for PgConnection { + async fn create_comment( + &mut self, + new_comment: NewWorkspaceComment, + ) -> Result { + use schema::workspace_comments; + + diesel::insert_into(workspace_comments::table) + .values(&new_comment) + .returning(WorkspaceComment::as_returning()) + .get_result(self) + .await + .map_err(Error::from) + } + + async fn create_reply( + &mut self, + new_comment: NewWorkspaceComment, + ) -> Result> { + let Some(parent_id) = new_comment.parent_id else { + // A reply must name a parent; a missing one is a caller contract + // violation, not a client-facing reply error. + return Err(Error::unexpected("create_reply called without a parent_id")); + }; + + let parent = self + .find_comment_in_workspace(new_comment.workspace_id, parent_id) + .await?; + let Some(parent) = parent else { + return Ok(Err(ReplyParentError::NotFound)); + }; + if parent.file_id != new_comment.file_id { + return Ok(Err(ReplyParentError::FileMismatch)); + } + if parent.parent_id.is_some() { + return Ok(Err(ReplyParentError::NotTopLevel)); + } + + let comment = self.create_comment(new_comment).await?; + Ok(Ok(comment)) + } + + async fn find_comment_in_workspace( + &mut self, + workspace_id: Uuid, + comment_id: Uuid, + ) -> Result> { + use schema::workspace_comments::{self, dsl}; + + workspace_comments::table + .filter(dsl::id.eq(comment_id)) + .filter(dsl::workspace_id.eq(workspace_id)) + .filter(dsl::deleted_at.is_null()) + .select(WorkspaceComment::as_select()) + .first(self) + .await + .optional() + .map_err(Error::from) + } + + async fn list_file_comments( + &mut self, + workspace_id: Uuid, + file_id: Uuid, + ) -> Result>> { + use schema::workspace_comments::dsl; + use schema::{accounts, workspace_comments}; + + // The author is one of two account FKs on the row (the other is + // resolved_by), so the join names the column explicitly. + let rows: Vec<(WorkspaceComment, AccountRefRow)> = workspace_comments::table + .inner_join(accounts::table.on(dsl::author_account_id.eq(accounts::id))) + .filter(dsl::workspace_id.eq(workspace_id)) + .filter(dsl::file_id.eq(file_id)) + .filter(dsl::deleted_at.is_null()) + .select(( + WorkspaceComment::as_select(), + ( + accounts::username, + accounts::display_name, + accounts::avatar_url, + ), + )) + // Oldest first: a thread reads top to bottom. + .order((dsl::created_at.asc(), dsl::id.asc())) + .load(self) + .await + .map_err(Error::from)?; + + Ok(rows + .into_iter() + .map(|(item, account)| WithAccountRef { item, account }) + .collect()) + } + + async fn cursor_list_workspace_comments( + &mut self, + workspace_id: Uuid, + pagination: CursorPagination, + filter: &CommentFilter, + ) -> Result>> { + use schema::workspace_comments::dsl; + use schema::{accounts, workspace_comments}; + + // 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 author is one of + // two account FKs, so the join names it explicitly. + let scoped = || { + let mut query = workspace_comments::table + .inner_join(accounts::table.on(dsl::author_account_id.eq(accounts::id))) + .filter(dsl::workspace_id.eq(workspace_id)) + .filter(dsl::deleted_at.is_null()) + .into_boxed(); + if let Some(file_id) = filter.file_id { + query = query.filter(dsl::file_id.eq(file_id)); + } + if let Some(author_account_id) = filter.author_account_id { + query = query.filter(dsl::author_account_id.eq(author_account_id)); + } + if let Some(resolved) = filter.resolved { + query = if resolved { + query.filter(dsl::resolved_at.is_not_null()) + } else { + query.filter(dsl::resolved_at.is_null()) + }; + } + 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 = ( + WorkspaceComment::as_select(), + ( + accounts::username, + accounts::display_name, + accounts::avatar_url, + ), + ); + + let rows: Vec<(WorkspaceComment, AccountRefRow)> = 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: Vec> = rows + .into_iter() + .map(|(item, account)| WithAccountRef { item, account }) + .collect(); + + Ok(CursorPage::new(items, total, pagination.limit, |row| { + (row.item.created_at.into(), row.item.id) + })) + } + + async fn update_comment_body( + &mut self, + comment_id: Uuid, + updates: UpdateWorkspaceComment, + ) -> Result { + use schema::workspace_comments::{self, dsl}; + + // Scope to a live row so an edit cannot revive a soft-deleted comment. + diesel::update( + workspace_comments::table + .filter(dsl::id.eq(comment_id)) + .filter(dsl::deleted_at.is_null()), + ) + .set(&updates) + .returning(WorkspaceComment::as_returning()) + .get_result(self) + .await + .map_err(Error::from) + } + + async fn resolve_comment( + &mut self, + comment_id: Uuid, + resolved_by: Uuid, + ) -> Result { + use schema::workspace_comments::{self, dsl}; + + diesel::update( + workspace_comments::table + .filter(dsl::id.eq(comment_id)) + .filter(dsl::deleted_at.is_null()), + ) + .set((dsl::resolved_at.eq(now), dsl::resolved_by.eq(resolved_by))) + .returning(WorkspaceComment::as_returning()) + .get_result(self) + .await + .map_err(Error::from) + } + + async fn reopen_comment(&mut self, comment_id: Uuid) -> Result { + use schema::workspace_comments::{self, dsl}; + + diesel::update( + workspace_comments::table + .filter(dsl::id.eq(comment_id)) + .filter(dsl::deleted_at.is_null()), + ) + .set(( + dsl::resolved_at.eq(None::), + dsl::resolved_by.eq(None::), + )) + .returning(WorkspaceComment::as_returning()) + .get_result(self) + .await + .map_err(Error::from) + } + + async fn delete_comment(&mut self, comment_id: Uuid) -> Result<()> { + use schema::workspace_comments::{self, dsl}; + + // Scope to a live row so a concurrent delete is not overwritten with a + // fresh `deleted_at`. + diesel::update( + workspace_comments::table + .filter(dsl::id.eq(comment_id)) + .filter(dsl::deleted_at.is_null()), + ) + .set(dsl::deleted_at.eq(now)) + .execute(self) + .await + .map_err(Error::from)?; + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::{ReplyParentError, WorkspaceCommentRepository}; + use crate::model::{NewAccount, NewWorkspaceComment, UpdateWorkspaceComment}; + use crate::query::AccountRepository; + use crate::test_util::TestDatabase; + use crate::types::{CommentFilter, CursorPagination}; + + #[tokio::test] + async fn create_list_and_soft_delete() -> anyhow::Result<()> { + let db = TestDatabase::start().await; + let (author, workspace_id, _pipeline_id, file_id) = db.seed_pipeline_and_file().await; + let mut conn = db.client.get_connection().await?; + + let comment = conn + .create_comment(NewWorkspaceComment::test(workspace_id, file_id, author)) + .await?; + assert!(comment.parent_id.is_none()); + assert!(comment.resolved_at.is_none()); + + let rows = conn.list_file_comments(workspace_id, file_id).await?; + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].item.id, comment.id); + + // Soft-delete drops it from the listing. + conn.delete_comment(comment.id).await?; + assert!( + conn.find_comment_in_workspace(workspace_id, comment.id) + .await? + .is_none() + ); + assert!( + conn.list_file_comments(workspace_id, file_id) + .await? + .is_empty() + ); + Ok(()) + } + + #[tokio::test] + async fn replies_are_validated_one_level() -> anyhow::Result<()> { + let db = TestDatabase::start().await; + let (author, workspace_id, _pipeline_id, file_id) = db.seed_pipeline_and_file().await; + let mut conn = db.client.get_connection().await?; + + let top = conn + .create_comment(NewWorkspaceComment::test(workspace_id, file_id, author)) + .await?; + + // A valid reply to a top-level comment. + let reply = conn + .create_reply(NewWorkspaceComment { + parent_id: Some(top.id), + ..NewWorkspaceComment::test(workspace_id, file_id, author) + }) + .await? + .expect("reply should be accepted"); + assert_eq!(reply.parent_id, Some(top.id)); + + // A reply to a reply is rejected: threads are one level deep. + let nested = conn + .create_reply(NewWorkspaceComment { + parent_id: Some(reply.id), + ..NewWorkspaceComment::test(workspace_id, file_id, author) + }) + .await?; + assert_eq!(nested, Err(ReplyParentError::NotTopLevel)); + + // A reply naming an unknown parent is rejected. + let orphan = conn + .create_reply(NewWorkspaceComment { + parent_id: Some(uuid::Uuid::now_v7()), + ..NewWorkspaceComment::test(workspace_id, file_id, author) + }) + .await?; + assert_eq!(orphan, Err(ReplyParentError::NotFound)); + Ok(()) + } + + #[tokio::test] + async fn resolve_reopen_and_body_edit() -> anyhow::Result<()> { + let db = TestDatabase::start().await; + let (author, workspace_id, _pipeline_id, file_id) = db.seed_pipeline_and_file().await; + let mut conn = db.client.get_connection().await?; + + let comment = conn + .create_comment(NewWorkspaceComment::test(workspace_id, file_id, author)) + .await?; + + let resolved = conn.resolve_comment(comment.id, author).await?; + assert!(resolved.resolved_at.is_some()); + assert_eq!(resolved.resolved_by, Some(author)); + + let reopened = conn.reopen_comment(comment.id).await?; + assert!(reopened.resolved_at.is_none()); + assert!(reopened.resolved_by.is_none()); + + let edited = conn + .update_comment_body( + comment.id, + UpdateWorkspaceComment { + body: Some("Edited body.".to_owned()), + }, + ) + .await?; + assert_eq!(edited.body, "Edited body."); + Ok(()) + } + + #[tokio::test] + async fn cursor_list_filters_by_author_and_resolved() -> anyhow::Result<()> { + let db = TestDatabase::start().await; + let (alice, workspace_id, _pipeline_id, file_id) = db.seed_pipeline_and_file().await; + let bob = conn_seed_account(&db).await; + let mut conn = db.client.get_connection().await?; + + let a = conn + .create_comment(NewWorkspaceComment::test(workspace_id, file_id, alice)) + .await?; + let _b = conn + .create_comment(NewWorkspaceComment::test(workspace_id, file_id, bob)) + .await?; + conn.resolve_comment(a.id, alice).await?; + + let all = conn + .cursor_list_workspace_comments( + workspace_id, + CursorPagination::new(50), + &CommentFilter::default(), + ) + .await?; + assert_eq!(all.items.len(), 2); + + let just_alice = conn + .cursor_list_workspace_comments( + workspace_id, + CursorPagination::new(50), + &CommentFilter { + author_account_id: Some(alice), + ..Default::default() + }, + ) + .await?; + assert_eq!(just_alice.items.len(), 1); + + let resolved_only = conn + .cursor_list_workspace_comments( + workspace_id, + CursorPagination::new(50), + &CommentFilter { + resolved: Some(true), + ..Default::default() + }, + ) + .await?; + assert_eq!(resolved_only.items.len(), 1); + assert_eq!(resolved_only.items[0].item.id, a.id); + Ok(()) + } + + /// Seeds an extra account for the multi-author test. + async fn conn_seed_account(db: &TestDatabase) -> uuid::Uuid { + let mut conn = db.client.get_connection().await.expect("connection"); + conn.create_account(NewAccount::test()) + .await + .expect("seed account") + .id + } +} diff --git a/crates/nvisy-postgres/src/query/workspace_member.rs b/crates/nvisy-postgres/src/query/workspace_member.rs index 4478e20c..514d815f 100644 --- a/crates/nvisy-postgres/src/query/workspace_member.rs +++ b/crates/nvisy-postgres/src/query/workspace_member.rs @@ -10,8 +10,8 @@ use crate::model::{ Account, NewWorkspaceMember, UpdateWorkspaceMember, Workspace, WorkspaceMember, }; use crate::types::{ - AccountRefRow, CursorPage, CursorPagination, MemberFilter, NotificationEvent, OffsetPagination, - WorkspaceRole, + AccountRefRow, CursorPage, CursorPagination, Handle, MemberFilter, NotificationEvent, + OffsetPagination, WorkspaceRole, }; use crate::{Error, PgConnection, Result, schema}; @@ -33,6 +33,15 @@ pub trait WorkspaceMemberRepository { member_account_id: Uuid, ) -> impl Future>> + Send; + /// Resolves a set of usernames to the account ids of those that are members + /// of the workspace, in one query. Non-members and unknown usernames are + /// omitted; the result is deduplicated by account. + fn find_member_ids_by_usernames( + &mut self, + workspace_id: Uuid, + usernames: &[Handle], + ) -> impl Future>> + Send; + /// Updates a workspace member with partial changes. fn update_workspace_member( &mut self, @@ -150,6 +159,33 @@ impl WorkspaceMemberRepository for PgConnection { Ok(member) } + async fn find_member_ids_by_usernames( + &mut self, + workspace_id: Uuid, + usernames: &[Handle], + ) -> Result> { + use schema::workspace_members::dsl as members; + use schema::{accounts, workspace_members}; + + if usernames.is_empty() { + return Ok(Vec::new()); + } + + // Join members to their account and keep those whose username is in the + // set — one round-trip instead of a lookup per handle. + let ids: Vec = workspace_members::table + .inner_join(accounts::table.on(members::account_id.eq(accounts::id))) + .filter(members::workspace_id.eq(workspace_id)) + .filter(accounts::username.eq_any(usernames)) + .filter(accounts::deleted_at.is_null()) + .select(members::account_id) + .load(self) + .await + .map_err(Error::from)?; + + Ok(ids) + } + async fn update_workspace_member( &mut self, workspace_id: Uuid, diff --git a/crates/nvisy-postgres/src/schema.rs b/crates/nvisy-postgres/src/schema.rs index 37a26a46..011af3dd 100644 --- a/crates/nvisy-postgres/src/schema.rs +++ b/crates/nvisy-postgres/src/schema.rs @@ -236,6 +236,25 @@ diesel::table! { } } +diesel::table! { + use diesel::sql_types::*; + + workspace_comments (id) { + id -> Uuid, + workspace_id -> Uuid, + file_id -> Uuid, + author_account_id -> Uuid, + parent_id -> Nullable, + body -> Text, + anchor -> Nullable, + resolved_at -> Nullable, + resolved_by -> Nullable, + created_at -> Timestamptz, + updated_at -> Timestamptz, + deleted_at -> Nullable, + } +} + diesel::table! { use diesel::sql_types::*; use super::sql_types::SyncMode; @@ -565,6 +584,7 @@ diesel::joinable!(event_outbox -> workspaces (workspace_id)); diesel::joinable!(workspace_activities -> accounts (account_id)); diesel::joinable!(workspace_activities -> workspaces (workspace_id)); diesel::joinable!(workspace_assignments -> workspaces (workspace_id)); +diesel::joinable!(workspace_comments -> 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)); @@ -605,6 +625,7 @@ diesel::allow_tables_to_appear_in_same_query!( event_outbox, workspace_activities, workspace_assignments, + workspace_comments, workspace_connection_schedule, workspace_connection_syncs, workspace_connections, diff --git a/crates/nvisy-postgres/src/types/constraint/comments.rs b/crates/nvisy-postgres/src/types/constraint/comments.rs new file mode 100644 index 00000000..4fcfb0c1 --- /dev/null +++ b/crates/nvisy-postgres/src/types/constraint/comments.rs @@ -0,0 +1,14 @@ +//! Workspace comments table constraint violations. + +use strum::EnumString; + +/// Workspace comments table constraint violations. +#[derive(Debug, Clone, Copy, Eq, PartialEq, EnumString)] +pub enum WorkspaceCommentConstraints { + /// The body is empty once trimmed, or longer than the maximum. + #[strum(serialize = "workspace_comments_body_length")] + BodyLength, + /// The anchor JSON exceeds the maximum stored size. + #[strum(serialize = "workspace_comments_anchor_size")] + AnchorSize, +} diff --git a/crates/nvisy-postgres/src/types/constraint/mod.rs b/crates/nvisy-postgres/src/types/constraint/mod.rs index b5e66def..cf82b93e 100644 --- a/crates/nvisy-postgres/src/types/constraint/mod.rs +++ b/crates/nvisy-postgres/src/types/constraint/mod.rs @@ -30,6 +30,7 @@ mod pipelines; // Assignment-related constraint modules mod assignments; +mod comments; mod workspace_connection_syncs; mod workspace_connections; @@ -42,6 +43,7 @@ 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::comments::WorkspaceCommentConstraints; pub use self::detections::WorkspaceDetectionConstraints; pub use self::files::WorkspaceFileConstraints; pub use self::pipeline_references::WorkspacePipelineReferenceConstraints; @@ -85,6 +87,9 @@ pub enum ConstraintViolation { // Assignment-related constraints WorkspaceAssignment(WorkspaceAssignmentConstraints), + // Comment-related constraints + WorkspaceComment(WorkspaceCommentConstraints), + // Detection / pipeline-related constraints WorkspacePipeline(WorkspacePipelineConstraints), WorkspaceDetection(WorkspaceDetectionConstraints), @@ -143,6 +148,7 @@ impl ConstraintViolation { WorkspaceWebhook, WorkspaceFile, WorkspaceAssignment, + WorkspaceComment, 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 93dd81e7..977015d2 100644 --- a/crates/nvisy-postgres/src/types/enums/activity_type.rs +++ b/crates/nvisy-postgres/src/types/enums/activity_type.rs @@ -84,6 +84,12 @@ db_enum! { PolicyUpdated = "policy.updated", /// Policy was deleted. PolicyDeleted = "policy.deleted", + /// A comment was created. + CommentCreated = "comment.created", + /// A comment thread was resolved. + CommentResolved = "comment.resolved", + /// A comment was deleted. + CommentDeleted = "comment.deleted", } } diff --git a/crates/nvisy-postgres/src/types/enums/notification_event.rs b/crates/nvisy-postgres/src/types/enums/notification_event.rs index d41fc9bf..bb338c71 100644 --- a/crates/nvisy-postgres/src/types/enums/notification_event.rs +++ b/crates/nvisy-postgres/src/types/enums/notification_event.rs @@ -26,5 +26,7 @@ db_enum! { FileAssigned = "file.assigned", /// The reviewer was unassigned from a file. FileUnassigned = "file.unassigned", + /// An account was mentioned in a comment. + CommentMentioned = "comment.mentioned", } } diff --git a/crates/nvisy-postgres/src/types/enums/webhook_event.rs b/crates/nvisy-postgres/src/types/enums/webhook_event.rs index 8a44361b..e64eb099 100644 --- a/crates/nvisy-postgres/src/types/enums/webhook_event.rs +++ b/crates/nvisy-postgres/src/types/enums/webhook_event.rs @@ -64,6 +64,10 @@ db_enum! { PolicyUpdated = "policy.updated", /// A policy was deleted. PolicyDeleted = "policy.deleted", + /// A comment was created. + CommentCreated = "comment.created", + /// A comment thread was resolved. + CommentResolved = "comment.resolved", } } @@ -99,6 +103,7 @@ impl WebhookEvent { WebhookEvent::PolicyCreated | WebhookEvent::PolicyUpdated | WebhookEvent::PolicyDeleted => "policy", + WebhookEvent::CommentCreated | WebhookEvent::CommentResolved => "comment", } } diff --git a/crates/nvisy-postgres/src/types/filtering/comments.rs b/crates/nvisy-postgres/src/types/filtering/comments.rs new file mode 100644 index 00000000..a18a619a --- /dev/null +++ b/crates/nvisy-postgres/src/types/filtering/comments.rs @@ -0,0 +1,24 @@ +//! Filtering options for comment queries. + +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +/// Filter options for workspace comments. +/// +/// 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 CommentFilter { + /// Filter by the file the comment is on. + #[serde(skip_serializing_if = "Option::is_none")] + pub file_id: Option, + /// Filter by the comment's author. + #[serde(skip_serializing_if = "Option::is_none")] + pub author_account_id: Option, + /// Filter by resolution state: `Some(true)` = resolved only, `Some(false)` = + /// open only, `None` = either. + #[serde(skip_serializing_if = "Option::is_none")] + pub resolved: Option, +} diff --git a/crates/nvisy-postgres/src/types/filtering/mod.rs b/crates/nvisy-postgres/src/types/filtering/mod.rs index 7df9322e..9828cfeb 100644 --- a/crates/nvisy-postgres/src/types/filtering/mod.rs +++ b/crates/nvisy-postgres/src/types/filtering/mod.rs @@ -1,12 +1,14 @@ //! Filtering options for database queries. mod assignments; +mod comments; mod detections; mod files; mod invites; mod members; pub use assignments::AssignmentFilter; +pub use comments::CommentFilter; 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 532d6e36..72a1e94a 100644 --- a/crates/nvisy-postgres/src/types/json/activity_params.rs +++ b/crates/nvisy-postgres/src/types/json/activity_params.rs @@ -149,6 +149,17 @@ pub struct PolicyActivityParams { pub policy_slug: Handle, } +/// Params of a comment activity (`comment.*`). +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase")] +pub struct CommentActivityParams { + /// Id of the comment. + pub comment_id: Uuid, + /// Id of the file the comment is on. + pub file_id: Uuid, +} + /// The typed payload of an audit-log activity, tagged by `type` with its params /// under `data` (the same `{type, data}` envelope the notification payload and /// outbox event use). @@ -282,6 +293,16 @@ pub enum ActivityPayload { /// A policy was deleted. #[serde(rename = "policy.deleted")] PolicyDeleted(PolicyActivityParams), + + /// A comment was created. + #[serde(rename = "comment.created")] + CommentCreated(CommentActivityParams), + /// A comment thread was resolved. + #[serde(rename = "comment.resolved")] + CommentResolved(CommentActivityParams), + /// A comment was deleted. + #[serde(rename = "comment.deleted")] + CommentDeleted(CommentActivityParams), } impl ActivityPayload { @@ -327,6 +348,9 @@ impl ActivityPayload { ActivityPayload::PolicyCreated(_) => ActivityType::PolicyCreated, ActivityPayload::PolicyUpdated(_) => ActivityType::PolicyUpdated, ActivityPayload::PolicyDeleted(_) => ActivityType::PolicyDeleted, + ActivityPayload::CommentCreated(_) => ActivityType::CommentCreated, + ActivityPayload::CommentResolved(_) => ActivityType::CommentResolved, + ActivityPayload::CommentDeleted(_) => ActivityType::CommentDeleted, } } @@ -345,7 +369,8 @@ impl ActivityPayload { | ActivityPayload::InviteCanceled(_) | ActivityPayload::WebhookCreated(_) | ActivityPayload::WebhookUpdated(_) - | ActivityPayload::WebhookDeleted(_) => return None, + | ActivityPayload::WebhookDeleted(_) + | ActivityPayload::CommentDeleted(_) => return None, ActivityPayload::MemberAdded(_) => W::MemberAdded, ActivityPayload::MemberUpdated(_) => W::MemberUpdated, @@ -375,6 +400,8 @@ impl ActivityPayload { ActivityPayload::PolicyCreated(_) => W::PolicyCreated, ActivityPayload::PolicyUpdated(_) => W::PolicyUpdated, ActivityPayload::PolicyDeleted(_) => W::PolicyDeleted, + ActivityPayload::CommentCreated(_) => W::CommentCreated, + ActivityPayload::CommentResolved(_) => W::CommentResolved, }) } @@ -425,6 +452,10 @@ impl ActivityPayload { | ActivityPayload::PolicyUpdated(p) | ActivityPayload::PolicyDeleted(p) => Some(p.policy_id.to_string()), + ActivityPayload::CommentCreated(p) + | ActivityPayload::CommentResolved(p) + | ActivityPayload::CommentDeleted(p) => Some(p.comment_id.to_string()), + ActivityPayload::WorkspaceCreated(_) | ActivityPayload::WorkspaceUpdated(_) | ActivityPayload::WorkspaceDeleted(_) @@ -491,6 +522,11 @@ impl ActivityPayload { ActivityPayload::PolicyCreated(p) | ActivityPayload::PolicyUpdated(p) | ActivityPayload::PolicyDeleted(p) => Some(p.policy_slug.to_string()), + + // A comment has no human-readable name; it is addressed by id only. + ActivityPayload::CommentCreated(_) + | ActivityPayload::CommentResolved(_) + | ActivityPayload::CommentDeleted(_) => None, } } } diff --git a/crates/nvisy-postgres/src/types/json/mod.rs b/crates/nvisy-postgres/src/types/json/mod.rs index 0a9da15f..e01caff0 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, AssignmentActivityParams, ConnectionActivityParams, DetectionActivityParams, - FileActivityParams, InviteActivityParams, MemberActivityParams, PipelineActivityParams, - PolicyActivityParams, ProviderActivityParams, RedactionActivityParams, WebhookActivityParams, - WorkspaceActivityParams, + ActivityPayload, AssignmentActivityParams, CommentActivityParams, ConnectionActivityParams, + DetectionActivityParams, FileActivityParams, InviteActivityParams, MemberActivityParams, + PipelineActivityParams, PolicyActivityParams, ProviderActivityParams, RedactionActivityParams, + WebhookActivityParams, WorkspaceActivityParams, }; pub use detection_metadata::DetectionMetadata; pub use notification_params::{ - ConnectionSyncCompletedParams, ConnectionSyncFailedParams, DetectionCompletedParams, - DetectionFailedParams, FileAssignedParams, FileUnassignedParams, MemberJoinedParams, - NotificationPayload, RedactionCreatedParams, + CommentMentionedParams, ConnectionSyncCompletedParams, ConnectionSyncFailedParams, + DetectionCompletedParams, 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 a612fee8..58a8d43e 100644 --- a/crates/nvisy-postgres/src/types/json/notification_params.rs +++ b/crates/nvisy-postgres/src/types/json/notification_params.rs @@ -123,6 +123,19 @@ pub struct FileUnassignedParams { pub file_name: Option, } +/// Params of a `comment.mentioned` notification, sent to a mentioned account. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase")] +pub struct CommentMentionedParams { + /// Id of the comment the account was mentioned in. + pub comment_id: Uuid, + /// Id of the file the comment is on. + pub file_id: Uuid, + /// Username of the account that wrote the comment (the mentioner). + pub author_username: Handle, +} + /// 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). /// @@ -164,6 +177,10 @@ pub enum NotificationPayload { /// The reviewer was unassigned from a file. #[serde(rename = "file.unassigned")] FileUnassigned(FileUnassignedParams), + + /// The account was mentioned in a comment. + #[serde(rename = "comment.mentioned")] + CommentMentioned(CommentMentionedParams), } impl NotificationPayload { @@ -180,6 +197,7 @@ impl NotificationPayload { NotificationPayload::DetectionFailed(_) => NotificationEvent::DetectionFailed, NotificationPayload::FileAssigned(_) => NotificationEvent::FileAssigned, NotificationPayload::FileUnassigned(_) => NotificationEvent::FileUnassigned, + NotificationPayload::CommentMentioned(_) => NotificationEvent::CommentMentioned, } } diff --git a/crates/nvisy-postgres/src/types/mod.rs b/crates/nvisy-postgres/src/types/mod.rs index d71c878f..dc3e3836 100644 --- a/crates/nvisy-postgres/src/types/mod.rs +++ b/crates/nvisy-postgres/src/types/mod.rs @@ -14,10 +14,11 @@ pub use constraint::{ AccountApiTokenConstraints, AccountConstraints, AccountIdentityConstraints, AccountNotificationConstraints, ChatMessageConstraints, ChatSessionConstraints, ConstraintViolation, WorkspaceActivitiesConstraints, WorkspaceAssignmentConstraints, - WorkspaceConnectionConstraints, WorkspaceConnectionSyncConstraints, WorkspaceConstraints, - WorkspaceDetectionConstraints, WorkspaceFileConstraints, WorkspaceInviteConstraints, - WorkspaceMemberConstraints, WorkspacePipelineConstraints, - WorkspacePipelineReferenceConstraints, WorkspacePolicyConstraints, WorkspaceWebhookConstraints, + WorkspaceCommentConstraints, WorkspaceConnectionConstraints, + WorkspaceConnectionSyncConstraints, WorkspaceConstraints, WorkspaceDetectionConstraints, + WorkspaceFileConstraints, WorkspaceInviteConstraints, WorkspaceMemberConstraints, + WorkspacePipelineConstraints, WorkspacePipelineReferenceConstraints, + WorkspacePolicyConstraints, WorkspaceWebhookConstraints, }; pub use enums::{ ActivityType, ApiTokenType, AssignmentStatus, ChatRole, ConnectionType, DetectionStatus, @@ -25,18 +26,20 @@ pub use enums::{ PipelineTriggerType, ProviderType, SyncDeletionPolicy, SyncMode, SyncStatus, SyncTriggerType, WebhookEvent, WebhookStatus, WorkspaceRole, }; -pub use filtering::{AssignmentFilter, DetectionFilter, FileFilter, InviteFilter, MemberFilter}; +pub use filtering::{ + AssignmentFilter, CommentFilter, DetectionFilter, FileFilter, InviteFilter, MemberFilter, +}; pub use handle::{HANDLE_MAX_LENGTH, HANDLE_MIN_LENGTH, Handle, HandleError}; pub use json::{ - ActivityPayload, AssignmentActivityParams, ConnectionActivityParams, - ConnectionSyncCompletedParams, ConnectionSyncFailedParams, DetectionActivityParams, - DetectionCompletedParams, DetectionFailedParams, DetectionMetadata, FileActivityParams, - FileAssignedParams, FileUnassignedParams, InvalidHeader, InviteActivityParams, Json, - MemberActivityParams, MemberJoinedParams, NotificationPayload, PipelineActivityParams, - PipelineMetadata, PolicyActivityParams, ProviderActivityParams, RasterPolicy, - RedactionActivityParams, RedactionCreatedParams, Retention, RetentionOverride, RetentionScope, - RetentionSettings, WebhookActivityParams, WebhookHeaders, WorkspaceActivityParams, - WorkspaceMetadata, WorkspaceSettings, + ActivityPayload, AssignmentActivityParams, CommentActivityParams, CommentMentionedParams, + ConnectionActivityParams, ConnectionSyncCompletedParams, ConnectionSyncFailedParams, + DetectionActivityParams, DetectionCompletedParams, DetectionFailedParams, DetectionMetadata, + FileActivityParams, FileAssignedParams, FileUnassignedParams, InvalidHeader, + InviteActivityParams, Json, MemberActivityParams, MemberJoinedParams, NotificationPayload, + PipelineActivityParams, PipelineMetadata, PolicyActivityParams, ProviderActivityParams, + RasterPolicy, RedactionActivityParams, RedactionCreatedParams, Retention, RetentionOverride, + RetentionScope, RetentionSettings, WebhookActivityParams, WebhookHeaders, + WorkspaceActivityParams, WorkspaceMetadata, WorkspaceSettings, }; pub use pagination::{Cursor, CursorPage, CursorPagination, OffsetPage, OffsetPagination}; pub use prefixed_id::{ diff --git a/crates/nvisy-server/src/extract/auth/authorized.rs b/crates/nvisy-server/src/extract/auth/authorized.rs index 60a7816c..54adc71a 100644 --- a/crates/nvisy-server/src/extract/auth/authorized.rs +++ b/crates/nvisy-server/src/extract/auth/authorized.rs @@ -154,6 +154,9 @@ authz_permissions! { RunRedactions => Permission::RunRedactions, ViewAssignments => Permission::ViewAssignments, AssignTasks => Permission::AssignTasks, + ViewComments => Permission::ViewComments, + Comment => Permission::Comment, + ResolveComments => Permission::ResolveComments, ViewAnalytics => Permission::ViewAnalytics, ViewActivity => Permission::ViewActivity, UseChat => Permission::UseChat, diff --git a/crates/nvisy-server/src/extract/auth/permission.rs b/crates/nvisy-server/src/extract/auth/permission.rs index 4d8889d6..ae73b569 100644 --- a/crates/nvisy-server/src/extract/auth/permission.rs +++ b/crates/nvisy-server/src/extract/auth/permission.rs @@ -58,6 +58,14 @@ pub enum Permission { /// Can assign files to reviewers and unassign them. AssignTasks, + // Comment permissions + /// Can view comments on files. + ViewComments, + /// Can write comments and replies (and edit or delete one's own). + Comment, + /// Can resolve and reopen comment threads. + ResolveComments, + // Reporting permissions /// Can view workspace analytics. ViewAnalytics, @@ -133,6 +141,9 @@ impl Permission { | Self::ViewPipelines | Self::ViewDetections | Self::ViewAssignments + | Self::ViewComments + | Self::Comment + | Self::ResolveComments | Self::ViewAnalytics | Self::ViewActivity | Self::ViewMembers diff --git a/crates/nvisy-server/src/handler/comments.rs b/crates/nvisy-server/src/handler/comments.rs new file mode 100644 index 00000000..14e7d748 --- /dev/null +++ b/crates/nvisy-server/src/handler/comments.rs @@ -0,0 +1,640 @@ +//! Comment handlers: threaded discussion on a file, with @-mentions and resolve. +//! +//! A comment is authored by a workspace member on a file, optionally a one-level +//! reply. `@username` mentions notify those workspace members. Viewing, writing, +//! and resolving all require the corresponding Reviewer-tier permission; editing +//! and deleting a comment are restricted to its author. + +use std::collections::BTreeSet; + +use aide::axum::ApiRouter; +use aide::transform::TransformOperation; +use axum::extract::State; +use axum::http::StatusCode; +use nvisy_postgres::model::{NewWorkspaceComment, UpdateWorkspaceComment, WorkspaceComment}; +use nvisy_postgres::query::{ + ReplyParentError, WorkspaceCommentRepository, WorkspaceFileRepository, + WorkspaceMemberRepository, +}; +use nvisy_postgres::types::Handle; +use nvisy_postgres::{AsyncConnection, PgClient, PgConn}; +use uuid::Uuid; + +use crate::extract::{Authorized, Json, Path, Query, SecurityContext, ValidateJson, markers}; +use crate::handler::request::{ + CommentPathParams, CreateComment, CursorPagination, UpdateComment, WorkspaceCommentsQuery, + WorkspaceFilePathParams, +}; +use crate::handler::response::{Comment, CommentsPage}; +use crate::handler::utility::resolve_account_ref; +use crate::response::{Error, ErrorKind, ErrorResponse, Result}; +use crate::service::{ + CommentCreated, CommentDeleted, CommentResolved, EventEmitter, EventOrigin, ServiceState, + WorkspaceEvent, +}; + +/// Tracing target for comment operations. +const TRACING_TARGET: &str = "nvisy_server::handler::comments"; + +/// Posts a comment on a file, or a reply to another comment. +/// +/// A reply names its `parentId` (one level only). `@username` mentions in the +/// body notify those workspace members. Requires `Comment`. +#[tracing::instrument( + skip_all, + fields( + account_id = %authz.account_id, + workspace_id = %authz.workspace.id, + file_id = %path_params.file_id, + ) +)] +async fn create_comment( + State(pg_client): State, + authz: Authorized, + Path(path_params): Path, + security: SecurityContext, + ValidateJson(request): ValidateJson, +) -> Result<(StatusCode, Json)> { + tracing::debug!(target: TRACING_TARGET, "Posting comment"); + + let workspace = authz.workspace; + let mut conn = pg_client.get_connection().await?; + + // The file must exist in the workspace. + conn.find_file_in_workspace(workspace.id, path_params.file_id) + .await? + .ok_or_else(|| Error::not_found("file"))?; + + // Resolve @-mentions to workspace-member account ids, excluding the author + // (no self-notification) and de-duplicated. A mentioned handle that is not a + // workspace member is ignored rather than rejected. + let mentioned = + resolve_mentions(&mut conn, workspace.id, &request.body, authz.account_id).await?; + + // Store the typed anchor as its JSON; the DB column is modality-agnostic. + let anchor = request + .anchor + .map(serde_json::to_value) + .transpose() + .map_err(|err| { + ErrorKind::InternalServerError + .with_message("Failed to encode comment anchor") + .with_context(err.to_string()) + })?; + + let new_comment = NewWorkspaceComment { + workspace_id: workspace.id, + file_id: path_params.file_id, + author_account_id: authz.account_id, + parent_id: request.parent_id, + body: request.body, + anchor, + }; + + let author_username = resolve_account_ref(&mut conn, authz.account_id) + .await? + .username; + + // Create the comment and record its event in one transaction so the row and + // its event commit or roll back together. + let comment = conn + .transaction(async |conn| { + let comment = if new_comment.parent_id.is_some() { + match conn.create_reply(new_comment).await? { + Ok(comment) => comment, + Err(err) => return Ok(Err(err)), + } + } else { + conn.create_comment(new_comment).await? + }; + + emit_comment_event( + conn, + workspace_origin(workspace.id, authz.account_id, &security), + WorkspaceEvent::CommentCreated(CommentCreated { + comment_id: comment.id, + file_id: comment.file_id, + author_username: author_username.clone(), + mentioned, + }), + ) + .await?; + Ok::<_, Error>(Ok(comment)) + }) + .await? + .map_err(reply_parent_error)?; + + let author = resolve_account_ref(&mut conn, comment.author_account_id).await?; + + tracing::info!(target: TRACING_TARGET, comment_id = %comment.id, "Comment posted"); + + Ok(( + StatusCode::CREATED, + Json(Comment::from_model(comment, author)), + )) +} + +fn create_comment_docs(op: TransformOperation) -> TransformOperation { + op.summary("Post a comment") + .description( + "Posts a comment on a file, or a reply to another comment (one level). \ + @username mentions notify those members. Requires the Comment permission.", + ) + .response::<201, Json>() + .response::<400, Json>() + .response::<401, Json>() + .response::<403, Json>() + .response::<404, Json>() +} + +/// Lists a file's comments, oldest first (a thread reads top to bottom). +/// +/// Requires `ViewComments`. +#[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_comments( + State(pg_client): State, + authz: Authorized, + Path(path_params): Path, +) -> Result<(StatusCode, Json>)> { + tracing::debug!(target: TRACING_TARGET, "Listing file comments"); + + let workspace = authz.workspace; + let mut conn = pg_client.get_connection().await?; + + conn.find_file_in_workspace(workspace.id, path_params.file_id) + .await? + .ok_or_else(|| Error::not_found("file"))?; + + let rows = conn + .list_file_comments(workspace.id, path_params.file_id) + .await?; + + let comments = rows + .into_iter() + .map(|row| Comment::from_model(row.item, row.account.into())) + .collect(); + + Ok((StatusCode::OK, Json(comments))) +} + +fn list_file_comments_docs(op: TransformOperation) -> TransformOperation { + op.summary("List a file's comments") + .description("Returns the comments on a file, oldest first.") + .response::<200, Json>>() + .response::<401, Json>() + .response::<403, Json>() + .response::<404, Json>() +} + +/// Lists a workspace's comments with cursor pagination. +/// +/// Filter by `fileId`, `author`, and `resolved`. Requires `ViewComments`. +#[tracing::instrument( + skip_all, + fields( + account_id = %authz.account_id, + workspace_id = %authz.workspace.id, + ) +)] +async fn list_workspace_comments( + State(pg_client): State, + authz: Authorized, + Query(pagination): Query, + Query(query): Query, +) -> Result<(StatusCode, Json)> { + tracing::debug!(target: TRACING_TARGET, "Listing workspace comments"); + + let workspace = authz.workspace; + let mut conn = pg_client.get_connection().await?; + + let page = conn + .cursor_list_workspace_comments(workspace.id, pagination.into(), &query.into()) + .await?; + + let response = CommentsPage::from_cursor_page(page, |row| { + Comment::from_model(row.item, row.account.into()) + }); + + Ok((StatusCode::OK, Json(response))) +} + +fn list_workspace_comments_docs(op: TransformOperation) -> TransformOperation { + op.summary("List workspace comments") + .description( + "Returns the workspace's comments, most recent first, with optional \ + file, author, and resolved filters.", + ) + .response::<200, Json>() + .response::<401, Json>() + .response::<403, Json>() +} + +/// Edits a comment's body. Restricted to the comment's author. +#[tracing::instrument( + skip_all, + fields( + account_id = %authz.account_id, + workspace_id = %authz.workspace.id, + comment_id = %path_params.comment_id, + ) +)] +async fn update_comment( + State(pg_client): State, + authz: Authorized, + Path(path_params): Path, + ValidateJson(request): ValidateJson, +) -> Result<(StatusCode, Json)> { + tracing::debug!(target: TRACING_TARGET, "Editing comment"); + + let workspace = authz.workspace; + let mut conn = pg_client.get_connection().await?; + + let comment = find_comment(&mut conn, workspace.id, path_params.comment_id).await?; + + // Only the author may edit their own comment. + if comment.author_account_id != authz.account_id { + return Err(ErrorKind::Forbidden + .with_message("Only the author can edit this comment") + .with_resource("workspace_comment")); + } + + let updated = conn + .update_comment_body( + comment.id, + UpdateWorkspaceComment { + body: Some(request.body), + }, + ) + .await?; + + let author = resolve_account_ref(&mut conn, updated.author_account_id).await?; + + tracing::info!(target: TRACING_TARGET, "Comment edited"); + + Ok((StatusCode::OK, Json(Comment::from_model(updated, author)))) +} + +fn update_comment_docs(op: TransformOperation) -> TransformOperation { + op.summary("Edit a comment") + .description("Edits a comment's body. Only the author may edit their own comment.") + .response::<200, Json>() + .response::<400, Json>() + .response::<401, Json>() + .response::<403, Json>() + .response::<404, Json>() +} + +/// Deletes a comment (soft delete). Restricted to the comment's author. +#[tracing::instrument( + skip_all, + fields( + account_id = %authz.account_id, + workspace_id = %authz.workspace.id, + comment_id = %path_params.comment_id, + ) +)] +async fn delete_comment( + State(pg_client): State, + authz: Authorized, + Path(path_params): Path, + security: SecurityContext, +) -> Result { + tracing::debug!(target: TRACING_TARGET, "Deleting comment"); + + let workspace = authz.workspace; + let mut conn = pg_client.get_connection().await?; + + let comment = find_comment(&mut conn, workspace.id, path_params.comment_id).await?; + + // Only the author may delete their own comment. + if comment.author_account_id != authz.account_id { + return Err(ErrorKind::Forbidden + .with_message("Only the author can delete this comment") + .with_resource("workspace_comment")); + } + + conn.transaction(async |conn| { + conn.delete_comment(comment.id).await?; + emit_comment_event( + conn, + workspace_origin(workspace.id, authz.account_id, &security), + WorkspaceEvent::CommentDeleted(CommentDeleted { + comment_id: comment.id, + file_id: comment.file_id, + }), + ) + .await?; + Ok::<_, Error>(()) + }) + .await?; + + tracing::info!(target: TRACING_TARGET, "Comment deleted"); + + Ok(StatusCode::OK) +} + +fn delete_comment_docs(op: TransformOperation) -> TransformOperation { + op.summary("Delete a comment") + .description("Soft-deletes a comment. Only the author may delete their own comment.") + .response::<200, ()>() + .response::<401, Json>() + .response::<403, Json>() + .response::<404, Json>() +} + +/// Resolves a comment thread. Requires `ResolveComments`. +#[tracing::instrument( + skip_all, + fields( + account_id = %authz.account_id, + workspace_id = %authz.workspace.id, + comment_id = %path_params.comment_id, + ) +)] +async fn resolve_comment( + State(pg_client): State, + authz: Authorized, + Path(path_params): Path, + security: SecurityContext, +) -> Result<(StatusCode, Json)> { + tracing::debug!(target: TRACING_TARGET, "Resolving comment"); + + let workspace = authz.workspace; + let mut conn = pg_client.get_connection().await?; + + let comment = find_comment(&mut conn, workspace.id, path_params.comment_id).await?; + require_top_level(&comment)?; + + // Already resolved: return it unchanged rather than overwriting the original + // resolver/timestamp (the audit record) and emitting a duplicate event. + if comment.resolved_at.is_some() { + let author = resolve_account_ref(&mut conn, comment.author_account_id).await?; + return Ok((StatusCode::OK, Json(Comment::from_model(comment, author)))); + } + + let resolved = conn + .transaction(async |conn| { + let resolved = conn.resolve_comment(comment.id, authz.account_id).await?; + emit_comment_event( + conn, + workspace_origin(workspace.id, authz.account_id, &security), + WorkspaceEvent::CommentResolved(CommentResolved { + comment_id: comment.id, + file_id: comment.file_id, + }), + ) + .await?; + Ok::<_, Error>(resolved) + }) + .await?; + + let author = resolve_account_ref(&mut conn, resolved.author_account_id).await?; + + tracing::info!(target: TRACING_TARGET, "Comment resolved"); + + Ok((StatusCode::OK, Json(Comment::from_model(resolved, author)))) +} + +fn resolve_comment_docs(op: TransformOperation) -> TransformOperation { + op.summary("Resolve a comment") + .description("Resolves a comment thread, closing the discussion. Requires ResolveComments.") + .response::<200, Json>() + .response::<401, Json>() + .response::<403, Json>() + .response::<404, Json>() +} + +/// Reopens a resolved comment thread. Requires `ResolveComments`. +#[tracing::instrument( + skip_all, + fields( + account_id = %authz.account_id, + workspace_id = %authz.workspace.id, + comment_id = %path_params.comment_id, + ) +)] +async fn reopen_comment( + State(pg_client): State, + authz: Authorized, + Path(path_params): Path, +) -> Result<(StatusCode, Json)> { + tracing::debug!(target: TRACING_TARGET, "Reopening comment"); + + let workspace = authz.workspace; + let mut conn = pg_client.get_connection().await?; + + let comment = find_comment(&mut conn, workspace.id, path_params.comment_id).await?; + require_top_level(&comment)?; + let reopened = conn.reopen_comment(comment.id).await?; + let author = resolve_account_ref(&mut conn, reopened.author_account_id).await?; + + tracing::info!(target: TRACING_TARGET, "Comment reopened"); + + Ok((StatusCode::OK, Json(Comment::from_model(reopened, author)))) +} + +fn reopen_comment_docs(op: TransformOperation) -> TransformOperation { + op.summary("Reopen a comment") + .description("Reopens a resolved comment thread. Requires ResolveComments.") + .response::<200, Json>() + .response::<401, Json>() + .response::<403, Json>() + .response::<404, Json>() +} + +/// Finds a live comment in the workspace or returns a 404. +async fn find_comment( + conn: &mut PgConn, + workspace_id: Uuid, + comment_id: Uuid, +) -> Result { + conn.find_comment_in_workspace(workspace_id, comment_id) + .await? + .ok_or_else(|| Error::not_found("workspace_comment")) +} + +/// Rejects a reply where a top-level comment is required: a reply inherits its +/// thread's resolution state, so only the thread's top-level comment can be +/// resolved or reopened. +fn require_top_level(comment: &WorkspaceComment) -> Result<()> { + if comment.parent_id.is_some() { + return Err(ErrorKind::BadRequest + .with_message("Resolve the thread's top-level comment, not a reply") + .with_resource("workspace_comment")); + } + Ok(()) +} + +/// Extracts the raw handle text of each `@username` mention in `body`. +/// +/// A mention is an `@` that starts a token (preceded by start-of-string or a +/// non-alphanumeric, non-`@` char, so an email's `@` is not a mention) followed +/// by handle characters (`[a-z0-9-]`). Validation (length, dash rules) is left to +/// [`Handle::parse`]; this only slices candidate spans. +fn parse_mentions(body: &str) -> Vec { + let bytes = body.as_bytes(); + let mut mentions = Vec::new(); + let mut i = 0; + while let Some(at) = body[i..].find('@') { + let at = i + at; + // The `@` must begin a token: preceded by nothing, or by a char that is + // not part of a word and not another `@`. A preceding ASCII alphanumeric + // (so `a@b.com` is an email, not a mention) or any non-ASCII byte >= 0x80 + // (a multibyte letter like `é`, so `café@bob` is not a mention) counts as + // part of a word. + let boundary = at == 0 || { + let prev = bytes[at - 1]; + !(prev.is_ascii_alphanumeric() || prev >= 0x80 || prev == b'@') + }; + let start = at + 1; + let end = start + + body[start..] + .find(|c: char| !matches!(c, 'a'..='z' | '0'..='9' | '-')) + .unwrap_or(body.len() - start); + if boundary && end > start { + mentions.push(body[start..end].to_owned()); + } + i = end.max(at + 1); + } + mentions +} + +/// Parses `@username` mentions from `body`, resolving each to a workspace-member +/// account id — de-duplicated, excluding `author` (no self-notification), and +/// skipping handles that are not members of the workspace. +async fn resolve_mentions( + conn: &mut PgConn, + workspace_id: Uuid, + body: &str, + author: Uuid, +) -> Result> { + // De-duplicate the raw mention text first (a repeated mention resolves once), + // then parse each into a valid handle. + let handles: Vec = parse_mentions(body) + .into_iter() + .collect::>() + .into_iter() + .filter_map(|m| Handle::parse(m).ok()) + .collect(); + + if handles.is_empty() { + return Ok(Vec::new()); + } + + // Resolve all mentioned handles to workspace-member account ids in one query, + // then drop the author (no self-notification). + let mut recipients = conn + .find_member_ids_by_usernames(workspace_id, &handles) + .await?; + recipients.retain(|&id| id != author); + Ok(recipients) +} + +/// Maps a reply-parent validation failure to a client error. +fn reply_parent_error(err: ReplyParentError) -> Error<'static> { + match err { + ReplyParentError::NotFound => ErrorKind::NotFound.with_resource("workspace_comment"), + ReplyParentError::FileMismatch => ErrorKind::BadRequest + .with_message("The parent comment is on a different file") + .with_resource("workspace_comment"), + ReplyParentError::NotTopLevel => ErrorKind::BadRequest + .with_message("Cannot reply to a reply; comment threads are one level deep") + .with_resource("workspace_comment"), + } +} + +/// Builds the event origin shared by every comment event. +fn workspace_origin<'a>( + workspace_id: Uuid, + account_id: Uuid, + security: &'a SecurityContext, +) -> EventOrigin<'a> { + EventOrigin { + workspace_id, + account_id, + security, + } +} + +/// Emits one comment event onto the outbox. +async fn emit_comment_event( + conn: &mut PgConn, + origin: EventOrigin<'_>, + event: WorkspaceEvent, +) -> Result<()> { + conn.emit_event(origin, event).await?; + Ok(()) +} + +/// Returns an [`ApiRouter`] with all comment routes. +pub fn routes() -> ApiRouter { + use aide::axum::routing::*; + + ApiRouter::new() + .api_route( + "/workspaces/{workspaceSlug}/files/{fileId}/comments/", + post_with(create_comment, create_comment_docs) + .get_with(list_file_comments, list_file_comments_docs), + ) + .api_route( + "/workspaces/{workspaceSlug}/comments/", + get_with(list_workspace_comments, list_workspace_comments_docs), + ) + .api_route( + "/workspaces/{workspaceSlug}/comments/{commentId}/", + patch_with(update_comment, update_comment_docs) + .delete_with(delete_comment, delete_comment_docs), + ) + .api_route( + "/workspaces/{workspaceSlug}/comments/{commentId}/resolve/", + post_with(resolve_comment, resolve_comment_docs) + .delete_with(reopen_comment, reopen_comment_docs), + ) + .with_path_items(|item| item.tag("Comments")) +} + +#[cfg(test)] +mod tests { + use super::parse_mentions; + + #[test] + fn parses_mentions_and_ignores_emails() { + // A leading mention, a mid-sentence mention, and an email whose @ is not a + // mention. + assert_eq!( + parse_mentions("@alice please review, cc @bob-smith — not user@example.com"), + vec!["alice".to_owned(), "bob-smith".to_owned()], + ); + } + + #[test] + fn no_mentions_yields_empty() { + assert!(parse_mentions("just a plain comment, no pings").is_empty()); + assert!(parse_mentions("").is_empty()); + // A bare @ with no handle text produces nothing. + assert!(parse_mentions("look @ this").is_empty()); + } + + #[test] + fn mention_stops_at_non_handle_chars() { + // The handle ends at whitespace/punctuation; trailing text is not included. + assert_eq!(parse_mentions("hey @carol!"), vec!["carol".to_owned()]); + assert_eq!(parse_mentions("(@dave)"), vec!["dave".to_owned()]); + } + + #[test] + fn non_ascii_letter_before_at_is_not_a_boundary() { + // A multibyte letter (é) before `@` means the `@` is embedded in a word, + // not a mention — like an email local part. + assert!(parse_mentions("café@bob").is_empty()); + // But a real mention after an accented word (with a space) still parses. + assert_eq!(parse_mentions("café @bob"), vec!["bob".to_owned()]); + } +} diff --git a/crates/nvisy-server/src/handler/mod.rs b/crates/nvisy-server/src/handler/mod.rs index bb7ac798..d85554e1 100644 --- a/crates/nvisy-server/src/handler/mod.rs +++ b/crates/nvisy-server/src/handler/mod.rs @@ -14,6 +14,7 @@ pub(crate) use auth_oidc::consume_reauth_proof; mod avatars; mod catalog; mod chat; +mod comments; mod connection_oauth; mod connection_syncs; mod connections; @@ -81,6 +82,7 @@ fn private_routes(service_state: ServiceState) -> ApiRouter { .merge(analytics::routes()) .merge(members::routes()) .merge(assignments::routes()) + .merge(comments::routes()) .merge(connections::routes()) .merge(providers::routes()) .merge(connection_oauth::private_routes()) diff --git a/crates/nvisy-server/src/handler/request/comments.rs b/crates/nvisy-server/src/handler/request/comments.rs new file mode 100644 index 00000000..35f8546f --- /dev/null +++ b/crates/nvisy-server/src/handler/request/comments.rs @@ -0,0 +1,135 @@ +//! Comment request types (post a comment/reply, edit, filter). + +use elide_pipeline::modality::audio::AudioLocation; +use elide_pipeline::modality::image::ImageLocation; +use elide_pipeline::modality::tabular::TabularLocation; +use elide_pipeline::modality::text::TextLocation; +use garde::Validate; +use nvisy_postgres::types::CommentFilter; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::extract::validators::validate_non_blank; + +/// Where a comment is pinned within a file: a location in one of the four +/// modalities, tagged so a single stored JSON value carries its own modality. +/// +/// Each variant wraps the engine's own location type ([`elide_pipeline`]), so a +/// comment anchors to exactly what a detection/redaction does — a page region for +/// paginated/image documents, a time span for audio/video, a text span for +/// transcripts, a cell for tabular data. The engine's location types carry no +/// modality discriminator of their own, so the `modality` tag here supplies it. +#[must_use] +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(tag = "modality", rename_all = "snake_case")] +pub enum CommentAnchor { + /// A span within text/transcript content. + Text(TextLocation), + /// A region on a page of image or paginated (PDF) content. + Image(ImageLocation), + /// A time span within audio/video content. + Audio(AudioLocation), + /// A cell (and optional intra-cell span) of tabular content. + Tabular(TabularLocation), +} + +/// Path parameters addressing one comment by its opaque id. +#[must_use] +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct CommentPathParams { + /// Unique identifier of the comment. + pub comment_id: Uuid, +} + +/// Request payload to post a comment on a file, or a reply to another comment. +/// +/// Omit `parent_id` for a top-level comment; set it to reply (one level only). +/// `@username` mentions in the body notify those workspace members. +#[must_use] +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Validate)] +#[serde(rename_all = "camelCase")] +pub struct CreateComment { + /// The comment text (1-10000 characters). + #[garde(length(chars, min = 1, max = 10_000), custom(validate_non_blank))] + pub body: String, + /// The comment this replies to, for a one-level thread. Omit for a top-level + /// comment. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[garde(skip)] + pub parent_id: Option, + /// Where in the file the comment is pinned. Omit for a file-level comment + /// (no pin). + #[serde(default, skip_serializing_if = "Option::is_none")] + #[garde(skip)] + pub anchor: Option, +} + +/// Request payload to edit a comment's body. +#[must_use] +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Validate)] +#[serde(rename_all = "camelCase")] +pub struct UpdateComment { + /// The new comment text (1-10000 characters). + #[garde(length(chars, min = 1, max = 10_000), custom(validate_non_blank))] + pub body: String, +} + +/// Query parameters for listing a workspace's comments. +/// +/// Every field is an optional filter; unset fields impose no constraint. +#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct WorkspaceCommentsQuery { + /// Filter by the file the comment is on. + pub file_id: Option, + /// Filter by the comment's author. + pub author: Option, + /// Filter by resolution state: `true` = resolved only, `false` = open only. + pub resolved: Option, +} + +impl From for CommentFilter { + fn from(query: WorkspaceCommentsQuery) -> Self { + CommentFilter { + file_id: query.file_id, + author_account_id: query.author, + resolved: query.resolved, + } + } +} + +#[cfg(test)] +mod tests { + use super::CommentAnchor; + + #[test] + fn anchor_carries_its_modality_tag() { + // An image anchor round-trips through JSON with a `modality` discriminator, + // so a stored anchor is self-describing across modalities. + let json = serde_json::json!({ + "modality": "image", + "bounding_box": { "min": { "x": 1.0, "y": 2.0 }, "max": { "x": 3.0, "y": 4.0 } }, + "page": 2 + }); + let anchor: CommentAnchor = + serde_json::from_value(json.clone()).expect("image anchor decodes"); + assert!(matches!(anchor, CommentAnchor::Image(_))); + // Re-encoding keeps the modality tag. + let reencoded = serde_json::to_value(&anchor).expect("encodes"); + assert_eq!(reencoded["modality"], "image"); + } + + #[test] + fn anchor_modality_selects_the_variant() { + let text = serde_json::json!({ + "modality": "text", + "coord": { "kind": "decoded", "range": { "start": 0, "end": 5 }, "source": [] } + }); + assert!(matches!( + serde_json::from_value::(text), + Ok(CommentAnchor::Text(_)) + )); + } +} diff --git a/crates/nvisy-server/src/handler/request/mod.rs b/crates/nvisy-server/src/handler/request/mod.rs index bb4c6e3d..abe95b6d 100644 --- a/crates/nvisy-server/src/handler/request/mod.rs +++ b/crates/nvisy-server/src/handler/request/mod.rs @@ -5,6 +5,7 @@ mod activities; mod assignments; mod authentications; mod chat; +mod comments; mod connection_syncs; mod connections; mod detections; @@ -28,6 +29,7 @@ pub use activities::*; pub use assignments::*; pub use authentications::*; pub use chat::*; +pub use comments::*; pub use connection_syncs::*; pub use connections::*; pub use detections::*; diff --git a/crates/nvisy-server/src/handler/response/comments.rs b/crates/nvisy-server/src/handler/response/comments.rs new file mode 100644 index 00000000..749ccb17 --- /dev/null +++ b/crates/nvisy-server/src/handler/response/comments.rs @@ -0,0 +1,71 @@ +//! Comment response types. + +use jiff::Timestamp; +use nvisy_postgres::model::WorkspaceComment as CommentModel; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use super::{AccountRef, Page}; +use crate::handler::request::CommentAnchor; + +/// Response type for a comment on a file. +/// +/// A comment is authored by a workspace member, optionally a one-level reply +/// (`parentId`), optionally pinned to a location within the file (`anchor`), and +/// can be resolved to close its thread. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct Comment { + /// Unique identifier of the comment. + pub id: Uuid, + /// File the comment is on. + pub file_id: Uuid, + /// The comment this replies to, for a one-level thread; `None` for a + /// top-level comment. + #[serde(skip_serializing_if = "Option::is_none")] + pub parent_id: Option, + /// Account that wrote the comment. + pub author: AccountRef, + /// The comment text. + pub body: String, + /// Location within the file the comment is pinned to, when set. `None` for a + /// file-level comment. + #[serde(skip_serializing_if = "Option::is_none")] + pub anchor: Option, + /// Whether the thread is resolved. + pub resolved: bool, + /// When the thread was resolved, when resolved. + #[serde(skip_serializing_if = "Option::is_none")] + pub resolved_at: Option, + /// When the comment was created. + pub created_at: Timestamp, + /// When the comment was last updated. + pub updated_at: Timestamp, +} + +/// Paginated response for comments. +pub type CommentsPage = Page; + +impl Comment { + /// Creates a comment response from the database model and the resolved author + /// reference. + pub fn from_model(comment: CommentModel, author: AccountRef) -> Self { + Self { + id: comment.id, + file_id: comment.file_id, + parent_id: comment.parent_id, + author, + body: comment.body, + // The anchor is stored as its typed JSON; decode it back, treating an + // undecodable value as no anchor rather than failing the read. + anchor: comment + .anchor + .and_then(|value| serde_json::from_value(value).ok()), + resolved: comment.resolved_at.is_some(), + resolved_at: comment.resolved_at.map(Into::into), + created_at: comment.created_at.into(), + updated_at: comment.updated_at.into(), + } + } +} diff --git a/crates/nvisy-server/src/handler/response/mod.rs b/crates/nvisy-server/src/handler/response/mod.rs index 2e7acc02..ab2c6002 100644 --- a/crates/nvisy-server/src/handler/response/mod.rs +++ b/crates/nvisy-server/src/handler/response/mod.rs @@ -12,6 +12,7 @@ mod assignments; mod authentications; mod catalog; mod chat; +mod comments; mod connection_syncs; mod connections; mod detections; @@ -37,6 +38,7 @@ pub use assignments::*; pub use authentications::*; pub use catalog::*; pub use chat::*; +pub use comments::*; pub use connection_syncs::*; pub use connections::*; pub use detections::*; diff --git a/crates/nvisy-server/src/response/error/pg_error.rs b/crates/nvisy-server/src/response/error/pg_error.rs index 74a08e2d..869b721f 100644 --- a/crates/nvisy-server/src/response/error/pg_error.rs +++ b/crates/nvisy-server/src/response/error/pg_error.rs @@ -30,6 +30,7 @@ impl From for Error<'static> { ConstraintViolation::WorkspaceWebhook(c) => c.into(), ConstraintViolation::WorkspaceFile(c) => c.into(), ConstraintViolation::WorkspaceAssignment(c) => c.into(), + ConstraintViolation::WorkspaceComment(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/response/error/pg_workspace.rs b/crates/nvisy-server/src/response/error/pg_workspace.rs index 0513841f..76ee3524 100644 --- a/crates/nvisy-server/src/response/error/pg_workspace.rs +++ b/crates/nvisy-server/src/response/error/pg_workspace.rs @@ -1,8 +1,9 @@ //! Workspace-related constraint violation error handlers. use nvisy_postgres::types::{ - WorkspaceActivitiesConstraints, WorkspaceAssignmentConstraints, WorkspaceConstraints, - WorkspaceInviteConstraints, WorkspaceMemberConstraints, WorkspaceWebhookConstraints, + WorkspaceActivitiesConstraints, WorkspaceAssignmentConstraints, WorkspaceCommentConstraints, + WorkspaceConstraints, WorkspaceInviteConstraints, WorkspaceMemberConstraints, + WorkspaceWebhookConstraints, }; use super::{Error, ErrorKind}; @@ -61,6 +62,20 @@ impl From for Error<'static> { } } +impl From for Error<'static> { + fn from(c: WorkspaceCommentConstraints) -> Self { + let error = match c { + WorkspaceCommentConstraints::BodyLength => ErrorKind::BadRequest + .with_message("Comment body must be between 1 and 10000 characters"), + WorkspaceCommentConstraints::AnchorSize => { + ErrorKind::BadRequest.with_message("Comment anchor is too large") + } + }; + + error.with_resource("workspace_comment") + } +} + impl From for Error<'static> { fn from(c: WorkspaceInviteConstraints) -> Self { let error = match c { diff --git a/crates/nvisy-server/src/service/event/mod.rs b/crates/nvisy-server/src/service/event/mod.rs index 839dc8d8..68a54793 100644 --- a/crates/nvisy-server/src/service/event/mod.rs +++ b/crates/nvisy-server/src/service/event/mod.rs @@ -22,14 +22,14 @@ 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::{ - 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, + AssignmentStatusChanged, CommentCreated, CommentDeleted, CommentResolved, 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 f83f4435..2388ee6a 100644 --- a/crates/nvisy-server/src/service/event/workspace_event.rs +++ b/crates/nvisy-server/src/service/event/workspace_event.rs @@ -12,14 +12,14 @@ //! change. 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, + ActivityPayload, AssignmentActivityParams, AssignmentStatus, CommentActivityParams, + CommentMentionedParams, 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; @@ -77,6 +77,10 @@ workspace_events! { PolicyCreated => "policy.created", PolicyUpdated => "policy.updated", PolicyDeleted => "policy.deleted", + + CommentCreated => "comment.created", + CommentResolved => "comment.resolved", + CommentDeleted => "comment.deleted", } /// The webhook body for a file event: just the file's display name. @@ -786,3 +790,103 @@ fn policy_activity(policy_id: Uuid, policy_slug: &Handle) -> PolicyActivityParam policy_slug: policy_slug.clone(), } } + +/// A comment was created on a file. Notifies each mentioned account (never the +/// author, even if they @-mention themselves). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CommentCreated { + pub comment_id: Uuid, + pub file_id: Uuid, + /// Username of the comment's author, shown in the mention notification. + pub author_username: Handle, + /// Accounts mentioned in the comment body, to notify. Empty when none. + pub mentioned: Vec, +} + +impl EventKind for CommentCreated { + const TAG: &'static str = "comment.created"; + + fn resource_id(&self) -> Uuid { + self.comment_id + } + + fn activity(&self) -> ActivityPayload { + ActivityPayload::CommentCreated(CommentActivityParams { + comment_id: self.comment_id, + file_id: self.file_id, + }) + } + + fn webhook(&self) -> Option { + Some(WebhookDelivery { + event: WebhookEvent::CommentCreated, + body: None, + }) + } + + fn notification(self) -> Vec { + // One "you were mentioned" notification per mentioned account. + self.mentioned + .into_iter() + .map(|recipient| Notification { + target: NotifyTarget::Account(recipient), + payload: NotificationPayload::CommentMentioned(CommentMentionedParams { + comment_id: self.comment_id, + file_id: self.file_id, + author_username: self.author_username.clone(), + }), + }) + .collect() + } +} + +/// A comment thread was resolved. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CommentResolved { + pub comment_id: Uuid, + pub file_id: Uuid, +} + +impl EventKind for CommentResolved { + const TAG: &'static str = "comment.resolved"; + + fn resource_id(&self) -> Uuid { + self.comment_id + } + + fn activity(&self) -> ActivityPayload { + ActivityPayload::CommentResolved(CommentActivityParams { + comment_id: self.comment_id, + file_id: self.file_id, + }) + } + + fn webhook(&self) -> Option { + Some(WebhookDelivery { + event: WebhookEvent::CommentResolved, + body: None, + }) + } +} + +/// A comment was deleted. Recorded in the activity log only (no webhook). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CommentDeleted { + pub comment_id: Uuid, + pub file_id: Uuid, +} + +impl EventKind for CommentDeleted { + const TAG: &'static str = "comment.deleted"; + + fn resource_id(&self) -> Uuid { + self.comment_id + } + + fn activity(&self) -> ActivityPayload { + ActivityPayload::CommentDeleted(CommentActivityParams { + comment_id: self.comment_id, + file_id: self.file_id, + }) + } +} diff --git a/crates/nvisy-server/src/service/mod.rs b/crates/nvisy-server/src/service/mod.rs index 85e7f7f8..9a5718d5 100644 --- a/crates/nvisy-server/src/service/mod.rs +++ b/crates/nvisy-server/src/service/mod.rs @@ -49,15 +49,16 @@ pub use crate::service::detection::{ }; pub use crate::service::engine::{EngineConfig, EngineService, UnknownFormatToken}; pub use crate::service::event::{ - 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, + AssignmentStatusChanged, CommentCreated, CommentDeleted, CommentResolved, 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/migrations/2026-09-11-040235_comments/down.sql b/migrations/2026-09-11-040235_comments/down.sql new file mode 100644 index 00000000..3053a3d3 --- /dev/null +++ b/migrations/2026-09-11-040235_comments/down.sql @@ -0,0 +1,9 @@ +-- Revert the comments table. +-- Objects are dropped in reverse order of creation. + +DROP TABLE IF EXISTS workspace_comments; + +-- The comment.* 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-11-040235_comments/up.sql b/migrations/2026-09-11-040235_comments/up.sql new file mode 100644 index 00000000..35878dc6 --- /dev/null +++ b/migrations/2026-09-11-040235_comments/up.sql @@ -0,0 +1,113 @@ +-- Comments: threaded discussion on a file under review. A comment is authored by +-- a workspace member, optionally anchored to a location within the file (a page +-- region, a time range, a text span — the modality-tagged anchor), optionally a +-- reply to another comment (one level), and can be resolved to close a thread. + +-- Comments table: one comment on a file. +CREATE TABLE workspace_comments ( + -- 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 "comments across the workspace" query is a + -- single indexed scan with no join to workspace_files. + workspace_id UUID NOT NULL REFERENCES workspaces (id) ON DELETE CASCADE, + file_id UUID NOT NULL, + + -- The comment's author. If their account is removed, their comments go with it. + author_account_id UUID NOT NULL REFERENCES accounts (id) ON DELETE CASCADE, + + -- A reply's parent, for one-level threads. NULL for a top-level comment. A + -- reply is removed with its parent (CASCADE), so a resolved/deleted thread + -- takes its replies. A parent is itself always top-level (enforced in the + -- repository), so threads never nest deeper than one level. + parent_id UUID DEFAULT NULL REFERENCES workspace_comments (id) ON DELETE CASCADE, + + -- The comment text. + body TEXT NOT NULL, + CONSTRAINT workspace_comments_body_length CHECK (length(trim(body)) BETWEEN 1 AND 10000), + + -- Optional location within the file the comment is pinned to, as a + -- modality-tagged anchor (page region, time range, text span, or table cell). + -- NULL for a file-level comment with no pin. Stored as the anchor's typed JSON. + anchor JSONB DEFAULT NULL, + CONSTRAINT workspace_comments_anchor_size CHECK (anchor IS NULL OR length(anchor::TEXT) <= 8192), + + -- Resolution: a resolved thread is closed. `resolved_at IS NULL` means open; + -- a timestamp means resolved, and `resolved_by` records who resolved it (kept + -- for the audit trail; SET NULL if that account is removed). Only a top-level + -- comment is resolvable (a reply inherits its thread's state). + resolved_at TIMESTAMPTZ DEFAULT NULL, + resolved_by UUID DEFAULT NULL REFERENCES accounts (id) ON DELETE SET NULL, + CONSTRAINT workspace_comments_resolved_consistent CHECK ( + (resolved_at IS NULL) = (resolved_by IS NULL) + ), + + -- Lifecycle timestamps + created_at TIMESTAMPTZ NOT NULL DEFAULT current_timestamp, + updated_at TIMESTAMPTZ NOT NULL DEFAULT current_timestamp, + deleted_at TIMESTAMPTZ DEFAULT NULL, + CONSTRAINT workspace_comments_updated_after_created CHECK (updated_at >= created_at), + CONSTRAINT workspace_comments_deleted_after_created CHECK (deleted_at IS NULL OR deleted_at >= created_at), + CONSTRAINT workspace_comments_resolved_after_created CHECK (resolved_at IS NULL OR resolved_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 comment on a file from another workspace cannot be + -- stored. Removing the file cascades its comments away. + CONSTRAINT workspace_comments_file_fkey FOREIGN KEY (workspace_id, file_id) + REFERENCES workspace_files (workspace_id, id) ON DELETE CASCADE +); + +-- A file's comment thread, oldest first (a discussion reads top to bottom). +CREATE INDEX workspace_comments_file_idx + ON workspace_comments (file_id, created_at) + WHERE deleted_at IS NULL; + +-- A thread's replies, oldest first. +CREATE INDEX workspace_comments_parent_idx + ON workspace_comments (parent_id, created_at) + WHERE parent_id IS NOT NULL AND deleted_at IS NULL; + +-- Workspace-scoped listing, newest first. +CREATE INDEX workspace_comments_workspace_idx + ON workspace_comments (workspace_id, created_at DESC) + WHERE deleted_at IS NULL; + +-- An author's comments, newest first. +CREATE INDEX workspace_comments_author_idx + ON workspace_comments (author_account_id, created_at DESC) + WHERE deleted_at IS NULL; + +-- Auto-maintain updated_at on writes (soft-delete column present). +SELECT setup_updated_at('workspace_comments'); + +COMMENT ON TABLE workspace_comments IS 'Threaded comments on a file under review, optionally anchored to a location.'; +COMMENT ON COLUMN workspace_comments.id IS 'Unique comment identifier'; +COMMENT ON COLUMN workspace_comments.workspace_id IS 'Denormalized workspace scope for fast per-workspace comment queries'; +COMMENT ON COLUMN workspace_comments.file_id IS 'File the comment is on'; +COMMENT ON COLUMN workspace_comments.author_account_id IS 'Account that wrote the comment'; +COMMENT ON COLUMN workspace_comments.parent_id IS 'Parent comment for a one-level reply; NULL for a top-level comment'; +COMMENT ON COLUMN workspace_comments.body IS 'Comment text (1-10000 chars)'; +COMMENT ON COLUMN workspace_comments.anchor IS 'Optional modality-tagged location within the file the comment is pinned to; NULL for a file-level comment'; +COMMENT ON COLUMN workspace_comments.resolved_at IS 'When the thread was resolved; NULL means open'; +COMMENT ON COLUMN workspace_comments.resolved_by IS 'Account that resolved the thread, for the audit trail; null if that account was removed'; +COMMENT ON COLUMN workspace_comments.created_at IS 'When the comment was created'; +COMMENT ON COLUMN workspace_comments.updated_at IS 'When the comment was last updated'; +COMMENT ON COLUMN workspace_comments.deleted_at IS 'Soft-deletion timestamp; NULL means live'; + +-- Comment lifecycle events feed the event sinks, each value added by this +-- migration (the migration that introduces comments). ALTER TYPE ... ADD VALUE +-- only adds labels here (no rows use them yet), so it stays transactional. +-- +-- Activity log records the full lifecycle. +ALTER TYPE ACTIVITY_TYPE ADD VALUE IF NOT EXISTS 'comment.created'; +ALTER TYPE ACTIVITY_TYPE ADD VALUE IF NOT EXISTS 'comment.resolved'; +ALTER TYPE ACTIVITY_TYPE ADD VALUE IF NOT EXISTS 'comment.deleted'; + +-- Webhooks carry creation and resolution (deletion is internal). +ALTER TYPE WEBHOOK_EVENT ADD VALUE IF NOT EXISTS 'comment.created'; +ALTER TYPE WEBHOOK_EVENT ADD VALUE IF NOT EXISTS 'comment.resolved'; + +-- In-app notifications go to each mentioned account. +ALTER TYPE NOTIFICATION_EVENT ADD VALUE IF NOT EXISTS 'comment.mentioned'; From a3f2f330ea7efb31c5062c454a84f954ca074d24 Mon Sep 17 00:00:00 2001 From: Oleh Martsokha Date: Fri, 11 Sep 2026 09:36:19 +0200 Subject: [PATCH 2/5] Rework comments into threads; merge AI assistant into threads, drop chat Reshape the comments feature around GitHub-issue-style threads and fold the standalone chat feature into it as an in-thread AI assistant. Threads: - A thread is the closable, optionally file-pinned unit; comments are its messages. Threads carry 0..N anchors (location pins), an optional title, and a timeline of immutable events (opened/closed/reopened/renamed, anchor added/removed) merged with comments by created_at. - Event vocabulary is thread.* end to end (thread.opened/closed/reopened/renamed/ deleted/anchor.added/anchor.removed/comment.created), across the activity, webhook, and workspace-event enums. - Lifecycle actions record both an in-thread timeline event and the existing workspace event (activity log + webhook). AI assistant in threads (chat feature removed): - Delete chat_sessions/chat_messages, CHAT_ROLE, the chat handler/service, and the UseChat permission. - A reserved assistant account (fixed id, no login identity) authors replies. A user @assistant in a comment enqueues an assistant-reply job (transactional outbox mirroring detection), which a worker answers via the workspace LLM provider and posts back as a comment. At-least-once, deduped airtight by a partial-unique parent_id + ON CONFLICT DO NOTHING (one live reply per parent). - Conversation-only context for now (no document RAG yet). Organization: - One file per entity for models, queries (four repository traits), and constraints; handlers split into threads.rs and comments.rs. - Migrations: _comments renamed to _threads, assistant account + jobs outbox merged into one _assistant migration, all aligned to the house comment style. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_018bKk1YEG4tZ69jzYVQvQL8 --- crates/nvisy-nats/src/stream/event_stream.rs | 32 + crates/nvisy-nats/src/stream/mod.rs | 2 +- crates/nvisy-postgres/src/lib.rs | 12 + .../nvisy-postgres/src/model/chat_message.rs | 56 - .../nvisy-postgres/src/model/chat_session.rs | 71 -- crates/nvisy-postgres/src/model/mod.rs | 19 +- .../src/model/workspace_assistant_job.rs | 64 ++ .../src/model/workspace_comment.rs | 95 -- .../src/model/workspace_thread.rs | 79 ++ .../src/model/workspace_thread_anchor.rs | 40 + .../src/model/workspace_thread_comment.rs | 76 ++ .../src/model/workspace_thread_event.rs | 50 + .../nvisy-postgres/src/query/chat_message.rs | 315 ------ .../nvisy-postgres/src/query/chat_session.rs | 223 ---- crates/nvisy-postgres/src/query/mod.rs | 16 +- .../src/query/workspace_assistant_job.rs | 274 +++++ .../src/query/workspace_comment.rs | 530 ---------- .../src/query/workspace_thread.rs | 690 ++++++++++++ .../src/query/workspace_thread_anchor.rs | 185 ++++ .../src/query/workspace_thread_comment.rs | 187 ++++ .../src/query/workspace_thread_event.rs | 88 ++ crates/nvisy-postgres/src/schema.rs | 136 ++- crates/nvisy-postgres/src/test_util.rs | 2 - .../src/types/constraint/chat_messages.rs | 16 - .../src/types/constraint/chat_sessions.rs | 14 - .../src/types/constraint/comments.rs | 14 - .../src/types/constraint/mod.rs | 30 +- .../constraint/workspace_thread_anchors.rs | 11 + .../constraint/workspace_thread_comments.rs | 11 + .../src/types/constraint/workspace_threads.rs | 14 + .../src/types/enums/activity_type.rs | 22 +- .../src/types/enums/chat_role.rs | 17 - crates/nvisy-postgres/src/types/enums/mod.rs | 7 +- .../src/types/enums/thread_event_kind.rs | 26 + .../src/types/enums/webhook_event.rs | 23 +- .../src/types/filtering/comments.rs | 14 +- .../nvisy-postgres/src/types/filtering/mod.rs | 2 +- .../src/types/json/activity_params.rs | 123 ++- crates/nvisy-postgres/src/types/json/mod.rs | 9 +- .../src/types/json/notification_params.rs | 8 +- crates/nvisy-postgres/src/types/mod.rs | 35 +- .../src/extract/auth/authorized.rs | 3 +- .../src/extract/auth/permission.rs | 11 +- crates/nvisy-server/src/handler/chat.rs | 364 ------- crates/nvisy-server/src/handler/comments.rs | 535 ++-------- crates/nvisy-server/src/handler/mod.rs | 4 +- .../nvisy-server/src/handler/request/chat.rs | 40 - .../src/handler/request/comments.rs | 106 +- .../nvisy-server/src/handler/request/mod.rs | 2 - .../nvisy-server/src/handler/response/chat.rs | 81 -- .../src/handler/response/comments.rs | 182 +++- .../nvisy-server/src/handler/response/mod.rs | 2 - crates/nvisy-server/src/handler/threads.rs | 997 ++++++++++++++++++ crates/nvisy-server/src/response/error/mod.rs | 1 - .../src/response/error/pg_chat.rs | 31 - .../src/response/error/pg_error.rs | 6 +- .../src/response/error/pg_workspace.rs | 40 +- .../src/service/assistant/coordinator.rs | 45 + .../src/service/assistant/drainer.rs | 225 ++++ .../nvisy-server/src/service/assistant/job.rs | 21 + .../nvisy-server/src/service/assistant/mod.rs | 22 + .../src/service/assistant/service.rs | 45 + .../src/service/assistant/worker.rs | 434 ++++++++ crates/nvisy-server/src/service/chat.rs | 191 ---- crates/nvisy-server/src/service/event/mod.rs | 15 +- .../src/service/event/workspace_event.rs | 151 ++- crates/nvisy-server/src/service/mod.rs | 43 +- migrations/2026-08-19-034709_chat/down.sql | 9 - migrations/2026-08-19-034709_chat/up.sql | 114 -- .../2026-09-11-040235_comments/down.sql | 9 - migrations/2026-09-11-040235_comments/up.sql | 113 -- migrations/2026-09-11-040235_threads/down.sql | 14 + migrations/2026-09-11-040235_threads/up.sql | 249 +++++ .../2026-09-11-050000_assistant/down.sql | 8 + migrations/2026-09-11-050000_assistant/up.sql | 77 ++ 75 files changed, 4748 insertions(+), 3080 deletions(-) delete mode 100644 crates/nvisy-postgres/src/model/chat_message.rs delete mode 100644 crates/nvisy-postgres/src/model/chat_session.rs create mode 100644 crates/nvisy-postgres/src/model/workspace_assistant_job.rs delete mode 100644 crates/nvisy-postgres/src/model/workspace_comment.rs create mode 100644 crates/nvisy-postgres/src/model/workspace_thread.rs create mode 100644 crates/nvisy-postgres/src/model/workspace_thread_anchor.rs create mode 100644 crates/nvisy-postgres/src/model/workspace_thread_comment.rs create mode 100644 crates/nvisy-postgres/src/model/workspace_thread_event.rs delete mode 100644 crates/nvisy-postgres/src/query/chat_message.rs delete mode 100644 crates/nvisy-postgres/src/query/chat_session.rs create mode 100644 crates/nvisy-postgres/src/query/workspace_assistant_job.rs delete mode 100644 crates/nvisy-postgres/src/query/workspace_comment.rs create mode 100644 crates/nvisy-postgres/src/query/workspace_thread.rs create mode 100644 crates/nvisy-postgres/src/query/workspace_thread_anchor.rs create mode 100644 crates/nvisy-postgres/src/query/workspace_thread_comment.rs create mode 100644 crates/nvisy-postgres/src/query/workspace_thread_event.rs delete mode 100644 crates/nvisy-postgres/src/types/constraint/chat_messages.rs delete mode 100644 crates/nvisy-postgres/src/types/constraint/chat_sessions.rs delete mode 100644 crates/nvisy-postgres/src/types/constraint/comments.rs create mode 100644 crates/nvisy-postgres/src/types/constraint/workspace_thread_anchors.rs create mode 100644 crates/nvisy-postgres/src/types/constraint/workspace_thread_comments.rs create mode 100644 crates/nvisy-postgres/src/types/constraint/workspace_threads.rs delete mode 100644 crates/nvisy-postgres/src/types/enums/chat_role.rs create mode 100644 crates/nvisy-postgres/src/types/enums/thread_event_kind.rs delete mode 100644 crates/nvisy-server/src/handler/chat.rs delete mode 100644 crates/nvisy-server/src/handler/request/chat.rs delete mode 100644 crates/nvisy-server/src/handler/response/chat.rs create mode 100644 crates/nvisy-server/src/handler/threads.rs delete mode 100644 crates/nvisy-server/src/response/error/pg_chat.rs create mode 100644 crates/nvisy-server/src/service/assistant/coordinator.rs create mode 100644 crates/nvisy-server/src/service/assistant/drainer.rs create mode 100644 crates/nvisy-server/src/service/assistant/job.rs create mode 100644 crates/nvisy-server/src/service/assistant/mod.rs create mode 100644 crates/nvisy-server/src/service/assistant/service.rs create mode 100644 crates/nvisy-server/src/service/assistant/worker.rs delete mode 100644 crates/nvisy-server/src/service/chat.rs delete mode 100644 migrations/2026-08-19-034709_chat/down.sql delete mode 100644 migrations/2026-08-19-034709_chat/up.sql delete mode 100644 migrations/2026-09-11-040235_comments/down.sql delete mode 100644 migrations/2026-09-11-040235_comments/up.sql create mode 100644 migrations/2026-09-11-040235_threads/down.sql create mode 100644 migrations/2026-09-11-040235_threads/up.sql create mode 100644 migrations/2026-09-11-050000_assistant/down.sql create mode 100644 migrations/2026-09-11-050000_assistant/up.sql diff --git a/crates/nvisy-nats/src/stream/event_stream.rs b/crates/nvisy-nats/src/stream/event_stream.rs index 61752304..91b8a7f9 100644 --- a/crates/nvisy-nats/src/stream/event_stream.rs +++ b/crates/nvisy-nats/src/stream/event_stream.rs @@ -99,6 +99,38 @@ where const SUBJECT: &'static str = "pipeline.detection.jobs"; } +/// Work queue for assistant-reply jobs. +/// +/// When a user addresses the assistant in a comment thread, a job is enqueued +/// here; a single shared durable consumer delivers it to one worker at a time +/// (at-least-once). The worker is idempotent on the triggering comment so a +/// redelivery does not post a second reply. Inference can be slow, so `ACK_WAIT` +/// exceeds the longest expected turn; `MAX_DELIVER` bounds retries so a job that +/// can never succeed (e.g. a workspace with no model provider) is not redelivered +/// forever. Messages expire after 1 hour so a backlog cannot pile up. +/// +/// Generic over its payload `M`; a consumer pins it with a type alias, e.g. +/// `type AssistantStream = nvisy_nats::stream::AssistantStream`. +pub enum AssistantStream { + #[doc(hidden)] + Never(PhantomData M>), +} + +impl EventStream for AssistantStream +where + M: Serialize + DeserializeOwned + Send + Sync + 'static, +{ + type Message = M; + + const ACK_WAIT: Option = Some(Duration::from_secs(5 * 60)); + const CONSUMER_NAME: &'static str = "assistant-worker"; + const DESCRIPTION: &'static str = "Assistant reply jobs"; + const MAX_AGE: Option = Some(Duration::from_secs(60 * 60)); + const MAX_DELIVER: Option = Some(5); + const NAME: &'static str = "ASSISTANT"; + const SUBJECT: &'static str = "assistant.replies"; +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/nvisy-nats/src/stream/mod.rs b/crates/nvisy-nats/src/stream/mod.rs index bb6fd510..9e66ad18 100644 --- a/crates/nvisy-nats/src/stream/mod.rs +++ b/crates/nvisy-nats/src/stream/mod.rs @@ -13,7 +13,7 @@ mod typed_sub; pub use core::EventStream; pub use broadcast_stream::BroadcastStream; -pub use event_stream::{ConnectionSyncStream, DetectionStream, WebhookStream}; +pub use event_stream::{AssistantStream, ConnectionSyncStream, DetectionStream, WebhookStream}; pub use typed_pub::EventPublisher; pub use typed_stream::{TypedMessage, TypedMessageStream}; pub use typed_sub::EventSubscriber; diff --git a/crates/nvisy-postgres/src/lib.rs b/crates/nvisy-postgres/src/lib.rs index be7bf82f..2a2b5585 100644 --- a/crates/nvisy-postgres/src/lib.rs +++ b/crates/nvisy-postgres/src/lib.rs @@ -10,6 +10,18 @@ pub(crate) const MIGRATIONS: diesel_migrations::EmbeddedMigrations = diesel_migrations::embed_migrations!("../../migrations"); +/// The fixed id of the reserved AI assistant account. +/// +/// Seeded by the `assistant_account` migration, this account authors the +/// assistant's replies in comment threads. It has no login identity (it cannot +/// authenticate) and is not a workspace member; mention resolution recognizes +/// its reserved handle directly. The constant lets code reference the account +/// without a lookup; it must match the id inserted by the migration. +pub const ASSISTANT_ACCOUNT_ID: uuid::Uuid = uuid::Uuid::from_u128(0x0a11); + +/// The reserved handle of the AI assistant account (see [`ASSISTANT_ACCOUNT_ID`]). +pub const ASSISTANT_HANDLE: &str = "assistant"; + /// Tracing target for database query operations. /// /// Use this target for logging query execution, results, and query-related errors. diff --git a/crates/nvisy-postgres/src/model/chat_message.rs b/crates/nvisy-postgres/src/model/chat_message.rs deleted file mode 100644 index 6074ea8b..00000000 --- a/crates/nvisy-postgres/src/model/chat_message.rs +++ /dev/null @@ -1,56 +0,0 @@ -//! Chat message model for PostgreSQL database operations. - -use diesel::prelude::*; -use jiff_diesel::Timestamp; -use uuid::Uuid; - -use crate::schema::chat_messages; -use crate::types::ChatRole; - -/// One message in a chat session. -#[derive(Debug, Clone, PartialEq, Queryable, Selectable)] -#[diesel(table_name = chat_messages)] -#[diesel(check_for_backend(diesel::pg::Pg))] -pub struct ChatMessage { - /// Unique message identifier. - pub id: Uuid, - /// Session this message belongs to. - pub session_id: Uuid, - /// Parent in the conversation tree; `None` is a root. - pub parent_id: Option, - /// Author of the message. - pub role: ChatRole, - /// Message text, XChaCha20-Poly1305 encrypted with the workspace key. - pub content: Vec, - /// Message creation timestamp. - pub created_at: Timestamp, -} - -/// Data for appending a new chat message. -#[derive(Debug, Clone, Insertable)] -#[diesel(table_name = chat_messages)] -#[diesel(check_for_backend(diesel::pg::Pg))] -#[must_use] -pub struct NewChatMessage { - /// Session this message belongs to. - pub session_id: Uuid, - /// Parent in the conversation tree; `None` is a root. - pub parent_id: Option, - /// Author of the message. - pub role: ChatRole, - /// Message text, XChaCha20-Poly1305 encrypted with the workspace key. - pub content: Vec, -} - -impl NewChatMessage { - /// A minimal root message in `session_id` with the given `role`, for tests. - #[cfg(any(feature = "test_util", test))] - pub fn test(session_id: Uuid, role: ChatRole) -> Self { - Self { - session_id, - parent_id: None, - role, - content: vec![1, 2, 3], - } - } -} diff --git a/crates/nvisy-postgres/src/model/chat_session.rs b/crates/nvisy-postgres/src/model/chat_session.rs deleted file mode 100644 index 06b89cde..00000000 --- a/crates/nvisy-postgres/src/model/chat_session.rs +++ /dev/null @@ -1,71 +0,0 @@ -//! Chat session model for PostgreSQL database operations. - -use diesel::prelude::*; -use jiff_diesel::Timestamp; -use uuid::Uuid; - -use crate::schema::chat_sessions; - -/// A workspace-scoped assistant conversation thread. -#[derive(Debug, Clone, PartialEq, Queryable, Selectable)] -#[diesel(table_name = chat_sessions)] -#[diesel(check_for_backend(diesel::pg::Pg))] -pub struct ChatSession { - /// Unique session identifier. - pub id: Uuid, - /// Workspace this session belongs to. - pub workspace_id: Uuid, - /// Account that opened the session. - pub account_id: Uuid, - /// Human-readable title, seeded from the first message. - pub title: String, - /// Active leaf of the message tree (the conversation's resume point). - pub current_message_id: Option, - /// Session creation timestamp. - pub created_at: Timestamp, - /// Timestamp of the most recent message. - pub updated_at: Timestamp, - /// Soft-deletion timestamp; `None` means live. - pub deleted_at: Option, -} - -/// Data for creating a new chat session. -#[derive(Debug, Clone, Insertable)] -#[diesel(table_name = chat_sessions)] -#[diesel(check_for_backend(diesel::pg::Pg))] -#[must_use] -pub struct NewChatSession { - /// Workspace this session belongs to. - pub workspace_id: Uuid, - /// Account that opened the session. - pub account_id: Uuid, - /// Human-readable title. - pub title: String, -} - -impl NewChatSession { - /// A minimal chat session for `workspace_id`, opened by `account_id`, for - /// tests. - #[cfg(any(feature = "test_util", test))] - pub fn test(workspace_id: Uuid, account_id: Uuid) -> Self { - Self { - workspace_id, - account_id, - title: "Test Chat".to_owned(), - } - } -} - -/// Data for updating a chat session. -#[derive(Debug, Default, Clone, AsChangeset)] -#[diesel(table_name = chat_sessions)] -#[diesel(check_for_backend(diesel::pg::Pg))] -#[must_use] -pub struct UpdateChatSession { - /// New title. - pub title: Option, - /// New active leaf of the message tree. - pub current_message_id: Option>, - /// New most-recent-activity timestamp. - pub updated_at: Option, -} diff --git a/crates/nvisy-postgres/src/model/mod.rs b/crates/nvisy-postgres/src/model/mod.rs index f83677b0..1093a5ae 100644 --- a/crates/nvisy-postgres/src/model/mod.rs +++ b/crates/nvisy-postgres/src/model/mod.rs @@ -7,14 +7,12 @@ mod account; mod account_api_token; mod account_identity; mod account_notification; -mod chat_message; -mod chat_session; mod event_outbox; mod pipeline_reference; mod workspace; mod workspace_activity; mod workspace_assignment; -mod workspace_comment; +mod workspace_assistant_job; mod workspace_connection; mod workspace_connection_schedule; mod workspace_connection_sync; @@ -30,6 +28,10 @@ mod workspace_pipeline; mod workspace_policy; mod workspace_provider; mod workspace_redaction; +mod workspace_thread; +mod workspace_thread_anchor; +mod workspace_thread_comment; +mod workspace_thread_event; mod workspace_webhook; // Account models @@ -39,9 +41,6 @@ pub use account_identity::{AccountIdentity, NewAccountIdentity}; pub use account_notification::{ AccountNotification, NewAccountNotification, UpdateAccountNotification, }; -// Chat models -pub use chat_message::{ChatMessage, NewChatMessage}; -pub use chat_session::{ChatSession, NewChatSession, UpdateChatSession}; pub use event_outbox::{EventOutbox, NewEventOutbox}; pub use pipeline_reference::PipelinePolicy; // Workspace models @@ -50,7 +49,7 @@ pub use workspace_activity::{NewWorkspaceActivity, WorkspaceActivity}; pub use workspace_assignment::{ NewWorkspaceAssignment, UpdateWorkspaceAssignment, WorkspaceAssignment, }; -pub use workspace_comment::{NewWorkspaceComment, UpdateWorkspaceComment, WorkspaceComment}; +pub use workspace_assistant_job::{NewWorkspaceAssistantJob, WorkspaceAssistantJob}; pub use workspace_connection::{ NewWorkspaceConnection, UpdateWorkspaceConnection, WorkspaceConnection, }; @@ -75,4 +74,10 @@ pub use workspace_pipeline::{NewWorkspacePipeline, UpdateWorkspacePipeline, Work pub use workspace_policy::{NewWorkspacePolicy, UpdateWorkspacePolicy, WorkspacePolicy}; pub use workspace_provider::{NewWorkspaceProvider, UpdateWorkspaceProvider, WorkspaceProvider}; pub use workspace_redaction::{NewWorkspaceRedaction, WorkspaceRedaction}; +pub use workspace_thread::{NewWorkspaceThread, UpdateWorkspaceThread, WorkspaceThread}; +pub use workspace_thread_anchor::{NewWorkspaceThreadAnchor, WorkspaceThreadAnchor}; +pub use workspace_thread_comment::{ + NewWorkspaceThreadComment, UpdateWorkspaceThreadComment, WorkspaceThreadComment, +}; +pub use workspace_thread_event::{NewWorkspaceThreadEvent, WorkspaceThreadEvent}; pub use workspace_webhook::{NewWorkspaceWebhook, UpdateWorkspaceWebhook, WorkspaceWebhook}; diff --git a/crates/nvisy-postgres/src/model/workspace_assistant_job.rs b/crates/nvisy-postgres/src/model/workspace_assistant_job.rs new file mode 100644 index 00000000..64799516 --- /dev/null +++ b/crates/nvisy-postgres/src/model/workspace_assistant_job.rs @@ -0,0 +1,64 @@ +//! Transactional-outbox model for assistant-reply jobs. + +use diesel::prelude::*; +use jiff_diesel::Timestamp; +use uuid::Uuid; + +use crate::schema::workspace_assistant_jobs; +use crate::types::OutboxStatus; + +/// A pending or processed assistant-reply outbox row: a serialized `AssistantJob` +/// awaiting (or past) publication to the assistant work-queue. +/// +/// The `job` column is an opaque JSON blob to this layer — a serialized +/// server-side `AssistantJob` — so the ORM stays free of the job vocabulary; the +/// drainer decodes it and publishes it. +#[derive(Debug, Clone, Queryable, Selectable)] +#[diesel(table_name = workspace_assistant_jobs)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct WorkspaceAssistantJob { + /// Unique outbox row identifier. + pub id: Uuid, + /// The comment that triggered this reply. + pub comment_id: Uuid, + /// The serialized assistant job. + pub job: serde_json::Value, + /// Processing state: pending, processed, or failed (dead-lettered). + pub status: OutboxStatus, + /// Number of publish attempts the drainer has made. + pub attempts: i32, + /// Earliest time the row may next be claimed; advanced by a backoff after + /// each failed attempt. + pub next_attempt_at: Timestamp, + /// When the job was queued. + pub created_at: Timestamp, + /// When a terminal (processed or failed) row was resolved by an operator; + /// `None` until then. A manual affordance for inspecting the outbox. + pub resolved_at: Option, +} + +/// A new assistant-reply outbox row, inserted in the same transaction as the +/// comment that triggers it. +#[derive(Debug, Clone, Insertable)] +#[diesel(table_name = workspace_assistant_jobs)] +#[diesel(check_for_backend(diesel::pg::Pg))] +#[must_use] +pub struct NewWorkspaceAssistantJob { + /// The comment that triggered this reply. + pub comment_id: Uuid, + /// The serialized assistant job. + pub job: serde_json::Value, +} + +impl NewWorkspaceAssistantJob { + /// A minimal pending outbox row for `comment_id`, with a placeholder job + /// payload, for tests. The status, attempts, and next-attempt time take their + /// database defaults, so the row is immediately due. + #[cfg(any(feature = "test_util", test))] + pub fn test(comment_id: Uuid) -> Self { + Self { + comment_id, + job: serde_json::json!({ "kind": "test" }), + } + } +} diff --git a/crates/nvisy-postgres/src/model/workspace_comment.rs b/crates/nvisy-postgres/src/model/workspace_comment.rs deleted file mode 100644 index d2445653..00000000 --- a/crates/nvisy-postgres/src/model/workspace_comment.rs +++ /dev/null @@ -1,95 +0,0 @@ -//! Workspace comment model for PostgreSQL database operations. - -use diesel::prelude::*; -use jiff_diesel::Timestamp; -use serde_json::Value; -use uuid::Uuid; - -use crate::schema::workspace_comments; - -/// A comment on a file under review. -/// -/// A comment is authored by a workspace member, optionally anchored to a location -/// within the file (a modality-tagged [`anchor`](Self::anchor)), optionally a -/// one-level reply to another comment ([`parent_id`](Self::parent_id)), and can -/// be resolved to close its thread. -#[derive(Debug, Clone, PartialEq, Queryable, Selectable)] -#[diesel(table_name = workspace_comments)] -#[diesel(check_for_backend(diesel::pg::Pg))] -pub struct WorkspaceComment { - /// Unique comment identifier. - pub id: Uuid, - /// Workspace this comment belongs to (denormalized for fast per-workspace - /// queries). - pub workspace_id: Uuid, - /// File the comment is on. - pub file_id: Uuid, - /// Account that wrote the comment. - pub author_account_id: Uuid, - /// Parent comment for a one-level reply; `None` for a top-level comment. - pub parent_id: Option, - /// The comment text. - pub body: String, - /// Optional modality-tagged location within the file the comment is pinned to. - /// `None` for a file-level comment. Stored as the anchor's typed JSON; the - /// handler layer decodes it into the typed anchor. - pub anchor: Option, - /// When the thread was resolved; `None` while open. - pub resolved_at: Option, - /// Account that resolved the thread, for the audit trail. `None` if open (or - /// if that account was since removed). - pub resolved_by: Option, - /// When the comment was created. - pub created_at: Timestamp, - /// When the comment was last updated. - pub updated_at: Timestamp, - /// When the comment was soft-deleted; `None` means live. - pub deleted_at: Option, -} - -/// Data for creating a new workspace comment. -#[derive(Debug, Default, Clone, Insertable)] -#[diesel(table_name = workspace_comments)] -#[diesel(check_for_backend(diesel::pg::Pg))] -#[must_use] -pub struct NewWorkspaceComment { - /// Workspace ID (required). - pub workspace_id: Uuid, - /// File ID (required). - pub file_id: Uuid, - /// Author account ID (required). - pub author_account_id: Uuid, - /// Parent comment for a reply; `None` for a top-level comment. - pub parent_id: Option, - /// The comment text (required). - pub body: String, - /// Optional anchor JSON. - pub anchor: Option, -} - -impl NewWorkspaceComment { - /// A minimal top-level comment on `file_id`, for tests. - #[cfg(any(feature = "test_util", test))] - pub fn test(workspace_id: Uuid, file_id: Uuid, author_account_id: Uuid) -> Self { - Self { - workspace_id, - file_id, - author_account_id, - body: "A test comment.".to_owned(), - ..Default::default() - } - } -} - -/// Data for updating a workspace comment's body. -/// -/// Only the body is editable. Resolution and soft-delete are separate repository -/// operations (they set their own audited timestamp columns). -#[derive(Debug, Clone, Default, AsChangeset)] -#[diesel(table_name = workspace_comments)] -#[diesel(check_for_backend(diesel::pg::Pg))] -#[must_use] -pub struct UpdateWorkspaceComment { - /// The new comment text. - pub body: Option, -} diff --git a/crates/nvisy-postgres/src/model/workspace_thread.rs b/crates/nvisy-postgres/src/model/workspace_thread.rs new file mode 100644 index 00000000..68f835e0 --- /dev/null +++ b/crates/nvisy-postgres/src/model/workspace_thread.rs @@ -0,0 +1,79 @@ +//! Workspace thread model: the closable, optionally file-anchored unit of +//! discussion. Its messages are +//! [`WorkspaceThreadComment`](super::WorkspaceThreadComment)s, its pins +//! [`WorkspaceThreadAnchor`](super::WorkspaceThreadAnchor)s, and its lifecycle +//! history [`WorkspaceThreadEvent`](super::WorkspaceThreadEvent)s. + +use diesel::prelude::*; +use jiff_diesel::Timestamp; +use uuid::Uuid; + +use crate::schema::workspace_threads; + +/// A discussion thread: the closable, optionally file-anchored unit. +#[derive(Debug, Clone, PartialEq, Queryable, Selectable)] +#[diesel(table_name = workspace_threads)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct WorkspaceThread { + /// Unique thread identifier. + pub id: Uuid, + /// Workspace this thread belongs to (denormalized). + pub workspace_id: Uuid, + /// File the thread is pinned to; `None` for a workspace-level thread. + pub file_id: Option, + /// Account that opened the thread. + pub author_account_id: Uuid, + /// Optional human-readable title; `None` for an untitled thread. + pub display_name: Option, + /// When the thread was closed; `None` while open. + pub closed_at: Option, + /// Account that closed the thread; `None` if open (or that account was + /// removed). + pub closed_by: Option, + /// When the thread was created. + pub created_at: Timestamp, + /// When the thread was last updated. + pub updated_at: Timestamp, + /// When the thread was soft-deleted; `None` means live. + pub deleted_at: Option, +} + +/// Data for creating a new thread. +#[derive(Debug, Default, Clone, Insertable)] +#[diesel(table_name = workspace_threads)] +#[diesel(check_for_backend(diesel::pg::Pg))] +#[must_use] +pub struct NewWorkspaceThread { + /// Workspace ID (required). + pub workspace_id: Uuid, + /// File the thread is pinned to; `None` for a workspace-level thread. + pub file_id: Option, + /// Opening author account ID (required). + pub author_account_id: Uuid, + /// Optional title. + pub display_name: Option, +} + +impl NewWorkspaceThread { + /// A minimal file-pinned thread opened by `author`, for tests. + #[cfg(any(feature = "test_util", test))] + pub fn test(workspace_id: Uuid, file_id: Uuid, author_account_id: Uuid) -> Self { + Self { + workspace_id, + file_id: Some(file_id), + author_account_id, + display_name: None, + } + } +} + +/// Data for updating a thread's title. +#[derive(Debug, Clone, Default, AsChangeset)] +#[diesel(table_name = workspace_threads)] +#[diesel(check_for_backend(diesel::pg::Pg))] +#[must_use] +pub struct UpdateWorkspaceThread { + /// The new title. `Some(None)` clears it, `Some(Some(name))` sets it, `None` + /// leaves it unchanged. + pub display_name: Option>, +} diff --git a/crates/nvisy-postgres/src/model/workspace_thread_anchor.rs b/crates/nvisy-postgres/src/model/workspace_thread_anchor.rs new file mode 100644 index 00000000..0ac51f38 --- /dev/null +++ b/crates/nvisy-postgres/src/model/workspace_thread_anchor.rs @@ -0,0 +1,40 @@ +//! Workspace thread-anchor model: a location within a thread's file the thread is +//! pinned to. A thread may have several; removal is a soft delete so timeline +//! events keep their referent. + +use diesel::prelude::*; +use jiff_diesel::Timestamp; +use serde_json::Value; +use uuid::Uuid; + +use crate::schema::workspace_thread_anchors; + +/// A location within a thread's file the thread is pinned to. +#[derive(Debug, Clone, PartialEq, Queryable, Selectable)] +#[diesel(table_name = workspace_thread_anchors)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct WorkspaceThreadAnchor { + /// Unique anchor identifier. + pub id: Uuid, + /// Thread this anchor pins. + pub thread_id: Uuid, + /// The modality-tagged location, as typed JSON. Decoded into the typed anchor + /// by the handler layer. + pub anchor: Value, + /// When the anchor was added. + pub created_at: Timestamp, + /// When the anchor was removed; `None` means live. + pub deleted_at: Option, +} + +/// Data for adding an anchor to a thread. +#[derive(Debug, Clone, Insertable)] +#[diesel(table_name = workspace_thread_anchors)] +#[diesel(check_for_backend(diesel::pg::Pg))] +#[must_use] +pub struct NewWorkspaceThreadAnchor { + /// Thread ID (required). + pub thread_id: Uuid, + /// The anchor JSON (required). + pub anchor: Value, +} diff --git a/crates/nvisy-postgres/src/model/workspace_thread_comment.rs b/crates/nvisy-postgres/src/model/workspace_thread_comment.rs new file mode 100644 index 00000000..9a0d9dcd --- /dev/null +++ b/crates/nvisy-postgres/src/model/workspace_thread_comment.rs @@ -0,0 +1,76 @@ +//! Workspace thread-comment model: one message within a thread. + +use diesel::prelude::*; +use jiff_diesel::Timestamp; +use uuid::Uuid; + +use crate::schema::workspace_thread_comments; + +/// A comment: one message within a thread. +#[derive(Debug, Clone, PartialEq, Queryable, Selectable)] +#[diesel(table_name = workspace_thread_comments)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct WorkspaceThreadComment { + /// Unique comment identifier. + pub id: Uuid, + /// For a reply, the comment it answers; `None` for an ordinary message. + pub parent_id: Option, + /// Workspace this comment belongs to (denormalized). + pub workspace_id: Uuid, + /// Thread this message belongs to. + pub thread_id: Uuid, + /// Account that wrote the message. + pub author_account_id: Uuid, + /// The message text. + pub body: String, + /// When the comment was created. + pub created_at: Timestamp, + /// When the comment was last updated. + pub updated_at: Timestamp, + /// When the comment was soft-deleted; `None` means live. + pub deleted_at: Option, +} + +/// Data for creating a new comment (a message in a thread). +#[derive(Debug, Default, Clone, Insertable)] +#[diesel(table_name = workspace_thread_comments)] +#[diesel(check_for_backend(diesel::pg::Pg))] +#[must_use] +pub struct NewWorkspaceThreadComment { + /// For a reply, the comment it answers; `None` for an ordinary comment. The + /// database's partial unique index on this column makes at most one live reply + /// exist per parent. + pub parent_id: Option, + /// Workspace ID (required). + pub workspace_id: Uuid, + /// Thread ID (required). + pub thread_id: Uuid, + /// Author account ID (required). + pub author_account_id: Uuid, + /// The message text (required). + pub body: String, +} + +impl NewWorkspaceThreadComment { + /// A minimal message in `thread_id`, for tests. + #[cfg(any(feature = "test_util", test))] + pub fn test(workspace_id: Uuid, thread_id: Uuid, author_account_id: Uuid) -> Self { + Self { + parent_id: None, + workspace_id, + thread_id, + author_account_id, + body: "A test comment.".to_owned(), + } + } +} + +/// Data for updating a comment's body. Only the body is editable. +#[derive(Debug, Clone, Default, AsChangeset)] +#[diesel(table_name = workspace_thread_comments)] +#[diesel(check_for_backend(diesel::pg::Pg))] +#[must_use] +pub struct UpdateWorkspaceThreadComment { + /// The new message text. + pub body: Option, +} diff --git a/crates/nvisy-postgres/src/model/workspace_thread_event.rs b/crates/nvisy-postgres/src/model/workspace_thread_event.rs new file mode 100644 index 00000000..240d4038 --- /dev/null +++ b/crates/nvisy-postgres/src/model/workspace_thread_event.rs @@ -0,0 +1,50 @@ +//! Workspace thread-event model: an immutable non-message entry in a thread's +//! timeline (opened, closed, reopened, renamed, or an anchor added/removed). + +use diesel::prelude::*; +use jiff_diesel::Timestamp; +use serde_json::Value; +use uuid::Uuid; + +use crate::schema::workspace_thread_events; +use crate::types::ThreadEventKind; + +/// An immutable non-message entry in a thread's timeline. +#[derive(Debug, Clone, PartialEq, Queryable, Selectable)] +#[diesel(table_name = workspace_thread_events)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct WorkspaceThreadEvent { + /// Unique event identifier. + pub id: Uuid, + /// Workspace this event belongs to (denormalized). + pub workspace_id: Uuid, + /// Thread this event belongs to. + pub thread_id: Uuid, + /// What happened. + pub kind: ThreadEventKind, + /// Account that performed the action; `None` if that account was removed. + pub actor_account_id: Option, + /// Event-specific detail (an anchor snapshot for anchor events, the new name + /// for a rename); `None` for open/close/reopen. + pub target: Option, + /// When the event happened. + pub created_at: Timestamp, +} + +/// Data for recording a new thread timeline event. +#[derive(Debug, Clone, Insertable)] +#[diesel(table_name = workspace_thread_events)] +#[diesel(check_for_backend(diesel::pg::Pg))] +#[must_use] +pub struct NewWorkspaceThreadEvent { + /// Workspace ID (required). + pub workspace_id: Uuid, + /// Thread ID (required). + pub thread_id: Uuid, + /// What happened (required). + pub kind: ThreadEventKind, + /// Account that performed the action. + pub actor_account_id: Option, + /// Event-specific detail; `None` for open/close/reopen. + pub target: Option, +} diff --git a/crates/nvisy-postgres/src/query/chat_message.rs b/crates/nvisy-postgres/src/query/chat_message.rs deleted file mode 100644 index 81776cf0..00000000 --- a/crates/nvisy-postgres/src/query/chat_message.rs +++ /dev/null @@ -1,315 +0,0 @@ -//! Chat messages repository. - -use std::future::Future; - -use diesel::prelude::*; -use diesel_async::RunQueryDsl; -use uuid::Uuid; - -use crate::model::{ChatMessage, NewChatMessage, UpdateChatSession}; -use crate::{Error, PgConnection, Result, schema}; - -/// What to update on a message's session when appending it, applied in the same -/// transaction as the insert so the message and its session state never diverge. -#[derive(Debug, Clone, Default)] -pub struct AppendSessionUpdate { - /// Point the session's active leaf at the newly appended message. - pub advance_leaf: bool, - /// Set the session's title (e.g. seeded from the first message). - pub title: Option, -} - -/// Repository for chat message database operations. -pub trait ChatMessageRepository { - /// Appends a message and updates its session per `session_update` in one - /// transaction, so the session's active leaf and title never diverge from - /// its messages. The session's `updated_at` is always bumped. Returns the - /// stored message. - fn append_chat_message( - &mut self, - new_message: NewChatMessage, - session_update: AppendSessionUpdate, - ) -> impl Future> + Send; - - /// Loads all of a session's messages (the whole tree), oldest first. - fn list_chat_messages( - &mut self, - session_id: Uuid, - ) -> impl Future>> + Send; - - /// Finds a message by id within a session, scoping a client-supplied parent - /// to the session it belongs to. - fn find_chat_message_in_session( - &mut self, - session_id: Uuid, - message_id: Uuid, - ) -> impl Future>> + Send; -} - -impl ChatMessageRepository for PgConnection { - async fn append_chat_message( - &mut self, - new_message: NewChatMessage, - session_update: AppendSessionUpdate, - ) -> Result { - use diesel::dsl::now; - use diesel_async::AsyncConnection; - use schema::{chat_messages, chat_sessions}; - - let session_id = new_message.session_id; - - // Insert the message and update its session atomically, so the active - // leaf and title never diverge from the messages. `updated_at` is always - // bumped so the session-list ordering reflects the latest message. The - // active leaf is set to the row just inserted (its id is known only here). - self.transaction(async |conn| { - let message = diesel::insert_into(chat_messages::table) - .values(&new_message) - .returning(ChatMessage::as_returning()) - .get_result(conn) - .await - .map_err(Error::from)?; - - let update = UpdateChatSession { - title: session_update.title, - current_message_id: session_update.advance_leaf.then_some(Some(message.id)), - updated_at: None, - }; - diesel::update(chat_sessions::table.filter(chat_sessions::id.eq(session_id))) - .set((update, chat_sessions::updated_at.eq(now))) - .execute(conn) - .await - .map_err(Error::from)?; - - Ok::<_, Error>(message) - }) - .await - } - - async fn list_chat_messages(&mut self, session_id: Uuid) -> Result> { - use schema::chat_messages::{self, dsl}; - - chat_messages::table - .filter(dsl::session_id.eq(session_id)) - .order(dsl::created_at.asc()) - .select(ChatMessage::as_select()) - .load(self) - .await - .map_err(Error::from) - } - - async fn find_chat_message_in_session( - &mut self, - session_id: Uuid, - message_id: Uuid, - ) -> Result> { - use schema::chat_messages::{self, dsl}; - - chat_messages::table - .filter(dsl::id.eq(message_id)) - .filter(dsl::session_id.eq(session_id)) - .select(ChatMessage::as_select()) - .first(self) - .await - .optional() - .map_err(Error::from) - } -} - -impl ChatMessage { - /// The active conversation path ending at `leaf_id`: the chain of messages - /// from the root down to that leaf, in chronological order. - /// - /// Follows `parent_id` links up from the leaf through `messages` (the - /// session's full message set), then reverses. A `None` leaf, or a leaf not - /// present, yields an empty path. Sessions are small, so walking the loaded - /// set in memory is cheaper and simpler than a recursive query. - #[must_use] - pub fn path_to(messages: &[ChatMessage], leaf_id: Option) -> Vec<&ChatMessage> { - use std::collections::HashMap; - - let by_id: HashMap = messages.iter().map(|m| (m.id, m)).collect(); - - let mut path = Vec::new(); - let mut cursor = leaf_id; - while let Some(id) = cursor { - let Some(message) = by_id.get(&id) else { break }; - path.push(*message); - cursor = message.parent_id; - } - path.reverse(); - path - } -} - -#[cfg(test)] -mod tests { - use jiff::{Span, Timestamp}; - use uuid::Uuid; - - use super::*; - use crate::PgConn; - use crate::model::{NewChatMessage, NewChatSession}; - use crate::query::{ChatMessageRepository, ChatSessionRepository}; - use crate::test_util::{TestDatabase, backdate}; - use crate::types::ChatRole; - - /// Seeds a chat session and returns its id. - async fn seed_session(db: &TestDatabase) -> anyhow::Result { - let (account_id, workspace_id) = db.seed_account_and_workspace().await; - let mut conn = db.client.get_connection().await?; - let session = conn - .create_chat_session(NewChatSession::test(workspace_id, account_id)) - .await?; - Ok(session.id) - } - - /// Appends a message, then backdates its `created_at` by `age`, so oldest-first - /// ordering is deterministic across messages in one test. - async fn append_at( - conn: &mut PgConn, - session_id: Uuid, - parent_id: Option, - role: ChatRole, - age: Span, - ) -> anyhow::Result { - let mut new = NewChatMessage::test(session_id, role); - new.parent_id = parent_id; - let message = conn - .append_chat_message(new, AppendSessionUpdate::default()) - .await?; - backdate::chat_message_created_at(conn, message.id, Timestamp::now() - age).await?; - Ok(message) - } - - #[tokio::test] - async fn append_advances_the_session_leaf_and_sets_the_title() -> anyhow::Result<()> { - let db = TestDatabase::start().await; - let (account_id, workspace_id) = db.seed_account_and_workspace().await; - let mut conn = db.client.get_connection().await?; - let session = conn - .create_chat_session(NewChatSession::test(workspace_id, account_id)) - .await?; - assert!(session.current_message_id.is_none()); - - let message = conn - .append_chat_message( - NewChatMessage::test(session.id, ChatRole::User), - AppendSessionUpdate { - advance_leaf: true, - title: Some("Seeded Title".to_owned()), - }, - ) - .await?; - - // The session's active leaf now points at the appended message, and the - // title was set — both in the same transaction as the insert. - let reread = conn - .find_chat_session_in_workspace(workspace_id, session.id) - .await? - .expect("session present"); - assert_eq!(reread.current_message_id, Some(message.id)); - assert_eq!(reread.title, "Seeded Title"); - Ok(()) - } - - #[tokio::test] - async fn find_in_session_is_scoped_and_list_is_oldest_first() -> anyhow::Result<()> { - let db = TestDatabase::start().await; - let session_id = seed_session(&db).await?; - let mut conn = db.client.get_connection().await?; - - // A root and a reply, root older so the oldest-first order is deterministic. - let root = append_at( - &mut conn, - session_id, - None, - ChatRole::User, - Span::new().hours(1), - ) - .await?; - let reply = append_at( - &mut conn, - session_id, - Some(root.id), - ChatRole::Assistant, - Span::new().minutes(1), - ) - .await?; - - // Found within its session, not under a different session id. - assert!( - conn.find_chat_message_in_session(session_id, root.id) - .await? - .is_some() - ); - assert!( - conn.find_chat_message_in_session(Uuid::now_v7(), root.id) - .await? - .is_none() - ); - - // The listing is the whole tree, oldest first. - let messages = conn.list_chat_messages(session_id).await?; - assert_eq!( - messages.iter().map(|m| m.id).collect::>(), - vec![root.id, reply.id] - ); - Ok(()) - } - - #[tokio::test] - async fn path_to_walks_from_root_to_leaf() -> anyhow::Result<()> { - let db = TestDatabase::start().await; - let session_id = seed_session(&db).await?; - let mut conn = db.client.get_connection().await?; - - // Build root -> a -> b, plus a sibling branch off root that is NOT on the path. - let root = append_at( - &mut conn, - session_id, - None, - ChatRole::User, - Span::new().hours(3), - ) - .await?; - let a = append_at( - &mut conn, - session_id, - Some(root.id), - ChatRole::Assistant, - Span::new().hours(2), - ) - .await?; - let b = append_at( - &mut conn, - session_id, - Some(a.id), - ChatRole::User, - Span::new().hours(1), - ) - .await?; - let _sibling = append_at( - &mut conn, - session_id, - Some(root.id), - ChatRole::Assistant, - Span::new().minutes(1), - ) - .await?; - - let messages = conn.list_chat_messages(session_id).await?; - - // The path to `b` is root -> a -> b, excluding the sibling branch. - let path = ChatMessage::path_to(&messages, Some(b.id)); - assert_eq!( - path.iter().map(|m| m.id).collect::>(), - vec![root.id, a.id, b.id] - ); - - // A None leaf, or a leaf not in the set, yields an empty path. - assert!(ChatMessage::path_to(&messages, None).is_empty()); - assert!(ChatMessage::path_to(&messages, Some(Uuid::now_v7())).is_empty()); - Ok(()) - } -} diff --git a/crates/nvisy-postgres/src/query/chat_session.rs b/crates/nvisy-postgres/src/query/chat_session.rs deleted file mode 100644 index d434758a..00000000 --- a/crates/nvisy-postgres/src/query/chat_session.rs +++ /dev/null @@ -1,223 +0,0 @@ -//! Chat sessions repository. - -use std::future::Future; - -use diesel::prelude::*; -use diesel_async::RunQueryDsl; -use uuid::Uuid; - -use crate::model::{ChatSession, NewChatSession}; -use crate::types::{CursorPage, CursorPagination}; -use crate::{Error, PgConnection, Result, schema}; - -/// Repository for chat session database operations. -pub trait ChatSessionRepository { - /// Creates a new chat session. - fn create_chat_session( - &mut self, - new_session: NewChatSession, - ) -> impl Future> + Send; - - /// Finds a live session by id within a workspace. - fn find_chat_session_in_workspace( - &mut self, - workspace_id: Uuid, - session_id: Uuid, - ) -> impl Future>> + Send; - - /// Lists a workspace's live sessions, newest first, cursor-paginated. - /// - /// Ordered by `(created_at, id)` — both immutable — so the cursor is stable - /// even as sessions are updated during pagination. - fn list_chat_sessions( - &mut self, - workspace_id: Uuid, - pagination: CursorPagination, - ) -> impl Future>> + Send; - - /// Soft-deletes a live session within a workspace, returning whether a live - /// session was deleted. - fn delete_chat_session( - &mut self, - workspace_id: Uuid, - session_id: Uuid, - ) -> impl Future> + Send; -} - -impl ChatSessionRepository for PgConnection { - async fn create_chat_session(&mut self, new_session: NewChatSession) -> Result { - use schema::chat_sessions; - - diesel::insert_into(chat_sessions::table) - .values(&new_session) - .returning(ChatSession::as_returning()) - .get_result(self) - .await - .map_err(Error::from) - } - - async fn find_chat_session_in_workspace( - &mut self, - workspace_id: Uuid, - session_id: Uuid, - ) -> Result> { - use schema::chat_sessions::{self, dsl}; - - chat_sessions::table - .filter(dsl::id.eq(session_id)) - .filter(dsl::workspace_id.eq(workspace_id)) - .filter(dsl::deleted_at.is_null()) - .select(ChatSession::as_select()) - .first(self) - .await - .optional() - .map_err(Error::from) - } - - async fn list_chat_sessions( - &mut self, - workspace_id: Uuid, - pagination: CursorPagination, - ) -> Result> { - use diesel::dsl::count_star; - use schema::chat_sessions::{self, dsl}; - - // Count only when the caller asked, so the default page skips the query. - let total = if pagination.include_count { - Some( - chat_sessions::table - .filter(dsl::workspace_id.eq(workspace_id)) - .filter(dsl::deleted_at.is_null()) - .select(count_star()) - .get_result(self) - .await - .map_err(Error::from)?, - ) - } else { - None - }; - - let mut query = chat_sessions::table - .filter(dsl::workspace_id.eq(workspace_id)) - .filter(dsl::deleted_at.is_null()) - .into_boxed(); - - if let Some(cursor) = &pagination.after { - let cursor_ts = jiff_diesel::Timestamp::from(cursor.timestamp); - query = query.filter( - dsl::created_at - .lt(cursor_ts) - .or(dsl::created_at.eq(cursor_ts).and(dsl::id.lt(cursor.id))), - ); - } - - let sessions: Vec = query - .select(ChatSession::as_select()) - .order((dsl::created_at.desc(), dsl::id.desc())) - .limit(pagination.fetch_limit()) - .load(self) - .await - .map_err(Error::from)?; - - Ok(CursorPage::new(sessions, total, pagination.limit, |s| { - (s.created_at.into(), s.id) - })) - } - - async fn delete_chat_session(&mut self, workspace_id: Uuid, session_id: Uuid) -> Result { - use diesel::dsl::now; - use schema::chat_sessions::{self, dsl}; - - let affected = diesel::update( - chat_sessions::table - .filter(dsl::id.eq(session_id)) - .filter(dsl::workspace_id.eq(workspace_id)) - .filter(dsl::deleted_at.is_null()), - ) - .set(dsl::deleted_at.eq(now)) - .execute(self) - .await - .map_err(Error::from)?; - - Ok(affected > 0) - } -} - -#[cfg(test)] -mod tests { - use jiff::{Span, Timestamp}; - use uuid::Uuid; - - use super::*; - use crate::model::NewChatSession; - use crate::query::ChatSessionRepository; - use crate::test_util::{TestDatabase, backdate}; - - #[tokio::test] - async fn create_find_scoped_and_soft_delete() -> anyhow::Result<()> { - let db = TestDatabase::start().await; - let (account_id, workspace_id) = db.seed_account_and_workspace().await; - let mut conn = db.client.get_connection().await?; - - let session = conn - .create_chat_session(NewChatSession::test(workspace_id, account_id)) - .await?; - - // Found within its workspace, not in another. - assert!( - conn.find_chat_session_in_workspace(workspace_id, session.id) - .await? - .is_some() - ); - assert!( - conn.find_chat_session_in_workspace(Uuid::now_v7(), session.id) - .await? - .is_none() - ); - - // Delete returns true once, then false (idempotent, live-scoped), and the - // session is hidden from the lookup. - assert!(conn.delete_chat_session(workspace_id, session.id).await?); - assert!(!conn.delete_chat_session(workspace_id, session.id).await?); - assert!( - conn.find_chat_session_in_workspace(workspace_id, session.id) - .await? - .is_none() - ); - Ok(()) - } - - #[tokio::test] - async fn list_is_newest_first_and_excludes_deleted() -> anyhow::Result<()> { - let db = TestDatabase::start().await; - let (account_id, workspace_id) = db.seed_account_and_workspace().await; - let mut conn = db.client.get_connection().await?; - - // Backdate `first` an hour so the newest-first order is deterministic. - let first = conn - .create_chat_session(NewChatSession::test(workspace_id, account_id)) - .await?; - backdate::chat_session_created_at( - &mut conn, - first.id, - Timestamp::now() - Span::new().hours(1), - ) - .await?; - let second = conn - .create_chat_session(NewChatSession::test(workspace_id, account_id)) - .await?; - let deleted = conn - .create_chat_session(NewChatSession::test(workspace_id, account_id)) - .await?; - assert!(conn.delete_chat_session(workspace_id, deleted.id).await?); - - let page = conn - .list_chat_sessions(workspace_id, CursorPagination::new(50)) - .await?; - assert_eq!( - page.items.iter().map(|s| s.id).collect::>(), - vec![second.id, first.id] - ); - Ok(()) - } -} diff --git a/crates/nvisy-postgres/src/query/mod.rs b/crates/nvisy-postgres/src/query/mod.rs index 0a62572b..3a0778b4 100644 --- a/crates/nvisy-postgres/src/query/mod.rs +++ b/crates/nvisy-postgres/src/query/mod.rs @@ -18,15 +18,13 @@ mod account_api_token; mod account_identity; mod account_notification; mod analytics; -mod chat_message; -mod chat_session; mod event_outbox; mod pipeline_reference; mod search; mod workspace; mod workspace_activity; mod workspace_assignment; -mod workspace_comment; +mod workspace_assistant_job; mod workspace_connection; mod workspace_connection_schedule; mod workspace_connection_sync; @@ -39,6 +37,10 @@ mod workspace_pipeline; mod workspace_policy; mod workspace_provider; mod workspace_redaction; +mod workspace_thread; +mod workspace_thread_anchor; +mod workspace_thread_comment; +mod workspace_thread_event; mod workspace_webhook; pub use account::AccountRepository; @@ -49,8 +51,6 @@ pub use analytics::{ AnalyticsSnapshot, DetectionDayPoint, DetectionDurations, DetectionStatusCount, StorageByKind, UsageByModel, WorkspaceAnalyticsRepository, }; -pub use chat_message::{AppendSessionUpdate, ChatMessageRepository}; -pub use chat_session::ChatSessionRepository; pub use event_outbox::EventOutboxRepository; pub use pipeline_reference::PipelineReferenceRepository; pub use workspace::WorkspaceRepository; @@ -58,7 +58,7 @@ pub use workspace_activity::{ActivityFilter, WorkspaceActivityRepository}; pub use workspace_assignment::{ AssignmentListRow, CreateAssignmentOutcome, WorkspaceAssignmentRepository, }; -pub use workspace_comment::{ReplyParentError, WorkspaceCommentRepository}; +pub use workspace_assistant_job::AssistantJobOutboxRepository; pub use workspace_connection::{ScheduledConnection, WorkspaceConnectionRepository}; pub use workspace_connection_schedule::WorkspaceConnectionScheduleRepository; pub use workspace_connection_sync::WorkspaceConnectionSyncRepository; @@ -71,4 +71,8 @@ pub use workspace_pipeline::WorkspacePipelineRepository; pub use workspace_policy::WorkspacePolicyRepository; pub use workspace_provider::WorkspaceProviderRepository; pub use workspace_redaction::WorkspaceRedactionRepository; +pub use workspace_thread::WorkspaceThreadRepository; +pub use workspace_thread_anchor::WorkspaceThreadAnchorRepository; +pub use workspace_thread_comment::WorkspaceThreadCommentRepository; +pub use workspace_thread_event::WorkspaceThreadEventRepository; pub use workspace_webhook::WorkspaceWebhookRepository; diff --git a/crates/nvisy-postgres/src/query/workspace_assistant_job.rs b/crates/nvisy-postgres/src/query/workspace_assistant_job.rs new file mode 100644 index 00000000..066928ba --- /dev/null +++ b/crates/nvisy-postgres/src/query/workspace_assistant_job.rs @@ -0,0 +1,274 @@ +//! Assistant-job outbox repository: the write side (insert in the create-comment +//! transaction) and the drainer side (claim a due batch, mark processed, defer or +//! dead-letter a failure). +//! +//! The drainer runs the claim and the subsequent `mark_*`/`defer_*` inside one +//! transaction per batch (see the assistant-job drainer), so the `FOR UPDATE SKIP +//! LOCKED` locks are held from claim through completion: no other drainer takes +//! the same rows, and a row's state transition commits atomically with its +//! publication. + +use std::future::Future; + +use diesel::prelude::*; +use diesel::sql_types::{BigInt, Timestamptz}; +use diesel_async::RunQueryDsl; +use uuid::Uuid; + +use crate::model::{NewWorkspaceAssistantJob, WorkspaceAssistantJob}; +use crate::types::OutboxStatus; +use crate::{Error, PgConnection, Result, schema}; + +/// Read and write operations on the assistant-job outbox. +pub trait AssistantJobOutboxRepository { + /// Inserts one job outbox row. Called in the same transaction as the comment + /// it queues, so the two commit atomically. + fn insert_assistant_job( + &mut self, + row: NewWorkspaceAssistantJob, + ) -> impl Future> + Send; + + /// Claims up to `limit` due pending rows for publication, oldest first. + /// + /// Due means unprocessed, not dead-lettered, and past its `next_attempt_at`, + /// so a row deferred by a backoff is skipped until its time arrives. Locks the + /// claimed rows with `FOR UPDATE SKIP LOCKED` so concurrent drainers take + /// disjoint batches without blocking each other; the lock is held for the + /// caller's transaction. Must run inside that transaction. + fn claim_assistant_job_batch( + &mut self, + limit: i64, + ) -> impl Future>> + Send; + + /// Marks a row processed (its job durably published), taking it out of the + /// pending set. Runs in the drainer's batch transaction. + fn mark_assistant_job_processed(&mut self, id: Uuid) + -> impl Future> + Send; + + /// Records a failed attempt: increments `attempts` and defers the next attempt + /// to `now() + backoff_secs` (computed by the database clock, so a drainer's + /// wall-clock skew cannot mis-schedule it), leaving the row pending for a + /// later retry. Runs in the drainer's batch transaction. + fn defer_assistant_job_attempt( + &mut self, + id: Uuid, + backoff_secs: i64, + ) -> impl Future> + Send; + + /// Dead-letters a row: increments `attempts` and marks it `Failed`, taking it + /// out of the pending set so a job that can never publish stops consuming + /// drain cycles. The row is retained for inspection. Runs in the drainer's + /// batch transaction. + fn mark_assistant_job_failed(&mut self, id: Uuid) -> impl Future> + Send; +} + +impl AssistantJobOutboxRepository for PgConnection { + async fn insert_assistant_job( + &mut self, + row: NewWorkspaceAssistantJob, + ) -> Result { + use schema::workspace_assistant_jobs; + + diesel::insert_into(workspace_assistant_jobs::table) + .values(&row) + .returning(WorkspaceAssistantJob::as_returning()) + .get_result(self) + .await + .map_err(Error::from) + } + + async fn claim_assistant_job_batch( + &mut self, + limit: i64, + ) -> Result> { + use schema::workspace_assistant_jobs::{self, dsl}; + + workspace_assistant_jobs::table + .filter(dsl::status.eq(OutboxStatus::Pending)) + .filter(dsl::next_attempt_at.le(diesel::dsl::now)) + .order((dsl::next_attempt_at.asc(), dsl::created_at.asc())) + .limit(limit) + .select(WorkspaceAssistantJob::as_select()) + .for_update() + .skip_locked() + .load(self) + .await + .map_err(Error::from) + } + + async fn mark_assistant_job_processed(&mut self, id: Uuid) -> Result<()> { + use schema::workspace_assistant_jobs::{self, dsl}; + + diesel::update(workspace_assistant_jobs::table.filter(dsl::id.eq(id))) + .set(( + dsl::status.eq(OutboxStatus::Processed), + dsl::attempts.eq(dsl::attempts + 1), + )) + .execute(self) + .await + .map_err(Error::from)?; + Ok(()) + } + + async fn defer_assistant_job_attempt(&mut self, id: Uuid, backoff_secs: i64) -> Result<()> { + use schema::workspace_assistant_jobs::{self, dsl}; + + // The row stays `Pending`; only its attempt count and next-due time move. + // `now() + (backoff_secs * interval '1 second')` schedules the next attempt + // by the database clock, not the drainer's. + let next_attempt_at = diesel::dsl::sql::("now() + (") + .bind::(backoff_secs) + .sql(" * interval '1 second')"); + diesel::update(workspace_assistant_jobs::table.filter(dsl::id.eq(id))) + .set(( + dsl::attempts.eq(dsl::attempts + 1), + dsl::next_attempt_at.eq(next_attempt_at), + )) + .execute(self) + .await + .map_err(Error::from)?; + Ok(()) + } + + async fn mark_assistant_job_failed(&mut self, id: Uuid) -> Result<()> { + use schema::workspace_assistant_jobs::{self, dsl}; + + diesel::update(workspace_assistant_jobs::table.filter(dsl::id.eq(id))) + .set(( + dsl::status.eq(OutboxStatus::Failed), + dsl::attempts.eq(dsl::attempts + 1), + )) + .execute(self) + .await + .map_err(Error::from)?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use diesel::{ExpressionMethods, OptionalExtension, QueryDsl, SelectableHelper}; + use diesel_async::RunQueryDsl; + use uuid::Uuid; + + use super::{AssistantJobOutboxRepository, OutboxStatus, WorkspaceAssistantJob, schema}; + use crate::model::{NewWorkspaceAssistantJob, NewWorkspaceThread}; + use crate::query::WorkspaceThreadRepository; + use crate::test_util::TestDatabase; + use crate::{AsyncConnection, PgConn, Result}; + + /// Seeds a thread with its opening comment and returns that comment's id — the + /// FK parent an outbox row needs. + async fn seed_comment(db: &TestDatabase) -> anyhow::Result { + let (author, workspace_id, _pipeline_id, file_id) = db.seed_pipeline_and_file().await; + let mut conn = db.client.get_connection().await?; + let (_thread, opening) = conn + .open_thread( + NewWorkspaceThread::test(workspace_id, file_id, author), + "@assistant help".to_owned(), + Vec::new(), + ) + .await?; + Ok(opening.id) + } + + /// Re-reads an outbox row by id, bypassing the repository (which has no + /// single-row getter) so tests can assert on its post-transition state. + async fn reread(conn: &mut PgConn, id: Uuid) -> anyhow::Result> { + use schema::workspace_assistant_jobs::dsl; + + let row = dsl::workspace_assistant_jobs + .filter(dsl::id.eq(id)) + .select(WorkspaceAssistantJob::as_select()) + .first(conn) + .await + .optional()?; + Ok(row) + } + + #[tokio::test] + async fn claim_then_process_removes_the_row_from_the_pending_set() -> anyhow::Result<()> { + let db = TestDatabase::start().await; + let comment_id = seed_comment(&db).await?; + let mut conn = db.client.get_connection().await?; + + let job = conn + .insert_assistant_job(NewWorkspaceAssistantJob::test(comment_id)) + .await?; + + let processed = conn + .transaction(async |conn| -> Result { + let batch = conn.claim_assistant_job_batch(10).await?; + assert_eq!(batch.len(), 1); + assert_eq!(batch[0].id, job.id); + conn.mark_assistant_job_processed(batch[0].id).await?; + Ok(batch[0].id) + }) + .await?; + assert_eq!(processed, job.id); + + let empty = conn + .transaction(async |conn| conn.claim_assistant_job_batch(10).await) + .await?; + assert!(empty.is_empty()); + Ok(()) + } + + #[tokio::test] + async fn defer_pushes_the_row_out_of_the_due_window() -> anyhow::Result<()> { + let db = TestDatabase::start().await; + let comment_id = seed_comment(&db).await?; + let mut conn = db.client.get_connection().await?; + + let job = conn + .insert_assistant_job(NewWorkspaceAssistantJob::test(comment_id)) + .await?; + + conn.transaction(async |conn| -> Result<()> { + let batch = conn.claim_assistant_job_batch(10).await?; + assert_eq!(batch.len(), 1); + conn.defer_assistant_job_attempt(batch[0].id, 3600).await?; + Ok(()) + }) + .await?; + + let due = conn + .transaction(async |conn| conn.claim_assistant_job_batch(10).await) + .await?; + assert!(due.is_empty(), "deferred row must not be due yet"); + + let reread = reread(&mut conn, job.id).await?.expect("row exists"); + assert_eq!(reread.attempts, 1); + Ok(()) + } + + #[tokio::test] + async fn mark_failed_dead_letters_the_row() -> anyhow::Result<()> { + let db = TestDatabase::start().await; + let comment_id = seed_comment(&db).await?; + let mut conn = db.client.get_connection().await?; + + let job = conn + .insert_assistant_job(NewWorkspaceAssistantJob::test(comment_id)) + .await?; + + conn.transaction(async |conn| -> Result<()> { + let batch = conn.claim_assistant_job_batch(10).await?; + conn.mark_assistant_job_failed(batch[0].id).await?; + Ok(()) + }) + .await?; + + let due = conn + .transaction(async |conn| conn.claim_assistant_job_batch(10).await) + .await?; + assert!(due.is_empty()); + + let reread = reread(&mut conn, job.id) + .await? + .expect("row retained for inspection"); + assert_eq!(reread.status, OutboxStatus::Failed); + assert_eq!(reread.attempts, 1); + Ok(()) + } +} diff --git a/crates/nvisy-postgres/src/query/workspace_comment.rs b/crates/nvisy-postgres/src/query/workspace_comment.rs deleted file mode 100644 index 6f7e400d..00000000 --- a/crates/nvisy-postgres/src/query/workspace_comment.rs +++ /dev/null @@ -1,530 +0,0 @@ -//! Workspace comments repository for threaded discussion on files. - -use std::future::Future; - -use diesel::dsl::now; -use diesel::prelude::*; -use diesel_async::RunQueryDsl; -use uuid::Uuid; - -use crate::model::{NewWorkspaceComment, UpdateWorkspaceComment, WorkspaceComment}; -use crate::types::{AccountRefRow, CommentFilter, CursorPage, CursorPagination, WithAccountRef}; -use crate::{Error, PgConnection, Result, schema}; - -/// The result of a [`create_comment`](WorkspaceCommentRepository::create_comment) -/// call whose parent reference is invalid. -/// -/// Returned instead of a raw FK error so the handler can map a bad reply target -/// to a clear client error. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum ReplyParentError { - /// The named parent does not exist in this workspace (or is deleted). - NotFound, - /// The named parent is on a different file than the reply. - FileMismatch, - /// The named parent is itself a reply; threads are only one level deep. - NotTopLevel, -} - -/// Repository for workspace comment database operations. -/// -/// Comments are threaded one level deep (a top-level comment and its direct -/// replies) and scoped to a file. Resolution and soft-delete are their own -/// audited operations. -pub trait WorkspaceCommentRepository { - /// Creates a top-level comment. Does not validate a parent (see - /// [`create_reply`](Self::create_reply) for replies). - fn create_comment( - &mut self, - new_comment: NewWorkspaceComment, - ) -> impl Future> + Send; - - /// Creates a reply to `parent_id`, validating that the parent exists in the - /// same workspace and file and is itself top-level. Returns the offending - /// [`ReplyParentError`] otherwise. - fn create_reply( - &mut self, - new_comment: NewWorkspaceComment, - ) -> impl Future>> + Send; - - /// Finds a live comment by id within a specific workspace. - fn find_comment_in_workspace( - &mut self, - workspace_id: Uuid, - comment_id: Uuid, - ) -> impl Future>> + Send; - - /// Lists a file's live comments, oldest first (a thread reads top to bottom), - /// each paired with the author's account reference. - fn list_file_comments( - &mut self, - workspace_id: Uuid, - file_id: Uuid, - ) -> impl Future>>> + Send; - - /// Lists a workspace's live comments with cursor pagination, each paired with - /// the author's account reference. - fn cursor_list_workspace_comments( - &mut self, - workspace_id: Uuid, - pagination: CursorPagination, - filter: &CommentFilter, - ) -> impl Future>>> + Send; - - /// Updates a comment's body. - fn update_comment_body( - &mut self, - comment_id: Uuid, - updates: UpdateWorkspaceComment, - ) -> impl Future> + Send; - - /// Resolves a comment thread, recording who resolved it. A no-op timestamp - /// change if already resolved. - fn resolve_comment( - &mut self, - comment_id: Uuid, - resolved_by: Uuid, - ) -> impl Future> + Send; - - /// Reopens a resolved comment thread (clears the resolution). - fn reopen_comment( - &mut self, - comment_id: Uuid, - ) -> impl Future> + Send; - - /// Soft-deletes a comment. - fn delete_comment(&mut self, comment_id: Uuid) -> impl Future> + Send; -} - -impl WorkspaceCommentRepository for PgConnection { - async fn create_comment( - &mut self, - new_comment: NewWorkspaceComment, - ) -> Result { - use schema::workspace_comments; - - diesel::insert_into(workspace_comments::table) - .values(&new_comment) - .returning(WorkspaceComment::as_returning()) - .get_result(self) - .await - .map_err(Error::from) - } - - async fn create_reply( - &mut self, - new_comment: NewWorkspaceComment, - ) -> Result> { - let Some(parent_id) = new_comment.parent_id else { - // A reply must name a parent; a missing one is a caller contract - // violation, not a client-facing reply error. - return Err(Error::unexpected("create_reply called without a parent_id")); - }; - - let parent = self - .find_comment_in_workspace(new_comment.workspace_id, parent_id) - .await?; - let Some(parent) = parent else { - return Ok(Err(ReplyParentError::NotFound)); - }; - if parent.file_id != new_comment.file_id { - return Ok(Err(ReplyParentError::FileMismatch)); - } - if parent.parent_id.is_some() { - return Ok(Err(ReplyParentError::NotTopLevel)); - } - - let comment = self.create_comment(new_comment).await?; - Ok(Ok(comment)) - } - - async fn find_comment_in_workspace( - &mut self, - workspace_id: Uuid, - comment_id: Uuid, - ) -> Result> { - use schema::workspace_comments::{self, dsl}; - - workspace_comments::table - .filter(dsl::id.eq(comment_id)) - .filter(dsl::workspace_id.eq(workspace_id)) - .filter(dsl::deleted_at.is_null()) - .select(WorkspaceComment::as_select()) - .first(self) - .await - .optional() - .map_err(Error::from) - } - - async fn list_file_comments( - &mut self, - workspace_id: Uuid, - file_id: Uuid, - ) -> Result>> { - use schema::workspace_comments::dsl; - use schema::{accounts, workspace_comments}; - - // The author is one of two account FKs on the row (the other is - // resolved_by), so the join names the column explicitly. - let rows: Vec<(WorkspaceComment, AccountRefRow)> = workspace_comments::table - .inner_join(accounts::table.on(dsl::author_account_id.eq(accounts::id))) - .filter(dsl::workspace_id.eq(workspace_id)) - .filter(dsl::file_id.eq(file_id)) - .filter(dsl::deleted_at.is_null()) - .select(( - WorkspaceComment::as_select(), - ( - accounts::username, - accounts::display_name, - accounts::avatar_url, - ), - )) - // Oldest first: a thread reads top to bottom. - .order((dsl::created_at.asc(), dsl::id.asc())) - .load(self) - .await - .map_err(Error::from)?; - - Ok(rows - .into_iter() - .map(|(item, account)| WithAccountRef { item, account }) - .collect()) - } - - async fn cursor_list_workspace_comments( - &mut self, - workspace_id: Uuid, - pagination: CursorPagination, - filter: &CommentFilter, - ) -> Result>> { - use schema::workspace_comments::dsl; - use schema::{accounts, workspace_comments}; - - // 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 author is one of - // two account FKs, so the join names it explicitly. - let scoped = || { - let mut query = workspace_comments::table - .inner_join(accounts::table.on(dsl::author_account_id.eq(accounts::id))) - .filter(dsl::workspace_id.eq(workspace_id)) - .filter(dsl::deleted_at.is_null()) - .into_boxed(); - if let Some(file_id) = filter.file_id { - query = query.filter(dsl::file_id.eq(file_id)); - } - if let Some(author_account_id) = filter.author_account_id { - query = query.filter(dsl::author_account_id.eq(author_account_id)); - } - if let Some(resolved) = filter.resolved { - query = if resolved { - query.filter(dsl::resolved_at.is_not_null()) - } else { - query.filter(dsl::resolved_at.is_null()) - }; - } - 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 = ( - WorkspaceComment::as_select(), - ( - accounts::username, - accounts::display_name, - accounts::avatar_url, - ), - ); - - let rows: Vec<(WorkspaceComment, AccountRefRow)> = 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: Vec> = rows - .into_iter() - .map(|(item, account)| WithAccountRef { item, account }) - .collect(); - - Ok(CursorPage::new(items, total, pagination.limit, |row| { - (row.item.created_at.into(), row.item.id) - })) - } - - async fn update_comment_body( - &mut self, - comment_id: Uuid, - updates: UpdateWorkspaceComment, - ) -> Result { - use schema::workspace_comments::{self, dsl}; - - // Scope to a live row so an edit cannot revive a soft-deleted comment. - diesel::update( - workspace_comments::table - .filter(dsl::id.eq(comment_id)) - .filter(dsl::deleted_at.is_null()), - ) - .set(&updates) - .returning(WorkspaceComment::as_returning()) - .get_result(self) - .await - .map_err(Error::from) - } - - async fn resolve_comment( - &mut self, - comment_id: Uuid, - resolved_by: Uuid, - ) -> Result { - use schema::workspace_comments::{self, dsl}; - - diesel::update( - workspace_comments::table - .filter(dsl::id.eq(comment_id)) - .filter(dsl::deleted_at.is_null()), - ) - .set((dsl::resolved_at.eq(now), dsl::resolved_by.eq(resolved_by))) - .returning(WorkspaceComment::as_returning()) - .get_result(self) - .await - .map_err(Error::from) - } - - async fn reopen_comment(&mut self, comment_id: Uuid) -> Result { - use schema::workspace_comments::{self, dsl}; - - diesel::update( - workspace_comments::table - .filter(dsl::id.eq(comment_id)) - .filter(dsl::deleted_at.is_null()), - ) - .set(( - dsl::resolved_at.eq(None::), - dsl::resolved_by.eq(None::), - )) - .returning(WorkspaceComment::as_returning()) - .get_result(self) - .await - .map_err(Error::from) - } - - async fn delete_comment(&mut self, comment_id: Uuid) -> Result<()> { - use schema::workspace_comments::{self, dsl}; - - // Scope to a live row so a concurrent delete is not overwritten with a - // fresh `deleted_at`. - diesel::update( - workspace_comments::table - .filter(dsl::id.eq(comment_id)) - .filter(dsl::deleted_at.is_null()), - ) - .set(dsl::deleted_at.eq(now)) - .execute(self) - .await - .map_err(Error::from)?; - - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use super::{ReplyParentError, WorkspaceCommentRepository}; - use crate::model::{NewAccount, NewWorkspaceComment, UpdateWorkspaceComment}; - use crate::query::AccountRepository; - use crate::test_util::TestDatabase; - use crate::types::{CommentFilter, CursorPagination}; - - #[tokio::test] - async fn create_list_and_soft_delete() -> anyhow::Result<()> { - let db = TestDatabase::start().await; - let (author, workspace_id, _pipeline_id, file_id) = db.seed_pipeline_and_file().await; - let mut conn = db.client.get_connection().await?; - - let comment = conn - .create_comment(NewWorkspaceComment::test(workspace_id, file_id, author)) - .await?; - assert!(comment.parent_id.is_none()); - assert!(comment.resolved_at.is_none()); - - let rows = conn.list_file_comments(workspace_id, file_id).await?; - assert_eq!(rows.len(), 1); - assert_eq!(rows[0].item.id, comment.id); - - // Soft-delete drops it from the listing. - conn.delete_comment(comment.id).await?; - assert!( - conn.find_comment_in_workspace(workspace_id, comment.id) - .await? - .is_none() - ); - assert!( - conn.list_file_comments(workspace_id, file_id) - .await? - .is_empty() - ); - Ok(()) - } - - #[tokio::test] - async fn replies_are_validated_one_level() -> anyhow::Result<()> { - let db = TestDatabase::start().await; - let (author, workspace_id, _pipeline_id, file_id) = db.seed_pipeline_and_file().await; - let mut conn = db.client.get_connection().await?; - - let top = conn - .create_comment(NewWorkspaceComment::test(workspace_id, file_id, author)) - .await?; - - // A valid reply to a top-level comment. - let reply = conn - .create_reply(NewWorkspaceComment { - parent_id: Some(top.id), - ..NewWorkspaceComment::test(workspace_id, file_id, author) - }) - .await? - .expect("reply should be accepted"); - assert_eq!(reply.parent_id, Some(top.id)); - - // A reply to a reply is rejected: threads are one level deep. - let nested = conn - .create_reply(NewWorkspaceComment { - parent_id: Some(reply.id), - ..NewWorkspaceComment::test(workspace_id, file_id, author) - }) - .await?; - assert_eq!(nested, Err(ReplyParentError::NotTopLevel)); - - // A reply naming an unknown parent is rejected. - let orphan = conn - .create_reply(NewWorkspaceComment { - parent_id: Some(uuid::Uuid::now_v7()), - ..NewWorkspaceComment::test(workspace_id, file_id, author) - }) - .await?; - assert_eq!(orphan, Err(ReplyParentError::NotFound)); - Ok(()) - } - - #[tokio::test] - async fn resolve_reopen_and_body_edit() -> anyhow::Result<()> { - let db = TestDatabase::start().await; - let (author, workspace_id, _pipeline_id, file_id) = db.seed_pipeline_and_file().await; - let mut conn = db.client.get_connection().await?; - - let comment = conn - .create_comment(NewWorkspaceComment::test(workspace_id, file_id, author)) - .await?; - - let resolved = conn.resolve_comment(comment.id, author).await?; - assert!(resolved.resolved_at.is_some()); - assert_eq!(resolved.resolved_by, Some(author)); - - let reopened = conn.reopen_comment(comment.id).await?; - assert!(reopened.resolved_at.is_none()); - assert!(reopened.resolved_by.is_none()); - - let edited = conn - .update_comment_body( - comment.id, - UpdateWorkspaceComment { - body: Some("Edited body.".to_owned()), - }, - ) - .await?; - assert_eq!(edited.body, "Edited body."); - Ok(()) - } - - #[tokio::test] - async fn cursor_list_filters_by_author_and_resolved() -> anyhow::Result<()> { - let db = TestDatabase::start().await; - let (alice, workspace_id, _pipeline_id, file_id) = db.seed_pipeline_and_file().await; - let bob = conn_seed_account(&db).await; - let mut conn = db.client.get_connection().await?; - - let a = conn - .create_comment(NewWorkspaceComment::test(workspace_id, file_id, alice)) - .await?; - let _b = conn - .create_comment(NewWorkspaceComment::test(workspace_id, file_id, bob)) - .await?; - conn.resolve_comment(a.id, alice).await?; - - let all = conn - .cursor_list_workspace_comments( - workspace_id, - CursorPagination::new(50), - &CommentFilter::default(), - ) - .await?; - assert_eq!(all.items.len(), 2); - - let just_alice = conn - .cursor_list_workspace_comments( - workspace_id, - CursorPagination::new(50), - &CommentFilter { - author_account_id: Some(alice), - ..Default::default() - }, - ) - .await?; - assert_eq!(just_alice.items.len(), 1); - - let resolved_only = conn - .cursor_list_workspace_comments( - workspace_id, - CursorPagination::new(50), - &CommentFilter { - resolved: Some(true), - ..Default::default() - }, - ) - .await?; - assert_eq!(resolved_only.items.len(), 1); - assert_eq!(resolved_only.items[0].item.id, a.id); - Ok(()) - } - - /// Seeds an extra account for the multi-author test. - async fn conn_seed_account(db: &TestDatabase) -> uuid::Uuid { - let mut conn = db.client.get_connection().await.expect("connection"); - conn.create_account(NewAccount::test()) - .await - .expect("seed account") - .id - } -} diff --git a/crates/nvisy-postgres/src/query/workspace_thread.rs b/crates/nvisy-postgres/src/query/workspace_thread.rs new file mode 100644 index 00000000..51fd0b50 --- /dev/null +++ b/crates/nvisy-postgres/src/query/workspace_thread.rs @@ -0,0 +1,690 @@ +//! Workspace thread repository: the closable, optionally file-anchored unit of +//! discussion. Opening a thread creates its first comment and records the +//! `thread.opened` timeline event; closing, reopening, and renaming each record +//! their own event. Deleting a thread hides it and its comments. + +use std::future::Future; + +use diesel::dsl::now; +use diesel::prelude::*; +use diesel_async::RunQueryDsl; +use serde_json::Value; +use uuid::Uuid; + +use super::workspace_thread_event::record_event; +use crate::model::{ + NewWorkspaceThread, NewWorkspaceThreadAnchor, NewWorkspaceThreadComment, WorkspaceThread, + WorkspaceThreadComment, +}; +use crate::types::{ + AccountRefRow, CursorPage, CursorPagination, ThreadEventKind, ThreadFilter, WithAccountRef, +}; +use crate::{AsyncConnection, Error, PgConnection, Result, schema}; + +/// Read and write operations on threads. +pub trait WorkspaceThreadRepository { + /// Opens a thread with its first comment and any initial anchors, recording + /// the `thread.opened` timeline event, in one transaction. Returns the created + /// thread and its opening comment. + fn open_thread( + &mut self, + new_thread: NewWorkspaceThread, + opening_body: String, + anchors: Vec, + ) -> impl Future> + Send; + + /// Finds a live thread by id within a workspace. + fn find_thread_in_workspace( + &mut self, + workspace_id: Uuid, + thread_id: Uuid, + ) -> impl Future>> + Send; + + /// Lists a workspace's live threads with cursor pagination, each paired with + /// the opening author's account reference. + fn cursor_list_threads( + &mut self, + workspace_id: Uuid, + pagination: CursorPagination, + filter: &ThreadFilter, + ) -> impl Future>>> + Send; + + /// Closes a thread, recording who closed it and a `thread.closed` timeline + /// event, in one transaction. The caller checks the open state first. + fn close_thread( + &mut self, + thread_id: Uuid, + actor: Uuid, + ) -> impl Future> + Send; + + /// Reopens a closed thread, recording a `thread.reopened` timeline event, in + /// one transaction. The caller checks the closed state first. + fn reopen_thread( + &mut self, + thread_id: Uuid, + actor: Uuid, + ) -> impl Future> + Send; + + /// Sets a thread's title (or clears it with `None`), recording a + /// `thread.renamed` timeline event carrying the new name, in one transaction. + fn rename_thread( + &mut self, + thread_id: Uuid, + display_name: Option, + actor: Uuid, + ) -> impl Future> + Send; + + /// Soft-deletes a thread and all of its comments (its anchors and events are + /// left in place, hidden with the thread). + fn delete_thread(&mut self, thread_id: Uuid) -> impl Future> + Send; +} + +impl WorkspaceThreadRepository for PgConnection { + async fn open_thread( + &mut self, + new_thread: NewWorkspaceThread, + opening_body: String, + anchors: Vec, + ) -> Result<(WorkspaceThread, WorkspaceThreadComment)> { + self.transaction(async |conn| { + let thread = { + use schema::workspace_threads; + diesel::insert_into(workspace_threads::table) + .values(&new_thread) + .returning(WorkspaceThread::as_returning()) + .get_result(conn) + .await + .map_err(Error::from)? + }; + + // Initial anchors are part of the opening act, so they are recorded + // without their own timeline events (the thread's creation covers it). + if !anchors.is_empty() { + use schema::workspace_thread_anchors; + let rows: Vec = anchors + .into_iter() + .map(|anchor| NewWorkspaceThreadAnchor { + thread_id: thread.id, + anchor, + }) + .collect(); + diesel::insert_into(workspace_thread_anchors::table) + .values(&rows) + .execute(conn) + .await + .map_err(Error::from)?; + } + + // Record the thread's opening as the first timeline event, so the + // stream begins with an explicit `thread.opened` entry. + record_event( + conn, + &thread, + ThreadEventKind::Opened, + thread.author_account_id, + None, + ) + .await?; + + let opening = { + use schema::workspace_thread_comments; + diesel::insert_into(workspace_thread_comments::table) + .values(&NewWorkspaceThreadComment { + parent_id: None, + workspace_id: thread.workspace_id, + thread_id: thread.id, + author_account_id: thread.author_account_id, + body: opening_body, + }) + .returning(WorkspaceThreadComment::as_returning()) + .get_result(conn) + .await + .map_err(Error::from)? + }; + Ok((thread, opening)) + }) + .await + } + + async fn find_thread_in_workspace( + &mut self, + workspace_id: Uuid, + thread_id: Uuid, + ) -> Result> { + use schema::workspace_threads::{self, dsl}; + + workspace_threads::table + .filter(dsl::id.eq(thread_id)) + .filter(dsl::workspace_id.eq(workspace_id)) + .filter(dsl::deleted_at.is_null()) + .select(WorkspaceThread::as_select()) + .first(self) + .await + .optional() + .map_err(Error::from) + } + + async fn cursor_list_threads( + &mut self, + workspace_id: Uuid, + pagination: CursorPagination, + filter: &ThreadFilter, + ) -> Result>> { + use schema::workspace_threads::dsl; + use schema::{accounts, workspace_threads}; + + // One scoped builder for both the count and the page. The opener is one of + // two account FKs (the other is closed_by), so the join names it. + let scoped = || { + let mut query = workspace_threads::table + .inner_join(accounts::table.on(dsl::author_account_id.eq(accounts::id))) + .filter(dsl::workspace_id.eq(workspace_id)) + .filter(dsl::deleted_at.is_null()) + .into_boxed(); + if let Some(file_id) = filter.file_id { + query = query.filter(dsl::file_id.eq(file_id)); + } + if let Some(author_account_id) = filter.author_account_id { + query = query.filter(dsl::author_account_id.eq(author_account_id)); + } + if let Some(closed) = filter.closed { + query = if closed { + query.filter(dsl::closed_at.is_not_null()) + } else { + query.filter(dsl::closed_at.is_null()) + }; + } + 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 = ( + WorkspaceThread::as_select(), + ( + accounts::username, + accounts::display_name, + accounts::avatar_url, + ), + ); + + let rows: Vec<(WorkspaceThread, AccountRefRow)> = 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: Vec> = rows + .into_iter() + .map(|(item, account)| WithAccountRef { item, account }) + .collect(); + + Ok(CursorPage::new(items, total, pagination.limit, |row| { + (row.item.created_at.into(), row.item.id) + })) + } + + async fn close_thread(&mut self, thread_id: Uuid, actor: Uuid) -> Result { + self.transaction(async |conn| { + use schema::workspace_threads::{self, dsl}; + + let thread = diesel::update( + workspace_threads::table + .filter(dsl::id.eq(thread_id)) + .filter(dsl::deleted_at.is_null()), + ) + .set((dsl::closed_at.eq(now), dsl::closed_by.eq(actor))) + .returning(WorkspaceThread::as_returning()) + .get_result(conn) + .await + .map_err(Error::from)?; + + record_event(conn, &thread, ThreadEventKind::Closed, actor, None).await?; + Ok(thread) + }) + .await + } + + async fn reopen_thread(&mut self, thread_id: Uuid, actor: Uuid) -> Result { + self.transaction(async |conn| { + use schema::workspace_threads::{self, dsl}; + + let thread = diesel::update( + workspace_threads::table + .filter(dsl::id.eq(thread_id)) + .filter(dsl::deleted_at.is_null()), + ) + .set(( + dsl::closed_at.eq(None::), + dsl::closed_by.eq(None::), + )) + .returning(WorkspaceThread::as_returning()) + .get_result(conn) + .await + .map_err(Error::from)?; + + record_event(conn, &thread, ThreadEventKind::Reopened, actor, None).await?; + Ok(thread) + }) + .await + } + + async fn rename_thread( + &mut self, + thread_id: Uuid, + display_name: Option, + actor: Uuid, + ) -> Result { + self.transaction(async |conn| { + use schema::workspace_threads::{self, dsl}; + + let thread = diesel::update( + workspace_threads::table + .filter(dsl::id.eq(thread_id)) + .filter(dsl::deleted_at.is_null()), + ) + .set(dsl::display_name.eq(display_name.clone())) + .returning(WorkspaceThread::as_returning()) + .get_result(conn) + .await + .map_err(Error::from)?; + + // The new name is the event's target so the timeline shows what it was + // renamed to (a cleared name is recorded as JSON null). + let target = serde_json::json!({ "displayName": display_name }); + record_event(conn, &thread, ThreadEventKind::Renamed, actor, Some(target)).await?; + Ok(thread) + }) + .await + } + + async fn delete_thread(&mut self, thread_id: Uuid) -> Result<()> { + self.transaction(async |conn| { + use schema::{workspace_thread_comments, workspace_threads}; + + // Soft-delete the thread and its live comments together, so a deleted + // thread leaves no live messages behind. (The FK cascade only fires on + // a hard delete; comments are hidden here by their own `deleted_at`.) + diesel::update( + workspace_threads::table + .filter(workspace_threads::id.eq(thread_id)) + .filter(workspace_threads::deleted_at.is_null()), + ) + .set(workspace_threads::deleted_at.eq(now)) + .execute(conn) + .await + .map_err(Error::from)?; + + diesel::update( + workspace_thread_comments::table + .filter(workspace_thread_comments::thread_id.eq(thread_id)) + .filter(workspace_thread_comments::deleted_at.is_null()), + ) + .set(workspace_thread_comments::deleted_at.eq(now)) + .execute(conn) + .await + .map_err(Error::from)?; + + Ok(()) + }) + .await + } +} + +#[cfg(test)] +mod tests { + use crate::model::{ + NewAccount, NewWorkspaceThread, NewWorkspaceThreadAnchor, NewWorkspaceThreadComment, + UpdateWorkspaceThreadComment, + }; + use crate::query::{ + AccountRepository, WorkspaceThreadAnchorRepository, WorkspaceThreadCommentRepository, + WorkspaceThreadEventRepository, WorkspaceThreadRepository, + }; + use crate::test_util::TestDatabase; + use crate::types::{CursorPagination, ThreadEventKind, ThreadFilter}; + + #[tokio::test] + async fn open_thread_creates_thread_and_opening_comment() -> anyhow::Result<()> { + let db = TestDatabase::start().await; + let (author, workspace_id, _pipeline_id, file_id) = db.seed_pipeline_and_file().await; + let mut conn = db.client.get_connection().await?; + + let (thread, opening) = conn + .open_thread( + NewWorkspaceThread::test(workspace_id, file_id, author), + "Opening message.".to_owned(), + Vec::new(), + ) + .await?; + assert_eq!(thread.file_id, Some(file_id)); + assert!(thread.closed_at.is_none()); + assert_eq!(opening.thread_id, thread.id); + assert_eq!(opening.body, "Opening message."); + + // A reply message lists after the opening one, oldest first. + let _reply = conn + .create_comment(NewWorkspaceThreadComment::test( + workspace_id, + thread.id, + author, + )) + .await?; + let msgs = conn.list_thread_comments(workspace_id, thread.id).await?; + assert_eq!(msgs.len(), 2); + assert_eq!(msgs[0].item.id, opening.id); + Ok(()) + } + + #[tokio::test] + async fn workspace_level_thread_has_no_file() -> anyhow::Result<()> { + let db = TestDatabase::start().await; + let (author, workspace_id) = db.seed_account_and_workspace().await; + let mut conn = db.client.get_connection().await?; + + let (thread, _opening) = conn + .open_thread( + NewWorkspaceThread { + workspace_id, + file_id: None, + author_account_id: author, + display_name: None, + }, + "A general workspace discussion.".to_owned(), + Vec::new(), + ) + .await?; + assert_eq!(thread.file_id, None); + Ok(()) + } + + #[tokio::test] + async fn close_reopen_records_timeline_events() -> anyhow::Result<()> { + let db = TestDatabase::start().await; + let (author, workspace_id, _pipeline_id, file_id) = db.seed_pipeline_and_file().await; + let mut conn = db.client.get_connection().await?; + + let (thread, _opening) = conn + .open_thread( + NewWorkspaceThread::test(workspace_id, file_id, author), + "Opening.".to_owned(), + Vec::new(), + ) + .await?; + + let closed = conn.close_thread(thread.id, author).await?; + assert!(closed.closed_at.is_some()); + assert_eq!(closed.closed_by, Some(author)); + + let reopened = conn.reopen_thread(thread.id, author).await?; + assert!(reopened.closed_at.is_none()); + + // The timeline records the open, then both transitions, oldest first. + let events = conn.list_thread_events(thread.id).await?; + let kinds: Vec<_> = events.iter().map(|(e, _)| e.kind).collect(); + assert_eq!( + kinds, + vec![ + ThreadEventKind::Opened, + ThreadEventKind::Closed, + ThreadEventKind::Reopened + ] + ); + // The actor is resolved to an account ref (the opener/closer/reopener). + assert!(events[0].1.is_some()); + + // Deleting the thread hides it and its messages. + conn.delete_thread(thread.id).await?; + assert!( + conn.find_thread_in_workspace(workspace_id, thread.id) + .await? + .is_none() + ); + assert!( + conn.list_thread_comments(workspace_id, thread.id) + .await? + .is_empty() + ); + Ok(()) + } + + #[tokio::test] + async fn anchors_add_remove_and_record_events() -> anyhow::Result<()> { + let db = TestDatabase::start().await; + let (author, workspace_id, _pipeline_id, file_id) = db.seed_pipeline_and_file().await; + let mut conn = db.client.get_connection().await?; + + // Open with one initial anchor (the initial anchor gets no event of its + // own; opening the thread records the single `thread.opened` event). + let (thread, _opening) = conn + .open_thread( + NewWorkspaceThread::test(workspace_id, file_id, author), + "Opening.".to_owned(), + vec![serde_json::json!({ "modality": "text", "span": [0, 5] })], + ) + .await?; + assert_eq!(conn.list_thread_anchors(thread.id).await?.len(), 1); + let opening_kinds: Vec<_> = conn + .list_thread_events(thread.id) + .await? + .into_iter() + .map(|(e, _)| e.kind) + .collect(); + assert_eq!(opening_kinds, vec![ThreadEventKind::Opened]); + + // Add a second anchor -> one anchor.added event. + let added = conn + .add_thread_anchor( + workspace_id, + NewWorkspaceThreadAnchor { + thread_id: thread.id, + anchor: serde_json::json!({ "modality": "text", "span": [10, 20] }), + }, + author, + ) + .await?; + assert_eq!(conn.list_thread_anchors(thread.id).await?.len(), 2); + + // Remove it -> anchor.removed event; live anchors back to one. + conn.remove_thread_anchor(workspace_id, added.id, author) + .await?; + assert_eq!(conn.list_thread_anchors(thread.id).await?.len(), 1); + assert!( + conn.find_thread_anchor(thread.id, added.id) + .await? + .is_none() + ); + + let kinds: Vec<_> = conn + .list_thread_events(thread.id) + .await? + .into_iter() + .map(|(e, _)| e.kind) + .collect(); + assert_eq!( + kinds, + vec![ + ThreadEventKind::Opened, + ThreadEventKind::AnchorAdded, + ThreadEventKind::AnchorRemoved + ] + ); + Ok(()) + } + + #[tokio::test] + async fn cursor_list_threads_filters_by_author_and_closed() -> anyhow::Result<()> { + let db = TestDatabase::start().await; + let (alice, workspace_id, _pipeline_id, file_id) = db.seed_pipeline_and_file().await; + let bob = { + let mut conn = db.client.get_connection().await?; + conn.create_account(NewAccount::test()).await?.id + }; + let mut conn = db.client.get_connection().await?; + + let (a, _) = conn + .open_thread( + NewWorkspaceThread::test(workspace_id, file_id, alice), + "a".to_owned(), + Vec::new(), + ) + .await?; + let _ = conn + .open_thread( + NewWorkspaceThread::test(workspace_id, file_id, bob), + "b".to_owned(), + Vec::new(), + ) + .await?; + conn.close_thread(a.id, alice).await?; + + let all = conn + .cursor_list_threads( + workspace_id, + CursorPagination::new(50), + &ThreadFilter::default(), + ) + .await?; + assert_eq!(all.items.len(), 2); + + let closed_only = conn + .cursor_list_threads( + workspace_id, + CursorPagination::new(50), + &ThreadFilter { + closed: Some(true), + ..Default::default() + }, + ) + .await?; + assert_eq!(closed_only.items.len(), 1); + assert_eq!(closed_only.items[0].item.id, a.id); + + let just_bob = conn + .cursor_list_threads( + workspace_id, + CursorPagination::new(50), + &ThreadFilter { + author_account_id: Some(bob), + ..Default::default() + }, + ) + .await?; + assert_eq!(just_bob.items.len(), 1); + Ok(()) + } + + #[tokio::test] + async fn comment_edit_and_delete() -> anyhow::Result<()> { + let db = TestDatabase::start().await; + let (author, workspace_id, _pipeline_id, file_id) = db.seed_pipeline_and_file().await; + let mut conn = db.client.get_connection().await?; + + let (thread, opening) = conn + .open_thread( + NewWorkspaceThread::test(workspace_id, file_id, author), + "Opening.".to_owned(), + Vec::new(), + ) + .await?; + + let edited = conn + .update_comment_body( + opening.id, + UpdateWorkspaceThreadComment { + body: Some("Edited.".to_owned()), + }, + ) + .await?; + assert_eq!(edited.body, "Edited."); + + conn.delete_comment(opening.id).await?; + assert!( + conn.find_comment_in_workspace(workspace_id, opening.id) + .await? + .is_none() + ); + // The thread still exists after deleting a message. + assert!( + conn.find_thread_in_workspace(workspace_id, thread.id) + .await? + .is_some() + ); + Ok(()) + } + + #[tokio::test] + async fn create_reply_is_unique_per_triggering_comment() -> anyhow::Result<()> { + let db = TestDatabase::start().await; + let (author, workspace_id, _pipeline_id, file_id) = db.seed_pipeline_and_file().await; + let mut conn = db.client.get_connection().await?; + + let (thread, trigger) = conn + .open_thread( + NewWorkspaceThread::test(workspace_id, file_id, author), + "@assistant help".to_owned(), + Vec::new(), + ) + .await?; + + let reply = |body: &str| NewWorkspaceThreadComment { + workspace_id, + thread_id: thread.id, + author_account_id: author, + parent_id: Some(trigger.id), + body: body.to_owned(), + }; + + // The first reply to the triggering comment posts. + let first = conn.create_reply(reply("first")).await?; + assert!(first.is_some()); + + // A second reply to the same comment is rejected by the partial unique + // index and reported as "already replied" (None), never a duplicate. + let second = conn.create_reply(reply("second")).await?; + assert!(second.is_none()); + + // Only the first reply is live. + let replies = conn.list_thread_comments(workspace_id, thread.id).await?; + let bodies: Vec<_> = replies.iter().map(|r| r.item.body.as_str()).collect(); + assert!(bodies.contains(&"first")); + assert!(!bodies.contains(&"second")); + + // After the first reply is soft-deleted, a new reply may be posted again + // (the unique index is partial on live rows). + conn.delete_comment(first.unwrap().id).await?; + let third = conn.create_reply(reply("third")).await?; + assert!(third.is_some()); + Ok(()) + } +} diff --git a/crates/nvisy-postgres/src/query/workspace_thread_anchor.rs b/crates/nvisy-postgres/src/query/workspace_thread_anchor.rs new file mode 100644 index 00000000..df144259 --- /dev/null +++ b/crates/nvisy-postgres/src/query/workspace_thread_anchor.rs @@ -0,0 +1,185 @@ +//! Workspace thread-anchor repository: a thread's location pins, added and +//! removed over its lifetime. Each add/remove records a timeline event snapshot +//! so the timeline renders the anchor even after its row is soft-removed. + +use std::future::Future; + +use diesel::dsl::now; +use diesel::prelude::*; +use diesel_async::RunQueryDsl; +use uuid::Uuid; + +use super::workspace_thread_event::anchor_snapshot; +use crate::model::{NewWorkspaceThreadAnchor, NewWorkspaceThreadEvent, WorkspaceThreadAnchor}; +use crate::types::ThreadEventKind; +use crate::{AsyncConnection, Error, PgConnection, Result, schema}; + +/// Read and write operations on a thread's anchors. +pub trait WorkspaceThreadAnchorRepository { + /// Adds an anchor to a thread, recording an `anchor.added` timeline event, in + /// one transaction. Returns the created anchor. + fn add_thread_anchor( + &mut self, + workspace_id: Uuid, + new_anchor: NewWorkspaceThreadAnchor, + actor: Uuid, + ) -> impl Future> + Send; + + /// Soft-removes an anchor, recording an `anchor.removed` timeline event, in + /// one transaction. Returns the removed anchor. + fn remove_thread_anchor( + &mut self, + workspace_id: Uuid, + anchor_id: Uuid, + actor: Uuid, + ) -> impl Future> + Send; + + /// Finds a live anchor by id within a thread. + fn find_thread_anchor( + &mut self, + thread_id: Uuid, + anchor_id: Uuid, + ) -> impl Future>> + Send; + + /// Lists a thread's live anchors, oldest first. + fn list_thread_anchors( + &mut self, + thread_id: Uuid, + ) -> impl Future>> + Send; + + /// Lists the live anchors of several threads in one query, ordered by thread + /// then creation. The caller groups them by `thread_id`; a thread with no + /// anchors is simply absent from the result. + fn list_anchors_for_threads( + &mut self, + thread_ids: &[Uuid], + ) -> impl Future>> + Send; +} + +impl WorkspaceThreadAnchorRepository for PgConnection { + async fn add_thread_anchor( + &mut self, + workspace_id: Uuid, + new_anchor: NewWorkspaceThreadAnchor, + actor: Uuid, + ) -> Result { + self.transaction(async |conn| { + use schema::{workspace_thread_anchors, workspace_thread_events}; + + let anchor = diesel::insert_into(workspace_thread_anchors::table) + .values(&new_anchor) + .returning(WorkspaceThreadAnchor::as_returning()) + .get_result::(conn) + .await + .map_err(Error::from)?; + + // The event snapshots the anchor so the timeline renders it even after + // the anchor row is removed. + diesel::insert_into(workspace_thread_events::table) + .values(&NewWorkspaceThreadEvent { + workspace_id, + thread_id: anchor.thread_id, + kind: ThreadEventKind::AnchorAdded, + actor_account_id: Some(actor), + target: Some(anchor_snapshot(&anchor)), + }) + .execute(conn) + .await + .map_err(Error::from)?; + + Ok(anchor) + }) + .await + } + + async fn remove_thread_anchor( + &mut self, + workspace_id: Uuid, + anchor_id: Uuid, + actor: Uuid, + ) -> Result { + self.transaction(async |conn| { + use schema::workspace_thread_anchors::{self, dsl}; + use schema::workspace_thread_events; + + let anchor = diesel::update( + workspace_thread_anchors::table + .filter(dsl::id.eq(anchor_id)) + .filter(dsl::deleted_at.is_null()), + ) + .set(dsl::deleted_at.eq(now)) + .returning(WorkspaceThreadAnchor::as_returning()) + .get_result::(conn) + .await + .map_err(Error::from)?; + + diesel::insert_into(workspace_thread_events::table) + .values(&NewWorkspaceThreadEvent { + workspace_id, + thread_id: anchor.thread_id, + kind: ThreadEventKind::AnchorRemoved, + actor_account_id: Some(actor), + target: Some(anchor_snapshot(&anchor)), + }) + .execute(conn) + .await + .map_err(Error::from)?; + + Ok(anchor) + }) + .await + } + + async fn find_thread_anchor( + &mut self, + thread_id: Uuid, + anchor_id: Uuid, + ) -> Result> { + use schema::workspace_thread_anchors::{self, dsl}; + + workspace_thread_anchors::table + .filter(dsl::id.eq(anchor_id)) + .filter(dsl::thread_id.eq(thread_id)) + .filter(dsl::deleted_at.is_null()) + .select(WorkspaceThreadAnchor::as_select()) + .first(self) + .await + .optional() + .map_err(Error::from) + } + + async fn list_thread_anchors(&mut self, thread_id: Uuid) -> Result> { + use schema::workspace_thread_anchors::{self, dsl}; + + workspace_thread_anchors::table + .filter(dsl::thread_id.eq(thread_id)) + .filter(dsl::deleted_at.is_null()) + .select(WorkspaceThreadAnchor::as_select()) + .order((dsl::created_at.asc(), dsl::id.asc())) + .load(self) + .await + .map_err(Error::from) + } + + async fn list_anchors_for_threads( + &mut self, + thread_ids: &[Uuid], + ) -> Result> { + use schema::workspace_thread_anchors::{self, dsl}; + + if thread_ids.is_empty() { + return Ok(Vec::new()); + } + + // One query for the whole page's anchors. Ordered by thread then creation + // so the caller can group into per-thread runs, each already oldest-first. + workspace_thread_anchors::table + .filter(dsl::thread_id.eq_any(thread_ids)) + .filter(dsl::deleted_at.is_null()) + .select(WorkspaceThreadAnchor::as_select()) + .order((dsl::thread_id.asc(), dsl::created_at.asc(), dsl::id.asc())) + .load(self) + .await + .map_err(Error::from) + } +} diff --git a/crates/nvisy-postgres/src/query/workspace_thread_comment.rs b/crates/nvisy-postgres/src/query/workspace_thread_comment.rs new file mode 100644 index 00000000..687f2632 --- /dev/null +++ b/crates/nvisy-postgres/src/query/workspace_thread_comment.rs @@ -0,0 +1,187 @@ +//! Workspace thread-comment repository: the messages within a thread. Includes +//! the reply path whose parent-scoped uniqueness makes a redelivered assistant +//! reply a no-op. + +use std::future::Future; + +use diesel::dsl::now; +use diesel::prelude::*; +use diesel_async::RunQueryDsl; +use uuid::Uuid; + +use crate::model::{ + NewWorkspaceThreadComment, UpdateWorkspaceThreadComment, WorkspaceThreadComment, +}; +use crate::types::{AccountRefRow, WithAccountRef}; +use crate::{Error, PgConnection, Result, schema}; + +/// Read and write operations on a thread's comments. +pub trait WorkspaceThreadCommentRepository { + /// Adds a comment (message) to a thread. + fn create_comment( + &mut self, + new_comment: NewWorkspaceThreadComment, + ) -> impl Future> + Send; + + /// Adds a reply (a comment whose `parent_id` is set), returning `Ok(None)` + /// when a live reply to that same parent already exists. + /// + /// The partial unique index on `parent_id` enforces at most one live reply per + /// parent at the database level; the insert uses `ON CONFLICT DO NOTHING` + /// against it, so a redelivered assistant job that re-inserts its reply yields + /// no row (`Ok(None)`) rather than posting a duplicate or erroring. + /// `new_comment.parent_id` must be set. + fn create_reply( + &mut self, + new_comment: NewWorkspaceThreadComment, + ) -> impl Future>> + Send; + + /// Finds a live comment by id within a workspace. + fn find_comment_in_workspace( + &mut self, + workspace_id: Uuid, + comment_id: Uuid, + ) -> impl Future>> + Send; + + /// Lists a thread's live comments, oldest first, each paired with the author's + /// account reference. + fn list_thread_comments( + &mut self, + workspace_id: Uuid, + thread_id: Uuid, + ) -> impl Future>>> + Send; + + /// Updates a comment's body. + fn update_comment_body( + &mut self, + comment_id: Uuid, + updates: UpdateWorkspaceThreadComment, + ) -> impl Future> + Send; + + /// Soft-deletes a comment. + fn delete_comment(&mut self, comment_id: Uuid) -> impl Future> + Send; +} + +impl WorkspaceThreadCommentRepository for PgConnection { + async fn create_comment( + &mut self, + new_comment: NewWorkspaceThreadComment, + ) -> Result { + use schema::workspace_thread_comments; + + diesel::insert_into(workspace_thread_comments::table) + .values(&new_comment) + .returning(WorkspaceThreadComment::as_returning()) + .get_result(self) + .await + .map_err(Error::from) + } + + async fn create_reply( + &mut self, + new_comment: NewWorkspaceThreadComment, + ) -> Result> { + use schema::workspace_thread_comments::{self, dsl}; + + // `ON CONFLICT (parent_id) WHERE parent_id IS NOT NULL AND deleted_at IS + // NULL DO NOTHING` targets the partial unique index: a live reply to this + // parent already exists, so the insert returns no row and we report it as + // "already replied" rather than posting a duplicate. + diesel::insert_into(workspace_thread_comments::table) + .values(&new_comment) + .on_conflict(dsl::parent_id) + .filter_target(dsl::parent_id.is_not_null().and(dsl::deleted_at.is_null())) + .do_nothing() + .returning(WorkspaceThreadComment::as_returning()) + .get_result(self) + .await + .optional() + .map_err(Error::from) + } + + async fn find_comment_in_workspace( + &mut self, + workspace_id: Uuid, + comment_id: Uuid, + ) -> Result> { + use schema::workspace_thread_comments::{self, dsl}; + + workspace_thread_comments::table + .filter(dsl::id.eq(comment_id)) + .filter(dsl::workspace_id.eq(workspace_id)) + .filter(dsl::deleted_at.is_null()) + .select(WorkspaceThreadComment::as_select()) + .first(self) + .await + .optional() + .map_err(Error::from) + } + + async fn list_thread_comments( + &mut self, + workspace_id: Uuid, + thread_id: Uuid, + ) -> Result>> { + use schema::workspace_thread_comments::dsl; + use schema::{accounts, workspace_thread_comments}; + + let rows: Vec<(WorkspaceThreadComment, AccountRefRow)> = workspace_thread_comments::table + .inner_join(accounts::table.on(dsl::author_account_id.eq(accounts::id))) + .filter(dsl::workspace_id.eq(workspace_id)) + .filter(dsl::thread_id.eq(thread_id)) + .filter(dsl::deleted_at.is_null()) + .select(( + WorkspaceThreadComment::as_select(), + ( + accounts::username, + accounts::display_name, + accounts::avatar_url, + ), + )) + // Oldest first: a discussion reads top to bottom. + .order((dsl::created_at.asc(), dsl::id.asc())) + .load(self) + .await + .map_err(Error::from)?; + + Ok(rows + .into_iter() + .map(|(item, account)| WithAccountRef { item, account }) + .collect()) + } + + async fn update_comment_body( + &mut self, + comment_id: Uuid, + updates: UpdateWorkspaceThreadComment, + ) -> Result { + use schema::workspace_thread_comments::{self, dsl}; + + diesel::update( + workspace_thread_comments::table + .filter(dsl::id.eq(comment_id)) + .filter(dsl::deleted_at.is_null()), + ) + .set(&updates) + .returning(WorkspaceThreadComment::as_returning()) + .get_result(self) + .await + .map_err(Error::from) + } + + async fn delete_comment(&mut self, comment_id: Uuid) -> Result<()> { + use schema::workspace_thread_comments::{self, dsl}; + + diesel::update( + workspace_thread_comments::table + .filter(dsl::id.eq(comment_id)) + .filter(dsl::deleted_at.is_null()), + ) + .set(dsl::deleted_at.eq(now)) + .execute(self) + .await + .map_err(Error::from)?; + + Ok(()) + } +} diff --git a/crates/nvisy-postgres/src/query/workspace_thread_event.rs b/crates/nvisy-postgres/src/query/workspace_thread_event.rs new file mode 100644 index 00000000..eb016d79 --- /dev/null +++ b/crates/nvisy-postgres/src/query/workspace_thread_event.rs @@ -0,0 +1,88 @@ +//! Workspace thread-event repository: the immutable timeline entries (opened, +//! closed, reopened, renamed, anchor added/removed) that the reader interleaves +//! with the comments. Also houses the shared helpers the thread and anchor +//! repositories use to record those events. + +use std::future::Future; + +use diesel::prelude::*; +use diesel_async::RunQueryDsl; +use serde_json::Value; +use uuid::Uuid; + +use crate::model::{ + NewWorkspaceThreadEvent, WorkspaceThread, WorkspaceThreadAnchor, WorkspaceThreadEvent, +}; +use crate::types::{AccountRefRow, ThreadEventKind}; +use crate::{Error, PgConnection, Result, schema}; + +/// Read operations on a thread's timeline events. +pub trait WorkspaceThreadEventRepository { + /// Lists a thread's timeline events, oldest first, each paired with the + /// actor's account reference when the actor still exists. + fn list_thread_events( + &mut self, + thread_id: Uuid, + ) -> impl Future)>>> + Send; +} + +impl WorkspaceThreadEventRepository for PgConnection { + async fn list_thread_events( + &mut self, + thread_id: Uuid, + ) -> Result)>> { + use schema::accounts; + use schema::workspace_thread_events::{self, dsl}; + + // The actor is nullable (SET NULL on account removal), so left-join it and + // load the account-ref column group as an `Option`. + workspace_thread_events::table + .left_join(accounts::table.on(dsl::actor_account_id.eq(accounts::id.nullable()))) + .filter(dsl::thread_id.eq(thread_id)) + .select(( + WorkspaceThreadEvent::as_select(), + ( + accounts::username, + accounts::display_name, + accounts::avatar_url, + ) + .nullable(), + )) + .order((dsl::created_at.asc(), dsl::id.asc())) + .load(self) + .await + .map_err(Error::from) + } +} + +/// Inserts one thread timeline event. Shared by the thread and anchor +/// repositories, which record events as part of their own transactions. +pub(crate) async fn record_event( + conn: &mut PgConnection, + thread: &WorkspaceThread, + kind: ThreadEventKind, + actor: Uuid, + target: Option, +) -> Result<()> { + use schema::workspace_thread_events; + + diesel::insert_into(workspace_thread_events::table) + .values(&NewWorkspaceThreadEvent { + workspace_id: thread.workspace_id, + thread_id: thread.id, + kind, + actor_account_id: Some(actor), + target, + }) + .execute(conn) + .await + .map_err(Error::from)?; + + Ok(()) +} + +/// A JSON snapshot of an anchor for a timeline event's `target`, so the timeline +/// renders a removed anchor without its (now soft-deleted) row. +pub(crate) fn anchor_snapshot(anchor: &WorkspaceThreadAnchor) -> Value { + serde_json::json!({ "anchorId": anchor.id, "anchor": anchor.anchor }) +} diff --git a/crates/nvisy-postgres/src/schema.rs b/crates/nvisy-postgres/src/schema.rs index 011af3dd..701f36ec 100644 --- a/crates/nvisy-postgres/src/schema.rs +++ b/crates/nvisy-postgres/src/schema.rs @@ -13,10 +13,6 @@ pub mod sql_types { #[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; - #[derive(diesel::query_builder::QueryId, diesel::sql_types::SqlType)] #[diesel(postgres_type(name = "connection_type"))] pub struct ConnectionType; @@ -73,6 +69,10 @@ pub mod sql_types { #[diesel(postgres_type(name = "sync_trigger_type"))] pub struct SyncTriggerType; + #[derive(diesel::query_builder::QueryId, diesel::sql_types::SqlType)] + #[diesel(postgres_type(name = "thread_event_kind"))] + pub struct ThreadEventKind; + #[derive(diesel::query_builder::QueryId, diesel::sql_types::SqlType)] #[diesel(postgres_type(name = "webhook_event"))] pub struct WebhookEvent; @@ -156,35 +156,6 @@ diesel::table! { } } -diesel::table! { - use diesel::sql_types::*; - use super::sql_types::ChatRole; - - chat_messages (id) { - id -> Uuid, - session_id -> Uuid, - parent_id -> Nullable, - role -> ChatRole, - content -> Bytea, - created_at -> Timestamptz, - } -} - -diesel::table! { - use diesel::sql_types::*; - - chat_sessions (id) { - id -> Uuid, - workspace_id -> Uuid, - account_id -> Uuid, - title -> Text, - current_message_id -> Nullable, - created_at -> Timestamptz, - updated_at -> Timestamptz, - deleted_at -> Nullable, - } -} - diesel::table! { use diesel::sql_types::*; use super::sql_types::OutboxStatus; @@ -238,20 +209,17 @@ diesel::table! { diesel::table! { use diesel::sql_types::*; + use super::sql_types::OutboxStatus; - workspace_comments (id) { + workspace_assistant_jobs (id) { id -> Uuid, - workspace_id -> Uuid, - file_id -> Uuid, - author_account_id -> Uuid, - parent_id -> Nullable, - body -> Text, - anchor -> Nullable, - resolved_at -> Nullable, - resolved_by -> Nullable, + comment_id -> Uuid, + job -> Jsonb, + status -> OutboxStatus, + attempts -> Int4, + next_attempt_at -> Timestamptz, created_at -> Timestamptz, - updated_at -> Timestamptz, - deleted_at -> Nullable, + resolved_at -> Nullable, } } @@ -531,6 +499,66 @@ diesel::table! { } } +diesel::table! { + use diesel::sql_types::*; + + workspace_thread_anchors (id) { + id -> Uuid, + thread_id -> Uuid, + anchor -> Jsonb, + created_at -> Timestamptz, + deleted_at -> Nullable, + } +} + +diesel::table! { + use diesel::sql_types::*; + + workspace_thread_comments (id) { + id -> Uuid, + parent_id -> Nullable, + workspace_id -> Uuid, + thread_id -> Uuid, + author_account_id -> Uuid, + body -> Text, + created_at -> Timestamptz, + updated_at -> Timestamptz, + deleted_at -> Nullable, + } +} + +diesel::table! { + use diesel::sql_types::*; + use super::sql_types::ThreadEventKind; + + workspace_thread_events (id) { + id -> Uuid, + workspace_id -> Uuid, + thread_id -> Uuid, + kind -> ThreadEventKind, + actor_account_id -> Nullable, + target -> Nullable, + created_at -> Timestamptz, + } +} + +diesel::table! { + use diesel::sql_types::*; + + workspace_threads (id) { + id -> Uuid, + workspace_id -> Uuid, + file_id -> Nullable, + author_account_id -> Uuid, + display_name -> Nullable, + closed_at -> Nullable, + closed_by -> Nullable, + created_at -> Timestamptz, + updated_at -> Timestamptz, + deleted_at -> Nullable, + } +} + diesel::table! { use diesel::sql_types::*; use super::sql_types::WebhookEvent; @@ -577,14 +605,12 @@ diesel::table! { diesel::joinable!(account_api_tokens -> accounts (account_id)); diesel::joinable!(account_identities -> accounts (account_id)); diesel::joinable!(account_notifications -> accounts (account_id)); -diesel::joinable!(chat_sessions -> accounts (account_id)); -diesel::joinable!(chat_sessions -> workspaces (workspace_id)); diesel::joinable!(event_outbox -> accounts (account_id)); diesel::joinable!(event_outbox -> workspaces (workspace_id)); diesel::joinable!(workspace_activities -> accounts (account_id)); diesel::joinable!(workspace_activities -> workspaces (workspace_id)); diesel::joinable!(workspace_assignments -> workspaces (workspace_id)); -diesel::joinable!(workspace_comments -> workspaces (workspace_id)); +diesel::joinable!(workspace_assistant_jobs -> workspace_thread_comments (comment_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)); @@ -611,6 +637,14 @@ diesel::joinable!(workspace_providers -> accounts (account_id)); diesel::joinable!(workspace_providers -> workspaces (workspace_id)); diesel::joinable!(workspace_redactions -> accounts (account_id)); diesel::joinable!(workspace_redactions -> workspace_detections (detection_id)); +diesel::joinable!(workspace_thread_anchors -> workspace_threads (thread_id)); +diesel::joinable!(workspace_thread_comments -> accounts (author_account_id)); +diesel::joinable!(workspace_thread_comments -> workspace_threads (thread_id)); +diesel::joinable!(workspace_thread_comments -> workspaces (workspace_id)); +diesel::joinable!(workspace_thread_events -> accounts (actor_account_id)); +diesel::joinable!(workspace_thread_events -> workspace_threads (thread_id)); +diesel::joinable!(workspace_thread_events -> workspaces (workspace_id)); +diesel::joinable!(workspace_threads -> workspaces (workspace_id)); diesel::joinable!(workspace_webhooks -> accounts (created_by)); diesel::joinable!(workspace_webhooks -> workspaces (workspace_id)); diesel::joinable!(workspaces -> accounts (created_by)); @@ -620,12 +654,10 @@ diesel::allow_tables_to_appear_in_same_query!( account_identities, account_notifications, accounts, - chat_messages, - chat_sessions, event_outbox, workspace_activities, workspace_assignments, - workspace_comments, + workspace_assistant_jobs, workspace_connection_schedule, workspace_connection_syncs, workspace_connections, @@ -642,6 +674,10 @@ diesel::allow_tables_to_appear_in_same_query!( workspace_policies, workspace_providers, workspace_redactions, + workspace_thread_anchors, + workspace_thread_comments, + workspace_thread_events, + workspace_threads, workspace_webhooks, workspaces, ); diff --git a/crates/nvisy-postgres/src/test_util.rs b/crates/nvisy-postgres/src/test_util.rs index f5d0086d..f2a57461 100644 --- a/crates/nvisy-postgres/src/test_util.rs +++ b/crates/nvisy-postgres/src/test_util.rs @@ -236,8 +236,6 @@ pub mod backdate { }; } - backdate!(chat_session_created_at = chat_sessions.created_at); - backdate!(chat_message_created_at = chat_messages.created_at); backdate!(activity_created_at = workspace_activities.created_at); backdate!(notification_created_at = account_notifications.created_at); backdate!(policy_created_at = workspace_policies.created_at); diff --git a/crates/nvisy-postgres/src/types/constraint/chat_messages.rs b/crates/nvisy-postgres/src/types/constraint/chat_messages.rs deleted file mode 100644 index 16aa8b35..00000000 --- a/crates/nvisy-postgres/src/types/constraint/chat_messages.rs +++ /dev/null @@ -1,16 +0,0 @@ -//! Chat messages table constraint violations. - -use strum::EnumString; - -/// Chat messages table constraint violations. -#[derive(Debug, Clone, Copy, Eq, PartialEq, EnumString)] -pub enum ChatMessageConstraints { - #[strum(serialize = "chat_messages_content_size")] - ContentSize, - - // Tree integrity: a parent must be in the same session. - #[strum(serialize = "chat_messages_id_session_key")] - IdSession, - #[strum(serialize = "chat_messages_parent_fkey")] - Parent, -} diff --git a/crates/nvisy-postgres/src/types/constraint/chat_sessions.rs b/crates/nvisy-postgres/src/types/constraint/chat_sessions.rs deleted file mode 100644 index b2eabab1..00000000 --- a/crates/nvisy-postgres/src/types/constraint/chat_sessions.rs +++ /dev/null @@ -1,14 +0,0 @@ -//! Chat sessions table constraint violations. - -use strum::EnumString; - -/// Chat sessions table constraint violations. -/// -/// Enumerates the constraints a client request can trip that map to a specific -/// non-500 response. Server-controlled invariants (ownership and active-leaf -/// foreign keys, timestamp ordering) fall through to the generic handler. -#[derive(Debug, Clone, Copy, Eq, PartialEq, EnumString)] -pub enum ChatSessionConstraints { - #[strum(serialize = "chat_sessions_title_length")] - TitleLength, -} diff --git a/crates/nvisy-postgres/src/types/constraint/comments.rs b/crates/nvisy-postgres/src/types/constraint/comments.rs deleted file mode 100644 index 4fcfb0c1..00000000 --- a/crates/nvisy-postgres/src/types/constraint/comments.rs +++ /dev/null @@ -1,14 +0,0 @@ -//! Workspace comments table constraint violations. - -use strum::EnumString; - -/// Workspace comments table constraint violations. -#[derive(Debug, Clone, Copy, Eq, PartialEq, EnumString)] -pub enum WorkspaceCommentConstraints { - /// The body is empty once trimmed, or longer than the maximum. - #[strum(serialize = "workspace_comments_body_length")] - BodyLength, - /// The anchor JSON exceeds the maximum stored size. - #[strum(serialize = "workspace_comments_anchor_size")] - AnchorSize, -} diff --git a/crates/nvisy-postgres/src/types/constraint/mod.rs b/crates/nvisy-postgres/src/types/constraint/mod.rs index cf82b93e..9d08b501 100644 --- a/crates/nvisy-postgres/src/types/constraint/mod.rs +++ b/crates/nvisy-postgres/src/types/constraint/mod.rs @@ -9,10 +9,6 @@ mod account_identities; mod account_notifications; mod accounts; -// Chat constraint modules -mod chat_messages; -mod chat_sessions; - // Workspace-related constraint modules mod workspace_activities; mod workspace_invites; @@ -30,7 +26,11 @@ mod pipelines; // Assignment-related constraint modules mod assignments; -mod comments; + +// Thread-related constraint modules +mod workspace_thread_anchors; +mod workspace_thread_comments; +mod workspace_threads; mod workspace_connection_syncs; mod workspace_connections; @@ -41,9 +41,6 @@ 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::comments::WorkspaceCommentConstraints; pub use self::detections::WorkspaceDetectionConstraints; pub use self::files::WorkspaceFileConstraints; pub use self::pipeline_references::WorkspacePipelineReferenceConstraints; @@ -54,6 +51,9 @@ pub use self::workspace_connections::WorkspaceConnectionConstraints; pub use self::workspace_invites::WorkspaceInviteConstraints; pub use self::workspace_members::WorkspaceMemberConstraints; pub use self::workspace_policies::WorkspacePolicyConstraints; +pub use self::workspace_thread_anchors::WorkspaceThreadAnchorConstraints; +pub use self::workspace_thread_comments::WorkspaceThreadCommentConstraints; +pub use self::workspace_threads::WorkspaceThreadConstraints; pub use self::workspace_webhooks::WorkspaceWebhookConstraints; pub use self::workspaces::WorkspaceConstraints; @@ -70,10 +70,6 @@ pub enum ConstraintViolation { AccountNotification(AccountNotificationConstraints), AccountApiToken(AccountApiTokenConstraints), - // Chat-related constraints - ChatSession(ChatSessionConstraints), - ChatMessage(ChatMessageConstraints), - // Workspace-related constraints Workspace(WorkspaceConstraints), WorkspaceMember(WorkspaceMemberConstraints), @@ -88,7 +84,9 @@ pub enum ConstraintViolation { WorkspaceAssignment(WorkspaceAssignmentConstraints), // Comment-related constraints - WorkspaceComment(WorkspaceCommentConstraints), + WorkspaceThread(WorkspaceThreadConstraints), + WorkspaceThreadAnchor(WorkspaceThreadAnchorConstraints), + WorkspaceThreadComment(WorkspaceThreadCommentConstraints), // Detection / pipeline-related constraints WorkspacePipeline(WorkspacePipelineConstraints), @@ -139,8 +137,6 @@ impl ConstraintViolation { AccountIdentity, AccountNotification, AccountApiToken, - ChatSession, - ChatMessage, Workspace, WorkspaceMember, WorkspaceInvite, @@ -148,7 +144,9 @@ impl ConstraintViolation { WorkspaceWebhook, WorkspaceFile, WorkspaceAssignment, - WorkspaceComment, + WorkspaceThread, + WorkspaceThreadAnchor, + WorkspaceThreadComment, WorkspacePipeline, WorkspaceDetection, WorkspacePipelineReference, diff --git a/crates/nvisy-postgres/src/types/constraint/workspace_thread_anchors.rs b/crates/nvisy-postgres/src/types/constraint/workspace_thread_anchors.rs new file mode 100644 index 00000000..4adef833 --- /dev/null +++ b/crates/nvisy-postgres/src/types/constraint/workspace_thread_anchors.rs @@ -0,0 +1,11 @@ +//! Workspace thread-anchors table constraint violations. + +use strum::EnumString; + +/// Workspace thread-anchors table constraint violations. +#[derive(Debug, Clone, Copy, Eq, PartialEq, EnumString)] +pub enum WorkspaceThreadAnchorConstraints { + /// The anchor JSON exceeds the maximum stored size. + #[strum(serialize = "workspace_thread_anchors_size")] + Size, +} diff --git a/crates/nvisy-postgres/src/types/constraint/workspace_thread_comments.rs b/crates/nvisy-postgres/src/types/constraint/workspace_thread_comments.rs new file mode 100644 index 00000000..6f0abe76 --- /dev/null +++ b/crates/nvisy-postgres/src/types/constraint/workspace_thread_comments.rs @@ -0,0 +1,11 @@ +//! Workspace thread-comments table constraint violations. + +use strum::EnumString; + +/// Workspace thread-comments table constraint violations. +#[derive(Debug, Clone, Copy, Eq, PartialEq, EnumString)] +pub enum WorkspaceThreadCommentConstraints { + /// The body is empty once trimmed, or longer than the maximum. + #[strum(serialize = "workspace_thread_comments_body_length")] + BodyLength, +} diff --git a/crates/nvisy-postgres/src/types/constraint/workspace_threads.rs b/crates/nvisy-postgres/src/types/constraint/workspace_threads.rs new file mode 100644 index 00000000..98e51f73 --- /dev/null +++ b/crates/nvisy-postgres/src/types/constraint/workspace_threads.rs @@ -0,0 +1,14 @@ +//! Workspace threads table constraint violations. + +use strum::EnumString; + +/// Workspace threads table constraint violations. +#[derive(Debug, Clone, Copy, Eq, PartialEq, EnumString)] +pub enum WorkspaceThreadConstraints { + /// The title is empty once trimmed, or longer than the maximum. + #[strum(serialize = "workspace_threads_display_name_length")] + DisplayNameLength, + /// The closed-at and closed-by columns disagree on the closed state. + #[strum(serialize = "workspace_threads_closed_consistent")] + ClosedConsistent, +} diff --git a/crates/nvisy-postgres/src/types/enums/activity_type.rs b/crates/nvisy-postgres/src/types/enums/activity_type.rs index 977015d2..389a71f1 100644 --- a/crates/nvisy-postgres/src/types/enums/activity_type.rs +++ b/crates/nvisy-postgres/src/types/enums/activity_type.rs @@ -84,12 +84,22 @@ db_enum! { PolicyUpdated = "policy.updated", /// Policy was deleted. PolicyDeleted = "policy.deleted", - /// A comment was created. - CommentCreated = "comment.created", - /// A comment thread was resolved. - CommentResolved = "comment.resolved", - /// A comment was deleted. - CommentDeleted = "comment.deleted", + /// A thread was opened. + ThreadOpened = "thread.opened", + /// A thread was closed. + ThreadClosed = "thread.closed", + /// A thread was reopened. + ThreadReopened = "thread.reopened", + /// A thread's title was changed. + ThreadRenamed = "thread.renamed", + /// A thread was deleted. + ThreadDeleted = "thread.deleted", + /// An anchor was added to a thread. + ThreadAnchorAdded = "thread.anchor.added", + /// An anchor was removed from a thread. + ThreadAnchorRemoved = "thread.anchor.removed", + /// A comment (message) was posted in a thread. + ThreadCommentCreated = "thread.comment.created", } } diff --git a/crates/nvisy-postgres/src/types/enums/chat_role.rs b/crates/nvisy-postgres/src/types/enums/chat_role.rs deleted file mode 100644 index df860600..00000000 --- a/crates/nvisy-postgres/src/types/enums/chat_role.rs +++ /dev/null @@ -1,17 +0,0 @@ -//! Chat message role enumeration. - -use super::db_enum; - -db_enum! { - /// The author of a chat message. - /// - /// Corresponds to the `CHAT_ROLE` PostgreSQL enum. - pub enum ChatRole = "crate::schema::sql_types::ChatRole" { - /// A system instruction (server-authored context). - System = "system", - /// A message from the account. - User = "user", - /// A reply from the model. - Assistant = "assistant", - } -} diff --git a/crates/nvisy-postgres/src/types/enums/mod.rs b/crates/nvisy-postgres/src/types/enums/mod.rs index 392b92b7..0affd979 100644 --- a/crates/nvisy-postgres/src/types/enums/mod.rs +++ b/crates/nvisy-postgres/src/types/enums/mod.rs @@ -10,9 +10,6 @@ pub mod identity_provider; pub mod notification_event; pub mod outbox_status; -// Chat-related enumerations -pub mod chat_role; - // Connection-related enumerations pub mod connection_type; pub mod provider_type; @@ -39,10 +36,11 @@ pub mod pipeline_trigger_type; // Assignment-related enumerations pub mod assignment_status; +pub mod thread_event_kind; + 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; pub use file_kind::FileKind; @@ -57,6 +55,7 @@ pub use sync_deletion_policy::SyncDeletionPolicy; pub use sync_mode::SyncMode; pub use sync_status::SyncStatus; pub use sync_trigger_type::SyncTriggerType; +pub use thread_event_kind::ThreadEventKind; pub use webhook_event::WebhookEvent; pub use webhook_status::WebhookStatus; pub use workspace_role::WorkspaceRole; diff --git a/crates/nvisy-postgres/src/types/enums/thread_event_kind.rs b/crates/nvisy-postgres/src/types/enums/thread_event_kind.rs new file mode 100644 index 00000000..ee775e53 --- /dev/null +++ b/crates/nvisy-postgres/src/types/enums/thread_event_kind.rs @@ -0,0 +1,26 @@ +//! Thread timeline event-kind enumeration. + +use super::db_enum; + +db_enum! { + /// The kind of a non-message entry in a comment thread's timeline. + /// + /// Corresponds to the `THREAD_EVENT_KIND` PostgreSQL enum. A thread's stream + /// interleaves comments (messages) with these events, so a reader sees who + /// opened, closed, reopened, or renamed the thread, and when anchors were + /// added or removed, between the messages. + pub enum ThreadEventKind = "crate::schema::sql_types::ThreadEventKind" { + /// The thread was opened. + Opened = "thread.opened", + /// The thread was closed. + Closed = "thread.closed", + /// The thread was reopened. + Reopened = "thread.reopened", + /// The thread's display name was changed. + Renamed = "thread.renamed", + /// An anchor (location pin) was added to the thread. + AnchorAdded = "thread.anchor.added", + /// An anchor was removed from the thread. + AnchorRemoved = "thread.anchor.removed", + } +} diff --git a/crates/nvisy-postgres/src/types/enums/webhook_event.rs b/crates/nvisy-postgres/src/types/enums/webhook_event.rs index e64eb099..0728ea29 100644 --- a/crates/nvisy-postgres/src/types/enums/webhook_event.rs +++ b/crates/nvisy-postgres/src/types/enums/webhook_event.rs @@ -64,10 +64,18 @@ db_enum! { PolicyUpdated = "policy.updated", /// A policy was deleted. PolicyDeleted = "policy.deleted", - /// A comment was created. - CommentCreated = "comment.created", - /// A comment thread was resolved. - CommentResolved = "comment.resolved", + /// A thread was opened. + ThreadOpened = "thread.opened", + /// A thread was closed. + ThreadClosed = "thread.closed", + /// A thread was reopened. + ThreadReopened = "thread.reopened", + /// A thread's title was changed. + ThreadRenamed = "thread.renamed", + /// An anchor was added to a thread. + ThreadAnchorAdded = "thread.anchor.added", + /// An anchor was removed from a thread. + ThreadAnchorRemoved = "thread.anchor.removed", } } @@ -103,7 +111,12 @@ impl WebhookEvent { WebhookEvent::PolicyCreated | WebhookEvent::PolicyUpdated | WebhookEvent::PolicyDeleted => "policy", - WebhookEvent::CommentCreated | WebhookEvent::CommentResolved => "comment", + WebhookEvent::ThreadOpened + | WebhookEvent::ThreadClosed + | WebhookEvent::ThreadReopened + | WebhookEvent::ThreadRenamed + | WebhookEvent::ThreadAnchorAdded + | WebhookEvent::ThreadAnchorRemoved => "thread", } } diff --git a/crates/nvisy-postgres/src/types/filtering/comments.rs b/crates/nvisy-postgres/src/types/filtering/comments.rs index a18a619a..b029a793 100644 --- a/crates/nvisy-postgres/src/types/filtering/comments.rs +++ b/crates/nvisy-postgres/src/types/filtering/comments.rs @@ -1,24 +1,24 @@ -//! Filtering options for comment queries. +//! Filtering options for comment-thread queries. use serde::{Deserialize, Serialize}; use uuid::Uuid; -/// Filter options for workspace comments. +/// Filter options for workspace comment threads. /// /// 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 CommentFilter { - /// Filter by the file the comment is on. +pub struct ThreadFilter { + /// Filter by the file the thread is on. #[serde(skip_serializing_if = "Option::is_none")] pub file_id: Option, - /// Filter by the comment's author. + /// Filter by the account that opened the thread. #[serde(skip_serializing_if = "Option::is_none")] pub author_account_id: Option, - /// Filter by resolution state: `Some(true)` = resolved only, `Some(false)` = + /// Filter by open/closed state: `Some(true)` = closed only, `Some(false)` = /// open only, `None` = either. #[serde(skip_serializing_if = "Option::is_none")] - pub resolved: Option, + pub closed: Option, } diff --git a/crates/nvisy-postgres/src/types/filtering/mod.rs b/crates/nvisy-postgres/src/types/filtering/mod.rs index 9828cfeb..e18c6cc2 100644 --- a/crates/nvisy-postgres/src/types/filtering/mod.rs +++ b/crates/nvisy-postgres/src/types/filtering/mod.rs @@ -8,7 +8,7 @@ mod invites; mod members; pub use assignments::AssignmentFilter; -pub use comments::CommentFilter; +pub use comments::ThreadFilter; 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 72a1e94a..76f65ac3 100644 --- a/crates/nvisy-postgres/src/types/json/activity_params.rs +++ b/crates/nvisy-postgres/src/types/json/activity_params.rs @@ -149,15 +149,47 @@ pub struct PolicyActivityParams { pub policy_slug: Handle, } -/// Params of a comment activity (`comment.*`). +/// Params of a comment-thread activity (`thread.*`). #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] #[serde(rename_all = "camelCase")] -pub struct CommentActivityParams { +pub struct ThreadActivityParams { + /// Id of the thread. + pub thread_id: Uuid, + /// Id of the file the thread is pinned to; omitted for a workspace-level + /// thread. + #[serde(skip_serializing_if = "Option::is_none", default)] + pub file_id: Option, +} + +/// Params of a comment-thread anchor activity (`thread.anchor.*`). +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase")] +pub struct ThreadAnchorActivityParams { + /// Id of the thread. + pub thread_id: Uuid, + /// Id of the anchor added or removed. + pub anchor_id: Uuid, + /// Id of the file the thread is pinned to; omitted for a workspace-level + /// thread. + #[serde(skip_serializing_if = "Option::is_none", default)] + pub file_id: Option, +} + +/// Params of a thread-comment activity (`thread.comment.created`). +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase")] +pub struct ThreadCommentActivityParams { /// Id of the comment. pub comment_id: Uuid, - /// Id of the file the comment is on. - pub file_id: Uuid, + /// Id of the thread the comment is in. + pub thread_id: Uuid, + /// Id of the file the thread is pinned to; omitted for a workspace-level + /// thread. + #[serde(skip_serializing_if = "Option::is_none", default)] + pub file_id: Option, } /// The typed payload of an audit-log activity, tagged by `type` with its params @@ -294,15 +326,30 @@ pub enum ActivityPayload { #[serde(rename = "policy.deleted")] PolicyDeleted(PolicyActivityParams), - /// A comment was created. - #[serde(rename = "comment.created")] - CommentCreated(CommentActivityParams), - /// A comment thread was resolved. - #[serde(rename = "comment.resolved")] - CommentResolved(CommentActivityParams), - /// A comment was deleted. - #[serde(rename = "comment.deleted")] - CommentDeleted(CommentActivityParams), + /// A thread was opened. + #[serde(rename = "thread.opened")] + ThreadOpened(ThreadActivityParams), + /// A thread was closed. + #[serde(rename = "thread.closed")] + ThreadClosed(ThreadActivityParams), + /// A thread was reopened. + #[serde(rename = "thread.reopened")] + ThreadReopened(ThreadActivityParams), + /// A thread's title was changed. + #[serde(rename = "thread.renamed")] + ThreadRenamed(ThreadActivityParams), + /// A thread was deleted. + #[serde(rename = "thread.deleted")] + ThreadDeleted(ThreadActivityParams), + /// An anchor was added to a thread. + #[serde(rename = "thread.anchor.added")] + ThreadAnchorAdded(ThreadAnchorActivityParams), + /// An anchor was removed from a thread. + #[serde(rename = "thread.anchor.removed")] + ThreadAnchorRemoved(ThreadAnchorActivityParams), + /// A comment (message) was posted in a thread. + #[serde(rename = "thread.comment.created")] + ThreadCommentCreated(ThreadCommentActivityParams), } impl ActivityPayload { @@ -348,9 +395,14 @@ impl ActivityPayload { ActivityPayload::PolicyCreated(_) => ActivityType::PolicyCreated, ActivityPayload::PolicyUpdated(_) => ActivityType::PolicyUpdated, ActivityPayload::PolicyDeleted(_) => ActivityType::PolicyDeleted, - ActivityPayload::CommentCreated(_) => ActivityType::CommentCreated, - ActivityPayload::CommentResolved(_) => ActivityType::CommentResolved, - ActivityPayload::CommentDeleted(_) => ActivityType::CommentDeleted, + ActivityPayload::ThreadOpened(_) => ActivityType::ThreadOpened, + ActivityPayload::ThreadClosed(_) => ActivityType::ThreadClosed, + ActivityPayload::ThreadReopened(_) => ActivityType::ThreadReopened, + ActivityPayload::ThreadRenamed(_) => ActivityType::ThreadRenamed, + ActivityPayload::ThreadDeleted(_) => ActivityType::ThreadDeleted, + ActivityPayload::ThreadAnchorAdded(_) => ActivityType::ThreadAnchorAdded, + ActivityPayload::ThreadAnchorRemoved(_) => ActivityType::ThreadAnchorRemoved, + ActivityPayload::ThreadCommentCreated(_) => ActivityType::ThreadCommentCreated, } } @@ -370,7 +422,8 @@ impl ActivityPayload { | ActivityPayload::WebhookCreated(_) | ActivityPayload::WebhookUpdated(_) | ActivityPayload::WebhookDeleted(_) - | ActivityPayload::CommentDeleted(_) => return None, + | ActivityPayload::ThreadDeleted(_) + | ActivityPayload::ThreadCommentCreated(_) => return None, ActivityPayload::MemberAdded(_) => W::MemberAdded, ActivityPayload::MemberUpdated(_) => W::MemberUpdated, @@ -400,8 +453,12 @@ impl ActivityPayload { ActivityPayload::PolicyCreated(_) => W::PolicyCreated, ActivityPayload::PolicyUpdated(_) => W::PolicyUpdated, ActivityPayload::PolicyDeleted(_) => W::PolicyDeleted, - ActivityPayload::CommentCreated(_) => W::CommentCreated, - ActivityPayload::CommentResolved(_) => W::CommentResolved, + ActivityPayload::ThreadOpened(_) => W::ThreadOpened, + ActivityPayload::ThreadClosed(_) => W::ThreadClosed, + ActivityPayload::ThreadReopened(_) => W::ThreadReopened, + ActivityPayload::ThreadRenamed(_) => W::ThreadRenamed, + ActivityPayload::ThreadAnchorAdded(_) => W::ThreadAnchorAdded, + ActivityPayload::ThreadAnchorRemoved(_) => W::ThreadAnchorRemoved, }) } @@ -452,9 +509,17 @@ impl ActivityPayload { | ActivityPayload::PolicyUpdated(p) | ActivityPayload::PolicyDeleted(p) => Some(p.policy_id.to_string()), - ActivityPayload::CommentCreated(p) - | ActivityPayload::CommentResolved(p) - | ActivityPayload::CommentDeleted(p) => Some(p.comment_id.to_string()), + ActivityPayload::ThreadOpened(p) + | ActivityPayload::ThreadClosed(p) + | ActivityPayload::ThreadReopened(p) + | ActivityPayload::ThreadRenamed(p) + | ActivityPayload::ThreadDeleted(p) => Some(p.thread_id.to_string()), + + ActivityPayload::ThreadAnchorAdded(p) | ActivityPayload::ThreadAnchorRemoved(p) => { + Some(p.anchor_id.to_string()) + } + + ActivityPayload::ThreadCommentCreated(p) => Some(p.comment_id.to_string()), ActivityPayload::WorkspaceCreated(_) | ActivityPayload::WorkspaceUpdated(_) @@ -523,10 +588,16 @@ impl ActivityPayload { | ActivityPayload::PolicyUpdated(p) | ActivityPayload::PolicyDeleted(p) => Some(p.policy_slug.to_string()), - // A comment has no human-readable name; it is addressed by id only. - ActivityPayload::CommentCreated(_) - | ActivityPayload::CommentResolved(_) - | ActivityPayload::CommentDeleted(_) => None, + // A thread/comment/anchor has no human-readable name; addressed by id + // only. + ActivityPayload::ThreadOpened(_) + | ActivityPayload::ThreadClosed(_) + | ActivityPayload::ThreadReopened(_) + | ActivityPayload::ThreadRenamed(_) + | ActivityPayload::ThreadDeleted(_) + | ActivityPayload::ThreadAnchorAdded(_) + | ActivityPayload::ThreadAnchorRemoved(_) + | ActivityPayload::ThreadCommentCreated(_) => None, } } } diff --git a/crates/nvisy-postgres/src/types/json/mod.rs b/crates/nvisy-postgres/src/types/json/mod.rs index e01caff0..3d04fef9 100644 --- a/crates/nvisy-postgres/src/types/json/mod.rs +++ b/crates/nvisy-postgres/src/types/json/mod.rs @@ -15,10 +15,11 @@ mod workspace_metadata; mod workspace_settings; pub use activity_params::{ - ActivityPayload, AssignmentActivityParams, CommentActivityParams, ConnectionActivityParams, - DetectionActivityParams, FileActivityParams, InviteActivityParams, MemberActivityParams, - PipelineActivityParams, PolicyActivityParams, ProviderActivityParams, RedactionActivityParams, - WebhookActivityParams, WorkspaceActivityParams, + ActivityPayload, AssignmentActivityParams, ConnectionActivityParams, DetectionActivityParams, + FileActivityParams, InviteActivityParams, MemberActivityParams, PipelineActivityParams, + PolicyActivityParams, ProviderActivityParams, RedactionActivityParams, ThreadActivityParams, + ThreadAnchorActivityParams, ThreadCommentActivityParams, WebhookActivityParams, + WorkspaceActivityParams, }; pub use detection_metadata::DetectionMetadata; pub use notification_params::{ diff --git a/crates/nvisy-postgres/src/types/json/notification_params.rs b/crates/nvisy-postgres/src/types/json/notification_params.rs index 58a8d43e..0d0d6d29 100644 --- a/crates/nvisy-postgres/src/types/json/notification_params.rs +++ b/crates/nvisy-postgres/src/types/json/notification_params.rs @@ -130,8 +130,12 @@ pub struct FileUnassignedParams { pub struct CommentMentionedParams { /// Id of the comment the account was mentioned in. pub comment_id: Uuid, - /// Id of the file the comment is on. - pub file_id: Uuid, + /// Id of the thread the comment is in. + pub thread_id: Uuid, + /// Id of the file the thread is on, when it is file-pinned; `None` for a + /// workspace-level thread. + #[serde(skip_serializing_if = "Option::is_none")] + pub file_id: Option, /// Username of the account that wrote the comment (the mentioner). pub author_username: Handle, } diff --git a/crates/nvisy-postgres/src/types/mod.rs b/crates/nvisy-postgres/src/types/mod.rs index dc3e3836..fc48e8bd 100644 --- a/crates/nvisy-postgres/src/types/mod.rs +++ b/crates/nvisy-postgres/src/types/mod.rs @@ -12,34 +12,35 @@ mod utilities; pub use constraint::{ AccountApiTokenConstraints, AccountConstraints, AccountIdentityConstraints, - AccountNotificationConstraints, ChatMessageConstraints, ChatSessionConstraints, - ConstraintViolation, WorkspaceActivitiesConstraints, WorkspaceAssignmentConstraints, - WorkspaceCommentConstraints, WorkspaceConnectionConstraints, + AccountNotificationConstraints, ConstraintViolation, WorkspaceActivitiesConstraints, + WorkspaceAssignmentConstraints, WorkspaceConnectionConstraints, WorkspaceConnectionSyncConstraints, WorkspaceConstraints, WorkspaceDetectionConstraints, WorkspaceFileConstraints, WorkspaceInviteConstraints, WorkspaceMemberConstraints, WorkspacePipelineConstraints, WorkspacePipelineReferenceConstraints, - WorkspacePolicyConstraints, WorkspaceWebhookConstraints, + WorkspacePolicyConstraints, WorkspaceThreadAnchorConstraints, + WorkspaceThreadCommentConstraints, WorkspaceThreadConstraints, WorkspaceWebhookConstraints, }; pub use enums::{ - ActivityType, ApiTokenType, AssignmentStatus, ChatRole, ConnectionType, DetectionStatus, - FileKind, IdentityProvider, InviteStatus, NotificationEvent, OutboxStatus, PipelineStatus, + ActivityType, ApiTokenType, AssignmentStatus, ConnectionType, DetectionStatus, FileKind, + IdentityProvider, InviteStatus, NotificationEvent, OutboxStatus, PipelineStatus, PipelineTriggerType, ProviderType, SyncDeletionPolicy, SyncMode, SyncStatus, SyncTriggerType, - WebhookEvent, WebhookStatus, WorkspaceRole, + ThreadEventKind, WebhookEvent, WebhookStatus, WorkspaceRole, }; pub use filtering::{ - AssignmentFilter, CommentFilter, DetectionFilter, FileFilter, InviteFilter, MemberFilter, + AssignmentFilter, DetectionFilter, FileFilter, InviteFilter, MemberFilter, ThreadFilter, }; pub use handle::{HANDLE_MAX_LENGTH, HANDLE_MIN_LENGTH, Handle, HandleError}; pub use json::{ - ActivityPayload, AssignmentActivityParams, CommentActivityParams, CommentMentionedParams, - ConnectionActivityParams, ConnectionSyncCompletedParams, ConnectionSyncFailedParams, - DetectionActivityParams, DetectionCompletedParams, DetectionFailedParams, DetectionMetadata, - FileActivityParams, FileAssignedParams, FileUnassignedParams, InvalidHeader, - InviteActivityParams, Json, MemberActivityParams, MemberJoinedParams, NotificationPayload, - PipelineActivityParams, PipelineMetadata, PolicyActivityParams, ProviderActivityParams, - RasterPolicy, RedactionActivityParams, RedactionCreatedParams, Retention, RetentionOverride, - RetentionScope, RetentionSettings, WebhookActivityParams, WebhookHeaders, - WorkspaceActivityParams, WorkspaceMetadata, WorkspaceSettings, + ActivityPayload, AssignmentActivityParams, CommentMentionedParams, ConnectionActivityParams, + ConnectionSyncCompletedParams, ConnectionSyncFailedParams, DetectionActivityParams, + DetectionCompletedParams, DetectionFailedParams, DetectionMetadata, FileActivityParams, + FileAssignedParams, FileUnassignedParams, InvalidHeader, InviteActivityParams, Json, + MemberActivityParams, MemberJoinedParams, NotificationPayload, PipelineActivityParams, + PipelineMetadata, PolicyActivityParams, ProviderActivityParams, RasterPolicy, + RedactionActivityParams, RedactionCreatedParams, Retention, RetentionOverride, RetentionScope, + RetentionSettings, ThreadActivityParams, ThreadAnchorActivityParams, + ThreadCommentActivityParams, 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/authorized.rs b/crates/nvisy-server/src/extract/auth/authorized.rs index 54adc71a..f4ad7680 100644 --- a/crates/nvisy-server/src/extract/auth/authorized.rs +++ b/crates/nvisy-server/src/extract/auth/authorized.rs @@ -156,10 +156,9 @@ authz_permissions! { AssignTasks => Permission::AssignTasks, ViewComments => Permission::ViewComments, Comment => Permission::Comment, - ResolveComments => Permission::ResolveComments, + CloseComments => Permission::CloseComments, ViewAnalytics => Permission::ViewAnalytics, ViewActivity => Permission::ViewActivity, - UseChat => Permission::UseChat, ViewMembers => Permission::ViewMembers, InviteMembers => Permission::InviteMembers, RemoveMembers => Permission::RemoveMembers, diff --git a/crates/nvisy-server/src/extract/auth/permission.rs b/crates/nvisy-server/src/extract/auth/permission.rs index ae73b569..0d3028b8 100644 --- a/crates/nvisy-server/src/extract/auth/permission.rs +++ b/crates/nvisy-server/src/extract/auth/permission.rs @@ -63,8 +63,8 @@ pub enum Permission { ViewComments, /// Can write comments and replies (and edit or delete one's own). Comment, - /// Can resolve and reopen comment threads. - ResolveComments, + /// Can close and reopen comment threads. + CloseComments, // Reporting permissions /// Can view workspace analytics. @@ -72,10 +72,6 @@ pub enum Permission { /// Can view the workspace activity log. ViewActivity, - // Chat permissions - /// Can use workspace chat sessions. - UseChat, - // Member management permissions /// Can view workspace members and their roles. ViewMembers, @@ -143,7 +139,7 @@ impl Permission { | Self::ViewAssignments | Self::ViewComments | Self::Comment - | Self::ResolveComments + | Self::CloseComments | Self::ViewAnalytics | Self::ViewActivity | Self::ViewMembers @@ -163,7 +159,6 @@ impl Permission { | Self::RunDetections | Self::RunRedactions | Self::AssignTasks - | Self::UseChat | Self::RunConnectionSyncs => WorkspaceRole::Editor, // Admin-level permissions (manage workspace resources) diff --git a/crates/nvisy-server/src/handler/chat.rs b/crates/nvisy-server/src/handler/chat.rs deleted file mode 100644 index bb16b5c4..00000000 --- a/crates/nvisy-server/src/handler/chat.rs +++ /dev/null @@ -1,364 +0,0 @@ -//! Assistant chat handlers: sessions and streaming messages. -//! -//! Chat is a workspace-scoped assistant. A session is a thread of messages; a -//! message POST persists the user's turn, streams the model's reply over SSE, -//! and persists the assembled reply when the stream ends. The model is the -//! workspace's language-model connection; it has no access to document contents. - -use aide::axum::ApiRouter; -use aide::transform::TransformOperation; -use async_stream::stream; -use axum::extract::State; -use axum::http::StatusCode; -use axum::response::sse::Event; -use futures::StreamExt; -use nvisy_postgres::PgClient; -use nvisy_postgres::model::NewChatSession; -use nvisy_postgres::query::{AppendSessionUpdate, ChatMessageRepository, ChatSessionRepository}; -use nvisy_postgres::types::ChatRole; -use tokio_util::sync::CancellationToken; - -use crate::extract::{Authorized, Json, Path, Query, ValidateJson, markers}; -use crate::handler::request::{ - ChatSessionPathParams, CreateChatSession, CursorPagination, SendChatMessage, -}; -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. -const TRACING_TARGET: &str = "nvisy_server::handler::chat"; - -/// How long a session title seeded from the first message may be. -const TITLE_MAX: usize = 80; - -/// The default session title, until seeded from the first message. -const DEFAULT_TITLE: &str = "New chat"; - -/// Maximum assistant-reply length, in bytes of plaintext. Kept below the -/// encrypted-content column limit (131072 bytes) with headroom for the -/// encryption framing (nonce, tag, chunking), so an accepted reply always fits. -const MAX_REPLY_BYTES: usize = 96 * 1024; - -/// Creates a new chat session in the workspace. -#[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, - ValidateJson(request): ValidateJson, -) -> Result<(StatusCode, Json)> { - let account_id = authz.account_id; - let workspace = authz.workspace; - let mut conn = pg_client.get_connection().await?; - - let session = conn - .create_chat_session(NewChatSession { - workspace_id: workspace.id, - account_id, - title: request.title.unwrap_or_else(|| DEFAULT_TITLE.to_owned()), - }) - .await?; - - tracing::info!(target: TRACING_TARGET, session_id = %session.id, "Chat session created"); - Ok((StatusCode::CREATED, Json(ChatSession::from_model(session)))) -} - -fn create_session_docs(op: TransformOperation) -> TransformOperation { - op.summary("Create chat session") - .description("Opens a new assistant chat session in the workspace.") - .response::<201, Json>() - .response::<401, Json>() - .response::<403, Json>() -} - -/// Lists the workspace's chat sessions, most recently active first. -#[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, - Query(pagination): Query, -) -> Result<(StatusCode, Json)> { - let workspace = authz.workspace; - let mut conn = pg_client.get_connection().await?; - - let page = conn - .list_chat_sessions(workspace.id, pagination.into()) - .await?; - let response = ChatSessionsPage::from_cursor_page(page, ChatSession::from_model); - - Ok((StatusCode::OK, Json(response))) -} - -fn list_sessions_docs(op: TransformOperation) -> TransformOperation { - op.summary("List chat sessions") - .description("Returns the workspace's chat sessions, newest first, cursor-paginated.") - .response::<200, Json>() - .response::<401, Json>() - .response::<403, Json>() -} - -/// Returns a session's messages in chronological order. -#[tracing::instrument(skip_all, fields(account_id = %authz.account_id, workspace_id = %authz.workspace.id, session_id = %path_params.session_id))] -async fn list_messages( - State(pg_client): State, - State(chat): State, - authz: Authorized, - Path(path_params): Path, -) -> Result<(StatusCode, Json>)> { - let workspace = authz.workspace; - let mut conn = pg_client.get_connection().await?; - - // Scope the session to the workspace before reading its messages. - conn.find_chat_session_in_workspace(workspace.id, path_params.session_id) - .await? - .ok_or_else(|| Error::not_found("chat session"))?; - - let messages = conn.list_chat_messages(path_params.session_id).await?; - let items = messages - .into_iter() - .map(|message| ChatMessage::from_model(message, workspace.id, &chat)) - .collect::>>()?; - - Ok((StatusCode::OK, Json(items))) -} - -fn list_messages_docs(op: TransformOperation) -> TransformOperation { - op.summary("List chat messages") - .description("Returns a session's messages in chronological order.") - .response::<200, Json>>() - .response::<401, Json>() - .response::<403, Json>() - .response::<404, Json>() -} - -/// Deletes a chat session. -#[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, - Path(path_params): Path, -) -> Result { - let workspace = authz.workspace; - let mut conn = pg_client.get_connection().await?; - - let deleted = conn - .delete_chat_session(workspace.id, path_params.session_id) - .await?; - if !deleted { - return Err(Error::not_found("chat session")); - } - - Ok(StatusCode::NO_CONTENT) -} - -fn delete_session_docs(op: TransformOperation) -> TransformOperation { - op.summary("Delete chat session") - .description("Soft-deletes a chat session.") - .response::<204, ()>() - .response::<401, Json>() - .response::<403, Json>() - .response::<404, Json>() -} - -/// Sends a message and streams the assistant's reply as Server-Sent Events. -/// -/// Persists the user message, streams the model's reply as `token` events, and -/// persists the assembled reply when the stream ends. Empty replies (the model -/// produced no text) are not persisted. -/// -/// Authenticated with a Bearer token; browsers should consume it via a `fetch` -/// stream rather than the native `EventSource`, which cannot send an -/// `Authorization` header. -#[tracing::instrument(skip_all, fields(account_id = %authz.account_id, workspace_id = %authz.workspace.id, session_id = %path_params.session_id))] -async fn send_message( - State(pg_client): State, - State(chat): State, - State(shutdown): State, - authz: Authorized, - Path(path_params): Path, - ValidateJson(request): ValidateJson, -) -> Result> { - let session_id = path_params.session_id; - let workspace_id = authz.workspace.id; - - let mut conn = pg_client.get_connection().await?; - - // Scope the session to the workspace before writing to it. - let session = conn - .find_chat_session_in_workspace(workspace_id, session_id) - .await? - .ok_or_else(|| Error::not_found("chat session"))?; - - // An explicit parent must belong to this session. The composite FK enforces - // this at write time, but reject it here — before inference — for a clean 404 - // rather than a failed insert after the model has run. - if let Some(parent_id) = request.parent_id - && conn - .find_chat_message_in_session(session_id, parent_id) - .await? - .is_none() - { - return Err(Error::not_found("chat message")); - } - - // The turn extends the branch the client is on: an explicit parent, else the - // session's current leaf. - let user_turn = TurnLocation { - workspace_id, - session_id, - parent_id: request.parent_id.or(session.current_message_id), - }; - - // Open the model turn BEFORE persisting anything: resolving the workspace's - // language-model connection can fail (409 when none is configured), and a - // failed send must not leave an orphan user turn in the history. - let mut tokens = chat - .stream_turn(&mut conn, user_turn, &request.content) - .await?; - - // The turn resolved: persist the user message under the branch, advancing the - // active leaf to it and seeding the title on the first message — all in one - // transaction so the session state can't diverge from its messages. - let user_message = chat - .append_message( - &mut conn, - user_turn, - ChatRole::User, - &request.content, - AppendSessionUpdate { - advance_leaf: true, - title: (session.title == DEFAULT_TITLE).then(|| seeded_title(&request.content)), - }, - ) - .await?; - - // The assistant reply replies to the user message just stored. - let reply_turn = TurnLocation { - parent_id: Some(user_message.id), - ..user_turn - }; - - drop(conn); - - let stream = stream! { - let mut reply = String::new(); - // Only a normal end-of-stream (`None`) is a complete reply. A shutdown, a - // generation error, or exceeding the reply limit stops mid-reply; - // persisting that would store a partial turn as if the assistant had - // finished, corrupting later history. - let completed = loop { - tokio::select! { - // Server shutting down: end the open stream promptly so it does - // not block graceful shutdown. - () = shutdown.cancelled() => break false, - next = tokens.next() => match next { - Some(Ok(delta)) => { - // Cap the reply so it always fits the encrypted-content - // column: a longer reply would fail to persist after the - // user already saw it, silently dropping it from history. - if reply.len() + delta.len() > MAX_REPLY_BYTES { - tracing::warn!(target: TRACING_TARGET, "Chat reply exceeded the size limit; stopping"); - yield error_event("The response exceeded the maximum length and was stopped."); - break false; - } - reply.push_str(&delta); - yield token_event(&ChatToken { delta }); - } - // A generation error: surface it and stop. - Some(Err(err)) => { - tracing::warn!(target: TRACING_TARGET, error = %err, "Chat generation failed"); - yield error_event(&err.to_string()); - break false; - } - // Generation finished normally. - None => break true, - }, - } - }; - - // Persist the assembled reply only on normal completion (best-effort: the - // user already saw it), under the user message it answered. - if completed - && !reply.is_empty() - && let Err(err) = chat.persist_reply(reply_turn, &reply).await - { - tracing::error!(target: TRACING_TARGET, error = %err, "Failed to persist assistant reply"); - } - }; - - Ok(SseResponse::new(stream)) -} - -fn send_message_docs(op: TransformOperation) -> TransformOperation { - op.summary("Send chat message") - .description( - "Sends a message and streams the assistant's reply as Server-Sent \ - Events. Each event's `data` is a `ChatToken` delta. Authenticate \ - with a Bearer token via a `fetch`-based client; the native \ - `EventSource` cannot send an `Authorization` header. 409 when the \ - workspace has no language model connection configured.", - ) - .response::<401, Json>() - .response::<403, Json>() - .response::<404, Json>() - .response::<409, Json>() -} - -/// One streamed chunk of the assistant's reply. -#[derive( - Debug, - Clone, - serde::Serialize, - serde::Deserialize, - schemars::JsonSchema -)] -#[serde(rename_all = "camelCase")] -pub struct ChatToken { - /// The text delta. - pub delta: String, -} - -/// Builds a `token` SSE event carrying a reply delta. -fn token_event(token: &ChatToken) -> Event { - Event::default() - .event("token") - .json_data(token) - .unwrap_or_else(|_| Event::default().event("token")) -} - -/// Builds an `error` SSE event carrying a failure message. -fn error_event(message: &str) -> Event { - Event::default().event("error").data(message) -} - -/// A session title seeded from the first message: trimmed to a single line and -/// capped so it reads well in a session list. -fn seeded_title(content: &str) -> String { - let line = content.trim().lines().next().unwrap_or("").trim(); - let mut title: String = line.chars().take(TITLE_MAX).collect(); - if title.trim().is_empty() { - title = "New chat".to_owned(); - } - title -} - -/// Returns the chat routes. -pub fn routes() -> ApiRouter { - use aide::axum::routing::*; - - ApiRouter::new() - .api_route( - "/workspaces/{workspaceSlug}/chat/sessions/", - post_with(create_session, create_session_docs) - .get_with(list_sessions, list_sessions_docs), - ) - .api_route( - "/workspaces/{workspaceSlug}/chat/sessions/{sessionId}/", - delete_with(delete_session, delete_session_docs), - ) - .api_route( - "/workspaces/{workspaceSlug}/chat/sessions/{sessionId}/messages/", - get_with(list_messages, list_messages_docs).post_with(send_message, send_message_docs), - ) - .with_path_items(|item| item.tag("Chat")) -} diff --git a/crates/nvisy-server/src/handler/comments.rs b/crates/nvisy-server/src/handler/comments.rs index 14e7d748..274a98c4 100644 --- a/crates/nvisy-server/src/handler/comments.rs +++ b/crates/nvisy-server/src/handler/comments.rs @@ -1,57 +1,44 @@ -//! Comment handlers: threaded discussion on a file, with @-mentions and resolve. -//! -//! A comment is authored by a workspace member on a file, optionally a one-level -//! reply. `@username` mentions notify those workspace members. Viewing, writing, -//! and resolving all require the corresponding Reviewer-tier permission; editing -//! and deleting a comment are restricted to its author. - -use std::collections::BTreeSet; +//! Comment handlers: posting, editing, and deleting the messages within a +//! thread. The thread lifecycle (open/close/reopen/rename/delete), anchors, and +//! the timeline live in the sibling `threads` module, which also owns the +//! helpers shared here (mention resolution, assistant enqueue, lookups). use aide::axum::ApiRouter; use aide::transform::TransformOperation; use axum::extract::State; use axum::http::StatusCode; -use nvisy_postgres::model::{NewWorkspaceComment, UpdateWorkspaceComment, WorkspaceComment}; -use nvisy_postgres::query::{ - ReplyParentError, WorkspaceCommentRepository, WorkspaceFileRepository, - WorkspaceMemberRepository, -}; -use nvisy_postgres::types::Handle; -use nvisy_postgres::{AsyncConnection, PgClient, PgConn}; -use uuid::Uuid; - -use crate::extract::{Authorized, Json, Path, Query, SecurityContext, ValidateJson, markers}; -use crate::handler::request::{ - CommentPathParams, CreateComment, CursorPagination, UpdateComment, WorkspaceCommentsQuery, - WorkspaceFilePathParams, +use nvisy_postgres::model::{NewWorkspaceThreadComment, UpdateWorkspaceThreadComment}; +use nvisy_postgres::query::WorkspaceThreadCommentRepository; +use nvisy_postgres::{AsyncConnection, PgClient}; + +use crate::extract::{Authorized, Json, Path, SecurityContext, ValidateJson, markers}; +use crate::handler::request::{CommentPathParams, CreateComment, ThreadPathParams, UpdateComment}; +use crate::handler::response::Comment; +use crate::handler::threads::{ + MentionOutcome, TRACING_TARGET, emit_comment_event, enqueue_assistant_if_addressed, + find_comment, find_thread, resolve_mentions, workspace_origin, }; -use crate::handler::response::{Comment, CommentsPage}; use crate::handler::utility::resolve_account_ref; use crate::response::{Error, ErrorKind, ErrorResponse, Result}; -use crate::service::{ - CommentCreated, CommentDeleted, CommentResolved, EventEmitter, EventOrigin, ServiceState, - WorkspaceEvent, -}; +use crate::service::{AssistantQueue, ServiceState, ThreadCommentCreated, WorkspaceEvent}; -/// Tracing target for comment operations. -const TRACING_TARGET: &str = "nvisy_server::handler::comments"; - -/// Posts a comment on a file, or a reply to another comment. +/// Posts a comment (message) in a thread. /// -/// A reply names its `parentId` (one level only). `@username` mentions in the -/// body notify those workspace members. Requires `Comment`. +/// `@username` mentions in the body notify those workspace members. Requires +/// `Comment`. #[tracing::instrument( skip_all, fields( account_id = %authz.account_id, workspace_id = %authz.workspace.id, - file_id = %path_params.file_id, + thread_id = %path_params.thread_id, ) )] async fn create_comment( State(pg_client): State, + State(assistant): State, authz: Authorized, - Path(path_params): Path, + Path(path_params): Path, security: SecurityContext, ValidateJson(request): ValidateJson, ) -> Result<(StatusCode, Json)> { @@ -60,69 +47,61 @@ async fn create_comment( let workspace = authz.workspace; let mut conn = pg_client.get_connection().await?; - // The file must exist in the workspace. - conn.find_file_in_workspace(workspace.id, path_params.file_id) - .await? - .ok_or_else(|| Error::not_found("file"))?; - - // Resolve @-mentions to workspace-member account ids, excluding the author - // (no self-notification) and de-duplicated. A mentioned handle that is not a - // workspace member is ignored rather than rejected. - let mentioned = - resolve_mentions(&mut conn, workspace.id, &request.body, authz.account_id).await?; - - // Store the typed anchor as its JSON; the DB column is modality-agnostic. - let anchor = request - .anchor - .map(serde_json::to_value) - .transpose() - .map_err(|err| { - ErrorKind::InternalServerError - .with_message("Failed to encode comment anchor") - .with_context(err.to_string()) - })?; - - let new_comment = NewWorkspaceComment { - workspace_id: workspace.id, - file_id: path_params.file_id, - author_account_id: authz.account_id, - parent_id: request.parent_id, - body: request.body, - anchor, - }; + // The thread must exist in the workspace (and be live). + let thread = find_thread(&mut conn, workspace.id, path_params.thread_id).await?; + + let MentionOutcome { + recipients, + addressed_assistant, + } = resolve_mentions(&mut conn, workspace.id, &request.body, authz.account_id).await?; let author_username = resolve_account_ref(&mut conn, authz.account_id) .await? .username; - // Create the comment and record its event in one transaction so the row and - // its event commit or roll back together. - let comment = conn + // Post the comment, record its event, and — if the assistant was addressed — + // queue the reply job, all in one transaction so they commit together. + let (comment, queued_assistant) = conn .transaction(async |conn| { - let comment = if new_comment.parent_id.is_some() { - match conn.create_reply(new_comment).await? { - Ok(comment) => comment, - Err(err) => return Ok(Err(err)), - } - } else { - conn.create_comment(new_comment).await? - }; + let comment = conn + .create_comment(NewWorkspaceThreadComment { + workspace_id: workspace.id, + thread_id: thread.id, + author_account_id: authz.account_id, + parent_id: None, + body: request.body, + }) + .await?; emit_comment_event( conn, workspace_origin(workspace.id, authz.account_id, &security), - WorkspaceEvent::CommentCreated(CommentCreated { + WorkspaceEvent::ThreadCommentCreated(ThreadCommentCreated { comment_id: comment.id, - file_id: comment.file_id, + thread_id: thread.id, + file_id: thread.file_id, author_username: author_username.clone(), - mentioned, + mentioned: recipients, }), ) .await?; - Ok::<_, Error>(Ok(comment)) + + let queued = enqueue_assistant_if_addressed( + conn, + addressed_assistant, + authz.account_id, + workspace.id, + thread.id, + comment.id, + ) + .await?; + Ok::<_, Error>((comment, queued)) }) - .await? - .map_err(reply_parent_error)?; + .await?; + + if queued_assistant { + assistant.wake_drainer(); + } let author = resolve_account_ref(&mut conn, comment.author_account_id).await?; @@ -137,8 +116,8 @@ async fn create_comment( fn create_comment_docs(op: TransformOperation) -> TransformOperation { op.summary("Post a comment") .description( - "Posts a comment on a file, or a reply to another comment (one level). \ - @username mentions notify those members. Requires the Comment permission.", + "Posts a comment (message) in a thread. @username mentions notify those \ + members. Requires the Comment permission.", ) .response::<201, Json>() .response::<400, Json>() @@ -147,95 +126,6 @@ fn create_comment_docs(op: TransformOperation) -> TransformOperation { .response::<404, Json>() } -/// Lists a file's comments, oldest first (a thread reads top to bottom). -/// -/// Requires `ViewComments`. -#[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_comments( - State(pg_client): State, - authz: Authorized, - Path(path_params): Path, -) -> Result<(StatusCode, Json>)> { - tracing::debug!(target: TRACING_TARGET, "Listing file comments"); - - let workspace = authz.workspace; - let mut conn = pg_client.get_connection().await?; - - conn.find_file_in_workspace(workspace.id, path_params.file_id) - .await? - .ok_or_else(|| Error::not_found("file"))?; - - let rows = conn - .list_file_comments(workspace.id, path_params.file_id) - .await?; - - let comments = rows - .into_iter() - .map(|row| Comment::from_model(row.item, row.account.into())) - .collect(); - - Ok((StatusCode::OK, Json(comments))) -} - -fn list_file_comments_docs(op: TransformOperation) -> TransformOperation { - op.summary("List a file's comments") - .description("Returns the comments on a file, oldest first.") - .response::<200, Json>>() - .response::<401, Json>() - .response::<403, Json>() - .response::<404, Json>() -} - -/// Lists a workspace's comments with cursor pagination. -/// -/// Filter by `fileId`, `author`, and `resolved`. Requires `ViewComments`. -#[tracing::instrument( - skip_all, - fields( - account_id = %authz.account_id, - workspace_id = %authz.workspace.id, - ) -)] -async fn list_workspace_comments( - State(pg_client): State, - authz: Authorized, - Query(pagination): Query, - Query(query): Query, -) -> Result<(StatusCode, Json)> { - tracing::debug!(target: TRACING_TARGET, "Listing workspace comments"); - - let workspace = authz.workspace; - let mut conn = pg_client.get_connection().await?; - - let page = conn - .cursor_list_workspace_comments(workspace.id, pagination.into(), &query.into()) - .await?; - - let response = CommentsPage::from_cursor_page(page, |row| { - Comment::from_model(row.item, row.account.into()) - }); - - Ok((StatusCode::OK, Json(response))) -} - -fn list_workspace_comments_docs(op: TransformOperation) -> TransformOperation { - op.summary("List workspace comments") - .description( - "Returns the workspace's comments, most recent first, with optional \ - file, author, and resolved filters.", - ) - .response::<200, Json>() - .response::<401, Json>() - .response::<403, Json>() -} - /// Edits a comment's body. Restricted to the comment's author. #[tracing::instrument( skip_all, @@ -262,13 +152,13 @@ async fn update_comment( if comment.author_account_id != authz.account_id { return Err(ErrorKind::Forbidden .with_message("Only the author can edit this comment") - .with_resource("workspace_comment")); + .with_resource("workspace_thread_comment")); } let updated = conn .update_comment_body( comment.id, - UpdateWorkspaceComment { + UpdateWorkspaceThreadComment { body: Some(request.body), }, ) @@ -304,7 +194,6 @@ async fn delete_comment( State(pg_client): State, authz: Authorized, Path(path_params): Path, - security: SecurityContext, ) -> Result { tracing::debug!(target: TRACING_TARGET, "Deleting comment"); @@ -317,23 +206,10 @@ async fn delete_comment( if comment.author_account_id != authz.account_id { return Err(ErrorKind::Forbidden .with_message("Only the author can delete this comment") - .with_resource("workspace_comment")); + .with_resource("workspace_thread_comment")); } - conn.transaction(async |conn| { - conn.delete_comment(comment.id).await?; - emit_comment_event( - conn, - workspace_origin(workspace.id, authz.account_id, &security), - WorkspaceEvent::CommentDeleted(CommentDeleted { - comment_id: comment.id, - file_id: comment.file_id, - }), - ) - .await?; - Ok::<_, Error>(()) - }) - .await?; + conn.delete_comment(comment.id).await?; tracing::info!(target: TRACING_TARGET, "Comment deleted"); @@ -349,292 +225,19 @@ fn delete_comment_docs(op: TransformOperation) -> TransformOperation { .response::<404, Json>() } -/// Resolves a comment thread. Requires `ResolveComments`. -#[tracing::instrument( - skip_all, - fields( - account_id = %authz.account_id, - workspace_id = %authz.workspace.id, - comment_id = %path_params.comment_id, - ) -)] -async fn resolve_comment( - State(pg_client): State, - authz: Authorized, - Path(path_params): Path, - security: SecurityContext, -) -> Result<(StatusCode, Json)> { - tracing::debug!(target: TRACING_TARGET, "Resolving comment"); - - let workspace = authz.workspace; - let mut conn = pg_client.get_connection().await?; - - let comment = find_comment(&mut conn, workspace.id, path_params.comment_id).await?; - require_top_level(&comment)?; - - // Already resolved: return it unchanged rather than overwriting the original - // resolver/timestamp (the audit record) and emitting a duplicate event. - if comment.resolved_at.is_some() { - let author = resolve_account_ref(&mut conn, comment.author_account_id).await?; - return Ok((StatusCode::OK, Json(Comment::from_model(comment, author)))); - } - - let resolved = conn - .transaction(async |conn| { - let resolved = conn.resolve_comment(comment.id, authz.account_id).await?; - emit_comment_event( - conn, - workspace_origin(workspace.id, authz.account_id, &security), - WorkspaceEvent::CommentResolved(CommentResolved { - comment_id: comment.id, - file_id: comment.file_id, - }), - ) - .await?; - Ok::<_, Error>(resolved) - }) - .await?; - - let author = resolve_account_ref(&mut conn, resolved.author_account_id).await?; - - tracing::info!(target: TRACING_TARGET, "Comment resolved"); - - Ok((StatusCode::OK, Json(Comment::from_model(resolved, author)))) -} - -fn resolve_comment_docs(op: TransformOperation) -> TransformOperation { - op.summary("Resolve a comment") - .description("Resolves a comment thread, closing the discussion. Requires ResolveComments.") - .response::<200, Json>() - .response::<401, Json>() - .response::<403, Json>() - .response::<404, Json>() -} - -/// Reopens a resolved comment thread. Requires `ResolveComments`. -#[tracing::instrument( - skip_all, - fields( - account_id = %authz.account_id, - workspace_id = %authz.workspace.id, - comment_id = %path_params.comment_id, - ) -)] -async fn reopen_comment( - State(pg_client): State, - authz: Authorized, - Path(path_params): Path, -) -> Result<(StatusCode, Json)> { - tracing::debug!(target: TRACING_TARGET, "Reopening comment"); - - let workspace = authz.workspace; - let mut conn = pg_client.get_connection().await?; - - let comment = find_comment(&mut conn, workspace.id, path_params.comment_id).await?; - require_top_level(&comment)?; - let reopened = conn.reopen_comment(comment.id).await?; - let author = resolve_account_ref(&mut conn, reopened.author_account_id).await?; - - tracing::info!(target: TRACING_TARGET, "Comment reopened"); - - Ok((StatusCode::OK, Json(Comment::from_model(reopened, author)))) -} - -fn reopen_comment_docs(op: TransformOperation) -> TransformOperation { - op.summary("Reopen a comment") - .description("Reopens a resolved comment thread. Requires ResolveComments.") - .response::<200, Json>() - .response::<401, Json>() - .response::<403, Json>() - .response::<404, Json>() -} - -/// Finds a live comment in the workspace or returns a 404. -async fn find_comment( - conn: &mut PgConn, - workspace_id: Uuid, - comment_id: Uuid, -) -> Result { - conn.find_comment_in_workspace(workspace_id, comment_id) - .await? - .ok_or_else(|| Error::not_found("workspace_comment")) -} - -/// Rejects a reply where a top-level comment is required: a reply inherits its -/// thread's resolution state, so only the thread's top-level comment can be -/// resolved or reopened. -fn require_top_level(comment: &WorkspaceComment) -> Result<()> { - if comment.parent_id.is_some() { - return Err(ErrorKind::BadRequest - .with_message("Resolve the thread's top-level comment, not a reply") - .with_resource("workspace_comment")); - } - Ok(()) -} - -/// Extracts the raw handle text of each `@username` mention in `body`. -/// -/// A mention is an `@` that starts a token (preceded by start-of-string or a -/// non-alphanumeric, non-`@` char, so an email's `@` is not a mention) followed -/// by handle characters (`[a-z0-9-]`). Validation (length, dash rules) is left to -/// [`Handle::parse`]; this only slices candidate spans. -fn parse_mentions(body: &str) -> Vec { - let bytes = body.as_bytes(); - let mut mentions = Vec::new(); - let mut i = 0; - while let Some(at) = body[i..].find('@') { - let at = i + at; - // The `@` must begin a token: preceded by nothing, or by a char that is - // not part of a word and not another `@`. A preceding ASCII alphanumeric - // (so `a@b.com` is an email, not a mention) or any non-ASCII byte >= 0x80 - // (a multibyte letter like `é`, so `café@bob` is not a mention) counts as - // part of a word. - let boundary = at == 0 || { - let prev = bytes[at - 1]; - !(prev.is_ascii_alphanumeric() || prev >= 0x80 || prev == b'@') - }; - let start = at + 1; - let end = start - + body[start..] - .find(|c: char| !matches!(c, 'a'..='z' | '0'..='9' | '-')) - .unwrap_or(body.len() - start); - if boundary && end > start { - mentions.push(body[start..end].to_owned()); - } - i = end.max(at + 1); - } - mentions -} - -/// Parses `@username` mentions from `body`, resolving each to a workspace-member -/// account id — de-duplicated, excluding `author` (no self-notification), and -/// skipping handles that are not members of the workspace. -async fn resolve_mentions( - conn: &mut PgConn, - workspace_id: Uuid, - body: &str, - author: Uuid, -) -> Result> { - // De-duplicate the raw mention text first (a repeated mention resolves once), - // then parse each into a valid handle. - let handles: Vec = parse_mentions(body) - .into_iter() - .collect::>() - .into_iter() - .filter_map(|m| Handle::parse(m).ok()) - .collect(); - - if handles.is_empty() { - return Ok(Vec::new()); - } - - // Resolve all mentioned handles to workspace-member account ids in one query, - // then drop the author (no self-notification). - let mut recipients = conn - .find_member_ids_by_usernames(workspace_id, &handles) - .await?; - recipients.retain(|&id| id != author); - Ok(recipients) -} - -/// Maps a reply-parent validation failure to a client error. -fn reply_parent_error(err: ReplyParentError) -> Error<'static> { - match err { - ReplyParentError::NotFound => ErrorKind::NotFound.with_resource("workspace_comment"), - ReplyParentError::FileMismatch => ErrorKind::BadRequest - .with_message("The parent comment is on a different file") - .with_resource("workspace_comment"), - ReplyParentError::NotTopLevel => ErrorKind::BadRequest - .with_message("Cannot reply to a reply; comment threads are one level deep") - .with_resource("workspace_comment"), - } -} - -/// Builds the event origin shared by every comment event. -fn workspace_origin<'a>( - workspace_id: Uuid, - account_id: Uuid, - security: &'a SecurityContext, -) -> EventOrigin<'a> { - EventOrigin { - workspace_id, - account_id, - security, - } -} - -/// Emits one comment event onto the outbox. -async fn emit_comment_event( - conn: &mut PgConn, - origin: EventOrigin<'_>, - event: WorkspaceEvent, -) -> Result<()> { - conn.emit_event(origin, event).await?; - Ok(()) -} - -/// Returns an [`ApiRouter`] with all comment routes. +/// Returns an [`ApiRouter`] with the comment routes. pub fn routes() -> ApiRouter { use aide::axum::routing::*; ApiRouter::new() .api_route( - "/workspaces/{workspaceSlug}/files/{fileId}/comments/", - post_with(create_comment, create_comment_docs) - .get_with(list_file_comments, list_file_comments_docs), - ) - .api_route( - "/workspaces/{workspaceSlug}/comments/", - get_with(list_workspace_comments, list_workspace_comments_docs), + "/workspaces/{workspaceSlug}/threads/{threadId}/comments/", + post_with(create_comment, create_comment_docs), ) .api_route( "/workspaces/{workspaceSlug}/comments/{commentId}/", patch_with(update_comment, update_comment_docs) .delete_with(delete_comment, delete_comment_docs), ) - .api_route( - "/workspaces/{workspaceSlug}/comments/{commentId}/resolve/", - post_with(resolve_comment, resolve_comment_docs) - .delete_with(reopen_comment, reopen_comment_docs), - ) .with_path_items(|item| item.tag("Comments")) } - -#[cfg(test)] -mod tests { - use super::parse_mentions; - - #[test] - fn parses_mentions_and_ignores_emails() { - // A leading mention, a mid-sentence mention, and an email whose @ is not a - // mention. - assert_eq!( - parse_mentions("@alice please review, cc @bob-smith — not user@example.com"), - vec!["alice".to_owned(), "bob-smith".to_owned()], - ); - } - - #[test] - fn no_mentions_yields_empty() { - assert!(parse_mentions("just a plain comment, no pings").is_empty()); - assert!(parse_mentions("").is_empty()); - // A bare @ with no handle text produces nothing. - assert!(parse_mentions("look @ this").is_empty()); - } - - #[test] - fn mention_stops_at_non_handle_chars() { - // The handle ends at whitespace/punctuation; trailing text is not included. - assert_eq!(parse_mentions("hey @carol!"), vec!["carol".to_owned()]); - assert_eq!(parse_mentions("(@dave)"), vec!["dave".to_owned()]); - } - - #[test] - fn non_ascii_letter_before_at_is_not_a_boundary() { - // A multibyte letter (é) before `@` means the `@` is embedded in a word, - // not a mention — like an email local part. - assert!(parse_mentions("café@bob").is_empty()); - // But a real mention after an accented word (with a space) still parses. - assert_eq!(parse_mentions("café @bob"), vec!["bob".to_owned()]); - } -} diff --git a/crates/nvisy-server/src/handler/mod.rs b/crates/nvisy-server/src/handler/mod.rs index d85554e1..edfbdbe6 100644 --- a/crates/nvisy-server/src/handler/mod.rs +++ b/crates/nvisy-server/src/handler/mod.rs @@ -13,7 +13,6 @@ mod authentication; pub(crate) use auth_oidc::consume_reauth_proof; mod avatars; mod catalog; -mod chat; mod comments; mod connection_oauth; mod connection_syncs; @@ -32,6 +31,7 @@ mod providers; mod redactions; pub mod request; pub mod response; +mod threads; mod tokens; mod utility; mod webhooks; @@ -82,11 +82,11 @@ fn private_routes(service_state: ServiceState) -> ApiRouter { .merge(analytics::routes()) .merge(members::routes()) .merge(assignments::routes()) + .merge(threads::routes()) .merge(comments::routes()) .merge(connections::routes()) .merge(providers::routes()) .merge(connection_oauth::private_routes()) - .merge(chat::routes()) .merge(connection_syncs::routes()) .merge(files::routes(service_state.upload.max_file_body_bytes)) .merge(pipelines::routes()) diff --git a/crates/nvisy-server/src/handler/request/chat.rs b/crates/nvisy-server/src/handler/request/chat.rs deleted file mode 100644 index adf1d476..00000000 --- a/crates/nvisy-server/src/handler/request/chat.rs +++ /dev/null @@ -1,40 +0,0 @@ -//! Assistant chat request types. - -use garde::Validate; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; -use uuid::Uuid; - -/// Path parameters for a chat session. -#[must_use] -#[derive(Debug, Serialize, Deserialize, JsonSchema)] -#[serde(rename_all = "camelCase")] -pub struct ChatSessionPathParams { - /// The session id. - pub session_id: Uuid, -} - -/// Request to create a chat session. -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Validate)] -#[serde(rename_all = "camelCase")] -#[garde(allow_unvalidated)] -pub struct CreateChatSession { - /// Optional title. Defaults to a title seeded from the first message. - #[garde(length(chars, min = 1, max = 255))] - pub title: Option, -} - -/// Request to send a message and stream the assistant's reply. -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Validate)] -#[serde(rename_all = "camelCase")] -#[garde(allow_unvalidated)] -pub struct SendChatMessage { - /// The user's message. - #[garde(length(chars, min = 1, max = 65536))] - pub content: String, - /// The message this turn replies to (the branch being extended). Omit to - /// continue from the session's current leaf; use an earlier message's id to - /// branch (e.g. edit-and-resend). - #[serde(default)] - pub parent_id: Option, -} diff --git a/crates/nvisy-server/src/handler/request/comments.rs b/crates/nvisy-server/src/handler/request/comments.rs index 35f8546f..4dc516c1 100644 --- a/crates/nvisy-server/src/handler/request/comments.rs +++ b/crates/nvisy-server/src/handler/request/comments.rs @@ -1,22 +1,22 @@ -//! Comment request types (post a comment/reply, edit, filter). +//! Comment-thread request types (open a thread, post a comment, edit, filter). use elide_pipeline::modality::audio::AudioLocation; use elide_pipeline::modality::image::ImageLocation; use elide_pipeline::modality::tabular::TabularLocation; use elide_pipeline::modality::text::TextLocation; use garde::Validate; -use nvisy_postgres::types::CommentFilter; +use nvisy_postgres::types::ThreadFilter; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use uuid::Uuid; use crate::extract::validators::validate_non_blank; -/// Where a comment is pinned within a file: a location in one of the four +/// Where a thread is pinned within a file: a location in one of the four /// modalities, tagged so a single stored JSON value carries its own modality. /// /// Each variant wraps the engine's own location type ([`elide_pipeline`]), so a -/// comment anchors to exactly what a detection/redaction does — a page region for +/// thread anchors to exactly what a detection/redaction does — a page region for /// paginated/image documents, a time span for audio/video, a text span for /// transcripts, a cell for tabular data. The engine's location types carry no /// modality discriminator of their own, so the `modality` tag here supplies it. @@ -34,6 +34,15 @@ pub enum CommentAnchor { Tabular(TabularLocation), } +/// Path parameters addressing one thread by its opaque id. +#[must_use] +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct ThreadPathParams { + /// Unique identifier of the thread. + pub thread_id: Uuid, +} + /// Path parameters addressing one comment by its opaque id. #[must_use] #[derive(Debug, Serialize, Deserialize, JsonSchema)] @@ -43,9 +52,64 @@ pub struct CommentPathParams { pub comment_id: Uuid, } -/// Request payload to post a comment on a file, or a reply to another comment. +/// Path parameters addressing one thread anchor by its opaque id. +#[must_use] +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct ThreadAnchorPathParams { + /// Unique identifier of the thread. + pub thread_id: Uuid, + /// Unique identifier of the anchor. + pub anchor_id: Uuid, +} + +/// Request payload to open a comment thread with its first message. +/// +/// A thread pins a discussion to a location within a file (`anchor`), to a file +/// as a whole (no anchor), or — when opened on the workspace endpoint — to no +/// file at all. `@username` mentions in the opening body notify those members. +#[must_use] +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Validate)] +#[serde(rename_all = "camelCase")] +pub struct OpenThread { + /// Optional title for the thread (1-255 characters). Omit for an untitled + /// thread. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[garde(inner(length(chars, min = 1, max = 255), custom(validate_non_blank)))] + pub display_name: Option, + /// The opening message text (1-10000 characters). + #[garde(length(chars, min = 1, max = 10_000), custom(validate_non_blank))] + pub body: String, + /// Locations within the file the thread is pinned to. Empty for a file-level + /// thread (no pin). Ignored for a workspace-level thread (no file). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + #[garde(length(max = 32))] + pub anchors: Vec, +} + +/// Request payload to rename a thread (set or clear its title). +#[must_use] +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Validate)] +#[serde(rename_all = "camelCase")] +pub struct RenameThread { + /// The new title (1-255 characters), or `null` to clear it. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[garde(inner(length(chars, min = 1, max = 255), custom(validate_non_blank)))] + pub display_name: Option, +} + +/// Request payload to add an anchor (location pin) to a thread. +#[must_use] +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Validate)] +#[serde(rename_all = "camelCase")] +pub struct AddThreadAnchor { + /// The location to pin. + #[garde(skip)] + pub anchor: CommentAnchor, +} + +/// Request payload to post a comment (message) in a thread. /// -/// Omit `parent_id` for a top-level comment; set it to reply (one level only). /// `@username` mentions in the body notify those workspace members. #[must_use] #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Validate)] @@ -54,16 +118,6 @@ pub struct CreateComment { /// The comment text (1-10000 characters). #[garde(length(chars, min = 1, max = 10_000), custom(validate_non_blank))] pub body: String, - /// The comment this replies to, for a one-level thread. Omit for a top-level - /// comment. - #[serde(default, skip_serializing_if = "Option::is_none")] - #[garde(skip)] - pub parent_id: Option, - /// Where in the file the comment is pinned. Omit for a file-level comment - /// (no pin). - #[serde(default, skip_serializing_if = "Option::is_none")] - #[garde(skip)] - pub anchor: Option, } /// Request payload to edit a comment's body. @@ -76,26 +130,26 @@ pub struct UpdateComment { pub body: String, } -/// Query parameters for listing a workspace's comments. +/// Query parameters for listing a workspace's threads. /// /// Every field is an optional filter; unset fields impose no constraint. #[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "camelCase")] -pub struct WorkspaceCommentsQuery { - /// Filter by the file the comment is on. +pub struct WorkspaceThreadsQuery { + /// Filter by the file the thread is pinned to. pub file_id: Option, - /// Filter by the comment's author. + /// Filter by the thread's opening author. pub author: Option, - /// Filter by resolution state: `true` = resolved only, `false` = open only. - pub resolved: Option, + /// Filter by open/closed state: `true` = closed only, `false` = open only. + pub closed: Option, } -impl From for CommentFilter { - fn from(query: WorkspaceCommentsQuery) -> Self { - CommentFilter { +impl From for ThreadFilter { + fn from(query: WorkspaceThreadsQuery) -> Self { + ThreadFilter { file_id: query.file_id, author_account_id: query.author, - resolved: query.resolved, + closed: query.closed, } } } diff --git a/crates/nvisy-server/src/handler/request/mod.rs b/crates/nvisy-server/src/handler/request/mod.rs index abe95b6d..b513354b 100644 --- a/crates/nvisy-server/src/handler/request/mod.rs +++ b/crates/nvisy-server/src/handler/request/mod.rs @@ -4,7 +4,6 @@ mod accounts; mod activities; mod assignments; mod authentications; -mod chat; mod comments; mod connection_syncs; mod connections; @@ -28,7 +27,6 @@ pub use accounts::*; pub use activities::*; pub use assignments::*; pub use authentications::*; -pub use chat::*; pub use comments::*; pub use connection_syncs::*; pub use connections::*; diff --git a/crates/nvisy-server/src/handler/response/chat.rs b/crates/nvisy-server/src/handler/response/chat.rs deleted file mode 100644 index 1e209a3a..00000000 --- a/crates/nvisy-server/src/handler/response/chat.rs +++ /dev/null @@ -1,81 +0,0 @@ -//! Assistant chat response types. - -use jiff::Timestamp; -use nvisy_postgres::model::{ChatMessage as ChatMessageModel, ChatSession as ChatSessionModel}; -use nvisy_postgres::types::ChatRole; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; -use uuid::Uuid; - -use super::Page; -use crate::response::Result; -use crate::service::ChatService; - -/// A chat session. -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] -#[serde(rename_all = "camelCase")] -pub struct ChatSession { - /// Unique session identifier. - pub id: Uuid, - /// Human-readable title. - pub title: String, - /// Active leaf of the message tree (the conversation's resume point). - #[serde(skip_serializing_if = "Option::is_none")] - pub current_message_id: Option, - /// When the session was created. - pub created_at: Timestamp, - /// When the session was last active. - pub updated_at: Timestamp, -} - -impl ChatSession { - /// Builds the response from a stored session. - pub fn from_model(session: ChatSessionModel) -> Self { - Self { - id: session.id, - title: session.title, - current_message_id: session.current_message_id, - created_at: session.created_at.into(), - updated_at: session.updated_at.into(), - } - } -} - -/// Paginated list of chat sessions. -pub type ChatSessionsPage = Page; - -/// A single chat message. -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] -#[serde(rename_all = "camelCase")] -pub struct ChatMessage { - /// Unique message identifier. - pub id: Uuid, - /// Parent in the conversation tree; absent for a root. - #[serde(skip_serializing_if = "Option::is_none")] - pub parent_id: Option, - /// Author of the message. - pub role: ChatRole, - /// Message text. - pub content: String, - /// When the message was created. - pub created_at: Timestamp, -} - -impl ChatMessage { - /// Builds the response from a stored message, decrypting its content under - /// the workspace key. - pub fn from_model( - message: ChatMessageModel, - workspace_id: Uuid, - chat: &ChatService, - ) -> Result { - let content = chat.decrypt_content(workspace_id, &message)?; - Ok(Self { - id: message.id, - parent_id: message.parent_id, - role: message.role, - content, - created_at: message.created_at.into(), - }) - } -} diff --git a/crates/nvisy-server/src/handler/response/comments.rs b/crates/nvisy-server/src/handler/response/comments.rs index 749ccb17..7095fafa 100644 --- a/crates/nvisy-server/src/handler/response/comments.rs +++ b/crates/nvisy-server/src/handler/response/comments.rs @@ -1,7 +1,11 @@ -//! Comment response types. +//! Comment-thread response types. use jiff::Timestamp; -use nvisy_postgres::model::WorkspaceComment as CommentModel; +use nvisy_postgres::model::{ + WorkspaceThread as ThreadModel, WorkspaceThreadAnchor as AnchorModel, + WorkspaceThreadComment as CommentModel, WorkspaceThreadEvent as EventModel, +}; +use nvisy_postgres::types::ThreadEventKind; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use uuid::Uuid; @@ -9,43 +13,147 @@ use uuid::Uuid; use super::{AccountRef, Page}; use crate::handler::request::CommentAnchor; -/// Response type for a comment on a file. +/// Response type for a comment thread. /// -/// A comment is authored by a workspace member, optionally a one-level reply -/// (`parentId`), optionally pinned to a location within the file (`anchor`), and -/// can be resolved to close its thread. +/// A thread is the closable unit of discussion: opened by a workspace member, +/// optionally pinned to a file (`fileId`) and locations within it (`anchors`), +/// and closable to end the conversation. Its stream is a [`ThreadEntry`] +/// timeline. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct Thread { + /// Unique identifier of the thread. + pub id: Uuid, + /// File the thread is pinned to; `None` for a workspace-level thread. + #[serde(skip_serializing_if = "Option::is_none")] + pub file_id: Option, + /// The thread's title; `None` for an untitled thread. + #[serde(skip_serializing_if = "Option::is_none")] + pub display_name: Option, + /// Account that opened the thread. + pub author: AccountRef, + /// The thread's live anchors (locations within the file it is pinned to). + /// Empty for a file-level or workspace-level thread. + pub anchors: Vec, + /// Whether the thread is closed. + pub closed: bool, + /// When the thread was closed, when closed. + #[serde(skip_serializing_if = "Option::is_none")] + pub closed_at: Option, + /// When the thread was created. + pub created_at: Timestamp, + /// When the thread was last updated. + pub updated_at: Timestamp, +} + +/// Response type for a thread anchor: one location pin within the thread's file. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct ThreadAnchor { + /// Unique identifier of the anchor. + pub id: Uuid, + /// The modality-tagged location. + pub anchor: CommentAnchor, + /// When the anchor was added. + pub created_at: Timestamp, +} + +/// Response type for a comment: one message within a thread. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "camelCase")] pub struct Comment { /// Unique identifier of the comment. pub id: Uuid, - /// File the comment is on. - pub file_id: Uuid, - /// The comment this replies to, for a one-level thread; `None` for a - /// top-level comment. - #[serde(skip_serializing_if = "Option::is_none")] - pub parent_id: Option, - /// Account that wrote the comment. + /// Thread this message belongs to. + pub thread_id: Uuid, + /// Account that wrote the message. pub author: AccountRef, - /// The comment text. + /// The message text. pub body: String, - /// Location within the file the comment is pinned to, when set. `None` for a - /// file-level comment. - #[serde(skip_serializing_if = "Option::is_none")] - pub anchor: Option, - /// Whether the thread is resolved. - pub resolved: bool, - /// When the thread was resolved, when resolved. - #[serde(skip_serializing_if = "Option::is_none")] - pub resolved_at: Option, /// When the comment was created. pub created_at: Timestamp, /// When the comment was last updated. pub updated_at: Timestamp, } -/// Paginated response for comments. -pub type CommentsPage = Page; +/// One non-message entry in a thread timeline (closed, reopened, anchor +/// added/removed). +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct ThreadEvent { + /// Unique identifier of the event. + pub id: Uuid, + /// What happened. + pub kind: ThreadEventKind, + /// Account that performed the action; `None` if that account was removed. + #[serde(skip_serializing_if = "Option::is_none")] + pub actor: Option, + /// Event-specific detail (an anchor snapshot for anchor events); `None` for + /// close/reopen. + #[serde(skip_serializing_if = "Option::is_none")] + pub target: Option, + /// When the event happened. + pub created_at: Timestamp, +} + +/// One entry in a thread's timeline: either a message or a lifecycle event, +/// tagged so a client renders them interleaved in order. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum ThreadEntry { + /// A message posted in the thread. + Comment(Comment), + /// A lifecycle event (closed, reopened, anchor added/removed). + Event(ThreadEvent), +} + +impl ThreadEntry { + /// The entry's timestamp, used to order the merged timeline. + pub fn timestamp(&self) -> Timestamp { + match self { + ThreadEntry::Comment(c) => c.created_at, + ThreadEntry::Event(e) => e.created_at, + } + } +} + +/// Paginated response for threads. +pub type ThreadsPage = Page; + +impl Thread { + /// Creates a thread response from the database model, its live anchors, and + /// the resolved author reference. + pub fn from_model(thread: ThreadModel, anchors: Vec, author: AccountRef) -> Self { + Self { + id: thread.id, + file_id: thread.file_id, + display_name: thread.display_name, + author, + anchors: anchors + .into_iter() + .filter_map(ThreadAnchor::from_model) + .collect(), + closed: thread.closed_at.is_some(), + closed_at: thread.closed_at.map(Into::into), + created_at: thread.created_at.into(), + updated_at: thread.updated_at.into(), + } + } +} + +impl ThreadAnchor { + /// Creates an anchor response, decoding its stored JSON into the typed + /// anchor. Returns `None` for an anchor whose JSON no longer decodes (treated + /// as absent rather than failing the read). + pub fn from_model(anchor: AnchorModel) -> Option { + let decoded = serde_json::from_value(anchor.anchor).ok()?; + Some(Self { + id: anchor.id, + anchor: decoded, + created_at: anchor.created_at.into(), + }) + } +} impl Comment { /// Creates a comment response from the database model and the resolved author @@ -53,19 +161,25 @@ impl Comment { pub fn from_model(comment: CommentModel, author: AccountRef) -> Self { Self { id: comment.id, - file_id: comment.file_id, - parent_id: comment.parent_id, + thread_id: comment.thread_id, author, body: comment.body, - // The anchor is stored as its typed JSON; decode it back, treating an - // undecodable value as no anchor rather than failing the read. - anchor: comment - .anchor - .and_then(|value| serde_json::from_value(value).ok()), - resolved: comment.resolved_at.is_some(), - resolved_at: comment.resolved_at.map(Into::into), created_at: comment.created_at.into(), updated_at: comment.updated_at.into(), } } } + +impl ThreadEvent { + /// Creates a thread-event response from the database model and the resolved + /// actor reference (absent if the actor's account was removed). + pub fn from_model(event: EventModel, actor: Option) -> Self { + Self { + id: event.id, + kind: event.kind, + actor, + target: event.target, + created_at: event.created_at.into(), + } + } +} diff --git a/crates/nvisy-server/src/handler/response/mod.rs b/crates/nvisy-server/src/handler/response/mod.rs index ab2c6002..378fd12a 100644 --- a/crates/nvisy-server/src/handler/response/mod.rs +++ b/crates/nvisy-server/src/handler/response/mod.rs @@ -11,7 +11,6 @@ mod analytics; mod assignments; mod authentications; mod catalog; -mod chat; mod comments; mod connection_syncs; mod connections; @@ -37,7 +36,6 @@ pub use analytics::*; pub use assignments::*; pub use authentications::*; pub use catalog::*; -pub use chat::*; pub use comments::*; pub use connection_syncs::*; pub use connections::*; diff --git a/crates/nvisy-server/src/handler/threads.rs b/crates/nvisy-server/src/handler/threads.rs new file mode 100644 index 00000000..90e3e9c6 --- /dev/null +++ b/crates/nvisy-server/src/handler/threads.rs @@ -0,0 +1,997 @@ +//! Thread handlers: the thread lifecycle (open, list, close, reopen, rename, +//! delete), its anchors, and its GitHub-issue-style timeline. The messages within +//! a thread are handled by the sibling `comments` module, which draws on the +//! mention-resolution, assistant-enqueue, and lookup helpers exported here. +//! +//! A thread is the closable unit: it is opened by a workspace member with a +//! first message, optionally pinned to a file and locations within it (anchors), +//! and can be closed, reopened, renamed, or deleted as a whole. Closing, +//! reopening, renaming, and anchor changes are recorded as timeline events +//! interleaved with the messages. `@username` mentions notify those workspace +//! members. Viewing, writing, and closing all require the corresponding +//! Reviewer-tier permission. + +use std::collections::{BTreeSet, HashMap}; + +use aide::axum::ApiRouter; +use aide::transform::TransformOperation; +use axum::extract::State; +use axum::http::StatusCode; +use nvisy_postgres::model::{ + NewWorkspaceAssistantJob, NewWorkspaceThread, NewWorkspaceThreadAnchor, WorkspaceThread, + WorkspaceThreadComment, +}; +use nvisy_postgres::query::{ + AssistantJobOutboxRepository, WorkspaceFileRepository, WorkspaceMemberRepository, + WorkspaceThreadAnchorRepository, WorkspaceThreadCommentRepository, + WorkspaceThreadEventRepository, WorkspaceThreadRepository, +}; +use nvisy_postgres::types::Handle; +use nvisy_postgres::{ASSISTANT_ACCOUNT_ID, ASSISTANT_HANDLE, AsyncConnection, PgClient, PgConn}; +use uuid::Uuid; + +use crate::extract::{Authorized, Json, Path, Query, SecurityContext, ValidateJson, markers}; +use crate::handler::request::{ + AddThreadAnchor, CommentAnchor, CursorPagination, OpenThread, RenameThread, + ThreadAnchorPathParams, ThreadPathParams, WorkspaceFilePathParams, WorkspaceThreadsQuery, +}; +use crate::handler::response::{ + Comment, Thread, ThreadAnchor, ThreadEntry, ThreadEvent, ThreadsPage, +}; +use crate::handler::utility::resolve_account_ref; +use crate::response::{Error, ErrorKind, ErrorResponse, Result}; +use crate::service::{ + AssistantJob, AssistantQueue, EventEmitter, EventOrigin, ServiceState, ThreadAnchorAdded, + ThreadAnchorRemoved, ThreadClosed, ThreadDeleted, ThreadOpened, ThreadRenamed, ThreadReopened, + WorkspaceEvent, +}; + +/// Tracing target for comment operations. +pub(crate) const TRACING_TARGET: &str = "nvisy_server::handler::comments"; + +/// Opens a thread pinned to a file, with its first message. +/// +/// Any `anchors` pin the thread to locations within the file; omit them for a +/// file-level thread. `@username` mentions in the opening body notify those +/// workspace members. Requires `Comment`. +#[tracing::instrument( + skip_all, + fields( + account_id = %authz.account_id, + workspace_id = %authz.workspace.id, + file_id = %path_params.file_id, + ) +)] +async fn open_file_thread( + State(pg_client): State, + State(assistant): State, + authz: Authorized, + Path(path_params): Path, + security: SecurityContext, + ValidateJson(request): ValidateJson, +) -> Result<(StatusCode, Json)> { + tracing::debug!(target: TRACING_TARGET, "Opening file thread"); + + let workspace = authz.workspace; + let mut conn = pg_client.get_connection().await?; + + // The file must exist in the workspace. + conn.find_file_in_workspace(workspace.id, path_params.file_id) + .await? + .ok_or_else(|| Error::not_found("file"))?; + + open_thread( + &mut conn, + workspace.id, + Some(path_params.file_id), + authz.account_id, + &security, + &assistant, + request, + ) + .await +} + +fn open_file_thread_docs(op: TransformOperation) -> TransformOperation { + op.summary("Open a file thread") + .description( + "Opens a comment thread on a file with its first message, optionally \ + pinned to locations within the file. @username mentions notify those \ + members. Requires the Comment permission.", + ) + .response::<201, Json>() + .response::<400, Json>() + .response::<401, Json>() + .response::<403, Json>() + .response::<404, Json>() +} + +/// Opens a workspace-level thread (not pinned to any file), with its first +/// message. +/// +/// `@username` mentions in the opening body notify those workspace members. +/// Requires `Comment`. +#[tracing::instrument( + skip_all, + fields( + account_id = %authz.account_id, + workspace_id = %authz.workspace.id, + ) +)] +async fn open_workspace_thread( + State(pg_client): State, + State(assistant): State, + authz: Authorized, + security: SecurityContext, + ValidateJson(request): ValidateJson, +) -> Result<(StatusCode, Json)> { + tracing::debug!(target: TRACING_TARGET, "Opening workspace thread"); + + let workspace = authz.workspace; + let mut conn = pg_client.get_connection().await?; + + open_thread( + &mut conn, + workspace.id, + None, + authz.account_id, + &security, + &assistant, + request, + ) + .await +} + +fn open_workspace_thread_docs(op: TransformOperation) -> TransformOperation { + op.summary("Open a workspace thread") + .description( + "Opens a workspace-level comment thread (not pinned to any file) with \ + its first message. @username mentions notify those members. Requires \ + the Comment permission.", + ) + .response::<201, Json>() + .response::<400, Json>() + .response::<401, Json>() + .response::<403, Json>() +} + +/// Opens a thread with its opening comment and any initial anchors, and records +/// the opened event, all in one transaction. Shared by the file and workspace +/// open endpoints. +async fn open_thread( + conn: &mut PgConn, + workspace_id: Uuid, + file_id: Option, + author_id: Uuid, + security: &SecurityContext, + assistant: &AssistantQueue, + request: OpenThread, +) -> Result<(StatusCode, Json)> { + // Resolve @-mentions to workspace-member account ids (author excluded, + // de-duplicated; a non-member handle is ignored) and note whether the + // assistant was addressed. + let MentionOutcome { + recipients, + addressed_assistant, + } = resolve_mentions(conn, workspace_id, &request.body, author_id).await?; + + // A file-level (or workspace-level) thread carries no anchors; a workspace + // thread never pins to a file. + let anchors = if file_id.is_some() { + encode_anchors(request.anchors)? + } else { + Vec::new() + }; + + let new_thread = NewWorkspaceThread { + workspace_id, + file_id, + author_account_id: author_id, + display_name: request.display_name, + }; + + let author_username = resolve_account_ref(conn, author_id).await?.username; + + // Open the thread (with its opening comment and anchors), record its event, + // and — if the assistant was addressed — queue the reply job, all in one + // transaction so the rows, the event, and the job commit or roll back + // together. + let (thread, queued_assistant) = conn + .transaction(async |conn| { + let (thread, opening) = conn.open_thread(new_thread, request.body, anchors).await?; + + emit_comment_event( + conn, + workspace_origin(workspace_id, author_id, security), + WorkspaceEvent::ThreadOpened(ThreadOpened { + thread_id: thread.id, + opening_comment_id: opening.id, + file_id: thread.file_id, + author_username: author_username.clone(), + mentioned: recipients, + }), + ) + .await?; + + let queued = enqueue_assistant_if_addressed( + conn, + addressed_assistant, + author_id, + workspace_id, + thread.id, + opening.id, + ) + .await?; + Ok::<_, Error>((thread, queued)) + }) + .await?; + + if queued_assistant { + assistant.wake_drainer(); + } + + let response = thread_response(conn, thread).await?; + + tracing::info!(target: TRACING_TARGET, thread_id = %response.id, "Thread opened"); + + Ok((StatusCode::CREATED, Json(response))) +} + +/// Lists a workspace's threads with cursor pagination, most recent first. +/// +/// Filter by `fileId`, `author`, and `closed`. Requires `ViewComments`. +#[tracing::instrument( + skip_all, + fields( + account_id = %authz.account_id, + workspace_id = %authz.workspace.id, + ) +)] +async fn list_threads( + State(pg_client): State, + authz: Authorized, + Query(pagination): Query, + Query(query): Query, +) -> Result<(StatusCode, Json)> { + tracing::debug!(target: TRACING_TARGET, "Listing threads"); + + let workspace = authz.workspace; + let mut conn = pg_client.get_connection().await?; + + let page = conn + .cursor_list_threads(workspace.id, pagination.into(), &query.into()) + .await?; + + // Fetch the whole page's live anchors in one query, then group them by thread + // (the query returns them ordered by thread, so each thread's anchors form a + // contiguous run). + let thread_ids: Vec = page.items.iter().map(|row| row.item.id).collect(); + let mut anchors_by_thread: HashMap> = HashMap::new(); + for anchor in conn.list_anchors_for_threads(&thread_ids).await? { + anchors_by_thread + .entry(anchor.thread_id) + .or_default() + .push(anchor); + } + + let threads = page + .items + .into_iter() + .map(|row| { + let anchors = anchors_by_thread.remove(&row.item.id).unwrap_or_default(); + Thread::from_model(row.item, anchors, row.account.into()) + }) + .collect(); + + let response = ThreadsPage { + items: threads, + total: page.total, + next_cursor: page.next_cursor, + }; + + Ok((StatusCode::OK, Json(response))) +} + +fn list_threads_docs(op: TransformOperation) -> TransformOperation { + op.summary("List threads") + .description( + "Returns the workspace's threads, most recent first, with optional \ + file, author, and closed filters.", + ) + .response::<200, Json>() + .response::<401, Json>() + .response::<403, Json>() +} + +/// Deletes a thread and all of its comments (soft delete). Requires +/// `CloseComments`. +#[tracing::instrument( + skip_all, + fields( + account_id = %authz.account_id, + workspace_id = %authz.workspace.id, + thread_id = %path_params.thread_id, + ) +)] +async fn delete_thread( + State(pg_client): State, + authz: Authorized, + Path(path_params): Path, + security: SecurityContext, +) -> Result { + tracing::debug!(target: TRACING_TARGET, "Deleting thread"); + + let workspace = authz.workspace; + let mut conn = pg_client.get_connection().await?; + + let thread = find_thread(&mut conn, workspace.id, path_params.thread_id).await?; + + conn.transaction(async |conn| { + conn.delete_thread(thread.id).await?; + emit_comment_event( + conn, + workspace_origin(workspace.id, authz.account_id, &security), + WorkspaceEvent::ThreadDeleted(ThreadDeleted { + thread_id: thread.id, + file_id: thread.file_id, + }), + ) + .await?; + Ok::<_, Error>(()) + }) + .await?; + + tracing::info!(target: TRACING_TARGET, "Thread deleted"); + + Ok(StatusCode::OK) +} + +fn delete_thread_docs(op: TransformOperation) -> TransformOperation { + op.summary("Delete a thread") + .description("Soft-deletes a thread and all of its comments. Requires CloseComments.") + .response::<200, ()>() + .response::<401, Json>() + .response::<403, Json>() + .response::<404, Json>() +} + +/// Closes a thread, ending its discussion. Requires `CloseComments`. +#[tracing::instrument( + skip_all, + fields( + account_id = %authz.account_id, + workspace_id = %authz.workspace.id, + thread_id = %path_params.thread_id, + ) +)] +async fn close_thread( + State(pg_client): State, + authz: Authorized, + Path(path_params): Path, + security: SecurityContext, +) -> Result<(StatusCode, Json)> { + tracing::debug!(target: TRACING_TARGET, "Closing thread"); + + let workspace = authz.workspace; + let mut conn = pg_client.get_connection().await?; + + let thread = find_thread(&mut conn, workspace.id, path_params.thread_id).await?; + + // Already closed: return it unchanged rather than overwriting the original + // closer/timestamp (the audit record) and emitting a duplicate event. + if thread.closed_at.is_some() { + let response = thread_response(&mut conn, thread).await?; + return Ok((StatusCode::OK, Json(response))); + } + + let closed = conn + .transaction(async |conn| { + let closed = conn.close_thread(thread.id, authz.account_id).await?; + emit_comment_event( + conn, + workspace_origin(workspace.id, authz.account_id, &security), + WorkspaceEvent::ThreadClosed(ThreadClosed { + thread_id: thread.id, + file_id: thread.file_id, + }), + ) + .await?; + Ok::<_, Error>(closed) + }) + .await?; + + let response = thread_response(&mut conn, closed).await?; + + tracing::info!(target: TRACING_TARGET, "Thread closed"); + + Ok((StatusCode::OK, Json(response))) +} + +fn close_thread_docs(op: TransformOperation) -> TransformOperation { + op.summary("Close a thread") + .description("Closes a thread, ending the discussion. Requires CloseComments.") + .response::<200, Json>() + .response::<401, Json>() + .response::<403, Json>() + .response::<404, Json>() +} + +/// Reopens a closed thread. Requires `CloseComments`. +#[tracing::instrument( + skip_all, + fields( + account_id = %authz.account_id, + workspace_id = %authz.workspace.id, + thread_id = %path_params.thread_id, + ) +)] +async fn reopen_thread( + State(pg_client): State, + authz: Authorized, + Path(path_params): Path, + security: SecurityContext, +) -> Result<(StatusCode, Json)> { + tracing::debug!(target: TRACING_TARGET, "Reopening thread"); + + let workspace = authz.workspace; + let mut conn = pg_client.get_connection().await?; + + let thread = find_thread(&mut conn, workspace.id, path_params.thread_id).await?; + + // Already open: return it unchanged rather than emitting a duplicate event. + if thread.closed_at.is_none() { + let response = thread_response(&mut conn, thread).await?; + return Ok((StatusCode::OK, Json(response))); + } + + let reopened = conn + .transaction(async |conn| { + let reopened = conn.reopen_thread(thread.id, authz.account_id).await?; + emit_comment_event( + conn, + workspace_origin(workspace.id, authz.account_id, &security), + WorkspaceEvent::ThreadReopened(ThreadReopened { + thread_id: thread.id, + file_id: thread.file_id, + }), + ) + .await?; + Ok::<_, Error>(reopened) + }) + .await?; + + let response = thread_response(&mut conn, reopened).await?; + + tracing::info!(target: TRACING_TARGET, "Thread reopened"); + + Ok((StatusCode::OK, Json(response))) +} + +fn reopen_thread_docs(op: TransformOperation) -> TransformOperation { + op.summary("Reopen a thread") + .description("Reopens a closed thread. Requires CloseComments.") + .response::<200, Json>() + .response::<401, Json>() + .response::<403, Json>() + .response::<404, Json>() +} + +/// Renames a thread (sets or clears its title). Requires `Comment`. +#[tracing::instrument( + skip_all, + fields( + account_id = %authz.account_id, + workspace_id = %authz.workspace.id, + thread_id = %path_params.thread_id, + ) +)] +async fn rename_thread( + State(pg_client): State, + authz: Authorized, + Path(path_params): Path, + security: SecurityContext, + ValidateJson(request): ValidateJson, +) -> Result<(StatusCode, Json)> { + tracing::debug!(target: TRACING_TARGET, "Renaming thread"); + + let workspace = authz.workspace; + let mut conn = pg_client.get_connection().await?; + + let thread = find_thread(&mut conn, workspace.id, path_params.thread_id).await?; + + let renamed = conn + .transaction(async |conn| { + let renamed = conn + .rename_thread(thread.id, request.display_name, authz.account_id) + .await?; + emit_comment_event( + conn, + workspace_origin(workspace.id, authz.account_id, &security), + WorkspaceEvent::ThreadRenamed(ThreadRenamed { + thread_id: thread.id, + file_id: thread.file_id, + }), + ) + .await?; + Ok::<_, Error>(renamed) + }) + .await?; + + let response = thread_response(&mut conn, renamed).await?; + + tracing::info!(target: TRACING_TARGET, "Thread renamed"); + + Ok((StatusCode::OK, Json(response))) +} + +fn rename_thread_docs(op: TransformOperation) -> TransformOperation { + op.summary("Rename a thread") + .description("Sets or clears a thread's title. Requires the Comment permission.") + .response::<200, Json>() + .response::<400, Json>() + .response::<401, Json>() + .response::<403, Json>() + .response::<404, Json>() +} + +/// Adds an anchor (location pin) to a thread. Requires `Comment`. +#[tracing::instrument( + skip_all, + fields( + account_id = %authz.account_id, + workspace_id = %authz.workspace.id, + thread_id = %path_params.thread_id, + ) +)] +async fn add_anchor( + State(pg_client): State, + authz: Authorized, + Path(path_params): Path, + security: SecurityContext, + ValidateJson(request): ValidateJson, +) -> Result<(StatusCode, Json)> { + tracing::debug!(target: TRACING_TARGET, "Adding thread anchor"); + + let workspace = authz.workspace; + let mut conn = pg_client.get_connection().await?; + + let thread = find_thread(&mut conn, workspace.id, path_params.thread_id).await?; + + // Only a file-pinned thread can carry anchors (there is no file to pin into + // for a workspace-level thread). + let file_id = thread.file_id.ok_or_else(|| { + ErrorKind::BadRequest.with_message("A workspace-level thread has no file to anchor to") + })?; + + let anchor_json = encode_anchor(&request.anchor)?; + + let anchor = conn + .transaction(async |conn| { + let anchor = conn + .add_thread_anchor( + workspace.id, + NewWorkspaceThreadAnchor { + thread_id: thread.id, + anchor: anchor_json, + }, + authz.account_id, + ) + .await?; + emit_comment_event( + conn, + workspace_origin(workspace.id, authz.account_id, &security), + WorkspaceEvent::ThreadAnchorAdded(ThreadAnchorAdded { + thread_id: thread.id, + anchor_id: anchor.id, + file_id: Some(file_id), + }), + ) + .await?; + Ok::<_, Error>(anchor) + }) + .await?; + + tracing::info!(target: TRACING_TARGET, anchor_id = %anchor.id, "Anchor added"); + + let response = ThreadAnchor::from_model(anchor) + .ok_or_else(|| ErrorKind::InternalServerError.with_message("Failed to encode anchor"))?; + Ok((StatusCode::CREATED, Json(response))) +} + +fn add_anchor_docs(op: TransformOperation) -> TransformOperation { + op.summary("Add a thread anchor") + .description( + "Adds a location pin to a file thread, recording an anchor.added timeline \ + event. Requires the Comment permission.", + ) + .response::<201, Json>() + .response::<400, Json>() + .response::<401, Json>() + .response::<403, Json>() + .response::<404, Json>() +} + +/// Removes an anchor from a thread (soft delete). Requires `Comment`. +#[tracing::instrument( + skip_all, + fields( + account_id = %authz.account_id, + workspace_id = %authz.workspace.id, + thread_id = %path_params.thread_id, + anchor_id = %path_params.anchor_id, + ) +)] +async fn remove_anchor( + State(pg_client): State, + authz: Authorized, + Path(path_params): Path, + security: SecurityContext, +) -> Result { + tracing::debug!(target: TRACING_TARGET, "Removing thread anchor"); + + let workspace = authz.workspace; + let mut conn = pg_client.get_connection().await?; + + let thread = find_thread(&mut conn, workspace.id, path_params.thread_id).await?; + conn.find_thread_anchor(thread.id, path_params.anchor_id) + .await? + .ok_or_else(|| Error::not_found("workspace_thread_anchor"))?; + + conn.transaction(async |conn| { + let anchor = conn + .remove_thread_anchor(workspace.id, path_params.anchor_id, authz.account_id) + .await?; + emit_comment_event( + conn, + workspace_origin(workspace.id, authz.account_id, &security), + WorkspaceEvent::ThreadAnchorRemoved(ThreadAnchorRemoved { + thread_id: thread.id, + anchor_id: anchor.id, + file_id: thread.file_id, + }), + ) + .await?; + Ok::<_, Error>(()) + }) + .await?; + + tracing::info!(target: TRACING_TARGET, "Anchor removed"); + + Ok(StatusCode::OK) +} + +fn remove_anchor_docs(op: TransformOperation) -> TransformOperation { + op.summary("Remove a thread anchor") + .description( + "Soft-removes a location pin from a thread, recording an anchor.removed \ + timeline event. Requires the Comment permission.", + ) + .response::<200, ()>() + .response::<401, Json>() + .response::<403, Json>() + .response::<404, Json>() +} + +/// Returns a thread's full timeline: comments and lifecycle events interleaved, +/// oldest first. Requires `ViewComments`. +#[tracing::instrument( + skip_all, + fields( + account_id = %authz.account_id, + workspace_id = %authz.workspace.id, + thread_id = %path_params.thread_id, + ) +)] +async fn list_thread_timeline( + State(pg_client): State, + authz: Authorized, + Path(path_params): Path, +) -> Result<(StatusCode, Json>)> { + tracing::debug!(target: TRACING_TARGET, "Listing thread timeline"); + + let workspace = authz.workspace; + let mut conn = pg_client.get_connection().await?; + + find_thread(&mut conn, workspace.id, path_params.thread_id).await?; + + let comments = conn + .list_thread_comments(workspace.id, path_params.thread_id) + .await?; + let events = conn.list_thread_events(path_params.thread_id).await?; + + // Merge the two ordered streams into one timeline, oldest first. Both are + // already `created_at`-ordered, so a single sort by timestamp suffices. + let mut entries: Vec = Vec::with_capacity(comments.len() + events.len()); + entries.extend( + comments + .into_iter() + .map(|row| ThreadEntry::Comment(Comment::from_model(row.item, row.account.into()))), + ); + entries.extend(events.into_iter().map(|(event, actor)| { + ThreadEntry::Event(ThreadEvent::from_model(event, actor.map(Into::into))) + })); + entries.sort_by_key(ThreadEntry::timestamp); + + Ok((StatusCode::OK, Json(entries))) +} + +fn list_thread_timeline_docs(op: TransformOperation) -> TransformOperation { + op.summary("List a thread's timeline") + .description( + "Returns the thread's timeline — comments and lifecycle events (closed, \ + reopened, anchor added/removed) interleaved, oldest first.", + ) + .response::<200, Json>>() + .response::<401, Json>() + .response::<403, Json>() + .response::<404, Json>() +} + +/// Builds a full [`Thread`] response for `thread`, loading its live anchors and +/// resolving its author. +async fn thread_response(conn: &mut PgConn, thread: WorkspaceThread) -> Result { + let anchors = conn.list_thread_anchors(thread.id).await?; + let author = resolve_account_ref(conn, thread.author_account_id).await?; + Ok(Thread::from_model(thread, anchors, author)) +} + +/// Finds a live thread in the workspace or returns a 404. +pub(crate) async fn find_thread( + conn: &mut PgConn, + workspace_id: Uuid, + thread_id: Uuid, +) -> Result { + conn.find_thread_in_workspace(workspace_id, thread_id) + .await? + .ok_or_else(|| Error::not_found("workspace_thread")) +} + +/// Finds a live comment in the workspace or returns a 404. +pub(crate) async fn find_comment( + conn: &mut PgConn, + workspace_id: Uuid, + comment_id: Uuid, +) -> Result { + conn.find_comment_in_workspace(workspace_id, comment_id) + .await? + .ok_or_else(|| Error::not_found("workspace_comment")) +} + +/// Encodes one typed anchor into its stored JSON. +fn encode_anchor(anchor: &CommentAnchor) -> Result { + serde_json::to_value(anchor).map_err(|err| { + ErrorKind::InternalServerError + .with_message("Failed to encode thread anchor") + .with_context(err.to_string()) + }) +} + +/// Encodes a list of typed anchors into their stored JSON. +fn encode_anchors(anchors: Vec) -> Result> { + anchors.iter().map(encode_anchor).collect() +} + +/// Extracts the raw handle text of each `@username` mention in `body`. +/// +/// A mention is an `@` that starts a token (preceded by start-of-string or a +/// non-alphanumeric, non-`@` char, so an email's `@` is not a mention) followed +/// by handle characters (`[a-z0-9-]`). Validation (length, dash rules) is left to +/// [`Handle::parse`]; this only slices candidate spans. +fn parse_mentions(body: &str) -> Vec { + let bytes = body.as_bytes(); + let mut mentions = Vec::new(); + let mut i = 0; + while let Some(at) = body[i..].find('@') { + let at = i + at; + // The `@` must begin a token: preceded by nothing, or by a char that is + // not part of a word and not another `@`. A preceding ASCII alphanumeric + // (so `a@b.com` is an email, not a mention) or any non-ASCII byte >= 0x80 + // (a multibyte letter like `é`, so `café@bob` is not a mention) counts as + // part of a word. + let boundary = at == 0 || { + let prev = bytes[at - 1]; + !(prev.is_ascii_alphanumeric() || prev >= 0x80 || prev == b'@') + }; + let start = at + 1; + let end = start + + body[start..] + .find(|c: char| !matches!(c, 'a'..='z' | '0'..='9' | '-')) + .unwrap_or(body.len() - start); + if boundary && end > start { + mentions.push(body[start..end].to_owned()); + } + i = end.max(at + 1); + } + mentions +} + +/// The outcome of resolving a comment body's `@`-mentions. +pub(crate) struct MentionOutcome { + /// Workspace-member account ids to notify (de-duplicated, author excluded). + pub(crate) recipients: Vec, + /// Whether the body addressed the reserved assistant handle (`@assistant`), + /// so an AI reply should be queued. The assistant is not a workspace member, + /// so it never appears in `recipients` — it is a job trigger, not a + /// notification target. + pub(crate) addressed_assistant: bool, +} + +/// Parses `@username` mentions from `body`. Resolves each human handle to a +/// workspace-member account id — de-duplicated, excluding `author` (no +/// self-notification), and skipping handles that are not members — and separately +/// reports whether the reserved assistant handle was addressed. +pub(crate) async fn resolve_mentions( + conn: &mut PgConn, + workspace_id: Uuid, + body: &str, + author: Uuid, +) -> Result { + // De-duplicate the raw mention text first (a repeated mention resolves once), + // then parse each into a valid handle. + let raw: BTreeSet = parse_mentions(body).into_iter().collect(); + + // The assistant's reserved handle is recognized directly: it is not a + // workspace member, so member resolution would never surface it. + let addressed_assistant = raw.iter().any(|m| m == ASSISTANT_HANDLE); + + let handles: Vec = raw + .into_iter() + .filter_map(|m| Handle::parse(m).ok()) + .collect(); + + if handles.is_empty() { + return Ok(MentionOutcome { + recipients: Vec::new(), + addressed_assistant, + }); + } + + // Resolve all mentioned handles to workspace-member account ids in one query, + // then drop the author (no self-notification). + let mut recipients = conn + .find_member_ids_by_usernames(workspace_id, &handles) + .await?; + recipients.retain(|&id| id != author); + Ok(MentionOutcome { + recipients, + addressed_assistant, + }) +} + +/// Queues an assistant-reply job for a just-created comment when it addressed the +/// assistant and was written by a human (not the assistant itself, so its own +/// replies never re-trigger it). Runs inside the comment's transaction so the job +/// commits atomically with the comment; returns whether a job was inserted, so +/// the caller can wake the drainer after commit. A serialization failure of the +/// tiny job payload is treated as fatal to the transaction (it should never +/// happen). +pub(crate) async fn enqueue_assistant_if_addressed( + conn: &mut PgConn, + addressed_assistant: bool, + author_id: Uuid, + workspace_id: Uuid, + thread_id: Uuid, + comment_id: Uuid, +) -> Result { + if !addressed_assistant || author_id == ASSISTANT_ACCOUNT_ID { + return Ok(false); + } + + let job = AssistantJob { + workspace_id, + thread_id, + comment_id, + }; + let payload = serde_json::to_value(&job).map_err(|err| { + ErrorKind::InternalServerError + .with_message("Failed to encode assistant job") + .with_context(err.to_string()) + })?; + conn.insert_assistant_job(NewWorkspaceAssistantJob { + comment_id, + job: payload, + }) + .await?; + Ok(true) +} + +/// Builds the event origin shared by every comment event. +pub(crate) fn workspace_origin<'a>( + workspace_id: Uuid, + account_id: Uuid, + security: &'a SecurityContext, +) -> EventOrigin<'a> { + EventOrigin { + workspace_id, + account_id, + security, + } +} + +/// Emits one comment event onto the outbox. +pub(crate) async fn emit_comment_event( + conn: &mut PgConn, + origin: EventOrigin<'_>, + event: WorkspaceEvent, +) -> Result<()> { + conn.emit_event(origin, event).await?; + Ok(()) +} + +/// Returns an [`ApiRouter`] with all comment-thread routes. +pub fn routes() -> ApiRouter { + use aide::axum::routing::*; + + ApiRouter::new() + .api_route( + "/workspaces/{workspaceSlug}/files/{fileId}/threads/", + post_with(open_file_thread, open_file_thread_docs), + ) + .api_route( + "/workspaces/{workspaceSlug}/threads/", + post_with(open_workspace_thread, open_workspace_thread_docs) + .get_with(list_threads, list_threads_docs), + ) + .api_route( + "/workspaces/{workspaceSlug}/threads/{threadId}/", + patch_with(rename_thread, rename_thread_docs) + .delete_with(delete_thread, delete_thread_docs), + ) + .api_route( + "/workspaces/{workspaceSlug}/threads/{threadId}/close/", + post_with(close_thread, close_thread_docs) + .delete_with(reopen_thread, reopen_thread_docs), + ) + .api_route( + "/workspaces/{workspaceSlug}/threads/{threadId}/anchors/", + post_with(add_anchor, add_anchor_docs), + ) + .api_route( + "/workspaces/{workspaceSlug}/threads/{threadId}/anchors/{anchorId}/", + delete_with(remove_anchor, remove_anchor_docs), + ) + .api_route( + "/workspaces/{workspaceSlug}/threads/{threadId}/timeline/", + get_with(list_thread_timeline, list_thread_timeline_docs), + ) + .with_path_items(|item| item.tag("Threads")) +} + +#[cfg(test)] +mod tests { + use super::parse_mentions; + + #[test] + fn parses_mentions_and_ignores_emails() { + // A leading mention, a mid-sentence mention, and an email whose @ is not a + // mention. + assert_eq!( + parse_mentions("@alice please review, cc @bob-smith — not user@example.com"), + vec!["alice".to_owned(), "bob-smith".to_owned()], + ); + } + + #[test] + fn no_mentions_yields_empty() { + assert!(parse_mentions("just a plain comment, no pings").is_empty()); + assert!(parse_mentions("").is_empty()); + // A bare @ with no handle text produces nothing. + assert!(parse_mentions("look @ this").is_empty()); + } + + #[test] + fn mention_stops_at_non_handle_chars() { + // The handle ends at whitespace/punctuation; trailing text is not included. + assert_eq!(parse_mentions("hey @carol!"), vec!["carol".to_owned()]); + assert_eq!(parse_mentions("(@dave)"), vec!["dave".to_owned()]); + } + + #[test] + fn non_ascii_letter_before_at_is_not_a_boundary() { + // A multibyte letter (é) before `@` means the `@` is embedded in a word, + // not a mention — like an email local part. + assert!(parse_mentions("café@bob").is_empty()); + // But a real mention after an accented word (with a space) still parses. + assert_eq!(parse_mentions("café @bob"), vec!["bob".to_owned()]); + } +} diff --git a/crates/nvisy-server/src/response/error/mod.rs b/crates/nvisy-server/src/response/error/mod.rs index 93951d8d..8653e9ae 100644 --- a/crates/nvisy-server/src/response/error/mod.rs +++ b/crates/nvisy-server/src/response/error/mod.rs @@ -12,7 +12,6 @@ mod nats_error; mod object_error; mod oidc_error; mod pg_account; -mod pg_chat; mod pg_document; mod pg_error; mod pg_pipeline; diff --git a/crates/nvisy-server/src/response/error/pg_chat.rs b/crates/nvisy-server/src/response/error/pg_chat.rs deleted file mode 100644 index 668dcd91..00000000 --- a/crates/nvisy-server/src/response/error/pg_chat.rs +++ /dev/null @@ -1,31 +0,0 @@ -//! Chat-related constraint violation error handlers. - -use nvisy_postgres::types::{ChatMessageConstraints, ChatSessionConstraints}; - -use super::{Error, ErrorKind}; - -impl From for Error<'static> { - fn from(c: ChatSessionConstraints) -> Self { - let error = match c { - ChatSessionConstraints::TitleLength => ErrorKind::BadRequest - .with_message("Chat title must be between 1 and 255 characters"), - }; - error.with_resource("chat_session") - } -} - -impl From for Error<'static> { - fn from(c: ChatMessageConstraints) -> Self { - let error = match c { - ChatMessageConstraints::ContentSize => { - ErrorKind::BadRequest.with_message("Chat message is empty or too large") - } - // A parent must be in the same session; a caller supplying a - // cross-session parent is a bad request. - ChatMessageConstraints::IdSession | ChatMessageConstraints::Parent => { - ErrorKind::BadRequest.with_message("Parent message does not belong to this session") - } - }; - error.with_resource("chat_message") - } -} diff --git a/crates/nvisy-server/src/response/error/pg_error.rs b/crates/nvisy-server/src/response/error/pg_error.rs index 869b721f..867c9207 100644 --- a/crates/nvisy-server/src/response/error/pg_error.rs +++ b/crates/nvisy-server/src/response/error/pg_error.rs @@ -21,8 +21,6 @@ impl From for Error<'static> { ConstraintViolation::AccountIdentity(c) => c.into(), ConstraintViolation::AccountNotification(c) => c.into(), ConstraintViolation::AccountApiToken(c) => c.into(), - ConstraintViolation::ChatSession(c) => c.into(), - ConstraintViolation::ChatMessage(c) => c.into(), ConstraintViolation::Workspace(c) => c.into(), ConstraintViolation::WorkspaceMember(c) => c.into(), ConstraintViolation::WorkspaceInvite(c) => c.into(), @@ -30,7 +28,9 @@ impl From for Error<'static> { ConstraintViolation::WorkspaceWebhook(c) => c.into(), ConstraintViolation::WorkspaceFile(c) => c.into(), ConstraintViolation::WorkspaceAssignment(c) => c.into(), - ConstraintViolation::WorkspaceComment(c) => c.into(), + ConstraintViolation::WorkspaceThread(c) => c.into(), + ConstraintViolation::WorkspaceThreadAnchor(c) => c.into(), + ConstraintViolation::WorkspaceThreadComment(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/response/error/pg_workspace.rs b/crates/nvisy-server/src/response/error/pg_workspace.rs index 76ee3524..c9f248c5 100644 --- a/crates/nvisy-server/src/response/error/pg_workspace.rs +++ b/crates/nvisy-server/src/response/error/pg_workspace.rs @@ -1,9 +1,9 @@ //! Workspace-related constraint violation error handlers. use nvisy_postgres::types::{ - WorkspaceActivitiesConstraints, WorkspaceAssignmentConstraints, WorkspaceCommentConstraints, - WorkspaceConstraints, WorkspaceInviteConstraints, WorkspaceMemberConstraints, - WorkspaceWebhookConstraints, + WorkspaceActivitiesConstraints, WorkspaceAssignmentConstraints, WorkspaceConstraints, + WorkspaceInviteConstraints, WorkspaceMemberConstraints, WorkspaceThreadAnchorConstraints, + WorkspaceThreadCommentConstraints, WorkspaceThreadConstraints, WorkspaceWebhookConstraints, }; use super::{Error, ErrorKind}; @@ -62,16 +62,38 @@ impl From for Error<'static> { } } -impl From for Error<'static> { - fn from(c: WorkspaceCommentConstraints) -> Self { +impl From for Error<'static> { + fn from(c: WorkspaceThreadConstraints) -> Self { let error = match c { - WorkspaceCommentConstraints::BodyLength => ErrorKind::BadRequest - .with_message("Comment body must be between 1 and 10000 characters"), - WorkspaceCommentConstraints::AnchorSize => { - ErrorKind::BadRequest.with_message("Comment anchor is too large") + WorkspaceThreadConstraints::DisplayNameLength => ErrorKind::BadRequest + .with_message("Thread title must be between 1 and 255 characters"), + WorkspaceThreadConstraints::ClosedConsistent => ErrorKind::InternalServerError + .with_message("Thread open/closed state is inconsistent"), + }; + + error.with_resource("workspace_thread") + } +} + +impl From for Error<'static> { + fn from(c: WorkspaceThreadAnchorConstraints) -> Self { + let error = match c { + WorkspaceThreadAnchorConstraints::Size => { + ErrorKind::BadRequest.with_message("Thread anchor is too large") } }; + error.with_resource("workspace_thread_anchor") + } +} + +impl From for Error<'static> { + fn from(c: WorkspaceThreadCommentConstraints) -> Self { + let error = match c { + WorkspaceThreadCommentConstraints::BodyLength => ErrorKind::BadRequest + .with_message("Comment body must be between 1 and 10000 characters"), + }; + error.with_resource("workspace_comment") } } diff --git a/crates/nvisy-server/src/service/assistant/coordinator.rs b/crates/nvisy-server/src/service/assistant/coordinator.rs new file mode 100644 index 00000000..241e54af --- /dev/null +++ b/crates/nvisy-server/src/service/assistant/coordinator.rs @@ -0,0 +1,45 @@ +//! In-process wake coordination for the assistant pipeline. + +use std::sync::Arc; + +use tokio::sync::Notify; + +/// The rendezvous between the assistant-job enqueue path and the outbox drainer. +/// +/// After a handler commits a new assistant-reply job, it calls [`wake`](Self::wake) +/// so the drainer drains the job at once instead of waiting for its next timer +/// tick; the drainer awaits [`notified`](Self::notified) alongside that timer. The +/// wake is in-process only and best-effort — the drainer's timer remains the +/// cross-instance and crash-safety fallback, and the Postgres claim keeps a job +/// single-drained across the fleet — so a missed wake only defers a drain to the +/// next tick, never strands a job. +/// +/// A single instance is shared (it is `Arc`-backed, so clones share one +/// [`Notify`]) between the [`AssistantQueue`](super::AssistantQueue) that +/// materialises per request and the long-lived +/// [`AssistantOutboxDrainer`](super::AssistantOutboxDrainer). +#[derive(Clone, Default)] +#[must_use = "the coordinator does nothing unless you wake or await it"] +pub struct AssistantCoordinator { + wake: Arc, +} + +impl AssistantCoordinator { + /// Creates a new coordinator with no pending wake. + pub fn new() -> Self { + Self::default() + } + + /// Wakes the drainer so a just-committed job is drained immediately. Coalescing + /// and best-effort: a wake with no waiter is remembered for the next + /// [`notified`](Self::notified), and extra wakes collapse into one. + pub fn wake(&self) { + self.wake.notify_one(); + } + + /// Waits for the next [`wake`](Self::wake). Awaited by the drainer alongside + /// its timer tick. + pub async fn notified(&self) { + self.wake.notified().await; + } +} diff --git a/crates/nvisy-server/src/service/assistant/drainer.rs b/crates/nvisy-server/src/service/assistant/drainer.rs new file mode 100644 index 00000000..4526c57a --- /dev/null +++ b/crates/nvisy-server/src/service/assistant/drainer.rs @@ -0,0 +1,225 @@ +//! Assistant-job outbox drainer. +//! +//! Publishes each pending assistant-reply outbox row onto the assistant +//! work-queue, so a reply queued transactionally with the triggering comment +//! reaches the worker even if the process crashes between the commit and the +//! publish. This is the relay half of the transactional outbox: the comment +//! handler writes the row in the comment's transaction, and this drains it to +//! NATS. + +use std::time::Duration; + +use nvisy_postgres::AsyncConnection; +use nvisy_postgres::model::WorkspaceAssistantJob; +use nvisy_postgres::query::AssistantJobOutboxRepository; +use tokio_util::sync::CancellationToken; + +use super::coordinator::AssistantCoordinator; +use super::job::AssistantJob; +use super::service::AssistantQueue; +use crate::response::{Error, Result}; +use crate::service::{Infra, Worker}; + +/// Tracing target for the assistant-job drainer. +const TRACING_TARGET: &str = "nvisy_server::service::assistant::drainer"; + +/// How often the drainer polls for due jobs. Short, since it is the enqueue +/// latency between addressing the assistant and the worker picking it up. +const TICK_INTERVAL: Duration = Duration::from_secs(5); + +/// Maximum jobs drained per tick, bounding the work (and lock hold) per pass. +const DRAIN_BATCH: i64 = 100; + +/// Base unit of the retry backoff (seconds): a failed row's next attempt is +/// deferred by `RETRY_BACKOFF_BASE_SECS * attempts` (linear), capped at +/// [`RETRY_BACKOFF_MAX_SECS`]. +const RETRY_BACKOFF_BASE_SECS: i64 = 30; + +/// Ceiling on the retry backoff (seconds), so a long-failing row still retries +/// periodically rather than backing off unboundedly. +const RETRY_BACKOFF_MAX_SECS: i64 = 60 * 60; + +/// How many failed attempts a row gets before the drainer dead-letters it, so a +/// job that can never publish (e.g. an undecodable payload) stops consuming drain +/// cycles instead of retrying forever. +const MAX_ATTEMPTS: i32 = 10; + +/// Cap on a single publish, so a slow or unavailable NATS server cannot hold the +/// batch transaction's row locks open indefinitely. A publish that exceeds this is +/// treated as a failed attempt (deferred with a backoff), releasing the locks. +const PUBLISH_TIMEOUT: Duration = Duration::from_secs(5); + +/// Drains the assistant-job outbox, publishing each pending job to the work-queue. +pub struct AssistantOutboxDrainer { + infra: Infra, + queue: AssistantQueue, + coordinator: AssistantCoordinator, +} + +/// The tally of one [`drain_batch`](AssistantOutboxDrainer::drain_batch) pass: of +/// the rows claimed, how many published, how many were deferred for a later retry, +/// and how many were dead-lettered. +struct DrainPass { + claimed: usize, + processed: usize, + deferred: usize, + dead_lettered: usize, +} + +impl Worker for AssistantOutboxDrainer { + type Output = Result<()>; + + fn name(&self) -> &'static str { + "assistant_outbox_drainer" + } + + async fn run(&self, cancel: CancellationToken) -> Result<()> { + tracing::info!(target: TRACING_TARGET, "Starting assistant-job drainer"); + + // The timer is the fallback (and cross-instance/crash safety net); the + // wake signal is the fast path so a job enqueued on this instance drains at + // once rather than waiting up to TICK_INTERVAL. A wake stored while a pass + // runs coalesces into one following pass, and the Postgres claim keeps a + // job single-drained no matter how many instances wake. + let mut ticker = tokio::time::interval(TICK_INTERVAL); + loop { + tokio::select! { + _ = cancel.cancelled() => break, + _ = ticker.tick() => self.tick(&cancel).await, + _ = self.coordinator.notified() => self.tick(&cancel).await, + } + } + + tracing::info!(target: TRACING_TARGET, "Assistant-job drainer stopped"); + Ok(()) + } +} + +impl AssistantOutboxDrainer { + /// Creates a new [`AssistantOutboxDrainer`]. + /// + /// Shares the [`AssistantCoordinator`] with the enqueue-side [`AssistantQueue`] + /// so a job committed on this instance wakes this drainer at once. + pub fn new(infra: Infra, coordinator: AssistantCoordinator) -> Self { + Self { + queue: AssistantQueue::new(infra.clone(), coordinator.clone()), + infra, + coordinator, + } + } + + /// One drain pass: claim and publish batches until a short page signals the due + /// set is drained, or until cancellation is requested. + async fn tick(&self, cancel: &CancellationToken) { + loop { + if cancel.is_cancelled() { + break; + } + match self.drain_batch().await { + Ok(pass) => { + if pass.deferred > 0 || pass.dead_lettered > 0 { + tracing::warn!( + target: TRACING_TARGET, + claimed = pass.claimed, + processed = pass.processed, + deferred = pass.deferred, + dead_lettered = pass.dead_lettered, + "Assistant-job drain pass had failing jobs", + ); + } else if pass.claimed > 0 { + tracing::debug!(target: TRACING_TARGET, processed = pass.processed, "Assistant-job drain pass published jobs"); + } + if pass.claimed < DRAIN_BATCH as usize { + break; + } + } + Err(err) => { + tracing::error!(target: TRACING_TARGET, error = %err, "Assistant-job drain pass failed"); + break; + } + } + } + } + + /// Drains one batch: claims due rows and publishes each to the work-queue, all + /// in one transaction. Returns the [`DrainPass`] tally. + /// + /// The transaction holds the claim's `FOR UPDATE SKIP LOCKED` locks through + /// completion, so the claim and each row's state transition commit atomically + /// and no other drainer takes the same rows. The publish runs inside the + /// transaction and gates `mark_processed`: this is at-least-once (a crash after + /// publish but before commit re-publishes on the next pass), which the worker's + /// dedup absorbs — the assistant only ever replies once per triggering comment. + /// A dead-lettered job simply means no reply is posted, so (unlike detection) + /// there is no domain entity to fail here. + async fn drain_batch(&self) -> Result { + let mut conn = self.infra.postgres.get_connection().await?; + + conn.transaction(async |conn| { + let batch = conn.claim_assistant_job_batch(DRAIN_BATCH).await?; + let mut pass = DrainPass { + claimed: batch.len(), + processed: 0, + deferred: 0, + dead_lettered: 0, + }; + + for row in batch { + match self.publish(&row).await { + Ok(()) => { + conn.mark_assistant_job_processed(row.id).await?; + pass.processed += 1; + } + // `attempts` counts prior failures; this attempt makes it + // `attempts + 1`. Once that reaches the cap, dead-letter the row + // instead of deferring it forever. A dead-lettered reply job just + // means the assistant never answers this message. + Err(()) if row.attempts + 1 >= MAX_ATTEMPTS => { + tracing::error!(target: TRACING_TARGET, id = %row.id, comment_id = %row.comment_id, attempts = row.attempts + 1, "Dead-lettering assistant job after too many failed attempts"); + conn.mark_assistant_job_failed(row.id).await?; + pass.dead_lettered += 1; + } + Err(()) => { + conn.defer_assistant_job_attempt(row.id, retry_backoff(row.attempts)) + .await?; + pass.deferred += 1; + } + } + } + + Ok::<_, Error>(pass) + }) + .await + } + + /// Decodes a row's job and publishes it to the work-queue. Returns `Err` if the + /// payload cannot decode or the publish fails, so the caller defers or + /// dead-letters it. + async fn publish(&self, row: &WorkspaceAssistantJob) -> std::result::Result<(), ()> { + let job = serde_json::from_value::(row.job.clone()).map_err(|err| { + tracing::error!(target: TRACING_TARGET, error = %err, id = %row.id, "Failed to decode assistant job"); + })?; + // Bound the publish so a hung NATS cannot hold the batch transaction's locks + // open; a timeout is a failed attempt like any other. + match tokio::time::timeout(PUBLISH_TIMEOUT, self.queue.enqueue(job)).await { + Ok(Ok(())) => Ok(()), + Ok(Err(err)) => { + tracing::warn!(target: TRACING_TARGET, error = %err, id = %row.id, "Failed to publish assistant job; deferring"); + Err(()) + } + Err(_elapsed) => { + tracing::warn!(target: TRACING_TARGET, id = %row.id, "Assistant-job publish timed out; deferring"); + Err(()) + } + } + } +} + +/// The delay in seconds before a failed row's next attempt: linear in `attempts` +/// (the count before this failure), capped at [`RETRY_BACKOFF_MAX_SECS`]. +fn retry_backoff(attempts: i32) -> i64 { + let steps = i64::from(attempts.max(0)) + 1; + RETRY_BACKOFF_BASE_SECS + .saturating_mul(steps) + .min(RETRY_BACKOFF_MAX_SECS) +} diff --git a/crates/nvisy-server/src/service/assistant/job.rs b/crates/nvisy-server/src/service/assistant/job.rs new file mode 100644 index 00000000..fdd4dc3c --- /dev/null +++ b/crates/nvisy-server/src/service/assistant/job.rs @@ -0,0 +1,21 @@ +//! The assistant-reply job payload and its work-queue alias. + +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +/// A queued assistant reply: enough to re-load the conversation and post the +/// reply. The worker re-reads the thread's comments fresh from these ids (rather +/// than carrying the conversation on the wire), so the reply reflects the thread +/// as it stands when the worker runs. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AssistantJob { + /// Workspace the thread belongs to. + pub workspace_id: Uuid, + /// Thread the assistant was addressed in. + pub thread_id: Uuid, + /// The comment that addressed the assistant (the triggering message). + pub comment_id: Uuid, +} + +/// The assistant work-queue, pinned to the [`AssistantJob`] payload. +pub type AssistantStream = nvisy_nats::stream::AssistantStream; diff --git a/crates/nvisy-server/src/service/assistant/mod.rs b/crates/nvisy-server/src/service/assistant/mod.rs new file mode 100644 index 00000000..2f690597 --- /dev/null +++ b/crates/nvisy-server/src/service/assistant/mod.rs @@ -0,0 +1,22 @@ +//! The assistant pipeline: answering comment-thread mentions of the AI assistant +//! with a background, at-least-once job queue. +//! +//! When a user addresses the assistant in a comment thread, the comment handler +//! enqueues a reply job (transactionally with the comment) via [`AssistantQueue`]. +//! The [`AssistantOutboxDrainer`] relays pending jobs to the assistant NATS +//! work-queue, and the [`AssistantWorker`] consumes them: it reads the thread's +//! conversation, runs the workspace's language model, and posts the reply as a +//! comment authored by the reserved assistant account. The [`AssistantCoordinator`] +//! wakes the drainer the moment a job commits. + +mod coordinator; +mod drainer; +mod job; +mod service; +mod worker; + +pub use coordinator::AssistantCoordinator; +pub use drainer::AssistantOutboxDrainer; +pub use job::AssistantJob; +pub use service::AssistantQueue; +pub use worker::AssistantWorker; diff --git a/crates/nvisy-server/src/service/assistant/service.rs b/crates/nvisy-server/src/service/assistant/service.rs new file mode 100644 index 00000000..70bd10af --- /dev/null +++ b/crates/nvisy-server/src/service/assistant/service.rs @@ -0,0 +1,45 @@ +//! Assistant enqueue service. +//! +//! The request-side counterpart to the [`AssistantWorker`](super::AssistantWorker): +//! publishes an assistant-reply job to the `AssistantStream` work-queue. Injected +//! into the comment handler so the handler stays thin and the NATS wiring lives in +//! one place. + +use super::coordinator::AssistantCoordinator; +use super::job::{AssistantJob, AssistantStream}; +use crate::response::Result; +use crate::service::Infra; + +/// Enqueues assistant-reply jobs onto the work-queue. +/// +/// Cheaply cloneable (holds the shared [`Infra`] clients and the +/// [`AssistantCoordinator`], all `Arc`-backed). +#[derive(Clone)] +#[must_use = "service does nothing unless you enqueue with it"] +pub struct AssistantQueue { + infra: Infra, + coordinator: AssistantCoordinator, +} + +impl AssistantQueue { + /// Creates a new [`AssistantQueue`]. + pub fn new(infra: Infra, coordinator: AssistantCoordinator) -> Self { + Self { infra, coordinator } + } + + /// Enqueues an assistant reply onto the work-queue for the worker to pick up. + pub async fn enqueue(&self, job: AssistantJob) -> Result<()> { + let publisher = self.infra.nats.event_publisher::().await?; + publisher.publish(&job).await?; + Ok(()) + } + + /// Wakes the assistant-job outbox drainer so a just-committed job is drained + /// immediately instead of waiting for the drainer's next timer tick. Call after + /// the transaction that inserted the job row has committed. Best-effort and + /// coalescing: the drainer's timer still covers a missed wake (a crash between + /// commit and this call, or a job that landed on another instance). + pub fn wake_drainer(&self) { + self.coordinator.wake(); + } +} diff --git a/crates/nvisy-server/src/service/assistant/worker.rs b/crates/nvisy-server/src/service/assistant/worker.rs new file mode 100644 index 00000000..ddc7fa35 --- /dev/null +++ b/crates/nvisy-server/src/service/assistant/worker.rs @@ -0,0 +1,434 @@ +//! Assistant reply worker. +//! +//! Consumes [`AssistantJob`]s from the `AssistantStream` work-queue and, in the +//! background, answers a comment that addressed the assistant: it reads the +//! thread's conversation, runs the workspace's language model over it, and posts +//! the reply as a comment authored by the reserved assistant account. That +//! comment flows through the normal comment-created event, so the thread's +//! timeline and mention notifications need no special handling here. + +use std::sync::Arc; +use std::time::Duration; + +use nvisy_inference::{ChatTurn, InferenceClient, InferenceConfig}; +use nvisy_postgres::model::{NewWorkspaceThreadComment, WorkspaceThread, WorkspaceThreadComment}; +use nvisy_postgres::query::{ + EventOutboxRepository, WorkspaceProviderRepository, WorkspaceThreadCommentRepository, + WorkspaceThreadRepository, +}; +use nvisy_postgres::types::ProviderType; +use nvisy_postgres::{ASSISTANT_ACCOUNT_ID, AsyncConnection, PgConn}; +use tokio::sync::Semaphore; +use tokio::task::JoinSet; +use tokio_util::sync::CancellationToken; +use uuid::Uuid; + +use super::job::{AssistantJob, AssistantStream}; +use crate::extract::SecurityContext; +use crate::response::{Error, ErrorKind, Result}; +use crate::service::{ + EventOrigin, Infra, ProviderConfig, ThreadCommentCreated, Worker, WorkspaceEvent, + event_outbox_row, +}; + +/// Tracing target for assistant worker operations. +const TRACING_TARGET: &str = "nvisy_server::worker::assistant"; + +/// The system prompt that frames the assistant. Conversation-only: it does not +/// yet receive the pinned document's contents (a later enhancement), so it must +/// not claim to have read the file. +const PREAMBLE: &str = "You are the assistant for a document redaction platform. \ + You are replying inside a comment thread where a user has mentioned you. \ + Help the user understand and operate their workspace: redaction policies, \ + detections, pipelines, and the discussion in this thread. You do not have \ + access to the contents of any document. Be concise and accurate."; + +/// Fallback concurrency when the runtime cannot report available parallelism. +const DEFAULT_ASSISTANT_CONCURRENCY: usize = 4; + +/// Background worker that answers assistant mentions off the request thread. +/// +/// Cheaply cloneable (every field is `Arc`-backed); a clone is handed to each +/// spawned per-job task so jobs run concurrently against the shared services. +#[derive(Clone)] +pub struct AssistantWorker { + infra: Infra, + /// Bounds how many assistant turns run at once. Inference is I/O-bound on the + /// model provider, but the bound keeps a burst of mentions from opening an + /// unbounded number of concurrent provider requests. + concurrency: Arc, +} + +impl Worker for AssistantWorker { + type Output = Result<()>; + + fn name(&self) -> &'static str { + "assistant" + } + + async fn run(&self, cancel: CancellationToken) -> Result<()> { + tracing::info!(target: TRACING_TARGET, "Starting assistant worker"); + + let result = self.run_inner(cancel).await; + + match &result { + Ok(()) => tracing::info!(target: TRACING_TARGET, "Assistant worker stopped"), + Err(err) => { + tracing::error!(target: TRACING_TARGET, error = %err, "Assistant worker failed") + } + } + + result + } +} + +impl AssistantWorker { + /// Creates a new `AssistantWorker`. + pub fn new(infra: Infra) -> Self { + let concurrency = std::thread::available_parallelism() + .map(|n| n.get()) + .unwrap_or(DEFAULT_ASSISTANT_CONCURRENCY); + Self { + infra, + concurrency: Arc::new(Semaphore::new(concurrency)), + } + } + + /// Consumes assistant jobs until cancelled. + /// + /// At-least-once: a job is acked once it reaches a terminal outcome (a reply + /// posted, or nothing left to do), and nacked for redelivery on a transient + /// error (a DB/pool blip). Redelivery is idempotent because a job whose reply + /// already landed is detected and skipped before a second reply is posted. + async fn run_inner(&self, cancel: CancellationToken) -> Result<()> { + let subscriber = self + .infra + .nats + .event_subscriber::() + .await?; + let mut stream = subscriber.subscribe().await?; + + // In-flight per-job tasks are owned here rather than detached, so shutdown + // can wait for them to settle their message (ack/nack). `JoinSet` also reaps + // finished tasks so the set does not grow unbounded. + let mut tasks: JoinSet<()> = JoinSet::new(); + + loop { + // Acquire a permit before pulling the next job so no more than + // `concurrency` turns are ever in flight; the pull, and thus the + // stream's redelivery lease, does not advance while every slot is busy. + let permit = tokio::select! { + _ = cancel.cancelled() => { + tracing::info!(target: TRACING_TARGET, "Assistant worker shutdown requested"); + break; + } + Some(_) = tasks.join_next() => continue, + permit = self.concurrency.clone().acquire_owned() => match permit { + Ok(permit) => permit, + Err(_) => break, + }, + }; + + tokio::select! { + _ = cancel.cancelled() => { + tracing::info!(target: TRACING_TARGET, "Assistant worker shutdown requested"); + break; + } + result = stream.next_with_timeout(Duration::from_secs(5)) => { + match result { + Ok(Some(mut message)) => { + let job = message.payload().clone(); + let worker = self.clone(); + tasks.spawn(async move { + let _permit = permit; + let outcome = worker.run_job(job).await; + let ack_result = match outcome { + JobOutcome::Done => message.ack().await, + JobOutcome::Retry => message.nack().await, + }; + if let Err(err) = ack_result { + tracing::error!(target: TRACING_TARGET, error = %err, ?outcome, "Failed to ack/nack assistant job"); + } + }); + } + Ok(None) => drop(permit), + Err(err) => { + drop(permit); + tracing::error!(target: TRACING_TARGET, error = %err, "Error receiving assistant job"); + tokio::time::sleep(Duration::from_secs(1)).await; + } + } + } + } + } + + // Shutdown: stop pulling and let in-flight turns finish so each settles its + // message. The app-wide shutdown timeout bounds this; a turn still running + // when it fires is aborted and its message redelivered, which the reply + // dedup makes idempotent. + if !tasks.is_empty() { + tracing::info!( + target: TRACING_TARGET, + in_flight = tasks.len(), + "Draining in-flight assistant jobs before stopping", + ); + while tasks.join_next().await.is_some() {} + } + Ok(()) + } + + /// Runs one assistant job: reads the thread, runs the model, posts the reply. + /// + /// Returns [`JobOutcome::Retry`] only for transient errors (no connection, a + /// failed load or persist), so the reply is eventually posted. Terminal + /// conditions — a missing thread/comment, a reply already posted, or no model + /// provider configured — return [`JobOutcome::Done`]: retrying would not help. + #[tracing::instrument(skip_all, fields(thread_id = %job.thread_id, comment_id = %job.comment_id, workspace_id = %job.workspace_id))] + async fn run_job(&self, job: AssistantJob) -> JobOutcome { + let mut conn = match self.infra.postgres.get_connection().await { + Ok(conn) => conn, + Err(err) => { + tracing::error!(target: TRACING_TARGET, error = %err, "Failed to get connection for assistant job"); + return JobOutcome::Retry; + } + }; + + match self.reply(&mut conn, &job).await { + Ok(()) => JobOutcome::Done, + Err(ReplyError::Transient(err)) => { + tracing::error!(target: TRACING_TARGET, error = %err, "Assistant reply failed transiently; will retry"); + JobOutcome::Retry + } + Err(ReplyError::Terminal(reason)) => { + tracing::warn!(target: TRACING_TARGET, %reason, "Assistant reply not posted; dropping job"); + JobOutcome::Done + } + } + } + + /// Loads the conversation, runs the model, and posts the reply. Distinguishes + /// transient failures (worth a redelivery) from terminal ones (drop the job). + async fn reply( + &self, + conn: &mut PgConn, + job: &AssistantJob, + ) -> std::result::Result<(), ReplyError> { + // The thread must still exist and be live. + let thread = conn + .find_thread_in_workspace(job.workspace_id, job.thread_id) + .await + .map_err(ReplyError::transient)? + .ok_or_else(|| ReplyError::terminal("thread no longer exists"))?; + + // Read the conversation oldest-first. + let comments = conn + .list_thread_comments(job.workspace_id, job.thread_id) + .await + .map_err(ReplyError::transient)?; + + // Idempotency: if the assistant has already replied to (i.e. after) the + // triggering comment, this is a redelivery — do not post a second reply. + if already_replied(&comments, job.comment_id) { + return Err(ReplyError::terminal("assistant already replied")); + } + + // Resolve the workspace's language-model client. A workspace with no model + // provider configured is a terminal condition — retrying will not conjure + // one — so drop the job rather than redeliver forever. + let client = self + .resolve_client(conn, job.workspace_id) + .await + .map_err(|_| ReplyError::terminal("no language model provider configured"))?; + + // Build the turn: prior comments become history, the triggering comment is + // the prompt. Skip the triggering comment in the history so it is not + // duplicated as both history and prompt. + let mut history = Vec::with_capacity(comments.len() + 1); + history.push(ChatTurn::system(PREAMBLE)); + let mut prompt = String::new(); + for row in &comments { + if row.item.id == job.comment_id { + prompt = row.item.body.clone(); + continue; + } + history.push(turn_for(&row.item)); + } + if prompt.is_empty() { + // The triggering comment vanished (deleted) between enqueue and now. + return Err(ReplyError::terminal("triggering comment no longer exists")); + } + + let answer = client + .chat(&prompt, history) + .await + .map_err(|err| ReplyError::terminal(format!("inference failed: {err}")))?; + let answer = answer.trim(); + if answer.is_empty() { + return Err(ReplyError::terminal("model returned an empty reply")); + } + + // Post the reply. The database's partial unique index on the triggering + // comment is the airtight guard: if a live reply already exists (a + // redelivered job that raced past the `already_replied` pre-check), the + // insert is rejected and nothing is posted. + let posted = self + .post_reply(conn, &thread, job.comment_id, answer) + .await + .map_err(ReplyError::transient)?; + if !posted { + return Err(ReplyError::terminal("assistant already replied")); + } + Ok(()) + } + + /// Resolves the workspace's language-model client from its configured LLM + /// provider. Errors if none is configured or the client cannot be built. + async fn resolve_client( + &self, + conn: &mut PgConn, + workspace_id: Uuid, + ) -> Result { + let provider = conn + .find_provider_by_type(workspace_id, ProviderType::Llm) + .await? + .ok_or_else(|| { + ErrorKind::Conflict + .with_message("This workspace has no language model provider configured") + .with_resource("provider") + })?; + + let config: ProviderConfig = self + .infra + .crypto + .decrypt_json(workspace_id, &provider.encrypted_data)?; + + let llm = match config { + ProviderConfig::Inference(InferenceConfig::Llm(llm)) => llm, + }; + + llm.connect(None).map_err(|err| { + ErrorKind::InternalServerError + .with_message("Failed to build the language model client") + .with_context(err.to_string()) + }) + } + + /// Posts the assistant's reply as a comment answering `trigger_comment_id`, + /// recording its comment-created event in the same transaction so the row and + /// its event commit together. Authored by the reserved assistant account. + /// + /// Returns `false` (posting nothing) when a live reply to `trigger_comment_id` + /// already exists — the database's partial unique index rejects the second + /// insert, making a redelivered job a no-op. + async fn post_reply( + &self, + conn: &mut PgConn, + thread: &WorkspaceThread, + trigger_comment_id: Uuid, + body: &str, + ) -> Result { + conn.transaction(async |conn| { + let Some(comment) = conn + .create_reply(NewWorkspaceThreadComment { + workspace_id: thread.workspace_id, + thread_id: thread.id, + author_account_id: ASSISTANT_ACCOUNT_ID, + parent_id: Some(trigger_comment_id), + body: body.to_owned(), + }) + .await? + else { + // A reply to this comment already exists; do not post again. + return Ok(false); + }; + + // The assistant is the author, so there are no mentions to notify and + // no @assistant self-trigger (the enqueue path only fires for a human + // author addressing the assistant). + let event = WorkspaceEvent::ThreadCommentCreated(ThreadCommentCreated { + comment_id: comment.id, + thread_id: thread.id, + file_id: thread.file_id, + author_username: nvisy_postgres::ASSISTANT_HANDLE.parse().map_err(|_| { + ErrorKind::InternalServerError.with_message("Invalid assistant handle") + })?, + mentioned: Vec::new(), + }); + let row = event_outbox_row( + EventOrigin { + workspace_id: thread.workspace_id, + account_id: ASSISTANT_ACCOUNT_ID, + security: &SecurityContext::default(), + }, + &event, + )?; + conn.insert_event_outbox(row).await?; + Ok::<_, Error>(true) + }) + .await + } +} + +/// Whether the assistant has already posted a comment at or after `comment_id` +/// (the triggering message) in this thread — the redelivery-dedup check. +/// +/// `comments` is oldest-first, so once the triggering comment is seen, any +/// later assistant-authored comment is a reply the worker already produced. +fn already_replied( + comments: &[nvisy_postgres::types::WithAccountRef], + comment_id: Uuid, +) -> bool { + let mut seen_trigger = false; + for row in comments { + if row.item.id == comment_id { + seen_trigger = true; + continue; + } + if seen_trigger && row.item.author_account_id == ASSISTANT_ACCOUNT_ID { + return true; + } + } + false +} + +/// Maps one stored comment to a chat turn: the assistant's own messages are the +/// assistant role, everyone else's are the user role. +fn turn_for(comment: &WorkspaceThreadComment) -> ChatTurn { + if comment.author_account_id == ASSISTANT_ACCOUNT_ID { + ChatTurn::assistant(comment.body.clone()) + } else { + ChatTurn::user(comment.body.clone()) + } +} + +/// Whether a consumed assistant job should be acked (done) or nacked (retry). +#[derive(Debug, Clone, Copy)] +enum JobOutcome { + /// Reached a terminal outcome or is safe to drop; ack the message. + Done, + /// Transient error; nack for redelivery. + Retry, +} + +/// Why an assistant reply did not complete: a transient error (redeliver) or a +/// terminal condition (drop the job). +enum ReplyError { + /// A transient failure (no connection, failed load or persist) — redeliver. + Transient(Error<'static>), + /// A terminal condition — retrying would not help, so drop the job. Carries a + /// short reason (borrowed for the fixed cases, owned for a formatted one). + Terminal(std::borrow::Cow<'static, str>), +} + +impl ReplyError { + /// Wraps a transient underlying error (any error convertible into the server + /// error, e.g. a `nvisy_postgres::Error` from a repository call). + fn transient(err: impl Into>) -> Self { + ReplyError::Transient(err.into()) + } + + /// A terminal condition with a reason (a static string or an owned message). + fn terminal(reason: impl Into>) -> Self { + ReplyError::Terminal(reason.into()) + } +} diff --git a/crates/nvisy-server/src/service/chat.rs b/crates/nvisy-server/src/service/chat.rs deleted file mode 100644 index 6bf5db82..00000000 --- a/crates/nvisy-server/src/service/chat.rs +++ /dev/null @@ -1,191 +0,0 @@ -//! Assistant chat service. -//! -//! [`ChatService`] resolves a workspace's language-model provider into an -//! [`InferenceClient`], persists a session's messages (encrypting their content -//! under the workspace key), and drives a streaming chat turn against the -//! session's history. - -use nvisy_inference::{ChatTurn, InferenceClient, InferenceConfig, TokenStream}; -use nvisy_postgres::PgConn; -use nvisy_postgres::model::{ChatMessage, NewChatMessage}; -use nvisy_postgres::query::{ - AppendSessionUpdate, ChatMessageRepository, WorkspaceProviderRepository, -}; -use nvisy_postgres::types::{ChatRole, ProviderType}; -use uuid::Uuid; - -use crate::response::{ErrorKind, Result}; -use crate::service::{Infra, ProviderConfig}; - -/// Where in a conversation a turn happens: the workspace and session it belongs -/// to, and the message it extends (its parent in the tree; `None` starts a new -/// root). -#[derive(Debug, Clone, Copy)] -pub struct TurnLocation { - /// Workspace owning the session (and its encryption key + model connection). - pub workspace_id: Uuid, - /// Session the turn belongs to. - pub session_id: Uuid, - /// The message this turn replies to; `None` is a root. - pub parent_id: Option, -} - -/// The assistant's system preamble. Kept deliberately narrow: this is a plain -/// chat assistant with no access to document contents (a hard constraint on a -/// redaction platform). -const PREAMBLE: &str = "You are the assistant for a document redaction platform. \ - Help the user understand and operate their workspace: redaction policies, \ - detections, and pipelines. You do not have access to the contents of any \ - document. Be concise and accurate."; - -/// Resolves a workspace's inference backend and drives streaming chat turns. -/// -/// Cloneable and cheap to pass around: holds the shared [`Infra`] clients (all -/// `Arc`-backed) and takes the per-request database connection as a method -/// argument. -#[derive(Clone)] -#[must_use = "service does nothing unless you use it"] -pub struct ChatService { - infra: Infra, -} - -impl ChatService { - /// Creates a new [`ChatService`]. - pub fn new(infra: Infra) -> Self { - Self { infra } - } - - /// Resolves the workspace's language-model connection into an inference - /// client. - /// - /// Errors when the workspace has no language-model connection configured - /// (`409 Conflict`), or when its stored config is not an inference config or - /// cannot build a client (`500`). - async fn resolve_client( - &self, - conn: &mut PgConn, - workspace_id: Uuid, - ) -> Result { - let provider = conn - .find_provider_by_type(workspace_id, ProviderType::Llm) - .await? - .ok_or_else(|| { - ErrorKind::Conflict - .with_message("This workspace has no language model provider configured") - .with_resource("provider") - })?; - - let config: ProviderConfig = self - .infra - .crypto - .decrypt_json(workspace_id, &provider.encrypted_data)?; - - // The lookup filtered to the LLM provider type, so the stored config must - // be an LLM; any other kind is a stored-data inconsistency. - let llm = match config { - ProviderConfig::Inference(InferenceConfig::Llm(llm)) => llm, - }; - - llm.connect(None).map_err(|err| { - ErrorKind::InternalServerError - .with_message("Failed to build the language model client") - .with_context(err.to_string()) - }) - } - - /// Streams the assistant's reply to `prompt`, using the conversation path - /// ending at `parent_id` as context. - /// - /// Loads the session's messages, walks the path (root → `parent_id`), - /// decrypts it, and streams the model's reply. Resolving the model connection - /// happens first, so a missing connection fails before the caller persists - /// anything. Returns a [`TokenStream`] of text deltas. - pub async fn stream_turn( - &self, - conn: &mut PgConn, - at: TurnLocation, - prompt: &str, - ) -> Result { - let client = self.resolve_client(conn, at.workspace_id).await?; - let messages = conn.list_chat_messages(at.session_id).await?; - let path = ChatMessage::path_to(&messages, at.parent_id); - let history = self.to_history(at.workspace_id, &path)?; - Ok(client.stream_chat(prompt, history)) - } - - /// Appends a message at `at` in the session's tree — encrypting its content - /// under the workspace key — and applies `session_update` (advance the active - /// leaf, set the title) in the same transaction, so a message and the session - /// state it implies never diverge. Returns the stored row. - pub async fn append_message( - &self, - conn: &mut PgConn, - at: TurnLocation, - role: ChatRole, - text: &str, - session_update: AppendSessionUpdate, - ) -> Result { - let content = self - .infra - .crypto - .encrypt(at.workspace_id, text.as_bytes())?; - Ok(conn - .append_chat_message( - NewChatMessage { - session_id: at.session_id, - parent_id: at.parent_id, - role, - content, - }, - session_update, - ) - .await?) - } - - /// Persists the assistant's assembled reply at `at`, advancing the session's - /// active leaf to it in the same transaction. - /// - /// Acquires its own connection: it runs after the stream completes, when the - /// request connection has already been released back to the pool. - pub async fn persist_reply(&self, at: TurnLocation, reply: &str) -> Result<()> { - let mut conn = self.infra.postgres.get_connection().await?; - self.append_message( - &mut conn, - at, - ChatRole::Assistant, - reply, - AppendSessionUpdate { - advance_leaf: true, - ..Default::default() - }, - ) - .await?; - Ok(()) - } - - /// Decrypts a stored message's content under the workspace key. - pub fn decrypt_content(&self, workspace_id: Uuid, message: &ChatMessage) -> Result { - let bytes = self.infra.crypto.decrypt(workspace_id, &message.content)?; - String::from_utf8(bytes).map_err(|err| { - ErrorKind::InternalServerError - .with_message("Stored chat message is not valid UTF-8") - .with_context(err.to_string()) - }) - } - - /// Builds the chat history from a decrypted path, preceded by the assistant - /// preamble as a system instruction. - fn to_history(&self, workspace_id: Uuid, path: &[&ChatMessage]) -> Result> { - let mut history = Vec::with_capacity(path.len() + 1); - history.push(ChatTurn::system(PREAMBLE)); - for message in path { - let content = self.decrypt_content(workspace_id, message)?; - history.push(match message.role { - ChatRole::System => ChatTurn::system(content), - ChatRole::User => ChatTurn::user(content), - ChatRole::Assistant => ChatTurn::assistant(content), - }); - } - Ok(history) - } -} diff --git a/crates/nvisy-server/src/service/event/mod.rs b/crates/nvisy-server/src/service/event/mod.rs index 68a54793..1b5bcb6d 100644 --- a/crates/nvisy-server/src/service/event/mod.rs +++ b/crates/nvisy-server/src/service/event/mod.rs @@ -22,13 +22,14 @@ 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::{ - AssignmentStatusChanged, CommentCreated, CommentDeleted, CommentResolved, 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, + 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, ThreadAnchorAdded, ThreadAnchorRemoved, ThreadClosed, ThreadCommentCreated, + ThreadDeleted, ThreadOpened, ThreadRenamed, ThreadReopened, WebhookCreated, WebhookDeleted, WebhookUpdated, WorkspaceCreated, WorkspaceDeleted, WorkspaceEvent, WorkspaceUpdated, }; diff --git a/crates/nvisy-server/src/service/event/workspace_event.rs b/crates/nvisy-server/src/service/event/workspace_event.rs index 2388ee6a..052455f4 100644 --- a/crates/nvisy-server/src/service/event/workspace_event.rs +++ b/crates/nvisy-server/src/service/event/workspace_event.rs @@ -12,14 +12,15 @@ //! change. use nvisy_postgres::types::{ - ActivityPayload, AssignmentActivityParams, AssignmentStatus, CommentActivityParams, - CommentMentionedParams, ConnectionActivityParams, ConnectionId, ConnectionSyncCompletedParams, + ActivityPayload, AssignmentActivityParams, AssignmentStatus, CommentMentionedParams, + 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, + ProviderId, RedactionActivityParams, RedactionCreatedParams, RedactionId, ThreadActivityParams, + ThreadAnchorActivityParams, ThreadCommentActivityParams, WebhookActivityParams, WebhookEvent, + WebhookId, WorkspaceActivityParams, WorkspaceRole, }; use serde::{Deserialize, Serialize}; use uuid::Uuid; @@ -78,9 +79,14 @@ workspace_events! { PolicyUpdated => "policy.updated", PolicyDeleted => "policy.deleted", - CommentCreated => "comment.created", - CommentResolved => "comment.resolved", - CommentDeleted => "comment.deleted", + ThreadOpened => "thread.opened", + ThreadClosed => "thread.closed", + ThreadReopened => "thread.reopened", + ThreadRenamed => "thread.renamed", + ThreadDeleted => "thread.deleted", + ThreadAnchorAdded => "thread.anchor.added", + ThreadAnchorRemoved => "thread.anchor.removed", + ThreadCommentCreated => "thread.comment.created", } /// The webhook body for a file event: just the file's display name. @@ -791,47 +797,52 @@ fn policy_activity(policy_id: Uuid, policy_slug: &Handle) -> PolicyActivityParam } } -/// A comment was created on a file. Notifies each mentioned account (never the -/// author, even if they @-mention themselves). +/// A thread was opened with its first message. Feeds activity + webhook, +/// and notifies each account mentioned in the opening body (never the author, +/// even if they @-mention themselves). #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct CommentCreated { - pub comment_id: Uuid, - pub file_id: Uuid, - /// Username of the comment's author, shown in the mention notification. +pub struct ThreadOpened { + pub thread_id: Uuid, + /// Id of the thread's opening comment, referenced by mention notifications. + pub opening_comment_id: Uuid, + pub file_id: Option, + /// Username of the thread's opener, shown in the mention notification. pub author_username: Handle, - /// Accounts mentioned in the comment body, to notify. Empty when none. + /// Accounts mentioned in the opening body, to notify. Empty when none. pub mentioned: Vec, } -impl EventKind for CommentCreated { - const TAG: &'static str = "comment.created"; +impl EventKind for ThreadOpened { + const TAG: &'static str = "thread.opened"; fn resource_id(&self) -> Uuid { - self.comment_id + self.thread_id } fn activity(&self) -> ActivityPayload { - ActivityPayload::CommentCreated(CommentActivityParams { - comment_id: self.comment_id, + ActivityPayload::ThreadOpened(ThreadActivityParams { + thread_id: self.thread_id, file_id: self.file_id, }) } fn webhook(&self) -> Option { Some(WebhookDelivery { - event: WebhookEvent::CommentCreated, + event: WebhookEvent::ThreadOpened, body: None, }) } fn notification(self) -> Vec { - // One "you were mentioned" notification per mentioned account. + // One "you were mentioned" notification per mentioned account. The + // opening message is part of the thread; its mentions notify here. self.mentioned .into_iter() .map(|recipient| Notification { target: NotifyTarget::Account(recipient), payload: NotificationPayload::CommentMentioned(CommentMentionedParams { - comment_id: self.comment_id, + comment_id: self.opening_comment_id, + thread_id: self.thread_id, file_id: self.file_id, author_username: self.author_username.clone(), }), @@ -840,53 +851,93 @@ impl EventKind for CommentCreated { } } -/// A comment thread was resolved. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct CommentResolved { - pub comment_id: Uuid, - pub file_id: Uuid, +// Thread close / reopen / rename: activity + webhook, no notification or extra +// fields. +crud_events! { + fields { thread_id: Uuid, file_id: Option } + id = thread_id; + activity(this) = ThreadActivityParams { thread_id: this.thread_id, file_id: this.file_id }; + webhook = yes; + + /// A thread was closed. + ThreadClosed => "thread.closed", + /// A thread was reopened. + ThreadReopened => "thread.reopened", + /// A thread's title was changed. + ThreadRenamed => "thread.renamed", } -impl EventKind for CommentResolved { - const TAG: &'static str = "comment.resolved"; +// Thread deletion: activity only, no webhook. +crud_events! { + fields { thread_id: Uuid, file_id: Option } + id = thread_id; + activity(this) = ThreadActivityParams { thread_id: this.thread_id, file_id: this.file_id }; + webhook = no; - fn resource_id(&self) -> Uuid { - self.comment_id - } + /// A thread was deleted. + ThreadDeleted => "thread.deleted", +} - fn activity(&self) -> ActivityPayload { - ActivityPayload::CommentResolved(CommentActivityParams { - comment_id: self.comment_id, - file_id: self.file_id, - }) - } +// Thread anchor add / remove: activity + webhook, keyed on the anchor. No +// notification. +crud_events! { + fields { thread_id: Uuid, anchor_id: Uuid, file_id: Option } + id = anchor_id; + activity(this) = ThreadAnchorActivityParams { + thread_id: this.thread_id, + anchor_id: this.anchor_id, + file_id: this.file_id, + }; + webhook = yes; - fn webhook(&self) -> Option { - Some(WebhookDelivery { - event: WebhookEvent::CommentResolved, - body: None, - }) - } + /// An anchor was added to a thread. + ThreadAnchorAdded => "thread.anchor.added", + /// An anchor was removed from a thread. + ThreadAnchorRemoved => "thread.anchor.removed", } -/// A comment was deleted. Recorded in the activity log only (no webhook). +/// A comment (message) was posted in a thread. Notifies each mentioned account +/// (never the author, even if they @-mention themselves); activity only, no +/// webhook (thread lifecycle carries the webhook signal). #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct CommentDeleted { +pub struct ThreadCommentCreated { pub comment_id: Uuid, - pub file_id: Uuid, + pub thread_id: Uuid, + pub file_id: Option, + /// Username of the comment's author, shown in the mention notification. + pub author_username: Handle, + /// Accounts mentioned in the comment body, to notify. Empty when none. + pub mentioned: Vec, } -impl EventKind for CommentDeleted { - const TAG: &'static str = "comment.deleted"; +impl EventKind for ThreadCommentCreated { + const TAG: &'static str = "thread.comment.created"; fn resource_id(&self) -> Uuid { self.comment_id } fn activity(&self) -> ActivityPayload { - ActivityPayload::CommentDeleted(CommentActivityParams { + ActivityPayload::ThreadCommentCreated(ThreadCommentActivityParams { comment_id: self.comment_id, + thread_id: self.thread_id, file_id: self.file_id, }) } + + fn notification(self) -> Vec { + // One "you were mentioned" notification per mentioned account. + self.mentioned + .into_iter() + .map(|recipient| Notification { + target: NotifyTarget::Account(recipient), + payload: NotificationPayload::CommentMentioned(CommentMentionedParams { + comment_id: self.comment_id, + thread_id: self.thread_id, + file_id: self.file_id, + author_username: self.author_username.clone(), + }), + }) + .collect() + } } diff --git a/crates/nvisy-server/src/service/mod.rs b/crates/nvisy-server/src/service/mod.rs index 9a5718d5..28c00c99 100644 --- a/crates/nvisy-server/src/service/mod.rs +++ b/crates/nvisy-server/src/service/mod.rs @@ -1,9 +1,9 @@ //! Application state and dependency injection. mod account_provisioner; +mod assistant; mod auth_issuer; mod avatar; -mod chat; mod crypto; mod detection; mod engine; @@ -37,9 +37,11 @@ use tokio_util::sync::CancellationToken; use crate::middleware::UploadConfig; use crate::response::CookieConfig; pub use crate::service::account_provisioner::AccountProvisioner; +pub use crate::service::assistant::{ + AssistantCoordinator, AssistantJob, AssistantOutboxDrainer, AssistantQueue, AssistantWorker, +}; pub use crate::service::auth_issuer::AuthIssuer; pub use crate::service::avatar::{AVATAR_CONTENT_TYPE, AvatarService, MAX_AVATAR_UPLOAD_BYTES}; -pub use crate::service::chat::{ChatService, TurnLocation}; pub use crate::service::crypto::{CryptoConfig, CryptoService}; pub(crate) use crate::service::crypto::{CryptoError, HashingReader, LimitedReader, Measurements}; pub(crate) use crate::service::detection::resolve_policies; @@ -49,14 +51,15 @@ pub use crate::service::detection::{ }; pub use crate::service::engine::{EngineConfig, EngineService, UnknownFormatToken}; pub use crate::service::event::{ - AssignmentStatusChanged, CommentCreated, CommentDeleted, CommentResolved, 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, + 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, ThreadAnchorAdded, ThreadAnchorRemoved, ThreadClosed, ThreadCommentCreated, + ThreadDeleted, ThreadOpened, ThreadRenamed, ThreadReopened, WebhookCreated, WebhookDeleted, WebhookDelivery, WebhookUpdated, WorkspaceCreated, WorkspaceDeleted, WorkspaceEvent, WorkspaceUpdated, event_outbox_row, }; @@ -120,6 +123,10 @@ pub struct ServiceState { // drainer, shared by the per-request `DetectionQueue` and the drainer. pub detection: DetectionCoordinator, + // In-process wake signal from the assistant enqueue path to its outbox + // drainer, shared by the per-request `AssistantQueue` and the drainer. + pub assistant: AssistantCoordinator, + // Operational: the app-wide shutdown signal (cancelled once on Ctrl+C/SIGTERM // so long-lived handlers and background workers wind down promptly) and the // cached health snapshot. @@ -212,6 +219,7 @@ impl ServiceState { endpoint_policy, engine, detection: DetectionCoordinator::new(), + assistant: AssistantCoordinator::new(), shutdown: CancellationToken::new(), health_cache: HealthCache::new(&health_config, health_checkers), password: PasswordService::new(), @@ -259,6 +267,11 @@ impl ServiceState { RunBlobStore::from_ref(self), DetectionQueue::from_ref(self), )); + workers.spawn(AssistantOutboxDrainer::new( + self.infra.clone(), + self.assistant.clone(), + )); + workers.spawn(AssistantWorker::new(self.infra.clone())); workers } } @@ -363,6 +376,7 @@ impl_di_field!( endpoint_policy: EndpointPolicy, engine: EngineService, detection: DetectionCoordinator, + assistant: AssistantCoordinator, shutdown: CancellationToken, health_cache: HealthCache, password: PasswordService, @@ -376,7 +390,6 @@ impl_di_field!( // Stateless services, composed from `Infra` on extraction: impl_di_compose!( AvatarService => AvatarService::new, - ChatService => ChatService::new, RunBlobStore => RunBlobStore::new, WebhookEmitter => WebhookEmitter::new, NotificationEmitter => NotificationEmitter::new, @@ -391,6 +404,14 @@ impl axum::extract::FromRef for DetectionQueue { } } +// `AssistantQueue` likewise composes from `Infra` and the shared +// `AssistantCoordinator`, so it needs a hand-written `FromRef`. +impl axum::extract::FromRef for AssistantQueue { + fn from_ref(state: &ServiceState) -> Self { + AssistantQueue::new(state.infra.clone(), state.assistant.clone()) + } +} + // `ExternalObjectStore` holds only the deployment's endpoint policy: impl axum::extract::FromRef for ExternalObjectStore { fn from_ref(state: &ServiceState) -> Self { diff --git a/migrations/2026-08-19-034709_chat/down.sql b/migrations/2026-08-19-034709_chat/down.sql deleted file mode 100644 index 7fd9dd58..00000000 --- a/migrations/2026-08-19-034709_chat/down.sql +++ /dev/null @@ -1,9 +0,0 @@ --- Revert the chat feature. --- Objects are dropped in reverse order of creation. - --- chat_messages and chat_sessions reference each other; dropping both in one --- statement resolves the cross constraint without CASCADE (which could silently --- remove unexpected external dependents). -DROP TABLE IF EXISTS chat_messages, chat_sessions; - -DROP TYPE IF EXISTS CHAT_ROLE; diff --git a/migrations/2026-08-19-034709_chat/up.sql b/migrations/2026-08-19-034709_chat/up.sql deleted file mode 100644 index d95f1cb8..00000000 --- a/migrations/2026-08-19-034709_chat/up.sql +++ /dev/null @@ -1,114 +0,0 @@ --- Chat: workspace-scoped assistant conversations. A standalone workspace --- resource. Each session is a thread of messages; the assistant's replies are --- produced by the workspace's inference connection. - --- Role of a chat message: who authored it. -CREATE TYPE CHAT_ROLE AS ENUM ( - 'system', -- System instruction (server-authored context) - 'user', -- A message from the account - 'assistant' -- A reply from the model -); - -COMMENT ON TYPE CHAT_ROLE IS 'Author of a chat message: system, user, or assistant.'; - --- Chat sessions table: one conversation thread within a workspace. -CREATE TABLE chat_sessions ( - -- Primary identifier - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - - -- References - workspace_id UUID NOT NULL REFERENCES workspaces (id) ON DELETE CASCADE, - account_id UUID NOT NULL REFERENCES accounts (id) ON DELETE CASCADE, - - -- Human-readable title, seeded from the first message (editable). - title TEXT NOT NULL DEFAULT 'New chat', - CONSTRAINT chat_sessions_title_length CHECK (length(trim(title)) BETWEEN 1 AND 255), - - -- The active leaf of the message tree: the message this conversation - -- currently ends at. A client resumes from here, and a new turn without an - -- explicit parent extends this. The FK is added after chat_messages exists. - current_message_id UUID DEFAULT NULL, - - -- Lifecycle timestamps - created_at TIMESTAMPTZ NOT NULL DEFAULT current_timestamp, - updated_at TIMESTAMPTZ NOT NULL DEFAULT current_timestamp, - deleted_at TIMESTAMPTZ DEFAULT NULL, - CONSTRAINT chat_sessions_updated_after_created CHECK (updated_at >= created_at), - CONSTRAINT chat_sessions_deleted_after_created CHECK (deleted_at IS NULL OR deleted_at >= created_at), - CONSTRAINT chat_sessions_deleted_after_updated CHECK (deleted_at IS NULL OR deleted_at >= updated_at) -); - --- Most recent live sessions per workspace (the session list). -CREATE INDEX chat_sessions_workspace_recent_idx - ON chat_sessions (workspace_id, updated_at DESC) - WHERE deleted_at IS NULL; - -COMMENT ON TABLE chat_sessions IS 'Workspace-scoped assistant conversation threads.'; -COMMENT ON COLUMN chat_sessions.id IS 'Unique session identifier'; -COMMENT ON COLUMN chat_sessions.workspace_id IS 'Workspace this session belongs to'; -COMMENT ON COLUMN chat_sessions.account_id IS 'Account that opened the session'; -COMMENT ON COLUMN chat_sessions.title IS 'Human-readable title (seeded from the first message)'; -COMMENT ON COLUMN chat_sessions.current_message_id IS 'Active leaf of the message tree (resume point)'; -COMMENT ON COLUMN chat_sessions.created_at IS 'Session creation timestamp'; -COMMENT ON COLUMN chat_sessions.updated_at IS 'Timestamp of the most recent message'; -COMMENT ON COLUMN chat_sessions.deleted_at IS 'Soft-deletion timestamp; NULL means live'; - --- Chat messages table: the conversation tree of a session. -CREATE TABLE chat_messages ( - -- Primary identifier - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - - -- References - session_id UUID NOT NULL REFERENCES chat_sessions (id) ON DELETE CASCADE, - - -- Composite key target: lets the tree/leaf foreign keys pin a message to a - -- specific session, so a parent (or a session's active leaf) can never point - -- at a message from another session. - CONSTRAINT chat_messages_id_session_key UNIQUE (id, session_id), - - -- The message this one replies to (its parent in the conversation tree). - -- NULL is a root. A regenerated reply is a sibling: another child of the same - -- parent. The active conversation is the path from a leaf back to the root. - -- The composite FK enforces that a parent is in the same session. - parent_id UUID DEFAULT NULL, - CONSTRAINT chat_messages_parent_fkey - FOREIGN KEY (parent_id, session_id) - REFERENCES chat_messages (id, session_id) ON DELETE CASCADE, - - -- Message details. The content is stored XChaCha20-Poly1305 encrypted with - -- the workspace-derived key (a user may paste sensitive text into the - -- assistant), so it is opaque bytes rather than searchable text. - role CHAT_ROLE NOT NULL, - content BYTEA NOT NULL, - CONSTRAINT chat_messages_content_size CHECK (length(content) BETWEEN 1 AND 131072), - - -- Lifecycle timestamp - created_at TIMESTAMPTZ NOT NULL DEFAULT current_timestamp -); - --- All of a session's messages (the whole tree; the path is walked in-app). -CREATE INDEX chat_messages_session_idx - ON chat_messages (session_id); - --- Walk a node's children (sibling branches), and back the parent composite FK. -CREATE INDEX chat_messages_parent_idx - ON chat_messages (parent_id, session_id) - WHERE parent_id IS NOT NULL; - --- The active-leaf pointer references a message in THIS session: the composite FK --- ties the session's own id to the referenced message's session_id. Added now --- that chat_messages exists. A deleted leaf clears the pointer rather than --- cascading the session. -ALTER TABLE chat_sessions - ADD CONSTRAINT chat_sessions_current_message_fkey - FOREIGN KEY (current_message_id, id) - REFERENCES chat_messages (id, session_id) - ON DELETE SET NULL (current_message_id); - -COMMENT ON TABLE chat_messages IS 'Messages of a chat session, as a conversation tree.'; -COMMENT ON COLUMN chat_messages.id IS 'Unique message identifier'; -COMMENT ON COLUMN chat_messages.session_id IS 'Session this message belongs to'; -COMMENT ON COLUMN chat_messages.parent_id IS 'Parent in the conversation tree; NULL is a root'; -COMMENT ON COLUMN chat_messages.role IS 'Author of the message (system, user, or assistant)'; -COMMENT ON COLUMN chat_messages.content IS 'XChaCha20-Poly1305 encrypted message text'; -COMMENT ON COLUMN chat_messages.created_at IS 'Message creation timestamp'; diff --git a/migrations/2026-09-11-040235_comments/down.sql b/migrations/2026-09-11-040235_comments/down.sql deleted file mode 100644 index 3053a3d3..00000000 --- a/migrations/2026-09-11-040235_comments/down.sql +++ /dev/null @@ -1,9 +0,0 @@ --- Revert the comments table. --- Objects are dropped in reverse order of creation. - -DROP TABLE IF EXISTS workspace_comments; - --- The comment.* 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-11-040235_comments/up.sql b/migrations/2026-09-11-040235_comments/up.sql deleted file mode 100644 index 35878dc6..00000000 --- a/migrations/2026-09-11-040235_comments/up.sql +++ /dev/null @@ -1,113 +0,0 @@ --- Comments: threaded discussion on a file under review. A comment is authored by --- a workspace member, optionally anchored to a location within the file (a page --- region, a time range, a text span — the modality-tagged anchor), optionally a --- reply to another comment (one level), and can be resolved to close a thread. - --- Comments table: one comment on a file. -CREATE TABLE workspace_comments ( - -- 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 "comments across the workspace" query is a - -- single indexed scan with no join to workspace_files. - workspace_id UUID NOT NULL REFERENCES workspaces (id) ON DELETE CASCADE, - file_id UUID NOT NULL, - - -- The comment's author. If their account is removed, their comments go with it. - author_account_id UUID NOT NULL REFERENCES accounts (id) ON DELETE CASCADE, - - -- A reply's parent, for one-level threads. NULL for a top-level comment. A - -- reply is removed with its parent (CASCADE), so a resolved/deleted thread - -- takes its replies. A parent is itself always top-level (enforced in the - -- repository), so threads never nest deeper than one level. - parent_id UUID DEFAULT NULL REFERENCES workspace_comments (id) ON DELETE CASCADE, - - -- The comment text. - body TEXT NOT NULL, - CONSTRAINT workspace_comments_body_length CHECK (length(trim(body)) BETWEEN 1 AND 10000), - - -- Optional location within the file the comment is pinned to, as a - -- modality-tagged anchor (page region, time range, text span, or table cell). - -- NULL for a file-level comment with no pin. Stored as the anchor's typed JSON. - anchor JSONB DEFAULT NULL, - CONSTRAINT workspace_comments_anchor_size CHECK (anchor IS NULL OR length(anchor::TEXT) <= 8192), - - -- Resolution: a resolved thread is closed. `resolved_at IS NULL` means open; - -- a timestamp means resolved, and `resolved_by` records who resolved it (kept - -- for the audit trail; SET NULL if that account is removed). Only a top-level - -- comment is resolvable (a reply inherits its thread's state). - resolved_at TIMESTAMPTZ DEFAULT NULL, - resolved_by UUID DEFAULT NULL REFERENCES accounts (id) ON DELETE SET NULL, - CONSTRAINT workspace_comments_resolved_consistent CHECK ( - (resolved_at IS NULL) = (resolved_by IS NULL) - ), - - -- Lifecycle timestamps - created_at TIMESTAMPTZ NOT NULL DEFAULT current_timestamp, - updated_at TIMESTAMPTZ NOT NULL DEFAULT current_timestamp, - deleted_at TIMESTAMPTZ DEFAULT NULL, - CONSTRAINT workspace_comments_updated_after_created CHECK (updated_at >= created_at), - CONSTRAINT workspace_comments_deleted_after_created CHECK (deleted_at IS NULL OR deleted_at >= created_at), - CONSTRAINT workspace_comments_resolved_after_created CHECK (resolved_at IS NULL OR resolved_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 comment on a file from another workspace cannot be - -- stored. Removing the file cascades its comments away. - CONSTRAINT workspace_comments_file_fkey FOREIGN KEY (workspace_id, file_id) - REFERENCES workspace_files (workspace_id, id) ON DELETE CASCADE -); - --- A file's comment thread, oldest first (a discussion reads top to bottom). -CREATE INDEX workspace_comments_file_idx - ON workspace_comments (file_id, created_at) - WHERE deleted_at IS NULL; - --- A thread's replies, oldest first. -CREATE INDEX workspace_comments_parent_idx - ON workspace_comments (parent_id, created_at) - WHERE parent_id IS NOT NULL AND deleted_at IS NULL; - --- Workspace-scoped listing, newest first. -CREATE INDEX workspace_comments_workspace_idx - ON workspace_comments (workspace_id, created_at DESC) - WHERE deleted_at IS NULL; - --- An author's comments, newest first. -CREATE INDEX workspace_comments_author_idx - ON workspace_comments (author_account_id, created_at DESC) - WHERE deleted_at IS NULL; - --- Auto-maintain updated_at on writes (soft-delete column present). -SELECT setup_updated_at('workspace_comments'); - -COMMENT ON TABLE workspace_comments IS 'Threaded comments on a file under review, optionally anchored to a location.'; -COMMENT ON COLUMN workspace_comments.id IS 'Unique comment identifier'; -COMMENT ON COLUMN workspace_comments.workspace_id IS 'Denormalized workspace scope for fast per-workspace comment queries'; -COMMENT ON COLUMN workspace_comments.file_id IS 'File the comment is on'; -COMMENT ON COLUMN workspace_comments.author_account_id IS 'Account that wrote the comment'; -COMMENT ON COLUMN workspace_comments.parent_id IS 'Parent comment for a one-level reply; NULL for a top-level comment'; -COMMENT ON COLUMN workspace_comments.body IS 'Comment text (1-10000 chars)'; -COMMENT ON COLUMN workspace_comments.anchor IS 'Optional modality-tagged location within the file the comment is pinned to; NULL for a file-level comment'; -COMMENT ON COLUMN workspace_comments.resolved_at IS 'When the thread was resolved; NULL means open'; -COMMENT ON COLUMN workspace_comments.resolved_by IS 'Account that resolved the thread, for the audit trail; null if that account was removed'; -COMMENT ON COLUMN workspace_comments.created_at IS 'When the comment was created'; -COMMENT ON COLUMN workspace_comments.updated_at IS 'When the comment was last updated'; -COMMENT ON COLUMN workspace_comments.deleted_at IS 'Soft-deletion timestamp; NULL means live'; - --- Comment lifecycle events feed the event sinks, each value added by this --- migration (the migration that introduces comments). ALTER TYPE ... ADD VALUE --- only adds labels here (no rows use them yet), so it stays transactional. --- --- Activity log records the full lifecycle. -ALTER TYPE ACTIVITY_TYPE ADD VALUE IF NOT EXISTS 'comment.created'; -ALTER TYPE ACTIVITY_TYPE ADD VALUE IF NOT EXISTS 'comment.resolved'; -ALTER TYPE ACTIVITY_TYPE ADD VALUE IF NOT EXISTS 'comment.deleted'; - --- Webhooks carry creation and resolution (deletion is internal). -ALTER TYPE WEBHOOK_EVENT ADD VALUE IF NOT EXISTS 'comment.created'; -ALTER TYPE WEBHOOK_EVENT ADD VALUE IF NOT EXISTS 'comment.resolved'; - --- In-app notifications go to each mentioned account. -ALTER TYPE NOTIFICATION_EVENT ADD VALUE IF NOT EXISTS 'comment.mentioned'; diff --git a/migrations/2026-09-11-040235_threads/down.sql b/migrations/2026-09-11-040235_threads/down.sql new file mode 100644 index 00000000..33573459 --- /dev/null +++ b/migrations/2026-09-11-040235_threads/down.sql @@ -0,0 +1,14 @@ +-- Revert the thread tables and the thread-event-kind type. +-- Objects are dropped in reverse order of creation (children reference threads). + +DROP TABLE IF EXISTS workspace_thread_events; +DROP TABLE IF EXISTS workspace_thread_comments; +DROP TABLE IF EXISTS workspace_thread_anchors; +DROP TABLE IF EXISTS workspace_threads; + +DROP TYPE IF EXISTS THREAD_EVENT_KIND; + +-- The thread.* / comment.* 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-11-040235_threads/up.sql b/migrations/2026-09-11-040235_threads/up.sql new file mode 100644 index 00000000..f884309e --- /dev/null +++ b/migrations/2026-09-11-040235_threads/up.sql @@ -0,0 +1,249 @@ +-- Threads: threaded discussion on a file under review, modeled after GitHub +-- issues. A thread is the closable, optionally file-anchored unit; its stream +-- interleaves comments (messages) and events (opened/closed/reopened, anchor +-- added or removed). A thread carries zero or more anchors — pins to locations +-- within its file — added and removed over its lifetime. Opening, closing, +-- reopening, and anchor changes are recorded both as in-thread timeline events +-- and as workspace events (activity log + webhooks). + +-- Kind of a thread timeline event (a non-message entry in a thread's stream). +CREATE TYPE THREAD_EVENT_KIND AS ENUM ( + 'thread.opened', -- The thread was opened + 'thread.closed', -- The thread was closed + 'thread.reopened', -- The thread was reopened + 'thread.renamed', -- The thread's display name was changed + 'thread.anchor.added', -- An anchor (location pin) was added to the thread + 'thread.anchor.removed' -- An anchor was removed from the thread +); + +COMMENT ON TYPE THREAD_EVENT_KIND IS 'The kind of a non-message entry in a thread timeline: opened, closed, reopened, renamed, or an anchor added/removed.'; + +-- Threads: the closable, optionally file-anchored unit of discussion. +CREATE TABLE workspace_threads ( + -- Primary identifier + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- References. A thread belongs to a workspace and is optionally about one of + -- its files: `file_id` NULL is a workspace-level discussion, a set `file_id` + -- pins it to that file. Anchors (locations within the file) live in + -- workspace_thread_anchors, since a thread may carry several. + workspace_id UUID NOT NULL REFERENCES workspaces (id) ON DELETE CASCADE, + file_id UUID DEFAULT NULL, + + -- The account that opened the thread. If it is removed, the thread goes too. + author_account_id UUID NOT NULL REFERENCES accounts (id) ON DELETE CASCADE, + + -- Optional human-readable title (like a GitHub issue title); NULL for an + -- untitled thread. Renaming it records a thread.renamed timeline event. + display_name TEXT DEFAULT NULL, + CONSTRAINT workspace_threads_display_name_length CHECK (display_name IS NULL OR length(trim(display_name)) BETWEEN 1 AND 255), + + -- Lifecycle state (open/closed). `closed_at IS NULL` means open; a timestamp + -- means closed, and `closed_by` records who closed it (kept for the audit + -- trail; SET NULL if that account is removed). This is the current state; the + -- per-transition history lives in workspace_thread_events. + closed_at TIMESTAMPTZ DEFAULT NULL, + closed_by UUID DEFAULT NULL REFERENCES accounts (id) ON DELETE SET NULL, + CONSTRAINT workspace_threads_closed_consistent CHECK ( + (closed_at IS NULL) = (closed_by IS NULL) + ), + + -- Lifecycle timestamps + created_at TIMESTAMPTZ NOT NULL DEFAULT current_timestamp, + updated_at TIMESTAMPTZ NOT NULL DEFAULT current_timestamp, + deleted_at TIMESTAMPTZ DEFAULT NULL, + CONSTRAINT workspace_threads_updated_after_created CHECK (updated_at >= created_at), + CONSTRAINT workspace_threads_deleted_after_created CHECK (deleted_at IS NULL OR deleted_at >= created_at), + CONSTRAINT workspace_threads_closed_after_created CHECK (closed_at IS NULL OR closed_at >= created_at), + + -- When a file is set, it is referenced with its workspace against + -- workspace_files (workspace_id, id), so the denormalized workspace_id must + -- match the file's own — a thread on a file from another workspace cannot be + -- stored, and removing the file cascades its threads away. With the default + -- MATCH SIMPLE, a NULL file_id skips this check, so a workspace-level thread + -- (no file) is allowed. + CONSTRAINT workspace_threads_file_fkey FOREIGN KEY (workspace_id, file_id) + REFERENCES workspace_files (workspace_id, id) ON DELETE CASCADE +); + +-- Thread anchors: locations within a thread's file the thread is pinned to. A +-- thread may have several, added and removed over its lifetime; removal is a soft +-- delete so the timeline's anchor.removed event keeps its referent. +CREATE TABLE workspace_thread_anchors ( + -- Primary identifier + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- References. The thread this anchor pins; deleting the thread removes it. + thread_id UUID NOT NULL REFERENCES workspace_threads (id) ON DELETE CASCADE, + + -- The location, as a modality-tagged anchor (page region, time range, text + -- span, or table cell). Stored as the anchor's typed JSON. + anchor JSONB NOT NULL, + CONSTRAINT workspace_thread_anchors_size CHECK (length(anchor::TEXT) <= 8192), + + -- Lifecycle timestamps. Removal is a soft delete (`deleted_at` set). + created_at TIMESTAMPTZ NOT NULL DEFAULT current_timestamp, + deleted_at TIMESTAMPTZ DEFAULT NULL, + CONSTRAINT workspace_thread_anchors_deleted_after_created CHECK (deleted_at IS NULL OR deleted_at >= created_at) +); + +-- Thread comments: one message within a thread. +CREATE TABLE workspace_thread_comments ( + -- Primary identifier + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- The comment this one answers, when it is a reply: for an assistant reply, + -- the message that addressed the assistant; NULL for an ordinary message. A + -- unique index below allows at most one live reply per parent, so a + -- redelivered assistant job cannot post a second reply. + parent_id UUID DEFAULT NULL REFERENCES workspace_thread_comments (id) ON DELETE SET NULL, + + -- References. Denormalized workspace scope for fast per-workspace queries, and + -- the thread this message belongs to; deleting the thread removes its comments + -- (CASCADE), so closing/deleting a discussion takes its messages. + workspace_id UUID NOT NULL REFERENCES workspaces (id) ON DELETE CASCADE, + thread_id UUID NOT NULL REFERENCES workspace_threads (id) ON DELETE CASCADE, + + -- The message author. If their account is removed, their comments go with it. + author_account_id UUID NOT NULL REFERENCES accounts (id) ON DELETE CASCADE, + + -- The message text. + body TEXT NOT NULL, + CONSTRAINT workspace_thread_comments_body_length CHECK (length(trim(body)) BETWEEN 1 AND 10000), + + -- Lifecycle timestamps + created_at TIMESTAMPTZ NOT NULL DEFAULT current_timestamp, + updated_at TIMESTAMPTZ NOT NULL DEFAULT current_timestamp, + deleted_at TIMESTAMPTZ DEFAULT NULL, + CONSTRAINT workspace_thread_comments_updated_after_created CHECK (updated_at >= created_at), + CONSTRAINT workspace_thread_comments_deleted_after_created CHECK (deleted_at IS NULL OR deleted_at >= created_at) +); + +-- Thread timeline events: the non-message entries in a thread's stream (opened, +-- closed, reopened, anchor added/removed). Immutable — an event is a fact that +-- happened, so there is no update or soft-delete; deleting the thread removes them. +CREATE TABLE workspace_thread_events ( + -- Primary identifier + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- References. Denormalized workspace scope (matching the sibling tables) and + -- the thread this event belongs to. + workspace_id UUID NOT NULL REFERENCES workspaces (id) ON DELETE CASCADE, + thread_id UUID NOT NULL REFERENCES workspace_threads (id) ON DELETE CASCADE, + + -- What happened. + kind THREAD_EVENT_KIND NOT NULL, + + -- Who did it. If their account is removed, keep the event but forget the actor + -- (the transition still happened). + actor_account_id UUID DEFAULT NULL REFERENCES accounts (id) ON DELETE SET NULL, + + -- Event-specific detail, when any: for an anchor event, a snapshot of the + -- anchor (its id and the anchor JSON), so the timeline renders it without the + -- anchor row (which may since have been removed). NULL for open/close/reopen. + target JSONB DEFAULT NULL, + CONSTRAINT workspace_thread_events_target_size CHECK (target IS NULL OR length(target::TEXT) <= 8192), + + -- When it happened (events are immutable, so only a creation timestamp). + created_at TIMESTAMPTZ NOT NULL DEFAULT current_timestamp +); + +-- A file's threads, newest first (the thread list on a document); only +-- file-pinned threads, so workspace-level threads do not bloat the index. +CREATE INDEX workspace_threads_file_idx + ON workspace_threads (file_id, created_at DESC) + WHERE file_id IS NOT NULL AND deleted_at IS NULL; + +-- Workspace-scoped thread listing, newest first, filterable by open/closed. +CREATE INDEX workspace_threads_workspace_idx + ON workspace_threads (workspace_id, created_at DESC) + WHERE deleted_at IS NULL; + +-- A thread's live anchors, oldest first. +CREATE INDEX workspace_thread_anchors_thread_idx + ON workspace_thread_anchors (thread_id, created_at) + WHERE deleted_at IS NULL; + +-- A thread's messages, oldest first (a discussion reads top to bottom). +CREATE INDEX workspace_thread_comments_thread_idx + ON workspace_thread_comments (thread_id, created_at) + WHERE deleted_at IS NULL; + +-- At most one live reply per parent comment: a redelivered assistant job +-- re-inserting its reply hits this and is treated as already answered, so the +-- assistant never double-replies to one mention. +CREATE UNIQUE INDEX workspace_thread_comments_parent_unique_idx + ON workspace_thread_comments (parent_id) + WHERE parent_id IS NOT NULL AND deleted_at IS NULL; + +-- A thread's timeline events, oldest first (merged with comments by the reader). +CREATE INDEX workspace_thread_events_thread_idx + ON workspace_thread_events (thread_id, created_at); + +-- Auto-maintain updated_at on writes (the tables that have the column). +SELECT setup_updated_at('workspace_threads'); +SELECT setup_updated_at('workspace_thread_comments'); + +COMMENT ON TABLE workspace_threads IS 'A closable, optionally file-anchored discussion thread on a file.'; +COMMENT ON COLUMN workspace_threads.id IS 'Unique thread identifier'; +COMMENT ON COLUMN workspace_threads.workspace_id IS 'Denormalized workspace scope for fast per-workspace thread queries'; +COMMENT ON COLUMN workspace_threads.file_id IS 'File the thread is pinned to; NULL for a workspace-level thread'; +COMMENT ON COLUMN workspace_threads.author_account_id IS 'Account that opened the thread'; +COMMENT ON COLUMN workspace_threads.display_name IS 'Optional human-readable title; NULL for an untitled thread (1-255 chars)'; +COMMENT ON COLUMN workspace_threads.closed_at IS 'When the thread was closed; NULL means open'; +COMMENT ON COLUMN workspace_threads.closed_by IS 'Account that closed the thread; null if open or that account was removed'; +COMMENT ON COLUMN workspace_threads.created_at IS 'Timestamp when the thread was opened'; +COMMENT ON COLUMN workspace_threads.updated_at IS 'Timestamp of the last update'; +COMMENT ON COLUMN workspace_threads.deleted_at IS 'Soft-deletion timestamp; NULL means live'; + +COMMENT ON TABLE workspace_thread_anchors IS 'A location within a thread''s file the thread is pinned to; a thread may have several.'; +COMMENT ON COLUMN workspace_thread_anchors.id IS 'Unique anchor identifier'; +COMMENT ON COLUMN workspace_thread_anchors.thread_id IS 'Thread this anchor pins'; +COMMENT ON COLUMN workspace_thread_anchors.anchor IS 'Modality-tagged location (page region, time range, text span, table cell), as typed JSON'; +COMMENT ON COLUMN workspace_thread_anchors.created_at IS 'Timestamp when the anchor was added'; +COMMENT ON COLUMN workspace_thread_anchors.deleted_at IS 'Soft-removal timestamp; NULL means live'; + +COMMENT ON TABLE workspace_thread_comments IS 'One message within a thread.'; +COMMENT ON COLUMN workspace_thread_comments.id IS 'Unique comment identifier'; +COMMENT ON COLUMN workspace_thread_comments.parent_id IS 'For a reply, the comment it answers; NULL otherwise (one live reply per parent)'; +COMMENT ON COLUMN workspace_thread_comments.workspace_id IS 'Denormalized workspace scope'; +COMMENT ON COLUMN workspace_thread_comments.thread_id IS 'Thread this message belongs to'; +COMMENT ON COLUMN workspace_thread_comments.author_account_id IS 'Account that wrote the message'; +COMMENT ON COLUMN workspace_thread_comments.body IS 'Message text (1-10000 chars)'; +COMMENT ON COLUMN workspace_thread_comments.created_at IS 'Timestamp when the comment was posted'; +COMMENT ON COLUMN workspace_thread_comments.updated_at IS 'Timestamp of the last edit'; +COMMENT ON COLUMN workspace_thread_comments.deleted_at IS 'Soft-deletion timestamp; NULL means live'; + +COMMENT ON TABLE workspace_thread_events IS 'An immutable non-message entry in a thread timeline: opened, closed, reopened, renamed, or anchor added/removed.'; +COMMENT ON COLUMN workspace_thread_events.id IS 'Unique event identifier'; +COMMENT ON COLUMN workspace_thread_events.workspace_id IS 'Denormalized workspace scope'; +COMMENT ON COLUMN workspace_thread_events.thread_id IS 'Thread this event belongs to'; +COMMENT ON COLUMN workspace_thread_events.kind IS 'What happened (thread.opened, thread.closed, thread.reopened, thread.renamed, thread.anchor.added, thread.anchor.removed)'; +COMMENT ON COLUMN workspace_thread_events.actor_account_id IS 'Account that performed the action; null if that account was removed'; +COMMENT ON COLUMN workspace_thread_events.target IS 'Event-specific detail (an anchor snapshot for anchor events, the new name for a rename); NULL for open/close/reopen'; +COMMENT ON COLUMN workspace_thread_events.created_at IS 'Timestamp when the event happened'; + +-- Thread lifecycle events feed the event sinks; each value is added by this +-- migration (the one that introduces threads). ALTER TYPE only adds labels here +-- (no rows use them yet), so it stays transactional. +-- +-- Activity log records the thread lifecycle plus each message posted. +ALTER TYPE ACTIVITY_TYPE ADD VALUE IF NOT EXISTS 'thread.opened'; +ALTER TYPE ACTIVITY_TYPE ADD VALUE IF NOT EXISTS 'thread.closed'; +ALTER TYPE ACTIVITY_TYPE ADD VALUE IF NOT EXISTS 'thread.reopened'; +ALTER TYPE ACTIVITY_TYPE ADD VALUE IF NOT EXISTS 'thread.renamed'; +ALTER TYPE ACTIVITY_TYPE ADD VALUE IF NOT EXISTS 'thread.deleted'; +ALTER TYPE ACTIVITY_TYPE ADD VALUE IF NOT EXISTS 'thread.anchor.added'; +ALTER TYPE ACTIVITY_TYPE ADD VALUE IF NOT EXISTS 'thread.anchor.removed'; +ALTER TYPE ACTIVITY_TYPE ADD VALUE IF NOT EXISTS 'thread.comment.created'; + +-- Webhooks carry the thread lifecycle (message-level noise is left off). +ALTER TYPE WEBHOOK_EVENT ADD VALUE IF NOT EXISTS 'thread.opened'; +ALTER TYPE WEBHOOK_EVENT ADD VALUE IF NOT EXISTS 'thread.closed'; +ALTER TYPE WEBHOOK_EVENT ADD VALUE IF NOT EXISTS 'thread.reopened'; +ALTER TYPE WEBHOOK_EVENT ADD VALUE IF NOT EXISTS 'thread.renamed'; +ALTER TYPE WEBHOOK_EVENT ADD VALUE IF NOT EXISTS 'thread.anchor.added'; +ALTER TYPE WEBHOOK_EVENT ADD VALUE IF NOT EXISTS 'thread.anchor.removed'; + +-- In-app notifications go to each mentioned account. +ALTER TYPE NOTIFICATION_EVENT ADD VALUE IF NOT EXISTS 'comment.mentioned'; diff --git a/migrations/2026-09-11-050000_assistant/down.sql b/migrations/2026-09-11-050000_assistant/down.sql new file mode 100644 index 00000000..f737dccd --- /dev/null +++ b/migrations/2026-09-11-050000_assistant/down.sql @@ -0,0 +1,8 @@ +-- Revert the assistant outbox table and the reserved assistant account. +-- Objects are dropped in reverse order of creation. + +DROP TABLE IF EXISTS workspace_assistant_jobs; + +-- The account's comments (and any threads it authored) cascade away with it +-- (author_account_id ... ON DELETE CASCADE). +DELETE FROM accounts WHERE id = '00000000-0000-0000-0000-000000000a11'; diff --git a/migrations/2026-09-11-050000_assistant/up.sql b/migrations/2026-09-11-050000_assistant/up.sql new file mode 100644 index 00000000..09a68985 --- /dev/null +++ b/migrations/2026-09-11-050000_assistant/up.sql @@ -0,0 +1,77 @@ +-- Assistant: the reserved AI assistant account and the transactional outbox that +-- queues its replies. A user addresses the assistant (@assistant) in a thread, +-- and a background worker posts the model's reply as a comment authored by this +-- account, so it is a real `accounts` row that the existing author foreign key, +-- account-reference resolution, and timeline rendering all handle unchanged. + +-- The reserved assistant account. It is not a person and not a workspace member: +-- it has no `account_identities` row, so no credential can authenticate as it and +-- it never uses the HTTP write path (the worker writes its comments +-- server-internally). Its id is a fixed, well-known constant (mirrored in the +-- Rust layer as ASSISTANT_ACCOUNT_ID) so code references it without a lookup. +INSERT INTO accounts (id, is_verified, username, display_name, email_address) +VALUES ( + '00000000-0000-0000-0000-000000000a11', + TRUE, + 'assistant', + 'Assistant', + 'assistant@system.nvisy.internal' +) +ON CONFLICT (id) DO NOTHING; + +-- Assistant-reply outbox: when a user posts a comment addressing the assistant, a +-- job row is inserted in the same transaction as the comment, then relayed by the +-- assistant drainer to the assistant NATS work-queue. A worker runs the model +-- over the thread's conversation and posts the reply. Mirrors the detection-job +-- outbox: at-least-once delivery, deduped by the worker. +CREATE TABLE workspace_assistant_jobs ( + -- Primary identifier + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- References. The comment that triggered this reply (the message addressing + -- the assistant); the row is deleted with its comment. + comment_id UUID NOT NULL REFERENCES workspace_thread_comments (id) ON DELETE CASCADE, + + -- The job: a serialized `AssistantJob` (the workspace, thread, and triggering + -- comment) the drainer publishes to the worker. + job JSONB NOT NULL, + CONSTRAINT workspace_assistant_jobs_job_size CHECK (length(job::TEXT) BETWEEN 2 AND 16384), + + -- Drainer bookkeeping: processing state, publish attempts, and the earliest + -- time the row may next be claimed (advanced by a backoff on each failed + -- attempt so a failing row does not spin at the head of the queue). + status OUTBOX_STATUS NOT NULL DEFAULT 'pending', + attempts INTEGER NOT NULL DEFAULT 0, + CONSTRAINT workspace_assistant_jobs_attempts_non_negative CHECK (attempts >= 0), + next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT current_timestamp, + + -- Lifecycle timestamps + created_at TIMESTAMPTZ NOT NULL DEFAULT current_timestamp, + resolved_at TIMESTAMPTZ DEFAULT NULL, + CONSTRAINT workspace_assistant_jobs_resolved_only_when_terminal + CHECK (resolved_at IS NULL OR status IN ('processed', 'failed')), + CONSTRAINT workspace_assistant_jobs_resolved_after_created + CHECK (resolved_at IS NULL OR resolved_at >= created_at) +); + +-- The drainer's claim queue: pending rows ordered by due time then age. Partial +-- so it stays small as processed and failed rows accumulate. +CREATE INDEX workspace_assistant_jobs_pending_idx + ON workspace_assistant_jobs (next_attempt_at, created_at) + WHERE status = 'pending'; + +-- Back the comment foreign key so a comment delete cascades without scanning the +-- whole outbox (Postgres does not index a referencing column automatically, and +-- the partial claim index above does not cover it). +CREATE INDEX workspace_assistant_jobs_comment_idx + ON workspace_assistant_jobs (comment_id); + +COMMENT ON TABLE workspace_assistant_jobs IS 'Transactional outbox of assistant-reply jobs, drained to the assistant NATS work-queue.'; +COMMENT ON COLUMN workspace_assistant_jobs.id IS 'Unique outbox row identifier'; +COMMENT ON COLUMN workspace_assistant_jobs.comment_id IS 'Comment that addressed the assistant and triggered this reply'; +COMMENT ON COLUMN workspace_assistant_jobs.job IS 'Serialized AssistantJob published to the worker (JSON, 2B-16KB)'; +COMMENT ON COLUMN workspace_assistant_jobs.status IS 'Processing state: pending, processed, or failed (dead-lettered)'; +COMMENT ON COLUMN workspace_assistant_jobs.attempts IS 'Number of publish attempts the drainer has made'; +COMMENT ON COLUMN workspace_assistant_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_assistant_jobs.created_at IS 'Timestamp when the job was queued'; +COMMENT ON COLUMN workspace_assistant_jobs.resolved_at IS 'When a terminal (processed or failed) row was resolved by an operator; NULL until then'; From 6421514f71bc9d42ff1b727620329df9f8150f19 Mon Sep 17 00:00:00 2001 From: Oleh Martsokha Date: Fri, 11 Sep 2026 10:47:55 +0200 Subject: [PATCH 3/5] Redesign pagination cursors, unify sort direction, refactor seed helpers Cursor pagination is now a generic `Cursor` typed per-query with a `keyset!` macro that applies the order + keyset predicate over any sort column, replacing the single untyped cursor. Each list builder declares its own key struct; the invite keyset now matches its sort column (Date vs Email), and the merged thread timeline paginates through a source-aware compound cursor so comments and events interleave in one stable order. `SortOrder` is unified with `Direction` into a single `Direction` (`Ascending`/`Descending`) living in `types/sorting`; the timeline endpoint is now paginated instead of unbounded. `Retention` defaults to `Ephemeral` (delete once processed) and its variants are renamed `Persistent`/`Ephemeral`/`Fixed { days }`; `is_noop` compares against an explicit keep-everything settings value rather than the default. The test seed helpers return named `SeededWorkspace`/`SeededPipeline` structs instead of anonymous tuples, and every call site accesses `seeded.*` fields directly. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_018bKk1YEG4tZ69jzYVQvQL8 --- .../src/query/account_api_token.rs | 78 ++-- .../src/query/account_notification.rs | 60 +-- crates/nvisy-postgres/src/query/analytics.rs | 67 +-- .../nvisy-postgres/src/query/event_outbox.rs | 12 +- crates/nvisy-postgres/src/query/mod.rs | 40 +- .../src/query/pipeline_reference.rs | 35 +- .../src/query/workspace_activity.rs | 102 +++-- .../src/query/workspace_assignment.rs | 121 +++--- .../src/query/workspace_assistant_job.rs | 4 +- .../src/query/workspace_connection.rs | 133 +++--- .../query/workspace_connection_schedule.rs | 7 +- .../src/query/workspace_connection_sync.rs | 140 +++--- .../src/query/workspace_detection.rs | 194 +++++---- .../src/query/workspace_detection_job.rs | 8 +- .../src/query/workspace_file.rs | 150 ++++--- .../src/query/workspace_invite.rs | 250 +++++++---- .../src/query/workspace_member.rs | 205 ++++----- .../src/query/workspace_pipeline.rs | 105 ++--- .../src/query/workspace_policy.rs | 94 +++-- .../src/query/workspace_provider.rs | 97 ++--- .../src/query/workspace_redaction.rs | 85 ++-- .../src/query/workspace_thread.rs | 249 ++++++++--- .../src/query/workspace_thread_comment.rs | 72 ++++ .../src/query/workspace_thread_event.rs | 128 ++++++ .../src/query/workspace_webhook.rs | 99 +++-- crates/nvisy-postgres/src/test_util.rs | 53 ++- .../src/types/json/pipeline_metadata.rs | 8 +- .../src/types/json/retention.rs | 67 +-- .../src/types/json/workspace_settings.rs | 4 +- crates/nvisy-postgres/src/types/mod.rs | 9 +- .../src/types/pagination/cursor.rs | 399 +++++++++--------- .../src/types/pagination/mod.rs | 3 +- .../nvisy-postgres/src/types/sorting/mod.rs | 20 +- crates/nvisy-server/src/handler/activities.rs | 2 +- .../nvisy-server/src/handler/assignments.rs | 2 +- .../src/handler/connection_syncs.rs | 4 +- .../nvisy-server/src/handler/connections.rs | 2 +- crates/nvisy-server/src/handler/detections.rs | 4 +- crates/nvisy-server/src/handler/files.rs | 2 +- crates/nvisy-server/src/handler/invites.rs | 2 +- crates/nvisy-server/src/handler/members.rs | 2 +- .../nvisy-server/src/handler/notifications.rs | 2 +- crates/nvisy-server/src/handler/pipelines.rs | 2 +- crates/nvisy-server/src/handler/policies.rs | 2 +- crates/nvisy-server/src/handler/providers.rs | 2 +- crates/nvisy-server/src/handler/redactions.rs | 2 +- .../src/handler/request/invites.rs | 4 +- .../src/handler/request/members.rs | 4 +- .../src/handler/request/paginations.rs | 17 +- .../src/handler/response/comments.rs | 28 +- crates/nvisy-server/src/handler/threads.rs | 50 ++- crates/nvisy-server/src/handler/tokens.rs | 2 +- crates/nvisy-server/src/handler/webhooks.rs | 2 +- crates/nvisy-server/src/handler/workspaces.rs | 5 +- .../src/middleware/specification.rs | 4 +- 55 files changed, 1925 insertions(+), 1319 deletions(-) diff --git a/crates/nvisy-postgres/src/query/account_api_token.rs b/crates/nvisy-postgres/src/query/account_api_token.rs index b9a07a8d..40a3f411 100644 --- a/crates/nvisy-postgres/src/query/account_api_token.rs +++ b/crates/nvisy-postgres/src/query/account_api_token.rs @@ -5,12 +5,24 @@ use std::time::Duration; use diesel::prelude::*; use diesel_async::RunQueryDsl; +use jiff::Timestamp; +use serde::{Deserialize, Serialize}; use uuid::Uuid; use crate::model::{AccountApiToken, NewAccountApiToken, UpdateAccountApiToken}; -use crate::types::{ApiTokenType, CursorPage, CursorPagination, session}; +use crate::types::{ApiTokenType, CursorPage, CursorPagination, keyset, session}; use crate::{Error, PgConnection, Result, schema}; +/// Keyset for paginating an account's API tokens: newest first by `issued_at`, +/// `id` as the tiebreaker. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ApiTokenCursor { + /// When the token was issued. + pub issued_at: Timestamp, + /// Token id (tiebreaker). + pub id: uuid::Uuid, +} + /// Repository for account API token database operations. /// /// Handles long-lived API tokens for programmatic access with support for @@ -110,7 +122,7 @@ pub trait AccountApiTokenRepository { fn cursor_list_account_api_tokens( &mut self, account_id: Uuid, - pagination: CursorPagination, + pagination: CursorPagination, ) -> impl Future>> + Send; /// Soft-deletes all expired account API tokens system-wide. @@ -358,20 +370,23 @@ impl AccountApiTokenRepository for PgConnection { async fn cursor_list_account_api_tokens( &mut self, account_id: Uuid, - pagination: CursorPagination, + pagination: CursorPagination, ) -> Result> { use diesel::dsl::{count_star, now}; use schema::account_api_tokens::{self, dsl}; - let base_filter = dsl::account_id - .eq(account_id) - .and(dsl::deleted_at.is_null()) - .and(dsl::expired_at.is_null().or(dsl::expired_at.gt(now))); + // One boxed base query reused for the count and the page. + let scoped = || { + account_api_tokens::table + .filter(dsl::account_id.eq(account_id)) + .filter(dsl::deleted_at.is_null()) + .filter(dsl::expired_at.is_null().or(dsl::expired_at.gt(now))) + .into_boxed() + }; let total = if pagination.include_count { Some( - account_api_tokens::table - .filter(base_filter) + scoped() .select(count_star()) .get_result(self) .await @@ -381,34 +396,27 @@ impl AccountApiTokenRepository for PgConnection { None }; - let items = if let Some(cursor) = &pagination.after { - let cursor_ts = jiff_diesel::Timestamp::from(cursor.timestamp); - account_api_tokens::table - .filter(base_filter) - .filter( - dsl::issued_at - .lt(cursor_ts) - .or(dsl::issued_at.eq(cursor_ts).and(dsl::id.lt(cursor.id))), - ) - .order((dsl::issued_at.desc(), dsl::id.desc())) - .limit(pagination.fetch_limit()) - .select(AccountApiToken::as_select()) - .load(self) - .await - .map_err(Error::from)? - } else { - account_api_tokens::table - .filter(base_filter) - .order((dsl::issued_at.desc(), dsl::id.desc())) - .limit(pagination.fetch_limit()) - .select(AccountApiToken::as_select()) - .load(self) - .await - .map_err(Error::from)? - }; + let after = pagination + .after_key() + .map(|k| (jiff_diesel::Timestamp::from(k.issued_at), k.id)); + let items = keyset!( + scoped(), + dsl::issued_at, + dsl::id, + pagination.direction, + after + ) + .select(AccountApiToken::as_select()) + .limit(pagination.fetch_limit()) + .load(self) + .await + .map_err(Error::from)?; Ok(CursorPage::new(items, total, pagination.limit, |t| { - (t.issued_at.into(), t.id) + ApiTokenCursor { + issued_at: t.issued_at.into(), + id: t.id, + } })) } diff --git a/crates/nvisy-postgres/src/query/account_notification.rs b/crates/nvisy-postgres/src/query/account_notification.rs index a16f069f..a3d52c1e 100644 --- a/crates/nvisy-postgres/src/query/account_notification.rs +++ b/crates/nvisy-postgres/src/query/account_notification.rs @@ -5,12 +5,23 @@ use std::future::Future; use diesel::prelude::*; use diesel_async::RunQueryDsl; use jiff::Timestamp; +use serde::{Deserialize, Serialize}; use uuid::Uuid; use crate::model::{AccountNotification, NewAccountNotification, UpdateAccountNotification}; -use crate::types::{CursorPage, CursorPagination}; +use crate::types::{CursorPage, CursorPagination, keyset}; use crate::{Error, PgConnection, Result, schema}; +/// Keyset for paginating an account's notifications: newest first by +/// `created_at`, `id` as the tiebreaker. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NotificationCursor { + /// When the notification was created. + pub created_at: Timestamp, + /// Notification id (tiebreaker). + pub id: uuid::Uuid, +} + /// Repository for account notification database operations. /// /// Handles user notifications including creation, delivery tracking, read status @@ -35,7 +46,7 @@ pub trait AccountNotificationRepository { fn cursor_list_account_notifications( &mut self, account_id: Uuid, - pagination: CursorPagination, + pagination: CursorPagination, ) -> impl Future>> + Send; /// Marks all unread account notifications as read. @@ -100,7 +111,7 @@ impl AccountNotificationRepository for PgConnection { async fn cursor_list_account_notifications( &mut self, acct_id: Uuid, - pagination: CursorPagination, + pagination: CursorPagination, ) -> Result> { use diesel::dsl::{count_star, now}; use schema::account_notifications::{self, dsl}; @@ -122,34 +133,25 @@ impl AccountNotificationRepository for PgConnection { None }; - let items = if let Some(cursor) = &pagination.after { - let cursor_ts = jiff_diesel::Timestamp::from(cursor.timestamp); - account_notifications::table - .filter(base_filter) - .filter( - dsl::created_at - .lt(cursor_ts) - .or(dsl::created_at.eq(cursor_ts).and(dsl::id.lt(cursor.id))), - ) - .order((dsl::created_at.desc(), dsl::id.desc())) - .limit(pagination.fetch_limit()) - .select(AccountNotification::as_select()) - .load(self) - .await - .map_err(Error::from)? - } else { - account_notifications::table - .filter(base_filter) - .order((dsl::created_at.desc(), dsl::id.desc())) - .limit(pagination.fetch_limit()) - .select(AccountNotification::as_select()) - .load(self) - .await - .map_err(Error::from)? - }; + let query = account_notifications::table + .filter(base_filter) + .into_boxed(); + + let after = pagination + .after_key() + .map(|k| (jiff_diesel::Timestamp::from(k.created_at), k.id)); + let items = keyset!(query, dsl::created_at, dsl::id, pagination.direction, after) + .limit(pagination.fetch_limit()) + .select(AccountNotification::as_select()) + .load(self) + .await + .map_err(Error::from)?; Ok(CursorPage::new(items, total, pagination.limit, |n| { - (n.created_at.into(), n.id) + NotificationCursor { + created_at: n.created_at.into(), + id: n.id, + } })) } diff --git a/crates/nvisy-postgres/src/query/analytics.rs b/crates/nvisy-postgres/src/query/analytics.rs index ca8a23f9..0dc49a8d 100644 --- a/crates/nvisy-postgres/src/query/analytics.rs +++ b/crates/nvisy-postgres/src/query/analytics.rs @@ -570,32 +570,35 @@ mod tests { #[tokio::test] async fn snapshot_aggregates_storage_detections_and_usage_by_group() -> anyhow::Result<()> { let db = TestDatabase::start().await; - let (account_id, workspace_id, pipeline_id, seed_file) = db.seed_pipeline_and_file().await; + let seeded = db.seed_pipeline_and_file().await; let mut conn = db.client.get_connection().await?; // Storage: the seeded original file plus a second original and a redacted. - let mut redacted = NewWorkspaceFile::test(workspace_id, account_id); + let mut redacted = NewWorkspaceFile::test(seeded.workspace_id, seeded.account_id); redacted.file_kind = Some(FileKind::Redacted); let _ = conn.create_workspace_file(redacted).await?; let _ = conn - .create_workspace_file(NewWorkspaceFile::test(workspace_id, account_id)) + .create_workspace_file(NewWorkspaceFile::test( + seeded.workspace_id, + seeded.account_id, + )) .await?; // Detections: one Complete (with a duration) and one Pending. let complete = completed_detection( &mut conn, - pipeline_id, - account_id, - seed_file, + seeded.pipeline_id, + seeded.account_id, + seeded.file_id, Span::new().hours(2), Span::new().seconds(10), ) .await?; let _pending = conn .create_workspace_detection(NewWorkspaceDetection::test( - pipeline_id, - account_id, - seed_file, + seeded.pipeline_id, + seeded.account_id, + seeded.file_id, )) .await?; @@ -606,7 +609,7 @@ mod tests { ]) .await?; - let snapshot = conn.snapshot(workspace_id).await?; + let snapshot = conn.snapshot(seeded.workspace_id).await?; // Storage: 2 original files + 1 redacted, grouped by kind. let original = snapshot @@ -654,60 +657,66 @@ mod tests { #[tokio::test] async fn snapshot_is_scoped_to_the_workspace() -> anyhow::Result<()> { let db = TestDatabase::start().await; - let (_a, other_workspace) = db.seed_account_and_workspace().await; + let seeded = db.seed_account_and_workspace().await; // A different workspace with a detection. - let (account_id, workspace_id, pipeline_id, seed_file) = db.seed_pipeline_and_file().await; + let other = db.seed_pipeline_and_file().await; let mut conn = db.client.get_connection().await?; let _ = conn .create_workspace_detection(NewWorkspaceDetection::test( - pipeline_id, - account_id, - seed_file, + other.pipeline_id, + other.account_id, + other.file_id, )) .await?; // The unrelated workspace sees none of it. - let snapshot = conn.snapshot(other_workspace).await?; + let snapshot = conn.snapshot(seeded.workspace_id).await?; assert!(snapshot.detections.is_empty()); assert!(snapshot.storage.is_empty()); assert!(snapshot.usage.is_empty()); // The owning workspace does. - assert!(!conn.snapshot(workspace_id).await?.detections.is_empty()); + assert!( + !conn + .snapshot(other.workspace_id) + .await? + .detections + .is_empty() + ); Ok(()) } #[tokio::test] async fn detections_by_day_buckets_within_the_window() -> anyhow::Result<()> { let db = TestDatabase::start().await; - let (account_id, workspace_id, pipeline_id, seed_file) = db.seed_pipeline_and_file().await; + let seeded = db.seed_pipeline_and_file().await; let mut conn = db.client.get_connection().await?; // Two detections started ~1 day ago (same UTC day), one ~3 days ago. let recent = completed_detection( &mut conn, - pipeline_id, - account_id, - seed_file, + seeded.pipeline_id, + seeded.account_id, + seeded.file_id, Span::new().hours(25), Span::new().seconds(5), ) .await?; let _recent2 = completed_detection( &mut conn, - pipeline_id, - account_id, - seed_file, + seeded.pipeline_id, + seeded.account_id, + seeded.file_id, Span::new().hours(26), Span::new().seconds(5), ) .await?; let _old = completed_detection( &mut conn, - pipeline_id, - account_id, - seed_file, + seeded.pipeline_id, + seeded.account_id, + seeded.file_id, Span::new().hours(72), Span::new().seconds(5), ) @@ -718,7 +727,9 @@ mod tests { // A window covering only the last two days excludes the 3-day-old one. let from = Timestamp::now() - Span::new().hours(48); let to = Timestamp::now() + Span::new().hours(1); - let points = conn.detections_by_day(workspace_id, from, to).await?; + let points = conn + .detections_by_day(seeded.workspace_id, from, to) + .await?; // One bucket (the two recent detections share a UTC day), 2 detections. let total: i64 = points.iter().map(|p| p.detections).sum(); diff --git a/crates/nvisy-postgres/src/query/event_outbox.rs b/crates/nvisy-postgres/src/query/event_outbox.rs index c7cc43cb..07280527 100644 --- a/crates/nvisy-postgres/src/query/event_outbox.rs +++ b/crates/nvisy-postgres/src/query/event_outbox.rs @@ -165,11 +165,11 @@ mod tests { #[tokio::test] async fn claim_then_process_removes_the_row_from_the_pending_set() -> anyhow::Result<()> { let db = TestDatabase::start().await; - let (account_id, workspace_id) = db.seed_account_and_workspace().await; + let seeded = db.seed_account_and_workspace().await; let mut conn = db.client.get_connection().await?; let row = conn - .insert_event_outbox(NewEventOutbox::test(workspace_id, account_id)) + .insert_event_outbox(NewEventOutbox::test(seeded.workspace_id, seeded.account_id)) .await?; // The drainer claims and processes in one transaction. @@ -195,11 +195,11 @@ mod tests { #[tokio::test] async fn defer_pushes_the_row_out_of_the_due_window() -> anyhow::Result<()> { let db = TestDatabase::start().await; - let (account_id, workspace_id) = db.seed_account_and_workspace().await; + let seeded = db.seed_account_and_workspace().await; let mut conn = db.client.get_connection().await?; let row = conn - .insert_event_outbox(NewEventOutbox::test(workspace_id, account_id)) + .insert_event_outbox(NewEventOutbox::test(seeded.workspace_id, seeded.account_id)) .await?; // Claim, then defer the attempt an hour into the future. @@ -226,11 +226,11 @@ mod tests { #[tokio::test] async fn mark_failed_dead_letters_the_row() -> anyhow::Result<()> { let db = TestDatabase::start().await; - let (account_id, workspace_id) = db.seed_account_and_workspace().await; + let seeded = db.seed_account_and_workspace().await; let mut conn = db.client.get_connection().await?; let row = conn - .insert_event_outbox(NewEventOutbox::test(workspace_id, account_id)) + .insert_event_outbox(NewEventOutbox::test(seeded.workspace_id, seeded.account_id)) .await?; conn.transaction(async |conn| -> Result<()> { diff --git a/crates/nvisy-postgres/src/query/mod.rs b/crates/nvisy-postgres/src/query/mod.rs index 3a0778b4..cc8ce96d 100644 --- a/crates/nvisy-postgres/src/query/mod.rs +++ b/crates/nvisy-postgres/src/query/mod.rs @@ -44,9 +44,9 @@ mod workspace_thread_event; mod workspace_webhook; pub use account::AccountRepository; -pub use account_api_token::AccountApiTokenRepository; +pub use account_api_token::{AccountApiTokenRepository, ApiTokenCursor}; pub use account_identity::{AccountIdentityRepository, DeleteIdentityOutcome, LinkIdentityOutcome}; -pub use account_notification::AccountNotificationRepository; +pub use account_notification::{AccountNotificationRepository, NotificationCursor}; pub use analytics::{ AnalyticsSnapshot, DetectionDayPoint, DetectionDurations, DetectionStatusCount, StorageByKind, UsageByModel, WorkspaceAnalyticsRepository, @@ -54,25 +54,31 @@ pub use analytics::{ pub use event_outbox::EventOutboxRepository; pub use pipeline_reference::PipelineReferenceRepository; pub use workspace::WorkspaceRepository; -pub use workspace_activity::{ActivityFilter, WorkspaceActivityRepository}; +pub use workspace_activity::{ActivityCursor, ActivityFilter, WorkspaceActivityRepository}; pub use workspace_assignment::{ - AssignmentListRow, CreateAssignmentOutcome, WorkspaceAssignmentRepository, + AssignmentCursor, AssignmentListRow, CreateAssignmentOutcome, WorkspaceAssignmentRepository, }; pub use workspace_assistant_job::AssistantJobOutboxRepository; -pub use workspace_connection::{ScheduledConnection, WorkspaceConnectionRepository}; +pub use workspace_connection::{ + ConnectionCursor, ScheduledConnection, WorkspaceConnectionRepository, +}; pub use workspace_connection_schedule::WorkspaceConnectionScheduleRepository; -pub use workspace_connection_sync::WorkspaceConnectionSyncRepository; -pub use workspace_detection::{DetectionFiles, DetectionListRow, WorkspaceDetectionRepository}; +pub use workspace_connection_sync::{ConnectionSyncCursor, WorkspaceConnectionSyncRepository}; +pub use workspace_detection::{ + DetectionCursor, DetectionFiles, DetectionListRow, WorkspaceDetectionRepository, +}; pub use workspace_detection_job::DetectionJobOutboxRepository; -pub use workspace_file::{ExpiredFileRef, ImportedFileRef, WorkspaceFileRepository}; -pub use workspace_invite::WorkspaceInviteRepository; -pub use workspace_member::WorkspaceMemberRepository; -pub use workspace_pipeline::WorkspacePipelineRepository; -pub use workspace_policy::WorkspacePolicyRepository; -pub use workspace_provider::WorkspaceProviderRepository; -pub use workspace_redaction::WorkspaceRedactionRepository; -pub use workspace_thread::WorkspaceThreadRepository; +pub use workspace_file::{ExpiredFileRef, FileCursor, ImportedFileRef, WorkspaceFileRepository}; +pub use workspace_invite::{InviteCursor, WorkspaceInviteRepository}; +pub use workspace_member::{ + AccountWorkspaceCursor, WorkspaceMemberCursor, WorkspaceMemberRepository, +}; +pub use workspace_pipeline::{PipelineCursor, WorkspacePipelineRepository}; +pub use workspace_policy::{PolicyCursor, WorkspacePolicyRepository}; +pub use workspace_provider::{ProviderCursor, WorkspaceProviderRepository}; +pub use workspace_redaction::{RedactionCursor, WorkspaceRedactionRepository}; +pub use workspace_thread::{ThreadCursor, WorkspaceThreadRepository}; pub use workspace_thread_anchor::WorkspaceThreadAnchorRepository; pub use workspace_thread_comment::WorkspaceThreadCommentRepository; -pub use workspace_thread_event::WorkspaceThreadEventRepository; -pub use workspace_webhook::WorkspaceWebhookRepository; +pub use workspace_thread_event::{TimelineCursor, TimelineSource, WorkspaceThreadEventRepository}; +pub use workspace_webhook::{WebhookCursor, WorkspaceWebhookRepository}; diff --git a/crates/nvisy-postgres/src/query/pipeline_reference.rs b/crates/nvisy-postgres/src/query/pipeline_reference.rs index dd88df77..b91bf903 100644 --- a/crates/nvisy-postgres/src/query/pipeline_reference.rs +++ b/crates/nvisy-postgres/src/query/pipeline_reference.rs @@ -182,19 +182,25 @@ mod tests { /// Seeds a pipeline plus `count` policies, returning `(workspace_id, /// pipeline_id, policy_ids)`. async fn seed(db: &TestDatabase, count: usize) -> anyhow::Result<(Uuid, Uuid, Vec)> { - let (account_id, workspace_id) = db.seed_account_and_workspace().await; + let seeded = db.seed_account_and_workspace().await; let mut conn = db.client.get_connection().await?; let pipeline = conn - .create_workspace_pipeline(NewWorkspacePipeline::test(workspace_id, account_id)) + .create_workspace_pipeline(NewWorkspacePipeline::test( + seeded.workspace_id, + seeded.account_id, + )) .await?; let mut policy_ids = Vec::new(); for _ in 0..count { let policy = conn - .create_workspace_policy(NewWorkspacePolicy::test(workspace_id, account_id)) + .create_workspace_policy(NewWorkspacePolicy::test( + seeded.workspace_id, + seeded.account_id, + )) .await?; policy_ids.push(policy.id); } - Ok((workspace_id, pipeline.id, policy_ids)) + Ok((seeded.workspace_id, pipeline.id, policy_ids)) } #[tokio::test] @@ -256,32 +262,41 @@ mod tests { #[tokio::test] async fn resolve_policy_slugs_preserves_order_and_rejects_unknown() -> anyhow::Result<()> { let db = TestDatabase::start().await; - let (account_id, workspace_id) = db.seed_account_and_workspace().await; + let seeded = db.seed_account_and_workspace().await; let mut conn = db.client.get_connection().await?; let alpha = conn - .create_workspace_policy(NewWorkspacePolicy::test(workspace_id, account_id)) + .create_workspace_policy(NewWorkspacePolicy::test( + seeded.workspace_id, + seeded.account_id, + )) .await?; let bravo = conn - .create_workspace_policy(NewWorkspacePolicy::test(workspace_id, account_id)) + .create_workspace_policy(NewWorkspacePolicy::test( + seeded.workspace_id, + seeded.account_id, + )) .await?; // Resolution preserves request order, not storage order. let resolved = conn - .resolve_policy_slugs(workspace_id, &[bravo.slug.clone(), alpha.slug.clone()]) + .resolve_policy_slugs( + seeded.workspace_id, + &[bravo.slug.clone(), alpha.slug.clone()], + ) .await?; assert_eq!(resolved, Some(vec![bravo.id, alpha.id])); // An empty request resolves to an empty vec (not `None`). assert_eq!( - conn.resolve_policy_slugs(workspace_id, &[]).await?, + conn.resolve_policy_slugs(seeded.workspace_id, &[]).await?, Some(Vec::new()) ); // If any slug is unknown, the whole set is rejected with `None`. let unknown = Handle::test(); assert_eq!( - conn.resolve_policy_slugs(workspace_id, &[alpha.slug.clone(), unknown]) + conn.resolve_policy_slugs(seeded.workspace_id, &[alpha.slug.clone(), unknown]) .await?, None ); diff --git a/crates/nvisy-postgres/src/query/workspace_activity.rs b/crates/nvisy-postgres/src/query/workspace_activity.rs index 63930449..4299a171 100644 --- a/crates/nvisy-postgres/src/query/workspace_activity.rs +++ b/crates/nvisy-postgres/src/query/workspace_activity.rs @@ -5,12 +5,25 @@ use std::future::Future; use diesel::prelude::*; use diesel_async::RunQueryDsl; use jiff::Timestamp; +use serde::{Deserialize, Serialize}; use uuid::Uuid; use crate::model::{NewWorkspaceActivity, WorkspaceActivity}; -use crate::types::{AccountRefRow, ActivityType, CursorPage, CursorPagination, WithAccountRef}; +use crate::types::{ + AccountRefRow, ActivityType, CursorPage, CursorPagination, WithAccountRef, keyset, +}; use crate::{Error, PgConnection, Result, schema}; +/// Keyset for paginating a workspace's activity log: newest first by `created_at`, +/// `id` as the tiebreaker. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ActivityCursor { + /// When the activity was recorded. + pub created_at: Timestamp, + /// Activity id (tiebreaker). + pub id: uuid::Uuid, +} + /// Predicates that narrow an activity listing, all optional (an empty filter /// matches every activity in the workspace). Shared by the paginated feed and the /// export so both apply the same constraints. @@ -46,7 +59,7 @@ pub trait WorkspaceActivityRepository { &mut self, workspace_id: Uuid, filter: ActivityFilter, - pagination: CursorPagination, + pagination: CursorPagination, ) -> impl Future>>> + Send; /// Lists a workspace's filtered activities oldest first (the natural order for @@ -80,7 +93,7 @@ impl WorkspaceActivityRepository for PgConnection { &mut self, workspace_id: Uuid, filter: ActivityFilter, - pagination: CursorPagination, + pagination: CursorPagination, ) -> Result>> { use diesel::dsl::count_star; use schema::workspace_activities::dsl; @@ -102,37 +115,31 @@ impl WorkspaceActivityRepository for PgConnection { None }; - let mut query = apply_activity_filter( + let query = apply_activity_filter( workspace_activities::table .filter(dsl::workspace_id.eq(workspace_id)) .into_boxed(), &filter, - ); - - if let Some(cursor) = &pagination.after { - let cursor_ts = jiff_diesel::Timestamp::from(cursor.timestamp); - query = query.filter( - dsl::created_at - .lt(cursor_ts) - .or(dsl::created_at.eq(cursor_ts).and(dsl::id.lt(cursor.id))), - ); - } - - let rows: Vec<(WorkspaceActivity, AccountRefRow)> = query - .inner_join(accounts::table) - .select(( - WorkspaceActivity::as_select(), - ( - accounts::username, - accounts::display_name, - accounts::avatar_url, - ), - )) - .order((dsl::created_at.desc(), dsl::id.desc())) - .limit(pagination.fetch_limit()) - .load(self) - .await - .map_err(Error::from)?; + ) + .inner_join(accounts::table); + + let after = pagination + .after_key() + .map(|k| (jiff_diesel::Timestamp::from(k.created_at), k.id)); + let rows: Vec<(WorkspaceActivity, AccountRefRow)> = + keyset!(query, dsl::created_at, dsl::id, pagination.direction, after) + .select(( + WorkspaceActivity::as_select(), + ( + accounts::username, + accounts::display_name, + accounts::avatar_url, + ), + )) + .limit(pagination.fetch_limit()) + .load(self) + .await + .map_err(Error::from)?; let items: Vec> = rows .into_iter() @@ -140,7 +147,10 @@ impl WorkspaceActivityRepository for PgConnection { .collect(); Ok(CursorPage::new(items, total, pagination.limit, |wc| { - (wc.item.created_at.into(), wc.item.id) + ActivityCursor { + created_at: wc.item.created_at.into(), + id: wc.item.id, + } })) } @@ -240,23 +250,23 @@ mod tests { #[tokio::test] async fn feed_lists_newest_first_and_export_lists_oldest_first() -> anyhow::Result<()> { let db = TestDatabase::start().await; - let (account_id, workspace_id) = db.seed_account_and_workspace().await; + let seeded = db.seed_account_and_workspace().await; let mut conn = db.client.get_connection().await?; // `first` is an hour old so the newest-first / oldest-first orders are // deterministic against `second`. let first = log( &mut conn, - workspace_id, - account_id, + seeded.workspace_id, + seeded.account_id, ActivityType::WorkspaceCreated, Some(Span::new().hours(1)), ) .await?; let second = log( &mut conn, - workspace_id, - account_id, + seeded.workspace_id, + seeded.account_id, ActivityType::WorkspaceUpdated, None, ) @@ -265,7 +275,7 @@ mod tests { // The paginated feed is newest first. let feed = conn .cursor_list_workspace_activity( - workspace_id, + seeded.workspace_id, ActivityFilter::default(), CursorPagination::new(50), ) @@ -277,7 +287,7 @@ mod tests { // The export is oldest first. let export = conn - .list_workspace_activity_for_export(workspace_id, ActivityFilter::default(), 50) + .list_workspace_activity_for_export(seeded.workspace_id, ActivityFilter::default(), 50) .await?; assert_eq!( export.iter().map(|a| a.item.id).collect::>(), @@ -289,7 +299,7 @@ mod tests { #[tokio::test] async fn filter_by_type_and_actor() -> anyhow::Result<()> { let db = TestDatabase::start().await; - let (owner_id, workspace_id) = db.seed_account_and_workspace().await; + let seeded = db.seed_account_and_workspace().await; let mut conn = db.client.get_connection().await?; // A second actor in the same workspace. @@ -297,23 +307,23 @@ mod tests { let created = log( &mut conn, - workspace_id, - owner_id, + seeded.workspace_id, + seeded.account_id, ActivityType::WorkspaceCreated, None, ) .await?; let _updated = log( &mut conn, - workspace_id, - owner_id, + seeded.workspace_id, + seeded.account_id, ActivityType::WorkspaceUpdated, None, ) .await?; let by_other = log( &mut conn, - workspace_id, + seeded.workspace_id, other_id, ActivityType::WorkspaceCreated, None, @@ -323,7 +333,7 @@ mod tests { // Type filter keeps only WorkspaceCreated (from either actor). let created_only = conn .cursor_list_workspace_activity( - workspace_id, + seeded.workspace_id, ActivityFilter { types: vec![ActivityType::WorkspaceCreated], ..Default::default() @@ -340,7 +350,7 @@ mod tests { // Actor filter keeps only the other actor's activity. let others = conn .cursor_list_workspace_activity( - workspace_id, + seeded.workspace_id, ActivityFilter { actor: Some(other_id), ..Default::default() diff --git a/crates/nvisy-postgres/src/query/workspace_assignment.rs b/crates/nvisy-postgres/src/query/workspace_assignment.rs index c07db316..ee77691a 100644 --- a/crates/nvisy-postgres/src/query/workspace_assignment.rs +++ b/crates/nvisy-postgres/src/query/workspace_assignment.rs @@ -4,15 +4,27 @@ use std::future::Future; use diesel::prelude::*; use diesel_async::RunQueryDsl; +use jiff::Timestamp; +use serde::{Deserialize, Serialize}; use uuid::Uuid; use crate::model::{NewWorkspaceAssignment, UpdateWorkspaceAssignment, WorkspaceAssignment}; use crate::types::{ AccountRefRow, AssignmentFilter, ConstraintViolation, CursorPage, CursorPagination, - WorkspaceAssignmentConstraints, + WorkspaceAssignmentConstraints, keyset, }; use crate::{Error, PgConnection, Result, schema}; +/// Keyset for paginating a workspace's assignments: newest first by `created_at`, +/// `id` as the tiebreaker. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AssignmentCursor { + /// When the assignment was created. + pub created_at: Timestamp, + /// Assignment id (tiebreaker). + pub id: uuid::Uuid, +} + /// One assignment paired with the reviewer's account reference and the name of /// the file under review. /// @@ -84,7 +96,7 @@ pub trait WorkspaceAssignmentRepository { fn cursor_list_workspace_assignments( &mut self, workspace_id: Uuid, - pagination: CursorPagination, + pagination: CursorPagination, filter: &AssignmentFilter, ) -> impl Future>> + Send; @@ -214,7 +226,7 @@ impl WorkspaceAssignmentRepository for PgConnection { async fn cursor_list_workspace_assignments( &mut self, workspace_id: Uuid, - pagination: CursorPagination, + pagination: CursorPagination, filter: &AssignmentFilter, ) -> Result> { use schema::workspace_assignments::dsl; @@ -255,8 +267,6 @@ impl WorkspaceAssignmentRepository for PgConnection { None }; - let query = scoped(); - let limit = pagination.fetch_limit(); let selection = ( WorkspaceAssignment::as_select(), ( @@ -267,31 +277,21 @@ impl WorkspaceAssignmentRepository for PgConnection { 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 after = pagination + .after_key() + .map(|k| (jiff_diesel::Timestamp::from(k.created_at), k.id)); + let rows: Vec<(WorkspaceAssignment, AccountRefRow, Option)> = keyset!( + scoped(), + dsl::created_at, + dsl::id, + pagination.direction, + after + ) + .select(selection) + .limit(pagination.fetch_limit()) + .load(self) + .await + .map_err(Error::from)?; let items = rows .into_iter() @@ -303,7 +303,10 @@ impl WorkspaceAssignmentRepository for PgConnection { .collect(); Ok(CursorPage::new(items, total, pagination.limit, |row| { - (row.assignment.created_at.into(), row.assignment.id) + AssignmentCursor { + created_at: row.assignment.created_at.into(), + id: row.assignment.id, + } })) } @@ -356,14 +359,14 @@ mod tests { #[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 seeded = 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, + seeded.workspace_id, + seeded.file_id, reviewer, )) .await?; @@ -373,26 +376,28 @@ mod tests { // a second row. let again = conn .create_workspace_assignment(NewWorkspaceAssignment::test( - workspace_id, - file_id, + seeded.workspace_id, + seeded.file_id, reviewer, )) .await?; assert_eq!(again, CreateAssignmentOutcome::AlreadyAssigned); - let file_rows = conn.list_file_assignments(workspace_id, file_id).await?; + let file_rows = conn + .list_file_assignments(seeded.workspace_id, seeded.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) + .find_file_assignment_for_assignee(seeded.workspace_id, seeded.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) + conn.find_file_assignment_for_assignee(seeded.workspace_id, seeded.file_id, other) .await? .is_none() ); @@ -402,14 +407,14 @@ mod tests { #[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 seeded = 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, + seeded.workspace_id, + seeded.file_id, reviewer, )) .await? @@ -430,7 +435,7 @@ mod tests { conn.delete_workspace_assignment(assignment.id).await?; assert!( - conn.find_assignment_in_workspace(workspace_id, assignment.id) + conn.find_assignment_in_workspace(seeded.workspace_id, assignment.id) .await? .is_none() ); @@ -440,7 +445,7 @@ mod tests { #[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 seeded = 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?; @@ -448,8 +453,8 @@ mod tests { for reviewer in [alice, bob] { let _ = conn .create_workspace_assignment(NewWorkspaceAssignment::test( - workspace_id, - file_id, + seeded.workspace_id, + seeded.file_id, reviewer, )) .await?; @@ -458,7 +463,7 @@ mod tests { // No filter: both reviewers' assignments. let all = conn .cursor_list_workspace_assignments( - workspace_id, + seeded.workspace_id, CursorPagination::new(50), &AssignmentFilter::default(), ) @@ -468,7 +473,7 @@ mod tests { // Filter to one reviewer. let just_alice = conn .cursor_list_workspace_assignments( - workspace_id, + seeded.workspace_id, CursorPagination::new(50), &AssignmentFilter { assignee_account_id: Some(alice), @@ -482,7 +487,7 @@ mod tests { // A status no assignment holds returns nothing. let none = conn .cursor_list_workspace_assignments( - workspace_id, + seeded.workspace_id, CursorPagination::new(50), &AssignmentFilter { status: Some(AssignmentStatus::Done), @@ -497,7 +502,7 @@ mod tests { #[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 seeded = db.seed_pipeline_and_file().await; let reviewer = db.seed_account().await; let mut conn = db.client.get_connection().await?; @@ -505,14 +510,14 @@ mod tests { // 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) + assigned_account_id: Some(seeded.account_id), + ..NewWorkspaceAssignment::test(seeded.workspace_id, seeded.file_id, reviewer) }) .await? else { panic!("expected a fresh assignment"); }; - assert_eq!(assignment.assigned_account_id, Some(assigner)); + assert_eq!(assignment.assigned_account_id, Some(seeded.account_id)); assert_eq!(assignment.assignee_account_id, reviewer); Ok(()) } @@ -520,7 +525,7 @@ mod tests { #[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 seeded = 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 @@ -529,19 +534,21 @@ mod tests { let file = conn .create_workspace_file(NewWorkspaceFile { display_name: Some("quarterly-report.pdf".to_owned()), - ..NewWorkspaceFile::test(workspace_id, assigner) + ..NewWorkspaceFile::test(seeded.workspace_id, seeded.account_id) }) .await?; let _ = conn .create_workspace_assignment(NewWorkspaceAssignment::test( - workspace_id, + seeded.workspace_id, file.id, reviewer.id, )) .await?; - let rows = conn.list_file_assignments(workspace_id, file.id).await?; + let rows = conn + .list_file_assignments(seeded.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); diff --git a/crates/nvisy-postgres/src/query/workspace_assistant_job.rs b/crates/nvisy-postgres/src/query/workspace_assistant_job.rs index 066928ba..94f2907e 100644 --- a/crates/nvisy-postgres/src/query/workspace_assistant_job.rs +++ b/crates/nvisy-postgres/src/query/workspace_assistant_job.rs @@ -160,11 +160,11 @@ mod tests { /// Seeds a thread with its opening comment and returns that comment's id — the /// FK parent an outbox row needs. async fn seed_comment(db: &TestDatabase) -> anyhow::Result { - let (author, workspace_id, _pipeline_id, file_id) = db.seed_pipeline_and_file().await; + let seeded = db.seed_pipeline_and_file().await; let mut conn = db.client.get_connection().await?; let (_thread, opening) = conn .open_thread( - NewWorkspaceThread::test(workspace_id, file_id, author), + NewWorkspaceThread::test(seeded.workspace_id, seeded.file_id, seeded.account_id), "@assistant help".to_owned(), Vec::new(), ) diff --git a/crates/nvisy-postgres/src/query/workspace_connection.rs b/crates/nvisy-postgres/src/query/workspace_connection.rs index 519ed761..609fc8db 100644 --- a/crates/nvisy-postgres/src/query/workspace_connection.rs +++ b/crates/nvisy-postgres/src/query/workspace_connection.rs @@ -4,12 +4,24 @@ use std::future::Future; use diesel::prelude::*; use diesel_async::RunQueryDsl; +use jiff::Timestamp; +use serde::{Deserialize, Serialize}; use uuid::Uuid; use crate::model::{NewWorkspaceConnection, UpdateWorkspaceConnection, WorkspaceConnection}; -use crate::types::{AccountRefRow, CursorPage, CursorPagination, WithAccountRef}; +use crate::types::{AccountRefRow, CursorPage, CursorPagination, WithAccountRef, keyset}; use crate::{Error, PgConnection, Result, schema}; +/// Keyset for paginating a workspace's connections: newest first by `created_at`, +/// `id` as the tiebreaker. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ConnectionCursor { + /// When the connection was created. + pub created_at: Timestamp, + /// Connection id (tiebreaker). + pub id: uuid::Uuid, +} + /// A sync-scheduled connection paired with its cron expression, as returned by /// [`WorkspaceConnectionRepository::list_scheduled_connections`]. The cron is /// non-optional: the query only lists connections whose schedule has one. The @@ -80,7 +92,7 @@ pub trait WorkspaceConnectionRepository { fn cursor_list_workspace_connections( &mut self, workspace_id: Uuid, - pagination: CursorPagination, + pagination: CursorPagination, providers: &[String], ) -> impl Future>>> + Send; @@ -211,7 +223,7 @@ impl WorkspaceConnectionRepository for PgConnection { async fn cursor_list_workspace_connections( &mut self, workspace_id: Uuid, - pagination: CursorPagination, + pagination: CursorPagination, providers: &[String], ) -> Result>> { use schema::workspace_connections::dsl; @@ -251,47 +263,23 @@ impl WorkspaceConnectionRepository for PgConnection { query = query.filter(dsl::provider.eq_any(providers.to_vec())); } - let limit = pagination.fetch_limit(); - + let after = pagination + .after_key() + .map(|k| (jiff_diesel::Timestamp::from(k.created_at), k.id)); let rows: Vec<(WorkspaceConnection, AccountRefRow)> = - 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(( - WorkspaceConnection::as_select(), - ( - accounts::username, - accounts::display_name, - accounts::avatar_url, - ), - )) - .order((dsl::created_at.desc(), dsl::id.desc())) - .limit(limit) - .load(self) - .await - .map_err(Error::from)? - } else { - query - .select(( - WorkspaceConnection::as_select(), - ( - accounts::username, - accounts::display_name, - accounts::avatar_url, - ), - )) - .order((dsl::created_at.desc(), dsl::id.desc())) - .limit(limit) - .load(self) - .await - .map_err(Error::from)? - }; + keyset!(query, dsl::created_at, dsl::id, pagination.direction, after) + .select(( + WorkspaceConnection::as_select(), + ( + accounts::username, + accounts::display_name, + accounts::avatar_url, + ), + )) + .limit(pagination.fetch_limit()) + .load(self) + .await + .map_err(Error::from)?; let items: Vec> = rows .into_iter() @@ -299,7 +287,10 @@ impl WorkspaceConnectionRepository for PgConnection { .collect(); Ok(CursorPage::new(items, total, pagination.limit, |wc| { - (wc.item.created_at.into(), wc.item.id) + ConnectionCursor { + created_at: wc.item.created_at.into(), + id: wc.item.id, + } })) } @@ -362,16 +353,19 @@ mod tests { #[tokio::test] async fn create_and_scoped_lookups_round_trip() -> anyhow::Result<()> { let db = TestDatabase::start().await; - let (account_id, workspace_id) = db.seed_account_and_workspace().await; + let seeded = db.seed_account_and_workspace().await; let mut conn = db.client.get_connection().await?; let connection = conn - .create_workspace_connection(NewWorkspaceConnection::test(workspace_id, account_id)) + .create_workspace_connection(NewWorkspaceConnection::test( + seeded.workspace_id, + seeded.account_id, + )) .await?; // Found within its own workspace. assert!( - conn.find_connection_in_workspace(workspace_id, connection.id) + conn.find_connection_in_workspace(seeded.workspace_id, connection.id) .await? .is_some() ); @@ -384,7 +378,7 @@ mod tests { // The creator join returns the connection with its creator's handle. let with_creator = conn - .find_connection_in_workspace_with_creator(workspace_id, connection.id) + .find_connection_in_workspace_with_creator(seeded.workspace_id, connection.id) .await? .expect("connection should be present"); assert_eq!(with_creator.item.id, connection.id); @@ -403,11 +397,14 @@ mod tests { #[tokio::test] async fn soft_delete_hides_the_row_from_reads_and_updates() -> anyhow::Result<()> { let db = TestDatabase::start().await; - let (account_id, workspace_id) = db.seed_account_and_workspace().await; + let seeded = db.seed_account_and_workspace().await; let mut conn = db.client.get_connection().await?; let connection = conn - .create_workspace_connection(NewWorkspaceConnection::test(workspace_id, account_id)) + .create_workspace_connection(NewWorkspaceConnection::test( + seeded.workspace_id, + seeded.account_id, + )) .await?; // An update applies to a live row. @@ -426,12 +423,12 @@ mod tests { // Every scoped read now excludes it. assert!( - conn.find_connection_in_workspace(workspace_id, connection.id) + conn.find_connection_in_workspace(seeded.workspace_id, connection.id) .await? .is_none() ); assert!( - conn.find_connection_in_workspace_with_creator(workspace_id, connection.id) + conn.find_connection_in_workspace_with_creator(seeded.workspace_id, connection.id) .await? .is_none() ); @@ -463,24 +460,30 @@ mod tests { #[tokio::test] async fn cursor_list_filters_by_provider_and_excludes_deleted() -> anyhow::Result<()> { let db = TestDatabase::start().await; - let (account_id, workspace_id) = db.seed_account_and_workspace().await; + let seeded = db.seed_account_and_workspace().await; let mut conn = db.client.get_connection().await?; // An s3 connection, an azure connection, and a deleted s3 connection. let s3 = conn - .create_workspace_connection(NewWorkspaceConnection::test(workspace_id, account_id)) + .create_workspace_connection(NewWorkspaceConnection::test( + seeded.workspace_id, + seeded.account_id, + )) .await?; - let mut azure = NewWorkspaceConnection::test(workspace_id, account_id); + let mut azure = NewWorkspaceConnection::test(seeded.workspace_id, seeded.account_id); azure.provider = "azure".to_owned(); let azure = conn.create_workspace_connection(azure).await?; let deleted = conn - .create_workspace_connection(NewWorkspaceConnection::test(workspace_id, account_id)) + .create_workspace_connection(NewWorkspaceConnection::test( + seeded.workspace_id, + seeded.account_id, + )) .await?; conn.delete_workspace_connection(deleted.id).await?; // No provider filter: both live connections, deleted excluded. let all = conn - .cursor_list_workspace_connections(workspace_id, CursorPagination::new(50), &[]) + .cursor_list_workspace_connections(seeded.workspace_id, CursorPagination::new(50), &[]) .await?; let ids: Vec<_> = all.items.iter().map(|c| c.item.id).collect(); assert_eq!(ids.len(), 2); @@ -490,7 +493,7 @@ mod tests { // Filtered to azure only. let azure_only = conn .cursor_list_workspace_connections( - workspace_id, + seeded.workspace_id, CursorPagination::new(50), &["azure".to_owned()], ) @@ -510,12 +513,15 @@ mod tests { async fn list_scheduled_connections_requires_active_cron_and_not_deleted() -> anyhow::Result<()> { let db = TestDatabase::start().await; - let (account_id, workspace_id) = db.seed_account_and_workspace().await; + let seeded = db.seed_account_and_workspace().await; let mut conn = db.client.get_connection().await?; // A scheduled, active connection: it should be listed with its cron. let scheduled = conn - .create_workspace_connection(NewWorkspaceConnection::test(workspace_id, account_id)) + .create_workspace_connection(NewWorkspaceConnection::test( + seeded.workspace_id, + seeded.account_id, + )) .await?; let mut schedule = NewWorkspaceConnectionSchedule::test(scheduled.id); schedule.schedule_cron = Some("0 * * * *".to_owned()); @@ -523,14 +529,17 @@ mod tests { // A connection whose schedule has no cron (manual-only): excluded. let manual = conn - .create_workspace_connection(NewWorkspaceConnection::test(workspace_id, account_id)) + .create_workspace_connection(NewWorkspaceConnection::test( + seeded.workspace_id, + seeded.account_id, + )) .await?; let _ = conn .create_connection_schedule(NewWorkspaceConnectionSchedule::test(manual.id)) .await?; // An inactive connection with a cron schedule: excluded. - let mut inactive = NewWorkspaceConnection::test(workspace_id, account_id); + let mut inactive = NewWorkspaceConnection::test(seeded.workspace_id, seeded.account_id); inactive.is_active = Some(false); let inactive = conn.create_workspace_connection(inactive).await?; let mut inactive_schedule = NewWorkspaceConnectionSchedule::test(inactive.id); diff --git a/crates/nvisy-postgres/src/query/workspace_connection_schedule.rs b/crates/nvisy-postgres/src/query/workspace_connection_schedule.rs index a5b7d637..c2ce5adf 100644 --- a/crates/nvisy-postgres/src/query/workspace_connection_schedule.rs +++ b/crates/nvisy-postgres/src/query/workspace_connection_schedule.rs @@ -136,10 +136,13 @@ mod tests { /// Seeds a connection in the fixture's workspace and returns its id — the FK /// parent a schedule row requires. async fn seed_connection(db: &TestDatabase) -> anyhow::Result { - let (account_id, workspace_id) = db.seed_account_and_workspace().await; + let seeded = db.seed_account_and_workspace().await; let mut conn = db.client.get_connection().await?; let connection = conn - .create_workspace_connection(NewWorkspaceConnection::test(workspace_id, account_id)) + .create_workspace_connection(NewWorkspaceConnection::test( + seeded.workspace_id, + seeded.account_id, + )) .await?; Ok(connection.id) } diff --git a/crates/nvisy-postgres/src/query/workspace_connection_sync.rs b/crates/nvisy-postgres/src/query/workspace_connection_sync.rs index d8179fec..f4bf11aa 100644 --- a/crates/nvisy-postgres/src/query/workspace_connection_sync.rs +++ b/crates/nvisy-postgres/src/query/workspace_connection_sync.rs @@ -4,12 +4,26 @@ use std::future::Future; use diesel::prelude::*; use diesel_async::RunQueryDsl; +use jiff::Timestamp; +use serde::{Deserialize, Serialize}; use uuid::Uuid; use crate::model::{NewWorkspaceConnectionSync, WorkspaceConnectionSync}; -use crate::types::{AccountRefRow, CursorPage, CursorPagination, SyncStatus, WithAccountRef}; +use crate::types::{ + AccountRefRow, CursorPage, CursorPagination, SyncStatus, WithAccountRef, keyset, +}; use crate::{Error, PgConnection, Result, schema}; +/// Keyset for paginating connection sync runs: newest first by `started_at`, `id` +/// as the tiebreaker. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ConnectionSyncCursor { + /// When the sync run started. + pub started_at: Timestamp, + /// Sync run id (tiebreaker). + pub id: uuid::Uuid, +} + /// Repository for workspace connection sync database operations. /// /// Handles sync lifecycle management including creation, status updates, @@ -55,7 +69,7 @@ pub trait WorkspaceConnectionSyncRepository { fn cursor_list_workspace_connection_syncs( &mut self, connection_id: Uuid, - pagination: CursorPagination, + pagination: CursorPagination, status_filter: Option, ) -> impl Future>>> + Send; @@ -71,7 +85,7 @@ pub trait WorkspaceConnectionSyncRepository { fn cursor_list_workspace_connection_syncs_all( &mut self, workspace_id: Uuid, - pagination: CursorPagination, + pagination: CursorPagination, status_filter: Option, providers: &[String], ) -> impl Future, Uuid)>>> + Send; @@ -210,7 +224,7 @@ impl WorkspaceConnectionSyncRepository for PgConnection { async fn cursor_list_workspace_connection_syncs( &mut self, connection_id: Uuid, - pagination: CursorPagination, + pagination: CursorPagination, status_filter: Option, ) -> Result>> { use schema::workspace_connection_syncs::dsl; @@ -245,47 +259,23 @@ impl WorkspaceConnectionSyncRepository for PgConnection { query = query.filter(dsl::status.eq(status)); } - let limit = pagination.fetch_limit(); - + let after = pagination + .after_key() + .map(|k| (jiff_diesel::Timestamp::from(k.started_at), k.id)); let rows: Vec<(WorkspaceConnectionSync, AccountRefRow)> = - if let Some(cursor) = &pagination.after { - let cursor_time = jiff_diesel::Timestamp::from(cursor.timestamp); - - query - .filter( - dsl::started_at - .lt(&cursor_time) - .or(dsl::started_at.eq(&cursor_time).and(dsl::id.lt(cursor.id))), - ) - .select(( - WorkspaceConnectionSync::as_select(), - ( - accounts::username, - accounts::display_name, - accounts::avatar_url, - ), - )) - .order((dsl::started_at.desc(), dsl::id.desc())) - .limit(limit) - .load(self) - .await - .map_err(Error::from)? - } else { - query - .select(( - WorkspaceConnectionSync::as_select(), - ( - accounts::username, - accounts::display_name, - accounts::avatar_url, - ), - )) - .order((dsl::started_at.desc(), dsl::id.desc())) - .limit(limit) - .load(self) - .await - .map_err(Error::from)? - }; + keyset!(query, dsl::started_at, dsl::id, pagination.direction, after) + .select(( + WorkspaceConnectionSync::as_select(), + ( + accounts::username, + accounts::display_name, + accounts::avatar_url, + ), + )) + .limit(pagination.fetch_limit()) + .load(self) + .await + .map_err(Error::from)?; let items: Vec> = rows .into_iter() @@ -293,14 +283,17 @@ impl WorkspaceConnectionSyncRepository for PgConnection { .collect(); Ok(CursorPage::new(items, total, pagination.limit, |wc| { - (wc.item.started_at.into(), wc.item.id) + ConnectionSyncCursor { + started_at: wc.item.started_at.into(), + id: wc.item.id, + } })) } async fn cursor_list_workspace_connection_syncs_all( &mut self, workspace_id: Uuid, - pagination: CursorPagination, + pagination: CursorPagination, status_filter: Option, providers: &[String], ) -> Result, Uuid)>> { @@ -341,7 +334,6 @@ impl WorkspaceConnectionSyncRepository for PgConnection { None }; - let limit = pagination.fetch_limit(); let selection = ( WorkspaceConnectionSync::as_select(), connections::id, @@ -352,31 +344,21 @@ impl WorkspaceConnectionSyncRepository for PgConnection { ), ); - let rows: Vec<(WorkspaceConnectionSync, Uuid, AccountRefRow)> = - if let Some(cursor) = &pagination.after { - let cursor_time = jiff_diesel::Timestamp::from(cursor.timestamp); - - scoped() - .filter( - runs::started_at.lt(&cursor_time).or(runs::started_at - .eq(&cursor_time) - .and(runs::id.lt(cursor.id))), - ) - .select(selection) - .order((runs::started_at.desc(), runs::id.desc())) - .limit(limit) - .load(self) - .await - .map_err(Error::from)? - } else { - scoped() - .select(selection) - .order((runs::started_at.desc(), runs::id.desc())) - .limit(limit) - .load(self) - .await - .map_err(Error::from)? - }; + let after = pagination + .after_key() + .map(|k| (jiff_diesel::Timestamp::from(k.started_at), k.id)); + let rows: Vec<(WorkspaceConnectionSync, Uuid, AccountRefRow)> = keyset!( + scoped(), + runs::started_at, + runs::id, + pagination.direction, + after + ) + .select(selection) + .limit(pagination.fetch_limit()) + .load(self) + .await + .map_err(Error::from)?; let items: Vec<(WithAccountRef, Uuid)> = rows .into_iter() @@ -387,8 +369,9 @@ impl WorkspaceConnectionSyncRepository for PgConnection { items, total, pagination.limit, - |(wc, _): &(WithAccountRef, Uuid)| { - (wc.item.started_at.into(), wc.item.id) + |(wc, _): &(WithAccountRef, Uuid)| ConnectionSyncCursor { + started_at: wc.item.started_at.into(), + id: wc.item.id, }, )) } @@ -554,12 +537,15 @@ mod tests { /// Seeds a connection in a fresh workspace, returning `(account_id, /// workspace_id, connection_id)` — the FK parents a sync requires. async fn seed_connection(db: &TestDatabase) -> anyhow::Result<(Uuid, Uuid, Uuid)> { - let (account_id, workspace_id) = db.seed_account_and_workspace().await; + let seeded = db.seed_account_and_workspace().await; let mut conn = db.client.get_connection().await?; let connection = conn - .create_workspace_connection(NewWorkspaceConnection::test(workspace_id, account_id)) + .create_workspace_connection(NewWorkspaceConnection::test( + seeded.workspace_id, + seeded.account_id, + )) .await?; - Ok((account_id, workspace_id, connection.id)) + Ok((seeded.account_id, seeded.workspace_id, connection.id)) } /// Creates a sync whose `started_at` is `ago` in the past, so time-based diff --git a/crates/nvisy-postgres/src/query/workspace_detection.rs b/crates/nvisy-postgres/src/query/workspace_detection.rs index f830718b..ca10af27 100644 --- a/crates/nvisy-postgres/src/query/workspace_detection.rs +++ b/crates/nvisy-postgres/src/query/workspace_detection.rs @@ -4,6 +4,8 @@ use std::future::Future; use diesel::prelude::*; use diesel_async::RunQueryDsl; +use jiff::Timestamp; +use serde::{Deserialize, Serialize}; use uuid::Uuid; use crate::model::{ @@ -11,10 +13,20 @@ use crate::model::{ WorkspaceDetection, WorkspacePipeline, }; use crate::types::{ - AccountRefRow, CursorPage, CursorPagination, DetectionFilter, DetectionStatus, Handle, + AccountRefRow, CursorPage, CursorPagination, DetectionFilter, DetectionStatus, Handle, keyset, }; use crate::{Error, PgConnection, Result, schema}; +/// Keyset for paginating detections: newest first by `started_at`, `id` as the +/// tiebreaker. Shared by the pipeline-scoped and workspace-scoped listings. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DetectionCursor { + /// When the detection started. + pub started_at: Timestamp, + /// Detection id (tiebreaker). + pub id: uuid::Uuid, +} + /// Resolved display name of a detection's input file. /// /// `None` when the file has been removed (e.g. by retention). Redacted outputs @@ -78,7 +90,7 @@ pub trait WorkspaceDetectionRepository { fn cursor_list_pipeline_detections( &mut self, pipeline_id: Uuid, - pagination: CursorPagination, + pagination: CursorPagination, filter: &DetectionFilter, ) -> impl Future>> + Send; @@ -94,7 +106,7 @@ pub trait WorkspaceDetectionRepository { fn cursor_list_workspace_detections( &mut self, workspace_id: Uuid, - pagination: CursorPagination, + pagination: CursorPagination, filter: &DetectionFilter, ) -> impl Future>> + Send; @@ -245,7 +257,7 @@ impl WorkspaceDetectionRepository for PgConnection { async fn cursor_list_pipeline_detections( &mut self, pipeline_id: Uuid, - pagination: CursorPagination, + pagination: CursorPagination, filter: &DetectionFilter, ) -> Result> { use schema::workspace_detections::dsl; @@ -291,8 +303,6 @@ impl WorkspaceDetectionRepository for PgConnection { None }; - let query = scoped(); - let limit = pagination.fetch_limit(); let selection = ( WorkspaceDetection::as_select(), ( @@ -304,31 +314,21 @@ impl WorkspaceDetectionRepository for PgConnection { workspace_files::display_name.nullable(), ); - let rows: Vec<(WorkspaceDetection, AccountRefRow, Handle, Option)> = - if let Some(cursor) = &pagination.after { - let cursor_time = jiff_diesel::Timestamp::from(cursor.timestamp); - - query - .filter( - dsl::started_at - .lt(&cursor_time) - .or(dsl::started_at.eq(&cursor_time).and(dsl::id.lt(cursor.id))), - ) - .select(selection) - .order((dsl::started_at.desc(), dsl::id.desc())) - .limit(limit) - .load(self) - .await - .map_err(Error::from)? - } else { - query - .select(selection) - .order((dsl::started_at.desc(), dsl::id.desc())) - .limit(limit) - .load(self) - .await - .map_err(Error::from)? - }; + let after = pagination + .after_key() + .map(|k| (jiff_diesel::Timestamp::from(k.started_at), k.id)); + let rows: Vec<(WorkspaceDetection, AccountRefRow, Handle, Option)> = keyset!( + scoped(), + dsl::started_at, + dsl::id, + pagination.direction, + after + ) + .select(selection) + .limit(pagination.fetch_limit()) + .load(self) + .await + .map_err(Error::from)?; let items = rows .into_iter() @@ -343,14 +343,17 @@ impl WorkspaceDetectionRepository for PgConnection { .collect(); Ok(CursorPage::new(items, total, pagination.limit, |row| { - (row.detection.started_at.into(), row.detection.id) + DetectionCursor { + started_at: row.detection.started_at.into(), + id: row.detection.id, + } })) } async fn cursor_list_workspace_detections( &mut self, workspace_id: Uuid, - pagination: CursorPagination, + pagination: CursorPagination, filter: &DetectionFilter, ) -> Result> { use schema::accounts::dsl as accounts; @@ -402,7 +405,6 @@ impl WorkspaceDetectionRepository for PgConnection { None }; - let limit = pagination.fetch_limit(); let selection = ( WorkspaceDetection::as_select(), pipelines::slug, @@ -414,33 +416,21 @@ impl WorkspaceDetectionRepository for PgConnection { files::display_name.nullable(), ); - let rows: Vec<(WorkspaceDetection, Handle, AccountRefRow, Option)> = - if let Some(cursor) = &pagination.after { - let cursor_time = jiff_diesel::Timestamp::from(cursor.timestamp); - - scoped() - .filter( - detections::started_at - .lt(&cursor_time) - .or(detections::started_at - .eq(&cursor_time) - .and(detections::id.lt(cursor.id))), - ) - .select(selection) - .order((detections::started_at.desc(), detections::id.desc())) - .limit(limit) - .load(self) - .await - .map_err(Error::from)? - } else { - scoped() - .select(selection) - .order((detections::started_at.desc(), detections::id.desc())) - .limit(limit) - .load(self) - .await - .map_err(Error::from)? - }; + let after = pagination + .after_key() + .map(|k| (jiff_diesel::Timestamp::from(k.started_at), k.id)); + let rows: Vec<(WorkspaceDetection, Handle, AccountRefRow, Option)> = keyset!( + scoped(), + detections::started_at, + detections::id, + pagination.direction, + after + ) + .select(selection) + .limit(pagination.fetch_limit()) + .load(self) + .await + .map_err(Error::from)?; let items = rows .into_iter() @@ -455,7 +445,10 @@ impl WorkspaceDetectionRepository for PgConnection { .collect(); Ok(CursorPage::new(items, total, pagination.limit, |row| { - (row.detection.started_at.into(), row.detection.id) + DetectionCursor { + started_at: row.detection.started_at.into(), + id: row.detection.id, + } })) } @@ -645,14 +638,14 @@ mod tests { #[tokio::test] async fn claim_transitions_pending_and_honors_a_fresh_lease() -> anyhow::Result<()> { let db = TestDatabase::start().await; - let (account_id, _ws, pipeline_id, file_id) = db.seed_pipeline_and_file().await; + let seeded = db.seed_pipeline_and_file().await; let mut conn = db.client.get_connection().await?; let detection = conn .create_workspace_detection(NewWorkspaceDetection::test( - pipeline_id, - account_id, - file_id, + seeded.pipeline_id, + seeded.account_id, + seeded.file_id, )) .await?; assert_eq!(detection.status, DetectionStatus::Pending); @@ -684,14 +677,14 @@ mod tests { #[tokio::test] async fn finalize_requires_holding_the_claim() -> anyhow::Result<()> { let db = TestDatabase::start().await; - let (account_id, _ws, pipeline_id, file_id) = db.seed_pipeline_and_file().await; + let seeded = db.seed_pipeline_and_file().await; let mut conn = db.client.get_connection().await?; let detection = conn .create_workspace_detection(NewWorkspaceDetection::test( - pipeline_id, - account_id, - file_id, + seeded.pipeline_id, + seeded.account_id, + seeded.file_id, )) .await?; let claimed = conn @@ -718,7 +711,7 @@ mod tests { .await? ); let (done, _pipeline) = conn - .find_workspace_detection_by_id(_ws, detection.id) + .find_workspace_detection_by_id(seeded.workspace_id, detection.id) .await? .expect("detection present"); assert_eq!(done.status, DetectionStatus::Complete); @@ -740,14 +733,14 @@ mod tests { #[tokio::test] async fn fail_detection_uses_the_same_claim_guard() -> anyhow::Result<()> { let db = TestDatabase::start().await; - let (account_id, _ws, pipeline_id, file_id) = db.seed_pipeline_and_file().await; + let seeded = db.seed_pipeline_and_file().await; let mut conn = db.client.get_connection().await?; let detection = conn .create_workspace_detection(NewWorkspaceDetection::test( - pipeline_id, - account_id, - file_id, + seeded.pipeline_id, + seeded.account_id, + seeded.file_id, )) .await?; let claimed = conn @@ -765,7 +758,7 @@ mod tests { .await? ); let (failed, _p) = conn - .find_workspace_detection_by_id(_ws, detection.id) + .find_workspace_detection_by_id(seeded.workspace_id, detection.id) .await? .expect("present"); assert_eq!(failed.status, DetectionStatus::Failed); @@ -776,15 +769,15 @@ mod tests { #[tokio::test] async fn fail_pending_detection_only_while_unclaimed() -> anyhow::Result<()> { let db = TestDatabase::start().await; - let (account_id, _ws, pipeline_id, file_id) = db.seed_pipeline_and_file().await; + let seeded = db.seed_pipeline_and_file().await; let mut conn = db.client.get_connection().await?; // A never-claimed detection can be failed by the enqueue-failure path. let pending = conn .create_workspace_detection(NewWorkspaceDetection::test( - pipeline_id, - account_id, - file_id, + seeded.pipeline_id, + seeded.account_id, + seeded.file_id, )) .await?; assert!( @@ -796,9 +789,9 @@ mod tests { // path is a no-op and does not clobber the outcome. let claimed_det = conn .create_workspace_detection(NewWorkspaceDetection::test( - pipeline_id, - account_id, - file_id, + seeded.pipeline_id, + seeded.account_id, + seeded.file_id, )) .await?; let _ = conn @@ -816,20 +809,20 @@ mod tests { #[tokio::test] async fn find_by_id_is_scoped_to_workspace_and_live_pipeline() -> anyhow::Result<()> { let db = TestDatabase::start().await; - let (account_id, workspace_id, pipeline_id, file_id) = db.seed_pipeline_and_file().await; + let seeded = db.seed_pipeline_and_file().await; let mut conn = db.client.get_connection().await?; let detection = conn .create_workspace_detection(NewWorkspaceDetection::test( - pipeline_id, - account_id, - file_id, + seeded.pipeline_id, + seeded.account_id, + seeded.file_id, )) .await?; // Found within its own workspace. assert!( - conn.find_workspace_detection_by_id(workspace_id, detection.id) + conn.find_workspace_detection_by_id(seeded.workspace_id, detection.id) .await? .is_some() ); @@ -845,21 +838,22 @@ mod tests { #[tokio::test] async fn idempotency_key_lookup_is_scoped_to_the_pipeline() -> anyhow::Result<()> { let db = TestDatabase::start().await; - let (account_id, _ws, pipeline_id, file_id) = db.seed_pipeline_and_file().await; + let seeded = db.seed_pipeline_and_file().await; let mut conn = db.client.get_connection().await?; - let mut detection = NewWorkspaceDetection::test(pipeline_id, account_id, file_id); + let mut detection = + NewWorkspaceDetection::test(seeded.pipeline_id, seeded.account_id, seeded.file_id); detection.idempotency_key = Some("key-123".to_owned()); let detection = conn.create_workspace_detection(detection).await?; let found = conn - .find_detection_by_idempotency_key(pipeline_id, "key-123") + .find_detection_by_idempotency_key(seeded.pipeline_id, "key-123") .await?; assert_eq!(found.map(|d| d.id), Some(detection.id)); // A different key does not match. assert!( - conn.find_detection_by_idempotency_key(pipeline_id, "other") + conn.find_detection_by_idempotency_key(seeded.pipeline_id, "other") .await? .is_none() ); @@ -869,22 +863,22 @@ mod tests { #[tokio::test] async fn cursor_list_filters_by_status_and_names_the_input_file() -> anyhow::Result<()> { let db = TestDatabase::start().await; - let (account_id, _ws, pipeline_id, file_id) = db.seed_pipeline_and_file().await; + let seeded = db.seed_pipeline_and_file().await; let mut conn = db.client.get_connection().await?; // A pending detection and a completed one on the same pipeline+file. let pending = conn .create_workspace_detection(NewWorkspaceDetection::test( - pipeline_id, - account_id, - file_id, + seeded.pipeline_id, + seeded.account_id, + seeded.file_id, )) .await?; let to_complete = conn .create_workspace_detection(NewWorkspaceDetection::test( - pipeline_id, - account_id, - file_id, + seeded.pipeline_id, + seeded.account_id, + seeded.file_id, )) .await?; let claimed = conn @@ -902,7 +896,7 @@ mod tests { // Filter to Pending: only the pending detection, and the input file is named. let page = conn .cursor_list_pipeline_detections( - pipeline_id, + seeded.pipeline_id, CursorPagination::new(50), &DetectionFilter { status: Some(DetectionStatus::Pending), diff --git a/crates/nvisy-postgres/src/query/workspace_detection_job.rs b/crates/nvisy-postgres/src/query/workspace_detection_job.rs index d545bb43..c80bd560 100644 --- a/crates/nvisy-postgres/src/query/workspace_detection_job.rs +++ b/crates/nvisy-postgres/src/query/workspace_detection_job.rs @@ -159,13 +159,13 @@ mod tests { /// Seeds a detection and returns its id — the FK parent an outbox row needs. async fn seed_detection(db: &TestDatabase) -> anyhow::Result { - let (account_id, _ws, pipeline_id, file_id) = db.seed_pipeline_and_file().await; + let seeded = db.seed_pipeline_and_file().await; let mut conn = db.client.get_connection().await?; let detection = conn .create_workspace_detection(NewWorkspaceDetection::test( - pipeline_id, - account_id, - file_id, + seeded.pipeline_id, + seeded.account_id, + seeded.file_id, )) .await?; Ok(detection.id) diff --git a/crates/nvisy-postgres/src/query/workspace_file.rs b/crates/nvisy-postgres/src/query/workspace_file.rs index a761af99..352abff5 100644 --- a/crates/nvisy-postgres/src/query/workspace_file.rs +++ b/crates/nvisy-postgres/src/query/workspace_file.rs @@ -4,7 +4,9 @@ use std::future::Future; use diesel::prelude::*; use diesel_async::RunQueryDsl; +use jiff::Timestamp; use pgtrgm::expression_methods::TrgmExpressionMethods; +use serde::{Deserialize, Serialize}; use uuid::Uuid; use crate::model::{ @@ -14,8 +16,18 @@ use crate::model::{ use crate::query::search::ilike_contains; use crate::types::{ AccountRefRow, CursorPage, CursorPagination, DetectionStatus, FileFilter, FileKind, - WithAccountRef, + WithAccountRef, keyset, }; + +/// Keyset for paginating a workspace's files: newest first by `created_at`, `id` +/// as the tiebreaker. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FileCursor { + /// When the file was created. + pub created_at: Timestamp, + /// File id (tiebreaker). + pub id: uuid::Uuid, +} use crate::{Error, PgConnection, Result, schema}; /// A live file imported from a connection, for deletion reconciliation. @@ -145,7 +157,7 @@ pub trait WorkspaceFileRepository { /// Recomputes `expires_at` for live files of `kind` in `workspace_id`, /// returning the number updated. Used to backfill when retention settings - /// change. `None` clears the expiry (retention became `Forever`). + /// change. `None` clears the expiry (retention became `Persistent`). fn backfill_files_expiry( &mut self, workspace_id: Uuid, @@ -181,7 +193,7 @@ pub trait WorkspaceFileRepository { fn cursor_list_workspace_files( &mut self, workspace_id: Uuid, - pagination: CursorPagination, + pagination: CursorPagination, filter: FileFilter, ) -> impl Future>>> + Send; @@ -619,7 +631,7 @@ impl WorkspaceFileRepository for PgConnection { async fn cursor_list_workspace_files( &mut self, workspace_id: Uuid, - pagination: CursorPagination, + pagination: CursorPagination, filter: FileFilter, ) -> Result>> { use schema::workspace_files::dsl; @@ -701,33 +713,11 @@ impl WorkspaceFileRepository for PgConnection { query = query.filter(dsl::file_hash_sha256.eq(hash)); } - let limit = pagination.fetch_limit(); - - // Apply cursor filter if present - let rows: Vec<(WorkspaceFile, AccountRefRow)> = 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(( - WorkspaceFile::as_select(), - ( - accounts::username, - accounts::display_name, - accounts::avatar_url, - ), - )) - .order((dsl::created_at.desc(), dsl::id.desc())) - .limit(limit) - .load(self) - .await - .map_err(Error::from)? - } else { - query + let after = pagination + .after_key() + .map(|k| (jiff_diesel::Timestamp::from(k.created_at), k.id)); + let rows: Vec<(WorkspaceFile, AccountRefRow)> = + keyset!(query, dsl::created_at, dsl::id, pagination.direction, after) .select(( WorkspaceFile::as_select(), ( @@ -736,12 +726,10 @@ impl WorkspaceFileRepository for PgConnection { accounts::avatar_url, ), )) - .order((dsl::created_at.desc(), dsl::id.desc())) - .limit(limit) + .limit(pagination.fetch_limit()) .load(self) .await - .map_err(Error::from)? - }; + .map_err(Error::from)?; let items: Vec> = rows .into_iter() @@ -749,7 +737,10 @@ impl WorkspaceFileRepository for PgConnection { .collect(); Ok(CursorPage::new(items, total, pagination.limit, |wc| { - (wc.item.created_at.into(), wc.item.id) + FileCursor { + created_at: wc.item.created_at.into(), + id: wc.item.id, + } })) } @@ -847,21 +838,24 @@ mod tests { #[tokio::test] async fn create_and_scoped_lookups_exclude_soft_deleted() -> anyhow::Result<()> { let db = TestDatabase::start().await; - let (account_id, workspace_id) = db.seed_account_and_workspace().await; + let seeded = db.seed_account_and_workspace().await; let mut conn = db.client.get_connection().await?; let file = conn - .create_workspace_file(NewWorkspaceFile::test(workspace_id, account_id)) + .create_workspace_file(NewWorkspaceFile::test( + seeded.workspace_id, + seeded.account_id, + )) .await?; assert!(conn.find_workspace_file_by_id(file.id).await?.is_some()); assert!( - conn.find_file_in_workspace(workspace_id, file.id) + conn.find_file_in_workspace(seeded.workspace_id, file.id) .await? .is_some() ); assert!( - conn.find_file_in_workspace_with_creator(workspace_id, file.id) + conn.find_file_in_workspace_with_creator(seeded.workspace_id, file.id) .await? .is_some() ); @@ -876,7 +870,7 @@ mod tests { conn.delete_workspace_file(file.id).await?; assert!(conn.find_workspace_file_by_id(file.id).await?.is_none()); assert!( - conn.find_file_in_workspace(workspace_id, file.id) + conn.find_file_in_workspace(seeded.workspace_id, file.id) .await? .is_none() ); @@ -886,25 +880,28 @@ mod tests { #[tokio::test] async fn cursor_list_only_documents_and_filters_by_extension_and_hash() -> anyhow::Result<()> { let db = TestDatabase::start().await; - let (account_id, workspace_id) = db.seed_account_and_workspace().await; + let seeded = db.seed_account_and_workspace().await; let mut conn = db.client.get_connection().await?; // A pdf original document, a txt original, and an audit blob (not a document). - let mut pdf = NewWorkspaceFile::test(workspace_id, account_id); + let mut pdf = NewWorkspaceFile::test(seeded.workspace_id, seeded.account_id); pdf.file_extension = Some("pdf".to_owned()); pdf.file_hash_sha256 = vec![7u8; 32]; let pdf = conn.create_workspace_file(pdf).await?; let txt = conn - .create_workspace_file(NewWorkspaceFile::test(workspace_id, account_id)) + .create_workspace_file(NewWorkspaceFile::test( + seeded.workspace_id, + seeded.account_id, + )) .await?; - let mut audit = NewWorkspaceFile::test(workspace_id, account_id); + let mut audit = NewWorkspaceFile::test(seeded.workspace_id, seeded.account_id); audit.file_kind = Some(FileKind::Audit); let audit = conn.create_workspace_file(audit).await?; // The document listing excludes the audit blob. let all = conn .cursor_list_workspace_files( - workspace_id, + seeded.workspace_id, CursorPagination::new(50), FileFilter::default(), ) @@ -916,7 +913,7 @@ mod tests { // Extension filter narrows to the pdf. let pdfs = conn .cursor_list_workspace_files( - workspace_id, + seeded.workspace_id, CursorPagination::new(50), FileFilter { extensions: Some(vec!["pdf".to_owned()]), @@ -932,7 +929,7 @@ mod tests { // Exact-hash filter (dedup lookup) finds the pdf by its content hash. let by_hash = conn .cursor_list_workspace_files( - workspace_id, + seeded.workspace_id, CursorPagination::new(50), FileFilter { hash: Some(vec![7u8; 32]), @@ -948,7 +945,7 @@ mod tests { // A present-but-empty extension set matches nothing (an active facet). let none = conn .cursor_list_workspace_files( - workspace_id, + seeded.workspace_id, CursorPagination::new(50), FileFilter { extensions: Some(vec![]), @@ -963,15 +960,18 @@ mod tests { #[tokio::test] async fn import_origin_round_trips_and_is_dropped_on_delete() -> anyhow::Result<()> { let db = TestDatabase::start().await; - let (account_id, workspace_id) = db.seed_account_and_workspace().await; + let seeded = db.seed_account_and_workspace().await; let mut conn = db.client.get_connection().await?; let connection = conn - .create_workspace_connection(NewWorkspaceConnection::test(workspace_id, account_id)) + .create_workspace_connection(NewWorkspaceConnection::test( + seeded.workspace_id, + seeded.account_id, + )) .await?; let file = conn .record_imported_file( - NewWorkspaceFile::test(workspace_id, account_id), + NewWorkspaceFile::test(seeded.workspace_id, seeded.account_id), connection.id, "remote/key.pdf".to_owned(), ) @@ -1002,17 +1002,20 @@ mod tests { #[tokio::test] async fn redacted_files_not_exported_excludes_already_exported() -> anyhow::Result<()> { let db = TestDatabase::start().await; - let (account_id, workspace_id) = db.seed_account_and_workspace().await; + let seeded = db.seed_account_and_workspace().await; let mut conn = db.client.get_connection().await?; let connection = conn - .create_workspace_connection(NewWorkspaceConnection::test(workspace_id, account_id)) + .create_workspace_connection(NewWorkspaceConnection::test( + seeded.workspace_id, + seeded.account_id, + )) .await?; // Two redacted files; one already exported to the connection. - let mut a = NewWorkspaceFile::test(workspace_id, account_id); + let mut a = NewWorkspaceFile::test(seeded.workspace_id, seeded.account_id); a.file_kind = Some(FileKind::Redacted); let a = conn.create_workspace_file(a).await?; - let mut b = NewWorkspaceFile::test(workspace_id, account_id); + let mut b = NewWorkspaceFile::test(seeded.workspace_id, seeded.account_id); b.file_kind = Some(FileKind::Redacted); let b = conn.create_workspace_file(b).await?; conn.record_exported_file(a.id, connection.id, "out/a.pdf".to_owned()) @@ -1020,7 +1023,7 @@ mod tests { // Only the not-yet-exported redacted file is returned. let pending = conn - .redacted_files_not_exported(workspace_id, connection.id) + .redacted_files_not_exported(seeded.workspace_id, connection.id) .await?; assert_eq!(pending.iter().map(|f| f.id).collect::>(), vec![b.id]); Ok(()) @@ -1029,18 +1032,18 @@ mod tests { #[tokio::test] async fn expiry_sweep_holds_files_of_in_progress_detections() -> anyhow::Result<()> { let db = TestDatabase::start().await; - let (account_id, workspace_id, pipeline_id, _seed_file) = db.seed_pipeline_and_file().await; + let seeded = db.seed_pipeline_and_file().await; let mut conn = db.client.get_connection().await?; // A free expired file: eligible for the sweep. - let free = expired_file(&mut conn, workspace_id, account_id).await?; + let free = expired_file(&mut conn, seeded.workspace_id, seeded.account_id).await?; // An expired file that is the input of a Pending detection. - let held_file = expired_file(&mut conn, workspace_id, account_id).await?; + let held_file = expired_file(&mut conn, seeded.workspace_id, seeded.account_id).await?; let _detection = conn .create_workspace_detection(NewWorkspaceDetection::test( - pipeline_id, - account_id, + seeded.pipeline_id, + seeded.account_id, held_file.id, )) .await?; @@ -1059,11 +1062,14 @@ mod tests { #[tokio::test] async fn purge_lifecycle_lists_then_stamps() -> anyhow::Result<()> { let db = TestDatabase::start().await; - let (account_id, workspace_id) = db.seed_account_and_workspace().await; + let seeded = db.seed_account_and_workspace().await; let mut conn = db.client.get_connection().await?; let file = conn - .create_workspace_file(NewWorkspaceFile::test(workspace_id, account_id)) + .create_workspace_file(NewWorkspaceFile::test( + seeded.workspace_id, + seeded.account_id, + )) .await?; // A live file is not pending purge. @@ -1099,19 +1105,25 @@ mod tests { #[tokio::test] async fn delete_files_in_workspace_transitions_only_live_scoped_rows() -> anyhow::Result<()> { let db = TestDatabase::start().await; - let (account_id, workspace_id) = db.seed_account_and_workspace().await; + let seeded = db.seed_account_and_workspace().await; let mut conn = db.client.get_connection().await?; let a = conn - .create_workspace_file(NewWorkspaceFile::test(workspace_id, account_id)) + .create_workspace_file(NewWorkspaceFile::test( + seeded.workspace_id, + seeded.account_id, + )) .await?; let b = conn - .create_workspace_file(NewWorkspaceFile::test(workspace_id, account_id)) + .create_workspace_file(NewWorkspaceFile::test( + seeded.workspace_id, + seeded.account_id, + )) .await?; // Deleting [a, b, unknown] returns exactly the two live rows it changed. let deleted = conn - .delete_files_in_workspace(workspace_id, &[a.id, b.id, Uuid::now_v7()]) + .delete_files_in_workspace(seeded.workspace_id, &[a.id, b.id, Uuid::now_v7()]) .await?; let mut deleted_ids: Vec<_> = deleted.iter().map(|f| f.id).collect(); deleted_ids.sort(); @@ -1121,7 +1133,7 @@ mod tests { // A second call transitions nothing (already deleted). assert!( - conn.delete_files_in_workspace(workspace_id, &[a.id, b.id]) + conn.delete_files_in_workspace(seeded.workspace_id, &[a.id, b.id]) .await? .is_empty() ); diff --git a/crates/nvisy-postgres/src/query/workspace_invite.rs b/crates/nvisy-postgres/src/query/workspace_invite.rs index 62ba1ef4..219e8a8f 100644 --- a/crates/nvisy-postgres/src/query/workspace_invite.rs +++ b/crates/nvisy-postgres/src/query/workspace_invite.rs @@ -5,15 +5,38 @@ use std::future::Future; use diesel::prelude::*; use diesel_async::RunQueryDsl; use jiff::Timestamp; +use serde::{Deserialize, Serialize}; use uuid::Uuid; use crate::model::{NewWorkspaceInvite, UpdateWorkspaceInvite, WorkspaceInvite}; use crate::types::{ - CursorPage, CursorPagination, InviteFilter, InviteSortBy, InviteSortField, InviteStatus, - SortOrder, + CursorPage, CursorPagination, InviteFilter, InviteSortBy, InviteSortField, InviteStatus, keyset, }; use crate::{Error, PgConnection, Result, schema}; +/// Keyset for paginating workspace invites. Invites can be sorted by date or by +/// email, so the cursor carries whichever field the sort uses — the keyset +/// comparison must run on the same column it orders by, or paging drifts. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "by", rename_all = "camelCase")] +pub enum InviteCursor { + /// Sorted by creation time. + Date { + /// When the invite was created. + created_at: Timestamp, + /// Invite id (tiebreaker). + id: Uuid, + }, + /// Sorted by invitee email. + Email { + /// The invitee email (invites with a null email are excluded from this + /// sort). + email: String, + /// Invite id (tiebreaker). + id: Uuid, + }, +} + /// Repository for workspace invitation database operations. /// /// Handles workspace invitations including creation, acceptance, rejection, and token @@ -70,7 +93,7 @@ pub trait WorkspaceInviteRepository { fn cursor_list_workspace_invites( &mut self, workspace_id: Uuid, - pagination: CursorPagination, + pagination: CursorPagination, sort_by: InviteSortBy, filter: InviteFilter, ) -> impl Future>> + Send; @@ -199,7 +222,7 @@ impl WorkspaceInviteRepository for PgConnection { async fn cursor_list_workspace_invites( &mut self, workspace_id: Uuid, - pagination: CursorPagination, + pagination: CursorPagination, sort_by: InviteSortBy, filter: InviteFilter, ) -> Result> { @@ -207,42 +230,32 @@ impl WorkspaceInviteRepository for PgConnection { use schema::workspace_invites::{self, dsl}; let sort_by_email = matches!(sort_by.field, InviteSortField::Email); + // The invite sort order is the keyset direction, so the order-by and the + // after-comparison always agree. + let direction = sort_by.order; let base_filter = dsl::workspace_id .eq(workspace_id) .and(dsl::invite_status.ne(InviteStatus::Canceled)); - // Build filtered query - let mut query = workspace_invites::table - .filter(base_filter.clone()) - .into_boxed(); - - if let Some(role) = filter.role { - query = query.filter(dsl::invited_role.eq(role)); - } - if sort_by_email { - query = query.filter(dsl::invitee_email.is_not_null()); - } - if let Some(cursor) = &pagination.after { - let cursor_ts = jiff_diesel::Timestamp::from(cursor.timestamp); - query = query.filter( - dsl::created_at - .lt(cursor_ts) - .or(dsl::created_at.eq(cursor_ts).and(dsl::id.lt(cursor.id))), - ); - } - - // Get total count - let total = if pagination.include_count { - let mut count_query = workspace_invites::table.filter(base_filter).into_boxed(); + // The scoped builder (filters shared by the count and the page). When + // sorting by email, null emails are excluded so the sort column is total. + let scoped = || { + let mut query = workspace_invites::table + .filter(base_filter.clone()) + .into_boxed(); if let Some(role) = filter.role { - count_query = count_query.filter(dsl::invited_role.eq(role)); + query = query.filter(dsl::invited_role.eq(role)); } if sort_by_email { - count_query = count_query.filter(dsl::invitee_email.is_not_null()); + query = query.filter(dsl::invitee_email.is_not_null()); } + query + }; + + let total = if pagination.include_count { Some( - count_query + scoped() .select(count_star()) .get_result(self) .await @@ -252,19 +265,25 @@ impl WorkspaceInviteRepository for PgConnection { None }; - // Execute with sort - let items = match (sort_by.field, sort_by.order) { - (InviteSortField::Email, SortOrder::Asc) => { - query.order((dsl::invitee_email.asc(), dsl::id.asc())) + // The keyset runs on whichever column the sort uses; the cursor carries the + // matching value, so a stray Email cursor on a Date sort (or vice-versa) + // simply starts a fresh page rather than drifting. + let items = match sort_by.field { + InviteSortField::Email => { + let after = match pagination.after_key() { + Some(InviteCursor::Email { email, id }) => Some((email.clone(), *id)), + _ => None, + }; + keyset!(scoped(), dsl::invitee_email, dsl::id, direction, after) } - (InviteSortField::Email, SortOrder::Desc) => { - query.order((dsl::invitee_email.desc(), dsl::id.desc())) - } - (InviteSortField::Date, SortOrder::Asc) => { - query.order((dsl::created_at.asc(), dsl::id.asc())) - } - (InviteSortField::Date, SortOrder::Desc) => { - query.order((dsl::created_at.desc(), dsl::id.desc())) + InviteSortField::Date => { + let after = match pagination.after_key() { + Some(InviteCursor::Date { created_at, id }) => { + Some((jiff_diesel::Timestamp::from(*created_at), *id)) + } + _ => None, + }; + keyset!(scoped(), dsl::created_at, dsl::id, direction, after) } } .select(WorkspaceInvite::as_select()) @@ -273,8 +292,19 @@ impl WorkspaceInviteRepository for PgConnection { .await .map_err(Error::from)?; - Ok(CursorPage::new(items, total, pagination.limit, |i| { - (i.created_at.into(), i.id) + Ok(CursorPage::new(items, total, pagination.limit, move |i| { + if sort_by_email { + InviteCursor::Email { + // A null email cannot appear here — the sort filters them out. + email: i.invitee_email.clone().unwrap_or_default(), + id: i.id, + } + } else { + InviteCursor::Date { + created_at: i.created_at.into(), + id: i.id, + } + } })) } @@ -310,7 +340,7 @@ mod tests { use crate::model::{NewWorkspaceInvite, WorkspaceInvite}; use crate::query::{WorkspaceInviteRepository, WorkspaceRepository}; use crate::test_util::TestDatabase; - use crate::types::{InviteSortBy, InviteSortField, SortOrder, WorkspaceRole}; + use crate::types::{Direction, InviteSortBy, InviteSortField, WorkspaceRole}; /// Creates an invite addressed to `email` (the `New*` test constructor leaves /// `invitee_email` NULL, an open invite code, so set it on the struct). @@ -328,11 +358,14 @@ mod tests { #[tokio::test] async fn create_defaults_and_lookups_are_workspace_scoped() -> anyhow::Result<()> { let db = TestDatabase::start().await; - let (owner_id, workspace_id) = db.seed_account_and_workspace().await; + let seeded = db.seed_account_and_workspace().await; let mut conn = db.client.get_connection().await?; let invite = conn - .create_workspace_invite(NewWorkspaceInvite::test(workspace_id, owner_id)) + .create_workspace_invite(NewWorkspaceInvite::test( + seeded.workspace_id, + seeded.account_id, + )) .await?; // Database defaults are applied. assert_eq!(invite.invite_status, InviteStatus::Pending); @@ -348,12 +381,12 @@ mod tests { // Found in its own workspace, not in another. assert!( - conn.find_invite_in_workspace(workspace_id, invite.id) + conn.find_invite_in_workspace(seeded.workspace_id, invite.id) .await? .is_some() ); let other_ws = conn - .create_workspace(crate::model::NewWorkspace::test(owner_id)) + .create_workspace(crate::model::NewWorkspace::test(seeded.account_id)) .await? .id; assert!( @@ -367,30 +400,45 @@ mod tests { #[tokio::test] async fn accept_reject_cancel_set_status_and_audit_fields() -> anyhow::Result<()> { let db = TestDatabase::start().await; - let (owner_id, workspace_id) = db.seed_account_and_workspace().await; + let seeded = db.seed_account_and_workspace().await; let mut conn = db.client.get_connection().await?; // Accept records a status and a response timestamp. let accepted = conn - .create_workspace_invite(NewWorkspaceInvite::test(workspace_id, owner_id)) + .create_workspace_invite(NewWorkspaceInvite::test( + seeded.workspace_id, + seeded.account_id, + )) + .await?; + let accepted = conn + .accept_workspace_invite(accepted.id, seeded.account_id) .await?; - let accepted = conn.accept_workspace_invite(accepted.id, owner_id).await?; assert_eq!(accepted.invite_status, InviteStatus::Accepted); assert!(accepted.responded_at.is_some()); // Reject records the declining actor as `updated_by`. let rejected = conn - .create_workspace_invite(NewWorkspaceInvite::test(workspace_id, owner_id)) + .create_workspace_invite(NewWorkspaceInvite::test( + seeded.workspace_id, + seeded.account_id, + )) + .await?; + let rejected = conn + .reject_workspace_invite(rejected.id, seeded.account_id) .await?; - let rejected = conn.reject_workspace_invite(rejected.id, owner_id).await?; assert_eq!(rejected.invite_status, InviteStatus::Declined); - assert_eq!(rejected.updated_by, owner_id); + assert_eq!(rejected.updated_by, seeded.account_id); // Cancel moves to Canceled. let canceled = conn - .create_workspace_invite(NewWorkspaceInvite::test(workspace_id, owner_id)) + .create_workspace_invite(NewWorkspaceInvite::test( + seeded.workspace_id, + seeded.account_id, + )) + .await?; + let canceled = conn + .cancel_workspace_invite(canceled.id, seeded.account_id) .await?; - let canceled = conn.cancel_workspace_invite(canceled.id, owner_id).await?; assert_eq!(canceled.invite_status, InviteStatus::Canceled); Ok(()) } @@ -399,25 +447,40 @@ mod tests { async fn find_pending_by_email_matches_only_pending_unexpired_in_workspace() -> anyhow::Result<()> { let db = TestDatabase::start().await; - let (owner_id, workspace_id) = db.seed_account_and_workspace().await; + let seeded = db.seed_account_and_workspace().await; let mut conn = db.client.get_connection().await?; // A pending invite with the target email matches. - let pending = - invite_with_email(&mut conn, workspace_id, owner_id, "invitee@example.com").await?; + let pending = invite_with_email( + &mut conn, + seeded.workspace_id, + seeded.account_id, + "invitee@example.com", + ) + .await?; let found = conn - .find_pending_workspace_invite_by_email(workspace_id, "invitee@example.com") + .find_pending_workspace_invite_by_email(seeded.workspace_id, "invitee@example.com") .await?; assert_eq!(found.map(|i| i.id), Some(pending.id)); // An accepted invite with the same email does NOT match. - let accepted = - invite_with_email(&mut conn, workspace_id, owner_id, "accepted@example.com").await?; - let _ = conn.accept_workspace_invite(accepted.id, owner_id).await?; + let accepted = invite_with_email( + &mut conn, + seeded.workspace_id, + seeded.account_id, + "accepted@example.com", + ) + .await?; + let _ = conn + .accept_workspace_invite(accepted.id, seeded.account_id) + .await?; assert!( - conn.find_pending_workspace_invite_by_email(workspace_id, "accepted@example.com") - .await? - .is_none() + conn.find_pending_workspace_invite_by_email( + seeded.workspace_id, + "accepted@example.com" + ) + .await? + .is_none() ); // The right email in the wrong workspace does not match. @@ -432,27 +495,35 @@ mod tests { #[tokio::test] async fn cursor_list_excludes_canceled_and_applies_role_filter() -> anyhow::Result<()> { let db = TestDatabase::start().await; - let (owner_id, workspace_id) = db.seed_account_and_workspace().await; + let seeded = db.seed_account_and_workspace().await; let mut conn = db.client.get_connection().await?; // A pending reviewer invite, an editor invite, and a canceled one. let reviewer = conn - .create_workspace_invite(NewWorkspaceInvite::test(workspace_id, owner_id)) + .create_workspace_invite(NewWorkspaceInvite::test( + seeded.workspace_id, + seeded.account_id, + )) .await?; - let mut editor = NewWorkspaceInvite::test(workspace_id, owner_id); + let mut editor = NewWorkspaceInvite::test(seeded.workspace_id, seeded.account_id); editor.invited_role = Some(WorkspaceRole::Editor); let editor = conn.create_workspace_invite(editor).await?; let canceled = conn - .create_workspace_invite(NewWorkspaceInvite::test(workspace_id, owner_id)) + .create_workspace_invite(NewWorkspaceInvite::test( + seeded.workspace_id, + seeded.account_id, + )) + .await?; + let _ = conn + .cancel_workspace_invite(canceled.id, seeded.account_id) .await?; - let _ = conn.cancel_workspace_invite(canceled.id, owner_id).await?; - let sort = InviteSortBy::new(InviteSortField::Date, SortOrder::Desc); + let sort = InviteSortBy::new(InviteSortField::Date, Direction::Descending); // No role filter: both non-canceled invites, canceled excluded. let all = conn .cursor_list_workspace_invites( - workspace_id, + seeded.workspace_id, CursorPagination::new(50), sort, InviteFilter::default(), @@ -466,7 +537,7 @@ mod tests { // Role filter narrows to the editor invite. let editors = conn .cursor_list_workspace_invites( - workspace_id, + seeded.workspace_id, CursorPagination::new(50), sort, InviteFilter { @@ -484,24 +555,37 @@ mod tests { #[tokio::test] async fn cursor_list_sorted_by_email_excludes_null_emails() -> anyhow::Result<()> { let db = TestDatabase::start().await; - let (owner_id, workspace_id) = db.seed_account_and_workspace().await; + let seeded = db.seed_account_and_workspace().await; let mut conn = db.client.get_connection().await?; // Two invites with emails, one open (null-email) code. - let bravo = - invite_with_email(&mut conn, workspace_id, owner_id, "bravo@example.com").await?; - let alpha = - invite_with_email(&mut conn, workspace_id, owner_id, "alpha@example.com").await?; + let bravo = invite_with_email( + &mut conn, + seeded.workspace_id, + seeded.account_id, + "bravo@example.com", + ) + .await?; + let alpha = invite_with_email( + &mut conn, + seeded.workspace_id, + seeded.account_id, + "alpha@example.com", + ) + .await?; let _open = conn - .create_workspace_invite(NewWorkspaceInvite::test(workspace_id, owner_id)) + .create_workspace_invite(NewWorkspaceInvite::test( + seeded.workspace_id, + seeded.account_id, + )) .await?; // Sorting by email ascending drops the null-email invite and orders the rest. let page = conn .cursor_list_workspace_invites( - workspace_id, + seeded.workspace_id, CursorPagination::new(50), - InviteSortBy::new(InviteSortField::Email, SortOrder::Asc), + InviteSortBy::new(InviteSortField::Email, Direction::Ascending), InviteFilter::default(), ) .await?; diff --git a/crates/nvisy-postgres/src/query/workspace_member.rs b/crates/nvisy-postgres/src/query/workspace_member.rs index 514d815f..1d4daa7a 100644 --- a/crates/nvisy-postgres/src/query/workspace_member.rs +++ b/crates/nvisy-postgres/src/query/workspace_member.rs @@ -4,6 +4,8 @@ use std::future::Future; use diesel::prelude::*; use diesel_async::RunQueryDsl; +use jiff::Timestamp; +use serde::{Deserialize, Serialize}; use uuid::Uuid; use crate::model::{ @@ -11,10 +13,31 @@ use crate::model::{ }; use crate::types::{ AccountRefRow, CursorPage, CursorPagination, Handle, MemberFilter, NotificationEvent, - OffsetPagination, WorkspaceRole, + OffsetPagination, WorkspaceRole, keyset, }; use crate::{Error, PgConnection, Result, schema}; +/// Keyset for paginating an account's workspaces: newest membership first by +/// `created_at`, with the workspace id as the tiebreaker (a member row has a +/// composite key, so there is no single `id` column). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AccountWorkspaceCursor { + /// When the membership was created. + pub created_at: Timestamp, + /// Workspace id (tiebreaker). + pub workspace_id: uuid::Uuid, +} + +/// Keyset for paginating a workspace's members: newest membership first by +/// `created_at`, with the account id as the tiebreaker. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WorkspaceMemberCursor { + /// When the membership was created. + pub created_at: Timestamp, + /// Account id (tiebreaker). + pub account_id: uuid::Uuid, +} + /// Repository for workspace member database operations. /// /// Handles workspace membership management including CRUD operations, role-based @@ -70,7 +93,7 @@ pub trait WorkspaceMemberRepository { fn cursor_list_account_workspaces_with_details( &mut self, account_id: Uuid, - pagination: CursorPagination, + pagination: CursorPagination, ) -> impl Future>> + Send; /// Returns the account ids of members holding any of `roles` who accept @@ -92,7 +115,7 @@ pub trait WorkspaceMemberRepository { fn cursor_list_workspace_members_with_accounts( &mut self, workspace_id: Uuid, - pagination: CursorPagination, + pagination: CursorPagination, filter: MemberFilter, ) -> impl Future>> + Send; @@ -248,7 +271,7 @@ impl WorkspaceMemberRepository for PgConnection { async fn cursor_list_account_workspaces_with_details( &mut self, account_id: Uuid, - pagination: CursorPagination, + pagination: CursorPagination, ) -> Result> { use diesel::dsl::count_star; use schema::{accounts, workspace_members, workspaces}; @@ -275,50 +298,43 @@ impl WorkspaceMemberRepository for PgConnection { None }; - // Build query - let mut query = workspace_members::table + let query = workspace_members::table .inner_join(workspaces::table.on(workspaces::id.eq(workspace_members::workspace_id))) .inner_join(accounts::table.on(accounts::id.eq(workspaces::created_by))) .filter(base_filter) .into_boxed(); - // Apply cursor filter if present - if let Some(cursor) = &pagination.after { - let cursor_ts = jiff_diesel::Timestamp::from(cursor.timestamp); - query = query.filter( - workspace_members::created_at - .lt(cursor_ts) - .or(workspace_members::created_at - .eq(cursor_ts) - .and(workspace_members::workspace_id.lt(cursor.id))), - ); - } - - let items = query - .order(( - workspace_members::created_at.desc(), - workspace_members::workspace_id.desc(), - )) - .limit(pagination.fetch_limit()) - .select(( - Workspace::as_select(), - WorkspaceMember::as_select(), - ( - accounts::username, - accounts::display_name, - accounts::avatar_url, - ), - )) - .load(self) - .await - .map_err(Error::from)?; + let after = pagination + .after_key() + .map(|k| (jiff_diesel::Timestamp::from(k.created_at), k.workspace_id)); + let items = keyset!( + query, + workspace_members::created_at, + workspace_members::workspace_id, + pagination.direction, + after + ) + .limit(pagination.fetch_limit()) + .select(( + Workspace::as_select(), + WorkspaceMember::as_select(), + ( + accounts::username, + accounts::display_name, + accounts::avatar_url, + ), + )) + .load(self) + .await + .map_err(Error::from)?; Ok(CursorPage::new( items, total, pagination.limit, - |(_, m, _): &(Workspace, WorkspaceMember, AccountRefRow)| { - (m.created_at.into(), m.workspace_id) + |(_, m, _): &(Workspace, WorkspaceMember, AccountRefRow)| AccountWorkspaceCursor { + created_at: m.created_at.into(), + workspace_id: m.workspace_id, }, )) } @@ -356,7 +372,7 @@ impl WorkspaceMemberRepository for PgConnection { async fn cursor_list_workspace_members_with_accounts( &mut self, workspace_id: Uuid, - pagination: CursorPagination, + pagination: CursorPagination, filter: MemberFilter, ) -> Result> { use diesel::dsl::count_star; @@ -399,41 +415,27 @@ impl WorkspaceMemberRepository for PgConnection { query = query.filter(workspace_members::member_role.eq(role)); } - // Apply cursor filter if present - let items = if let Some(cursor) = &pagination.after { - let cursor_ts = jiff_diesel::Timestamp::from(cursor.timestamp); - query - .filter( - workspace_members::created_at - .lt(cursor_ts) - .or(workspace_members::created_at - .eq(cursor_ts) - .and(workspace_members::account_id.lt(cursor.id))), - ) - .order(( - workspace_members::created_at.desc(), - workspace_members::account_id.desc(), - )) - .limit(pagination.fetch_limit()) - .select((WorkspaceMember::as_select(), Account::as_select())) - .load(self) - .await - .map_err(Error::from)? - } else { - query - .order(( - workspace_members::created_at.desc(), - workspace_members::account_id.desc(), - )) - .limit(pagination.fetch_limit()) - .select((WorkspaceMember::as_select(), Account::as_select())) - .load(self) - .await - .map_err(Error::from)? - }; + let after = pagination + .after_key() + .map(|k| (jiff_diesel::Timestamp::from(k.created_at), k.account_id)); + let items = keyset!( + query, + workspace_members::created_at, + workspace_members::account_id, + pagination.direction, + after + ) + .limit(pagination.fetch_limit()) + .select((WorkspaceMember::as_select(), Account::as_select())) + .load(self) + .await + .map_err(Error::from)?; Ok(CursorPage::new(items, total, pagination.limit, |(m, _)| { - (m.created_at.into(), m.account_id) + WorkspaceMemberCursor { + created_at: m.created_at.into(), + account_id: m.account_id, + } })) } @@ -524,28 +526,28 @@ mod tests { #[tokio::test] async fn add_find_update_remove_round_trip() -> anyhow::Result<()> { let db = TestDatabase::start().await; - let (owner_id, workspace_id) = db.seed_account_and_workspace().await; + let seeded = db.seed_account_and_workspace().await; let mut conn = db.client.get_connection().await?; let member = conn .add_workspace_member(NewWorkspaceMember::new( - workspace_id, - owner_id, + seeded.workspace_id, + seeded.account_id, WorkspaceRole::Owner, )) .await?; assert_eq!(member.member_role, WorkspaceRole::Owner); let found = conn - .find_workspace_member(workspace_id, owner_id) + .find_workspace_member(seeded.workspace_id, seeded.account_id) .await? .expect("member should exist"); - assert_eq!(found.account_id, owner_id); + assert_eq!(found.account_id, seeded.account_id); let updated = conn .update_workspace_member( - workspace_id, - owner_id, + seeded.workspace_id, + seeded.account_id, UpdateWorkspaceMember { member_role: Some(WorkspaceRole::Admin), ..Default::default() @@ -554,9 +556,10 @@ mod tests { .await?; assert_eq!(updated.member_role, WorkspaceRole::Admin); - conn.remove_workspace_member(workspace_id, owner_id).await?; + conn.remove_workspace_member(seeded.workspace_id, seeded.account_id) + .await?; assert!( - conn.find_workspace_member(workspace_id, owner_id) + conn.find_workspace_member(seeded.workspace_id, seeded.account_id) .await? .is_none() ); @@ -566,63 +569,64 @@ mod tests { #[tokio::test] async fn notification_recipients_respect_role_and_event_prefs() -> anyhow::Result<()> { let db = TestDatabase::start().await; - let (owner_id, workspace_id) = db.seed_account_and_workspace().await; + let seeded = db.seed_account_and_workspace().await; let mut conn = db.client.get_connection().await?; // An owner with default (empty) prefs accepts every event. let _ = conn .add_workspace_member(NewWorkspaceMember::new( - workspace_id, - owner_id, + seeded.workspace_id, + seeded.account_id, WorkspaceRole::Owner, )) .await?; // A viewer who opted in to ONLY `member.joined`. let viewer_id = conn.create_account(NewAccount::test()).await?.id; - let mut viewer = NewWorkspaceMember::new(workspace_id, viewer_id, WorkspaceRole::Reviewer); + let mut viewer = + NewWorkspaceMember::new(seeded.workspace_id, viewer_id, WorkspaceRole::Reviewer); viewer.notification_events_app = vec![Some(NotificationEvent::MemberJoined)]; let _ = conn.add_workspace_member(viewer).await?; // For `member.joined`, restricted to owners: only the owner matches. let owners_only = conn .notification_recipients_by_roles( - workspace_id, + seeded.workspace_id, &[WorkspaceRole::Owner], NotificationEvent::MemberJoined, ) .await?; - assert_eq!(owners_only, vec![owner_id]); + assert_eq!(owners_only, vec![seeded.account_id]); // For `member.joined` across owner+viewer: both accept it. let mut both = conn .notification_recipients_by_roles( - workspace_id, + seeded.workspace_id, &[WorkspaceRole::Owner, WorkspaceRole::Reviewer], NotificationEvent::MemberJoined, ) .await?; both.sort(); - let mut expected = vec![owner_id, viewer_id]; + let mut expected = vec![seeded.account_id, viewer_id]; expected.sort(); assert_eq!(both, expected); // For an event the viewer did NOT opt into: only the all-events owner. let detection = conn .notification_recipients_by_roles( - workspace_id, + seeded.workspace_id, &[WorkspaceRole::Owner, WorkspaceRole::Reviewer], NotificationEvent::DetectionCompleted, ) .await?; - assert_eq!(detection, vec![owner_id]); + assert_eq!(detection, vec![seeded.account_id]); Ok(()) } #[tokio::test] async fn accounts_share_workspace_detects_common_membership() -> anyhow::Result<()> { let db = TestDatabase::start().await; - let (owner_id, workspace_id) = db.seed_account_and_workspace().await; + let seeded = db.seed_account_and_workspace().await; let mut conn = db.client.get_connection().await?; let member_id = conn.create_account(NewAccount::test()).await?.id; @@ -630,23 +634,30 @@ mod tests { let _ = conn .add_workspace_member(NewWorkspaceMember::new( - workspace_id, - owner_id, + seeded.workspace_id, + seeded.account_id, WorkspaceRole::Owner, )) .await?; let _ = conn .add_workspace_member(NewWorkspaceMember::new( - workspace_id, + seeded.workspace_id, member_id, WorkspaceRole::Editor, )) .await?; // Two members of the same workspace share it. - assert!(conn.accounts_share_workspace(owner_id, member_id).await?); + assert!( + conn.accounts_share_workspace(seeded.account_id, member_id) + .await? + ); // The stranger is in no shared workspace. - assert!(!conn.accounts_share_workspace(owner_id, stranger_id).await?); + assert!( + !conn + .accounts_share_workspace(seeded.account_id, stranger_id) + .await? + ); // An account always shares with itself, even with no memberships. assert!( conn.accounts_share_workspace(stranger_id, stranger_id) diff --git a/crates/nvisy-postgres/src/query/workspace_pipeline.rs b/crates/nvisy-postgres/src/query/workspace_pipeline.rs index 21c7f8e9..1076e807 100644 --- a/crates/nvisy-postgres/src/query/workspace_pipeline.rs +++ b/crates/nvisy-postgres/src/query/workspace_pipeline.rs @@ -4,14 +4,28 @@ use std::future::Future; use diesel::prelude::*; use diesel_async::RunQueryDsl; +use jiff::Timestamp; use pgtrgm::expression_methods::TrgmExpressionMethods; +use serde::{Deserialize, Serialize}; use uuid::Uuid; use crate::model::{NewWorkspacePipeline, UpdateWorkspacePipeline, WorkspacePipeline}; use crate::query::search::ilike_contains; -use crate::types::{AccountRefRow, CursorPage, CursorPagination, PipelineStatus, WithAccountRef}; +use crate::types::{ + AccountRefRow, CursorPage, CursorPagination, PipelineStatus, WithAccountRef, keyset, +}; use crate::{Error, PgConnection, Result, schema}; +/// Keyset for paginating a workspace's pipelines: newest first by `created_at`, +/// `id` as the tiebreaker. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PipelineCursor { + /// When the pipeline was created. + pub created_at: Timestamp, + /// Pipeline id (tiebreaker). + pub id: uuid::Uuid, +} + /// Repository for pipeline database operations. /// /// Handles pipeline lifecycle management including creation, updates, @@ -38,7 +52,7 @@ pub trait WorkspacePipelineRepository { fn cursor_list_workspace_pipelines( &mut self, workspace_id: Uuid, - pagination: CursorPagination, + pagination: CursorPagination, status_filter: Option, search_term: Option<&str>, ) -> impl Future>>> + Send; @@ -106,7 +120,7 @@ impl WorkspacePipelineRepository for PgConnection { async fn cursor_list_workspace_pipelines( &mut self, workspace_id: Uuid, - pagination: CursorPagination, + pagination: CursorPagination, status_filter: Option, search_term: Option<&str>, ) -> Result>> { @@ -166,33 +180,11 @@ impl WorkspacePipelineRepository for PgConnection { ); } - let limit = pagination.fetch_limit(); - - let rows: Vec<(WorkspacePipeline, AccountRefRow)> = 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(( - WorkspacePipeline::as_select(), - ( - accounts::username, - accounts::display_name, - accounts::avatar_url, - ), - )) - .order((dsl::created_at.desc(), dsl::id.desc())) - .limit(limit) - .load(self) - .await - .map_err(Error::from)? - } else { - query + let after = pagination + .after_key() + .map(|k| (jiff_diesel::Timestamp::from(k.created_at), k.id)); + let rows: Vec<(WorkspacePipeline, AccountRefRow)> = + keyset!(query, dsl::created_at, dsl::id, pagination.direction, after) .select(( WorkspacePipeline::as_select(), ( @@ -201,12 +193,10 @@ impl WorkspacePipelineRepository for PgConnection { accounts::avatar_url, ), )) - .order((dsl::created_at.desc(), dsl::id.desc())) - .limit(limit) + .limit(pagination.fetch_limit()) .load(self) .await - .map_err(Error::from)? - }; + .map_err(Error::from)?; let items: Vec> = rows .into_iter() @@ -214,7 +204,10 @@ impl WorkspacePipelineRepository for PgConnection { .collect(); Ok(CursorPage::new(items, total, pagination.limit, |wc| { - (wc.item.created_at.into(), wc.item.id) + PipelineCursor { + created_at: wc.item.created_at.into(), + id: wc.item.id, + } })) } @@ -261,17 +254,20 @@ mod tests { #[tokio::test] async fn create_find_update_and_soft_delete() -> anyhow::Result<()> { let db = TestDatabase::start().await; - let (account_id, workspace_id) = db.seed_account_and_workspace().await; + let seeded = db.seed_account_and_workspace().await; let mut conn = db.client.get_connection().await?; let pipeline = conn - .create_workspace_pipeline(NewWorkspacePipeline::test(workspace_id, account_id)) + .create_workspace_pipeline(NewWorkspacePipeline::test( + seeded.workspace_id, + seeded.account_id, + )) .await?; let slug = pipeline.slug.as_str().to_owned(); // Found by slug within its workspace, with the creator handle. let found = conn - .find_pipeline_in_workspace_by_slug(workspace_id, &slug) + .find_pipeline_in_workspace_by_slug(seeded.workspace_id, &slug) .await?; assert_eq!(found.map(|p| p.item.id), Some(pipeline.id)); @@ -297,7 +293,7 @@ mod tests { // Soft delete hides it from the by-slug lookup. conn.delete_workspace_pipeline(pipeline.id).await?; assert!( - conn.find_pipeline_in_workspace_by_slug(workspace_id, &slug) + conn.find_pipeline_in_workspace_by_slug(seeded.workspace_id, &slug) .await? .is_none() ); @@ -307,24 +303,35 @@ mod tests { #[tokio::test] async fn cursor_list_filters_by_status_and_excludes_deleted() -> anyhow::Result<()> { let db = TestDatabase::start().await; - let (account_id, workspace_id) = db.seed_account_and_workspace().await; + let seeded = db.seed_account_and_workspace().await; let mut conn = db.client.get_connection().await?; // A draft, an enabled, and a deleted pipeline. let draft = conn - .create_workspace_pipeline(NewWorkspacePipeline::test(workspace_id, account_id)) + .create_workspace_pipeline(NewWorkspacePipeline::test( + seeded.workspace_id, + seeded.account_id, + )) .await?; - let mut enabled = NewWorkspacePipeline::test(workspace_id, account_id); + let mut enabled = NewWorkspacePipeline::test(seeded.workspace_id, seeded.account_id); enabled.status = Some(PipelineStatus::Enabled); let enabled = conn.create_workspace_pipeline(enabled).await?; let deleted = conn - .create_workspace_pipeline(NewWorkspacePipeline::test(workspace_id, account_id)) + .create_workspace_pipeline(NewWorkspacePipeline::test( + seeded.workspace_id, + seeded.account_id, + )) .await?; conn.delete_workspace_pipeline(deleted.id).await?; // No filter: both live pipelines, deleted excluded. let all = conn - .cursor_list_workspace_pipelines(workspace_id, CursorPagination::new(50), None, None) + .cursor_list_workspace_pipelines( + seeded.workspace_id, + CursorPagination::new(50), + None, + None, + ) .await?; let ids: Vec<_> = all.items.iter().map(|p| p.item.id).collect(); assert_eq!(ids.len(), 2); @@ -333,7 +340,7 @@ mod tests { // Filtered to Enabled. let enabled_only = conn .cursor_list_workspace_pipelines( - workspace_id, + seeded.workspace_id, CursorPagination::new(50), Some(PipelineStatus::Enabled), None, @@ -353,20 +360,20 @@ mod tests { #[tokio::test] async fn cursor_list_search_matches_display_name() -> anyhow::Result<()> { let db = TestDatabase::start().await; - let (account_id, workspace_id) = db.seed_account_and_workspace().await; + let seeded = db.seed_account_and_workspace().await; let mut conn = db.client.get_connection().await?; - let mut invoices = NewWorkspacePipeline::test(workspace_id, account_id); + let mut invoices = NewWorkspacePipeline::test(seeded.workspace_id, seeded.account_id); invoices.display_name = "Invoice Redaction".to_owned(); let invoices = conn.create_workspace_pipeline(invoices).await?; - let mut contracts = NewWorkspacePipeline::test(workspace_id, account_id); + let mut contracts = NewWorkspacePipeline::test(seeded.workspace_id, seeded.account_id); contracts.display_name = "Contract Review".to_owned(); let _ = conn.create_workspace_pipeline(contracts).await?; // A substring search finds only the matching pipeline. let page = conn .cursor_list_workspace_pipelines( - workspace_id, + seeded.workspace_id, CursorPagination::new(50), None, Some("invoice"), diff --git a/crates/nvisy-postgres/src/query/workspace_policy.rs b/crates/nvisy-postgres/src/query/workspace_policy.rs index b9747092..1404e4ff 100644 --- a/crates/nvisy-postgres/src/query/workspace_policy.rs +++ b/crates/nvisy-postgres/src/query/workspace_policy.rs @@ -4,12 +4,24 @@ use std::future::Future; use diesel::prelude::*; use diesel_async::RunQueryDsl; +use jiff::Timestamp; +use serde::{Deserialize, Serialize}; use uuid::Uuid; use crate::model::{NewWorkspacePolicy, UpdateWorkspacePolicy, WorkspacePolicy}; -use crate::types::{AccountRefRow, CursorPage, CursorPagination, WithAccountRef}; +use crate::types::{AccountRefRow, CursorPage, CursorPagination, WithAccountRef, keyset}; use crate::{Error, PgConnection, Result, schema}; +/// Keyset for paginating a workspace's policies: newest first by `created_at`, +/// `id` as the tiebreaker. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PolicyCursor { + /// When the policy was created. + pub created_at: Timestamp, + /// Policy id (tiebreaker). + pub id: uuid::Uuid, +} + /// Repository for workspace policy database operations. pub trait WorkspacePolicyRepository { /// Creates a new workspace policy record. @@ -38,7 +50,7 @@ pub trait WorkspacePolicyRepository { fn cursor_list_workspace_policies( &mut self, workspace_id: Uuid, - pagination: CursorPagination, + pagination: CursorPagination, ) -> impl Future>>> + Send; /// Updates a policy with new data. @@ -124,7 +136,7 @@ impl WorkspacePolicyRepository for PgConnection { async fn cursor_list_workspace_policies( &mut self, workspace_id: Uuid, - pagination: CursorPagination, + pagination: CursorPagination, ) -> Result>> { use schema::workspace_policies::dsl; use schema::{accounts, workspace_policies}; @@ -149,32 +161,11 @@ impl WorkspacePolicyRepository for PgConnection { .filter(dsl::deleted_at.is_null()) .into_boxed(); - let limit = pagination.fetch_limit(); - - let rows: Vec<(WorkspacePolicy, AccountRefRow)> = 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(( - WorkspacePolicy::as_select(), - ( - accounts::username, - accounts::display_name, - accounts::avatar_url, - ), - )) - .order((dsl::created_at.desc(), dsl::id.desc())) - .limit(limit) - .load(self) - .await - .map_err(Error::from)? - } else { - query + let after = pagination + .after_key() + .map(|k| (jiff_diesel::Timestamp::from(k.created_at), k.id)); + let rows: Vec<(WorkspacePolicy, AccountRefRow)> = + keyset!(query, dsl::created_at, dsl::id, pagination.direction, after) .select(( WorkspacePolicy::as_select(), ( @@ -183,12 +174,10 @@ impl WorkspacePolicyRepository for PgConnection { accounts::avatar_url, ), )) - .order((dsl::created_at.desc(), dsl::id.desc())) - .limit(limit) + .limit(pagination.fetch_limit()) .load(self) .await - .map_err(Error::from)? - }; + .map_err(Error::from)?; let items: Vec> = rows .into_iter() @@ -196,7 +185,10 @@ impl WorkspacePolicyRepository for PgConnection { .collect(); Ok(CursorPage::new(items, total, pagination.limit, |wc| { - (wc.item.created_at.into(), wc.item.id) + PolicyCursor { + created_at: wc.item.created_at.into(), + id: wc.item.id, + } })) } @@ -244,22 +236,25 @@ mod tests { #[tokio::test] async fn create_find_update_and_soft_delete() -> anyhow::Result<()> { let db = TestDatabase::start().await; - let (account_id, workspace_id) = db.seed_account_and_workspace().await; + let seeded = db.seed_account_and_workspace().await; let mut conn = db.client.get_connection().await?; let policy = conn - .create_workspace_policy(NewWorkspacePolicy::test(workspace_id, account_id)) + .create_workspace_policy(NewWorkspacePolicy::test( + seeded.workspace_id, + seeded.account_id, + )) .await?; let slug = policy.slug.as_str().to_owned(); // Found by id and by slug within the workspace. assert!( - conn.find_policy_in_workspace(workspace_id, policy.id) + conn.find_policy_in_workspace(seeded.workspace_id, policy.id) .await? .is_some() ); let by_slug = conn - .find_policy_in_workspace_by_slug(workspace_id, &slug) + .find_policy_in_workspace_by_slug(seeded.workspace_id, &slug) .await?; assert_eq!(by_slug.map(|p| p.item.id), Some(policy.id)); @@ -285,12 +280,12 @@ mod tests { // Soft delete hides it from both lookups. conn.delete_workspace_policy(policy.id).await?; assert!( - conn.find_policy_in_workspace(workspace_id, policy.id) + conn.find_policy_in_workspace(seeded.workspace_id, policy.id) .await? .is_none() ); assert!( - conn.find_policy_in_workspace_by_slug(workspace_id, &slug) + conn.find_policy_in_workspace_by_slug(seeded.workspace_id, &slug) .await? .is_none() ); @@ -300,27 +295,36 @@ mod tests { #[tokio::test] async fn cursor_list_returns_live_policies_newest_first() -> anyhow::Result<()> { let db = TestDatabase::start().await; - let (account_id, workspace_id) = db.seed_account_and_workspace().await; + let seeded = db.seed_account_and_workspace().await; let mut conn = db.client.get_connection().await?; // Backdate `first` an hour so it is unambiguously older than `second`; // without a distinct `created_at` the two could tie and the newest-first // order would not be well-defined. let first = conn - .create_workspace_policy(NewWorkspacePolicy::test(workspace_id, account_id)) + .create_workspace_policy(NewWorkspacePolicy::test( + seeded.workspace_id, + seeded.account_id, + )) .await?; backdate::policy_created_at(&mut conn, first.id, Timestamp::now() - Span::new().hours(1)) .await?; let second = conn - .create_workspace_policy(NewWorkspacePolicy::test(workspace_id, account_id)) + .create_workspace_policy(NewWorkspacePolicy::test( + seeded.workspace_id, + seeded.account_id, + )) .await?; let deleted = conn - .create_workspace_policy(NewWorkspacePolicy::test(workspace_id, account_id)) + .create_workspace_policy(NewWorkspacePolicy::test( + seeded.workspace_id, + seeded.account_id, + )) .await?; conn.delete_workspace_policy(deleted.id).await?; let page = conn - .cursor_list_workspace_policies(workspace_id, CursorPagination::new(50)) + .cursor_list_workspace_policies(seeded.workspace_id, CursorPagination::new(50)) .await?; assert_eq!( page.items.iter().map(|p| p.item.id).collect::>(), diff --git a/crates/nvisy-postgres/src/query/workspace_provider.rs b/crates/nvisy-postgres/src/query/workspace_provider.rs index 40dd3753..a9373ed0 100644 --- a/crates/nvisy-postgres/src/query/workspace_provider.rs +++ b/crates/nvisy-postgres/src/query/workspace_provider.rs @@ -4,12 +4,26 @@ use std::future::Future; use diesel::prelude::*; use diesel_async::RunQueryDsl; +use jiff::Timestamp; +use serde::{Deserialize, Serialize}; use uuid::Uuid; use crate::model::{NewWorkspaceProvider, UpdateWorkspaceProvider, WorkspaceProvider}; -use crate::types::{AccountRefRow, CursorPage, CursorPagination, ProviderType, WithAccountRef}; +use crate::types::{ + AccountRefRow, CursorPage, CursorPagination, ProviderType, WithAccountRef, keyset, +}; use crate::{Error, PgConnection, Result, schema}; +/// Keyset for paginating a workspace's providers: newest first by `created_at`, +/// `id` as the tiebreaker. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProviderCursor { + /// When the provider was created. + pub created_at: Timestamp, + /// Provider id (tiebreaker). + pub id: uuid::Uuid, +} + /// Repository for workspace inference-provider database operations. /// /// Handles provider lifecycle management including creation, updates, and @@ -56,7 +70,7 @@ pub trait WorkspaceProviderRepository { fn cursor_list_workspace_providers( &mut self, workspace_id: Uuid, - pagination: CursorPagination, + pagination: CursorPagination, providers: &[String], ) -> impl Future>>> + Send; @@ -165,7 +179,7 @@ impl WorkspaceProviderRepository for PgConnection { async fn cursor_list_workspace_providers( &mut self, workspace_id: Uuid, - pagination: CursorPagination, + pagination: CursorPagination, providers: &[String], ) -> Result>> { use schema::workspace_providers::dsl; @@ -202,33 +216,11 @@ impl WorkspaceProviderRepository for PgConnection { query = query.filter(dsl::provider.eq_any(providers.to_vec())); } - let limit = pagination.fetch_limit(); - - let rows: Vec<(WorkspaceProvider, AccountRefRow)> = 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(( - WorkspaceProvider::as_select(), - ( - accounts::username, - accounts::display_name, - accounts::avatar_url, - ), - )) - .order((dsl::created_at.desc(), dsl::id.desc())) - .limit(limit) - .load(self) - .await - .map_err(Error::from)? - } else { - query + let after = pagination + .after_key() + .map(|k| (jiff_diesel::Timestamp::from(k.created_at), k.id)); + let rows: Vec<(WorkspaceProvider, AccountRefRow)> = + keyset!(query, dsl::created_at, dsl::id, pagination.direction, after) .select(( WorkspaceProvider::as_select(), ( @@ -237,12 +229,10 @@ impl WorkspaceProviderRepository for PgConnection { accounts::avatar_url, ), )) - .order((dsl::created_at.desc(), dsl::id.desc())) - .limit(limit) + .limit(pagination.fetch_limit()) .load(self) .await - .map_err(Error::from)? - }; + .map_err(Error::from)?; let items: Vec> = rows .into_iter() @@ -250,7 +240,10 @@ impl WorkspaceProviderRepository for PgConnection { .collect(); Ok(CursorPage::new(items, total, pagination.limit, |wp| { - (wp.item.created_at.into(), wp.item.id) + ProviderCursor { + created_at: wp.item.created_at.into(), + id: wp.item.id, + } })) } @@ -308,7 +301,7 @@ mod tests { #[tokio::test] async fn find_by_type_returns_the_most_recent_active_provider() -> anyhow::Result<()> { let db = TestDatabase::start().await; - let (account_id, workspace_id) = db.seed_account_and_workspace().await; + let seeded = db.seed_account_and_workspace().await; let mut conn = db.client.get_connection().await?; // Two active LLM providers. `newer` is then updated, bumping its @@ -316,15 +309,15 @@ mod tests { // return `newer` — this is what exercises recency, not just presence. let _older = conn .create_workspace_provider(NewWorkspaceProvider::test( - workspace_id, - account_id, + seeded.workspace_id, + seeded.account_id, ProviderType::Llm, )) .await?; let newer = conn .create_workspace_provider(NewWorkspaceProvider::test( - workspace_id, - account_id, + seeded.workspace_id, + seeded.account_id, ProviderType::Llm, )) .await?; @@ -341,18 +334,18 @@ mod tests { // A disabled provider must never be returned, even if it is newest. let disabled = NewWorkspaceProvider { is_active: Some(false), - ..NewWorkspaceProvider::test(workspace_id, account_id, ProviderType::Llm) + ..NewWorkspaceProvider::test(seeded.workspace_id, seeded.account_id, ProviderType::Llm) }; let _ = conn.create_workspace_provider(disabled).await?; let found = conn - .find_provider_by_type(workspace_id, ProviderType::Llm) + .find_provider_by_type(seeded.workspace_id, ProviderType::Llm) .await?; assert_eq!(found.map(|p| p.id), Some(newer.id)); // A different kind in the same workspace is not matched. assert!( - conn.find_provider_by_type(workspace_id, ProviderType::Ner) + conn.find_provider_by_type(seeded.workspace_id, ProviderType::Ner) .await? .is_none() ); @@ -362,13 +355,13 @@ mod tests { #[tokio::test] async fn update_and_delete_are_scoped_to_live_rows() -> anyhow::Result<()> { let db = TestDatabase::start().await; - let (account_id, workspace_id) = db.seed_account_and_workspace().await; + let seeded = db.seed_account_and_workspace().await; let mut conn = db.client.get_connection().await?; let provider = conn .create_workspace_provider(NewWorkspaceProvider::test( - workspace_id, - account_id, + seeded.workspace_id, + seeded.account_id, ProviderType::Llm, )) .await?; @@ -376,7 +369,7 @@ mod tests { // Soft-delete it, then a second delete and an update both find no live row. conn.delete_workspace_provider(provider.id).await?; assert!( - conn.find_provider_in_workspace(workspace_id, provider.id) + conn.find_provider_in_workspace(seeded.workspace_id, provider.id) .await? .is_none() ); @@ -397,28 +390,28 @@ mod tests { #[tokio::test] async fn cursor_list_filters_by_provider_and_paginates() -> anyhow::Result<()> { let db = TestDatabase::start().await; - let (account_id, workspace_id) = db.seed_account_and_workspace().await; + let seeded = db.seed_account_and_workspace().await; let mut conn = db.client.get_connection().await?; for _ in 0..3 { let _ = conn .create_workspace_provider(NewWorkspaceProvider::test( - workspace_id, - account_id, + seeded.workspace_id, + seeded.account_id, ProviderType::Llm, )) .await?; } let page = conn - .cursor_list_workspace_providers(workspace_id, CursorPagination::new(50), &[]) + .cursor_list_workspace_providers(seeded.workspace_id, CursorPagination::new(50), &[]) .await?; assert_eq!(page.items.len(), 3); // A provider filter that matches nothing returns an empty page. let none = conn .cursor_list_workspace_providers( - workspace_id, + seeded.workspace_id, CursorPagination::new(50), &["anthropic".to_owned()], ) diff --git a/crates/nvisy-postgres/src/query/workspace_redaction.rs b/crates/nvisy-postgres/src/query/workspace_redaction.rs index 62e93a61..e7833bad 100644 --- a/crates/nvisy-postgres/src/query/workspace_redaction.rs +++ b/crates/nvisy-postgres/src/query/workspace_redaction.rs @@ -4,12 +4,24 @@ use std::future::Future; use diesel::prelude::*; use diesel_async::RunQueryDsl; +use jiff::Timestamp; +use serde::{Deserialize, Serialize}; use uuid::Uuid; use crate::model::{NewWorkspaceRedaction, WorkspaceRedaction}; -use crate::types::{CursorPage, CursorPagination}; +use crate::types::{CursorPage, CursorPagination, keyset}; use crate::{Error, PgConnection, Result, schema}; +/// Keyset for paginating a detection's redactions: newest first by `created_at`, +/// `id` as the tiebreaker. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RedactionCursor { + /// When the redaction was created. + pub created_at: Timestamp, + /// Redaction id (tiebreaker). + pub id: uuid::Uuid, +} + /// Repository for workspace redaction database operations. /// /// A redaction is one redact pass over a detection's analysis; a detection can @@ -37,7 +49,7 @@ pub trait WorkspaceRedactionRepository { fn cursor_list_detection_redactions( &mut self, detection_id: Uuid, - pagination: CursorPagination, + pagination: CursorPagination, ) -> impl Future>> + Send; } @@ -86,7 +98,7 @@ impl WorkspaceRedactionRepository for PgConnection { async fn cursor_list_detection_redactions( &mut self, detection_id: Uuid, - pagination: CursorPagination, + pagination: CursorPagination, ) -> Result> { use schema::workspace_redactions::{self, dsl}; @@ -104,37 +116,31 @@ impl WorkspaceRedactionRepository for PgConnection { None }; - let limit = pagination.fetch_limit(); - - let items: Vec = if let Some(cursor) = &pagination.after { - let cursor_time = jiff_diesel::Timestamp::from(cursor.timestamp); - - workspace_redactions::table - .filter(dsl::detection_id.eq(detection_id)) - .filter( - dsl::created_at - .lt(&cursor_time) - .or(dsl::created_at.eq(&cursor_time).and(dsl::id.lt(cursor.id))), - ) - .select(WorkspaceRedaction::as_select()) - .order((dsl::created_at.desc(), dsl::id.desc())) - .limit(limit) - .load(self) - .await - .map_err(Error::from)? - } else { - workspace_redactions::table - .filter(dsl::detection_id.eq(detection_id)) - .select(WorkspaceRedaction::as_select()) - .order((dsl::created_at.desc(), dsl::id.desc())) - .limit(limit) - .load(self) - .await - .map_err(Error::from)? - }; + let scoped = workspace_redactions::table + .filter(dsl::detection_id.eq(detection_id)) + .into_boxed(); + + let after = pagination + .after_key() + .map(|k| (jiff_diesel::Timestamp::from(k.created_at), k.id)); + let items: Vec = keyset!( + scoped, + dsl::created_at, + dsl::id, + pagination.direction, + after + ) + .select(WorkspaceRedaction::as_select()) + .limit(pagination.fetch_limit()) + .load(self) + .await + .map_err(Error::from)?; Ok(CursorPage::new(items, total, pagination.limit, |row| { - (row.created_at.into(), row.id) + RedactionCursor { + created_at: row.created_at.into(), + id: row.id, + } })) } } @@ -154,16 +160,21 @@ mod tests { /// Seeds a detection and returns `(account_id, workspace_id, pipeline_id, /// detection_id)` — a redaction's FK parent plus the context tests scope on. async fn seed_detection(db: &TestDatabase) -> anyhow::Result<(Uuid, Uuid, Uuid, Uuid)> { - let (account_id, workspace_id, pipeline_id, file_id) = db.seed_pipeline_and_file().await; + let seeded = db.seed_pipeline_and_file().await; let mut conn = db.client.get_connection().await?; let detection = conn .create_workspace_detection(NewWorkspaceDetection::test( - pipeline_id, - account_id, - file_id, + seeded.pipeline_id, + seeded.account_id, + seeded.file_id, )) .await?; - Ok((account_id, workspace_id, pipeline_id, detection.id)) + Ok(( + seeded.account_id, + seeded.workspace_id, + seeded.pipeline_id, + detection.id, + )) } #[tokio::test] diff --git a/crates/nvisy-postgres/src/query/workspace_thread.rs b/crates/nvisy-postgres/src/query/workspace_thread.rs index 51fd0b50..92f1cfb9 100644 --- a/crates/nvisy-postgres/src/query/workspace_thread.rs +++ b/crates/nvisy-postgres/src/query/workspace_thread.rs @@ -8,6 +8,8 @@ use std::future::Future; use diesel::dsl::now; use diesel::prelude::*; use diesel_async::RunQueryDsl; +use jiff::Timestamp; +use serde::{Deserialize, Serialize}; use serde_json::Value; use uuid::Uuid; @@ -18,9 +20,20 @@ use crate::model::{ }; use crate::types::{ AccountRefRow, CursorPage, CursorPagination, ThreadEventKind, ThreadFilter, WithAccountRef, + keyset, }; use crate::{AsyncConnection, Error, PgConnection, Result, schema}; +/// Keyset for paginating a workspace's threads: newest first by `created_at`, +/// `id` as the tiebreaker. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ThreadCursor { + /// When the thread was opened. + pub created_at: Timestamp, + /// Thread id (tiebreaker). + pub id: uuid::Uuid, +} + /// Read and write operations on threads. pub trait WorkspaceThreadRepository { /// Opens a thread with its first comment and any initial anchors, recording @@ -45,7 +58,7 @@ pub trait WorkspaceThreadRepository { fn cursor_list_threads( &mut self, workspace_id: Uuid, - pagination: CursorPagination, + pagination: CursorPagination, filter: &ThreadFilter, ) -> impl Future>>> + Send; @@ -167,7 +180,7 @@ impl WorkspaceThreadRepository for PgConnection { async fn cursor_list_threads( &mut self, workspace_id: Uuid, - pagination: CursorPagination, + pagination: CursorPagination, filter: &ThreadFilter, ) -> Result>> { use schema::workspace_threads::dsl; @@ -209,8 +222,6 @@ impl WorkspaceThreadRepository for PgConnection { None }; - let query = scoped(); - let limit = pagination.fetch_limit(); let selection = ( WorkspaceThread::as_select(), ( @@ -220,29 +231,21 @@ impl WorkspaceThreadRepository for PgConnection { ), ); - let rows: Vec<(WorkspaceThread, AccountRefRow)> = 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 after = pagination + .after_key() + .map(|k| (jiff_diesel::Timestamp::from(k.created_at), k.id)); + let rows: Vec<(WorkspaceThread, AccountRefRow)> = keyset!( + scoped(), + dsl::created_at, + dsl::id, + pagination.direction, + after + ) + .select(selection) + .limit(pagination.fetch_limit()) + .load(self) + .await + .map_err(Error::from)?; let items: Vec> = rows .into_iter() @@ -250,7 +253,10 @@ impl WorkspaceThreadRepository for PgConnection { .collect(); Ok(CursorPage::new(items, total, pagination.limit, |row| { - (row.item.created_at.into(), row.item.id) + ThreadCursor { + created_at: row.item.created_at.into(), + id: row.item.id, + } })) } @@ -368,8 +374,9 @@ mod tests { UpdateWorkspaceThreadComment, }; use crate::query::{ - AccountRepository, WorkspaceThreadAnchorRepository, WorkspaceThreadCommentRepository, - WorkspaceThreadEventRepository, WorkspaceThreadRepository, + AccountRepository, TimelineCursor, TimelineSource, WorkspaceThreadAnchorRepository, + WorkspaceThreadCommentRepository, WorkspaceThreadEventRepository, + WorkspaceThreadRepository, }; use crate::test_util::TestDatabase; use crate::types::{CursorPagination, ThreadEventKind, ThreadFilter}; @@ -377,17 +384,17 @@ mod tests { #[tokio::test] async fn open_thread_creates_thread_and_opening_comment() -> anyhow::Result<()> { let db = TestDatabase::start().await; - let (author, workspace_id, _pipeline_id, file_id) = db.seed_pipeline_and_file().await; + let seeded = db.seed_pipeline_and_file().await; let mut conn = db.client.get_connection().await?; let (thread, opening) = conn .open_thread( - NewWorkspaceThread::test(workspace_id, file_id, author), + NewWorkspaceThread::test(seeded.workspace_id, seeded.file_id, seeded.account_id), "Opening message.".to_owned(), Vec::new(), ) .await?; - assert_eq!(thread.file_id, Some(file_id)); + assert_eq!(thread.file_id, Some(seeded.file_id)); assert!(thread.closed_at.is_none()); assert_eq!(opening.thread_id, thread.id); assert_eq!(opening.body, "Opening message."); @@ -395,12 +402,14 @@ mod tests { // A reply message lists after the opening one, oldest first. let _reply = conn .create_comment(NewWorkspaceThreadComment::test( - workspace_id, + seeded.workspace_id, thread.id, - author, + seeded.account_id, )) .await?; - let msgs = conn.list_thread_comments(workspace_id, thread.id).await?; + let msgs = conn + .list_thread_comments(seeded.workspace_id, thread.id) + .await?; assert_eq!(msgs.len(), 2); assert_eq!(msgs[0].item.id, opening.id); Ok(()) @@ -409,15 +418,15 @@ mod tests { #[tokio::test] async fn workspace_level_thread_has_no_file() -> anyhow::Result<()> { let db = TestDatabase::start().await; - let (author, workspace_id) = db.seed_account_and_workspace().await; + let seeded = db.seed_account_and_workspace().await; let mut conn = db.client.get_connection().await?; let (thread, _opening) = conn .open_thread( NewWorkspaceThread { - workspace_id, + workspace_id: seeded.workspace_id, file_id: None, - author_account_id: author, + author_account_id: seeded.account_id, display_name: None, }, "A general workspace discussion.".to_owned(), @@ -431,22 +440,22 @@ mod tests { #[tokio::test] async fn close_reopen_records_timeline_events() -> anyhow::Result<()> { let db = TestDatabase::start().await; - let (author, workspace_id, _pipeline_id, file_id) = db.seed_pipeline_and_file().await; + let seeded = db.seed_pipeline_and_file().await; let mut conn = db.client.get_connection().await?; let (thread, _opening) = conn .open_thread( - NewWorkspaceThread::test(workspace_id, file_id, author), + NewWorkspaceThread::test(seeded.workspace_id, seeded.file_id, seeded.account_id), "Opening.".to_owned(), Vec::new(), ) .await?; - let closed = conn.close_thread(thread.id, author).await?; + let closed = conn.close_thread(thread.id, seeded.account_id).await?; assert!(closed.closed_at.is_some()); - assert_eq!(closed.closed_by, Some(author)); + assert_eq!(closed.closed_by, Some(seeded.account_id)); - let reopened = conn.reopen_thread(thread.id, author).await?; + let reopened = conn.reopen_thread(thread.id, seeded.account_id).await?; assert!(reopened.closed_at.is_none()); // The timeline records the open, then both transitions, oldest first. @@ -466,12 +475,12 @@ mod tests { // Deleting the thread hides it and its messages. conn.delete_thread(thread.id).await?; assert!( - conn.find_thread_in_workspace(workspace_id, thread.id) + conn.find_thread_in_workspace(seeded.workspace_id, thread.id) .await? .is_none() ); assert!( - conn.list_thread_comments(workspace_id, thread.id) + conn.list_thread_comments(seeded.workspace_id, thread.id) .await? .is_empty() ); @@ -481,14 +490,14 @@ mod tests { #[tokio::test] async fn anchors_add_remove_and_record_events() -> anyhow::Result<()> { let db = TestDatabase::start().await; - let (author, workspace_id, _pipeline_id, file_id) = db.seed_pipeline_and_file().await; + let seeded = db.seed_pipeline_and_file().await; let mut conn = db.client.get_connection().await?; // Open with one initial anchor (the initial anchor gets no event of its // own; opening the thread records the single `thread.opened` event). let (thread, _opening) = conn .open_thread( - NewWorkspaceThread::test(workspace_id, file_id, author), + NewWorkspaceThread::test(seeded.workspace_id, seeded.file_id, seeded.account_id), "Opening.".to_owned(), vec![serde_json::json!({ "modality": "text", "span": [0, 5] })], ) @@ -505,18 +514,18 @@ mod tests { // Add a second anchor -> one anchor.added event. let added = conn .add_thread_anchor( - workspace_id, + seeded.workspace_id, NewWorkspaceThreadAnchor { thread_id: thread.id, anchor: serde_json::json!({ "modality": "text", "span": [10, 20] }), }, - author, + seeded.account_id, ) .await?; assert_eq!(conn.list_thread_anchors(thread.id).await?.len(), 2); // Remove it -> anchor.removed event; live anchors back to one. - conn.remove_thread_anchor(workspace_id, added.id, author) + conn.remove_thread_anchor(seeded.workspace_id, added.id, seeded.account_id) .await?; assert_eq!(conn.list_thread_anchors(thread.id).await?.len(), 1); assert!( @@ -545,7 +554,7 @@ mod tests { #[tokio::test] async fn cursor_list_threads_filters_by_author_and_closed() -> anyhow::Result<()> { let db = TestDatabase::start().await; - let (alice, workspace_id, _pipeline_id, file_id) = db.seed_pipeline_and_file().await; + let seeded = db.seed_pipeline_and_file().await; let bob = { let mut conn = db.client.get_connection().await?; conn.create_account(NewAccount::test()).await?.id @@ -554,23 +563,23 @@ mod tests { let (a, _) = conn .open_thread( - NewWorkspaceThread::test(workspace_id, file_id, alice), + NewWorkspaceThread::test(seeded.workspace_id, seeded.file_id, seeded.account_id), "a".to_owned(), Vec::new(), ) .await?; let _ = conn .open_thread( - NewWorkspaceThread::test(workspace_id, file_id, bob), + NewWorkspaceThread::test(seeded.workspace_id, seeded.file_id, bob), "b".to_owned(), Vec::new(), ) .await?; - conn.close_thread(a.id, alice).await?; + conn.close_thread(a.id, seeded.account_id).await?; let all = conn .cursor_list_threads( - workspace_id, + seeded.workspace_id, CursorPagination::new(50), &ThreadFilter::default(), ) @@ -579,7 +588,7 @@ mod tests { let closed_only = conn .cursor_list_threads( - workspace_id, + seeded.workspace_id, CursorPagination::new(50), &ThreadFilter { closed: Some(true), @@ -592,7 +601,7 @@ mod tests { let just_bob = conn .cursor_list_threads( - workspace_id, + seeded.workspace_id, CursorPagination::new(50), &ThreadFilter { author_account_id: Some(bob), @@ -607,12 +616,12 @@ mod tests { #[tokio::test] async fn comment_edit_and_delete() -> anyhow::Result<()> { let db = TestDatabase::start().await; - let (author, workspace_id, _pipeline_id, file_id) = db.seed_pipeline_and_file().await; + let seeded = db.seed_pipeline_and_file().await; let mut conn = db.client.get_connection().await?; let (thread, opening) = conn .open_thread( - NewWorkspaceThread::test(workspace_id, file_id, author), + NewWorkspaceThread::test(seeded.workspace_id, seeded.file_id, seeded.account_id), "Opening.".to_owned(), Vec::new(), ) @@ -630,13 +639,13 @@ mod tests { conn.delete_comment(opening.id).await?; assert!( - conn.find_comment_in_workspace(workspace_id, opening.id) + conn.find_comment_in_workspace(seeded.workspace_id, opening.id) .await? .is_none() ); // The thread still exists after deleting a message. assert!( - conn.find_thread_in_workspace(workspace_id, thread.id) + conn.find_thread_in_workspace(seeded.workspace_id, thread.id) .await? .is_some() ); @@ -646,21 +655,21 @@ mod tests { #[tokio::test] async fn create_reply_is_unique_per_triggering_comment() -> anyhow::Result<()> { let db = TestDatabase::start().await; - let (author, workspace_id, _pipeline_id, file_id) = db.seed_pipeline_and_file().await; + let seeded = db.seed_pipeline_and_file().await; let mut conn = db.client.get_connection().await?; let (thread, trigger) = conn .open_thread( - NewWorkspaceThread::test(workspace_id, file_id, author), + NewWorkspaceThread::test(seeded.workspace_id, seeded.file_id, seeded.account_id), "@assistant help".to_owned(), Vec::new(), ) .await?; let reply = |body: &str| NewWorkspaceThreadComment { - workspace_id, + workspace_id: seeded.workspace_id, thread_id: thread.id, - author_account_id: author, + author_account_id: seeded.account_id, parent_id: Some(trigger.id), body: body.to_owned(), }; @@ -675,7 +684,9 @@ mod tests { assert!(second.is_none()); // Only the first reply is live. - let replies = conn.list_thread_comments(workspace_id, thread.id).await?; + let replies = conn + .list_thread_comments(seeded.workspace_id, thread.id) + .await?; let bodies: Vec<_> = replies.iter().map(|r| r.item.body.as_str()).collect(); assert!(bodies.contains(&"first")); assert!(!bodies.contains(&"second")); @@ -687,4 +698,108 @@ mod tests { assert!(third.is_some()); Ok(()) } + + /// A merged-timeline page: one entry with its sort key, mirroring how the + /// handler interleaves the two streams. `(created_at, source, id)`. + type Entry = (jiff::Timestamp, TimelineSource, uuid::Uuid); + + /// Fetches one page of the merged timeline (comments + events) after `cursor`, + /// mirroring the handler: pull `limit + 1` from each stream, merge by + /// `(created_at, source, id)`, keep `limit`, and return the next cursor. + async fn timeline_page( + conn: &mut crate::PgConn, + workspace_id: uuid::Uuid, + thread_id: uuid::Uuid, + after: Option<&TimelineCursor>, + limit: i64, + ) -> anyhow::Result<(Vec, Option)> { + let fetch = limit + 1; + let comments = conn + .list_thread_comments_after(workspace_id, thread_id, after, fetch) + .await?; + let events = conn + .list_thread_events_after(thread_id, after, fetch) + .await?; + + let mut merged: Vec = Vec::new(); + merged.extend( + comments + .iter() + .map(|c| (c.item.created_at.into(), TimelineSource::Comment, c.item.id)), + ); + merged.extend( + events + .iter() + .map(|(e, _)| (e.created_at.into(), TimelineSource::Event, e.id)), + ); + merged.sort(); + + let next = if merged.len() as i64 > limit { + merged.truncate(limit as usize); + merged + .last() + .map(|&(created_at, source, id)| TimelineCursor { + created_at, + source, + id, + }) + } else { + None + }; + Ok((merged, next)) + } + + #[tokio::test] + async fn timeline_pages_comments_and_events_in_one_order() -> anyhow::Result<()> { + let db = TestDatabase::start().await; + let seeded = db.seed_pipeline_and_file().await; + let mut conn = db.client.get_connection().await?; + + // Build a thread with a known set of timeline entries: opening (1 event + + // 1 comment), a reply comment, then close + reopen (2 events) = 5 entries. + let (thread, _opening) = conn + .open_thread( + NewWorkspaceThread::test(seeded.workspace_id, seeded.file_id, seeded.account_id), + "Opening.".to_owned(), + Vec::new(), + ) + .await?; + conn.create_comment(NewWorkspaceThreadComment::test( + seeded.workspace_id, + thread.id, + seeded.account_id, + )) + .await?; + conn.close_thread(thread.id, seeded.account_id).await?; + conn.reopen_thread(thread.id, seeded.account_id).await?; + + // The full merged timeline (a big first page) is every entry in order. + let (all, _) = timeline_page(&mut conn, seeded.workspace_id, thread.id, None, 50).await?; + assert_eq!(all.len(), 5); + // It is sorted ascending by (created_at, source, id). + let mut sorted = all.clone(); + sorted.sort(); + assert_eq!(all, sorted); + + // Paging in windows of 2 walks the same order with no gaps or repeats. + let mut paged: Vec = Vec::new(); + let mut cursor: Option = None; + loop { + let (page, next) = timeline_page( + &mut conn, + seeded.workspace_id, + thread.id, + cursor.as_ref(), + 2, + ) + .await?; + paged.extend(page); + match next { + Some(c) => cursor = Some(c), + None => break, + } + } + assert_eq!(paged, all); + Ok(()) + } } diff --git a/crates/nvisy-postgres/src/query/workspace_thread_comment.rs b/crates/nvisy-postgres/src/query/workspace_thread_comment.rs index 687f2632..fe13a92a 100644 --- a/crates/nvisy-postgres/src/query/workspace_thread_comment.rs +++ b/crates/nvisy-postgres/src/query/workspace_thread_comment.rs @@ -9,6 +9,7 @@ use diesel::prelude::*; use diesel_async::RunQueryDsl; use uuid::Uuid; +use super::workspace_thread_event::{StreamBound, TimelineCursor, TimelineSource}; use crate::model::{ NewWorkspaceThreadComment, UpdateWorkspaceThreadComment, WorkspaceThreadComment, }; @@ -51,6 +52,17 @@ pub trait WorkspaceThreadCommentRepository { thread_id: Uuid, ) -> impl Future>>> + Send; + /// Lists up to `limit` of a thread's live comments at or after a cursor + /// position, oldest first, each with the author's account reference. Backs the + /// merged, paginated timeline; the caller interleaves these with the events. + fn list_thread_comments_after( + &mut self, + workspace_id: Uuid, + thread_id: Uuid, + after: Option<&TimelineCursor>, + limit: i64, + ) -> impl Future>>> + Send; + /// Updates a comment's body. fn update_comment_body( &mut self, @@ -150,6 +162,66 @@ impl WorkspaceThreadCommentRepository for PgConnection { .collect()) } + async fn list_thread_comments_after( + &mut self, + workspace_id: Uuid, + thread_id: Uuid, + after: Option<&TimelineCursor>, + limit: i64, + ) -> Result>> { + use schema::workspace_thread_comments::dsl; + use schema::{accounts, workspace_thread_comments}; + + let mut query = workspace_thread_comments::table + .inner_join(accounts::table.on(dsl::author_account_id.eq(accounts::id))) + .filter(dsl::workspace_id.eq(workspace_id)) + .filter(dsl::thread_id.eq(thread_id)) + .filter(dsl::deleted_at.is_null()) + .into_boxed(); + + // Apply the per-stream keyset lower bound for this (comment) stream. + if let Some(cursor) = after { + match cursor.stream_bound(TimelineSource::Comment) { + StreamBound::AfterInstant { created_at } => { + query = + query.filter(dsl::created_at.gt(jiff_diesel::Timestamp::from(created_at))); + } + StreamBound::AfterId { created_at, id } => { + let at = jiff_diesel::Timestamp::from(created_at); + query = query.filter( + dsl::created_at + .gt(at) + .or(dsl::created_at.eq(at).and(dsl::id.gt(id))), + ); + } + StreamBound::FromInstant { created_at } => { + query = + query.filter(dsl::created_at.ge(jiff_diesel::Timestamp::from(created_at))); + } + } + } + + let rows: Vec<(WorkspaceThreadComment, AccountRefRow)> = query + .select(( + WorkspaceThreadComment::as_select(), + ( + accounts::username, + accounts::display_name, + accounts::avatar_url, + ), + )) + .order((dsl::created_at.asc(), dsl::id.asc())) + .limit(limit) + .load(self) + .await + .map_err(Error::from)?; + + Ok(rows + .into_iter() + .map(|(item, account)| WithAccountRef { item, account }) + .collect()) + } + async fn update_comment_body( &mut self, comment_id: Uuid, diff --git a/crates/nvisy-postgres/src/query/workspace_thread_event.rs b/crates/nvisy-postgres/src/query/workspace_thread_event.rs index eb016d79..8488b20a 100644 --- a/crates/nvisy-postgres/src/query/workspace_thread_event.rs +++ b/crates/nvisy-postgres/src/query/workspace_thread_event.rs @@ -7,6 +7,8 @@ use std::future::Future; use diesel::prelude::*; use diesel_async::RunQueryDsl; +use jiff::Timestamp; +use serde::{Deserialize, Serialize}; use serde_json::Value; use uuid::Uuid; @@ -16,6 +18,69 @@ use crate::model::{ use crate::types::{AccountRefRow, ThreadEventKind}; use crate::{Error, PgConnection, Result, schema}; +/// Which of the two timeline streams an entry came from. Its order is the +/// tiebreak between a comment and an event that share a `created_at`: a comment +/// sorts before an event at the same instant. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum TimelineSource { + /// A comment (message). + Comment, + /// A lifecycle event. + Event, +} + +/// Keyset for the merged thread timeline: comments and events ordered together by +/// `(created_at, source, id)` ascending. `source` breaks a `created_at` tie +/// between the two streams; `id` breaks a tie within one stream. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TimelineCursor { + /// When the entry was created. + pub created_at: Timestamp, + /// Which stream the entry came from. + pub source: TimelineSource, + /// Entry id (tiebreaker within a stream). + pub id: Uuid, +} + +impl TimelineCursor { + /// The lower bound for one stream's keyset query, given this cursor position. + /// + /// A stream's own [`TimelineSource`] decides how the cursor instant is + /// treated: + /// - source > cursor.source: every row at the cursor instant comes after it, + /// so include all of them (`same_instant_all = true`). + /// - source == cursor.source: only rows at the instant with a larger id. + /// - source < cursor.source: no row at the instant qualifies; page strictly + /// after the instant. + pub(crate) fn stream_bound(&self, source: TimelineSource) -> StreamBound { + use std::cmp::Ordering::{Equal, Greater, Less}; + match source.cmp(&self.source) { + Greater => StreamBound::FromInstant { + created_at: self.created_at, + }, + Equal => StreamBound::AfterId { + created_at: self.created_at, + id: self.id, + }, + Less => StreamBound::AfterInstant { + created_at: self.created_at, + }, + } + } +} + +/// A single stream's keyset lower bound, derived from a [`TimelineCursor`]. +pub(crate) enum StreamBound { + /// Include rows strictly after `created_at`. + AfterInstant { created_at: Timestamp }, + /// Include rows after `created_at`, plus rows at it with `id > id`. + AfterId { created_at: Timestamp, id: Uuid }, + /// Include rows at or after `created_at` (the whole instant qualifies). + FromInstant { created_at: Timestamp }, +} + /// Read operations on a thread's timeline events. pub trait WorkspaceThreadEventRepository { /// Lists a thread's timeline events, oldest first, each paired with the @@ -24,6 +89,16 @@ pub trait WorkspaceThreadEventRepository { &mut self, thread_id: Uuid, ) -> impl Future)>>> + Send; + + /// Lists up to `limit` of a thread's timeline events at or after a cursor + /// position, oldest first, each with its actor's account reference. Backs the + /// merged, paginated timeline; the caller interleaves these with the comments. + fn list_thread_events_after( + &mut self, + thread_id: Uuid, + after: Option<&TimelineCursor>, + limit: i64, + ) -> impl Future)>>> + Send; } impl WorkspaceThreadEventRepository for PgConnection { @@ -53,6 +128,59 @@ impl WorkspaceThreadEventRepository for PgConnection { .await .map_err(Error::from) } + + async fn list_thread_events_after( + &mut self, + thread_id: Uuid, + after: Option<&TimelineCursor>, + limit: i64, + ) -> Result)>> { + use schema::accounts; + use schema::workspace_thread_events::{self, dsl}; + + let mut query = workspace_thread_events::table + .left_join(accounts::table.on(dsl::actor_account_id.eq(accounts::id.nullable()))) + .filter(dsl::thread_id.eq(thread_id)) + .into_boxed(); + + // Apply the per-stream keyset lower bound for this (event) stream. + if let Some(cursor) = after { + match cursor.stream_bound(TimelineSource::Event) { + StreamBound::AfterInstant { created_at } => { + query = + query.filter(dsl::created_at.gt(jiff_diesel::Timestamp::from(created_at))); + } + StreamBound::AfterId { created_at, id } => { + let at = jiff_diesel::Timestamp::from(created_at); + query = query.filter( + dsl::created_at + .gt(at) + .or(dsl::created_at.eq(at).and(dsl::id.gt(id))), + ); + } + StreamBound::FromInstant { created_at } => { + query = + query.filter(dsl::created_at.ge(jiff_diesel::Timestamp::from(created_at))); + } + } + } + + query + .select(( + WorkspaceThreadEvent::as_select(), + ( + accounts::username, + accounts::display_name, + accounts::avatar_url, + ) + .nullable(), + )) + .order((dsl::created_at.asc(), dsl::id.asc())) + .limit(limit) + .load(self) + .await + .map_err(Error::from) + } } /// Inserts one thread timeline event. Shared by the thread and anchor diff --git a/crates/nvisy-postgres/src/query/workspace_webhook.rs b/crates/nvisy-postgres/src/query/workspace_webhook.rs index 6b0aba86..ea1cd1e0 100644 --- a/crates/nvisy-postgres/src/query/workspace_webhook.rs +++ b/crates/nvisy-postgres/src/query/workspace_webhook.rs @@ -4,14 +4,27 @@ use std::future::Future; use diesel::prelude::*; use diesel_async::RunQueryDsl; +use jiff::Timestamp; +use serde::{Deserialize, Serialize}; use uuid::Uuid; use crate::model::{NewWorkspaceWebhook, UpdateWorkspaceWebhook, WorkspaceWebhook}; use crate::types::{ AccountRefRow, CursorPage, CursorPagination, WebhookEvent, WebhookStatus, WithAccountRef, + keyset, }; use crate::{Error, PgConnection, Result, schema}; +/// Keyset for paginating a workspace's webhooks: newest first by `created_at`, +/// `id` as the tiebreaker. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WebhookCursor { + /// When the webhook was created. + pub created_at: Timestamp, + /// Webhook id (tiebreaker). + pub id: uuid::Uuid, +} + /// Repository for workspace webhook database operations. /// /// Handles webhook management including CRUD operations and status management. @@ -41,7 +54,7 @@ pub trait WorkspaceWebhookRepository { fn cursor_list_workspace_webhooks( &mut self, workspace_id: Uuid, - pagination: CursorPagination, + pagination: CursorPagination, ) -> impl Future>>> + Send; /// Updates a workspace webhook. @@ -156,7 +169,7 @@ impl WorkspaceWebhookRepository for PgConnection { async fn cursor_list_workspace_webhooks( &mut self, workspace_id: Uuid, - pagination: CursorPagination, + pagination: CursorPagination, ) -> Result>> { use schema::workspace_webhooks::dsl; use schema::{accounts, workspace_webhooks}; @@ -176,36 +189,29 @@ impl WorkspaceWebhookRepository for PgConnection { None }; - // Build query with cursor - let mut query = workspace_webhooks::table + let query = workspace_webhooks::table .inner_join(accounts::table) .filter(dsl::workspace_id.eq(workspace_id)) .filter(dsl::deleted_at.is_null()) .into_boxed(); - if let Some(cursor) = &pagination.after { - let cursor_ts = jiff_diesel::Timestamp::from(cursor.timestamp); - query = query.filter( - dsl::created_at - .lt(cursor_ts) - .or(dsl::created_at.eq(cursor_ts).and(dsl::id.lt(cursor.id))), - ); - } - - let rows: Vec<(WorkspaceWebhook, AccountRefRow)> = query - .select(( - WorkspaceWebhook::as_select(), - ( - accounts::username, - accounts::display_name, - accounts::avatar_url, - ), - )) - .order((dsl::created_at.desc(), dsl::id.desc())) - .limit(pagination.fetch_limit()) - .load(self) - .await - .map_err(Error::from)?; + let after = pagination + .after_key() + .map(|k| (jiff_diesel::Timestamp::from(k.created_at), k.id)); + let rows: Vec<(WorkspaceWebhook, AccountRefRow)> = + keyset!(query, dsl::created_at, dsl::id, pagination.direction, after) + .select(( + WorkspaceWebhook::as_select(), + ( + accounts::username, + accounts::display_name, + accounts::avatar_url, + ), + )) + .limit(pagination.fetch_limit()) + .load(self) + .await + .map_err(Error::from)?; let items: Vec> = rows .into_iter() @@ -213,7 +219,10 @@ impl WorkspaceWebhookRepository for PgConnection { .collect(); Ok(CursorPage::new(items, total, pagination.limit, |wc| { - (wc.item.created_at.into(), wc.item.id) + WebhookCursor { + created_at: wc.item.created_at.into(), + id: wc.item.id, + } })) } @@ -334,13 +343,13 @@ mod tests { #[tokio::test] async fn create_scoped_lookup_and_soft_delete() -> anyhow::Result<()> { let db = TestDatabase::start().await; - let (account_id, workspace_id) = db.seed_account_and_workspace().await; + let seeded = db.seed_account_and_workspace().await; let mut conn = db.client.get_connection().await?; let webhook = conn .create_workspace_webhook(NewWorkspaceWebhook::test( - workspace_id, - account_id, + seeded.workspace_id, + seeded.account_id, vec![WebhookEvent::FileCreated], )) .await?; @@ -351,7 +360,7 @@ mod tests { .is_some() ); assert!( - conn.find_webhook_in_workspace_with_creator(workspace_id, webhook.id) + conn.find_webhook_in_workspace_with_creator(seeded.workspace_id, webhook.id) .await? .is_some() ); @@ -375,13 +384,13 @@ mod tests { #[tokio::test] async fn success_resets_failures_and_failure_increments_them() -> anyhow::Result<()> { let db = TestDatabase::start().await; - let (account_id, workspace_id) = db.seed_account_and_workspace().await; + let seeded = db.seed_account_and_workspace().await; let mut conn = db.client.get_connection().await?; let webhook = conn .create_workspace_webhook(NewWorkspaceWebhook::test( - workspace_id, - account_id, + seeded.workspace_id, + seeded.account_id, vec![WebhookEvent::FileCreated], )) .await?; @@ -402,30 +411,30 @@ mod tests { #[tokio::test] async fn find_for_event_requires_enabled_subscribed_and_live() -> anyhow::Result<()> { let db = TestDatabase::start().await; - let (account_id, workspace_id) = db.seed_account_and_workspace().await; + let seeded = db.seed_account_and_workspace().await; let mut conn = db.client.get_connection().await?; // Subscribed and enabled: matches. let subscribed = conn .create_workspace_webhook(NewWorkspaceWebhook::test( - workspace_id, - account_id, + seeded.workspace_id, + seeded.account_id, vec![WebhookEvent::FileCreated, WebhookEvent::FileDeleted], )) .await?; // Subscribed to a different event only: does not match FileCreated. let _other_event = conn .create_workspace_webhook(NewWorkspaceWebhook::test( - workspace_id, - account_id, + seeded.workspace_id, + seeded.account_id, vec![WebhookEvent::MemberAdded], )) .await?; // Subscribed but suspended: excluded (not enabled). let suspended = conn .create_workspace_webhook(NewWorkspaceWebhook::test( - workspace_id, - account_id, + seeded.workspace_id, + seeded.account_id, vec![WebhookEvent::FileCreated], )) .await?; @@ -433,15 +442,15 @@ mod tests { // Subscribed but soft-deleted: excluded. let deleted = conn .create_workspace_webhook(NewWorkspaceWebhook::test( - workspace_id, - account_id, + seeded.workspace_id, + seeded.account_id, vec![WebhookEvent::FileCreated], )) .await?; conn.delete_workspace_webhook(deleted.id).await?; let matched = conn - .find_webhooks_for_event(workspace_id, WebhookEvent::FileCreated) + .find_webhooks_for_event(seeded.workspace_id, WebhookEvent::FileCreated) .await?; assert_eq!( matched.iter().map(|w| w.id).collect::>(), diff --git a/crates/nvisy-postgres/src/test_util.rs b/crates/nvisy-postgres/src/test_util.rs index f2a57461..3c9f2eb9 100644 --- a/crates/nvisy-postgres/src/test_util.rs +++ b/crates/nvisy-postgres/src/test_util.rs @@ -123,13 +123,13 @@ impl TestDatabase { .id } - /// Seeds an account and a workspace it owns, returning `(account_id, - /// workspace_id)` — the FK parents most workspace-scoped rows require. + /// Seeds an account and a workspace it owns — the FK parents most + /// workspace-scoped rows require. /// /// # Panics /// /// Panics if either insert fails. - pub async fn seed_account_and_workspace(&self) -> (Uuid, Uuid) { + pub async fn seed_account_and_workspace(&self) -> SeededWorkspace { let account_id = self.seed_account().await; let mut conn = self .client @@ -141,18 +141,23 @@ impl TestDatabase { .await .expect("failed to seed workspace"); - (account_id, workspace.id) + SeededWorkspace { + account_id, + workspace_id: workspace.id, + } } - /// Seeds an account, a workspace, a pipeline, and an input file, returning - /// `(account_id, workspace_id, pipeline_id, file_id)` — the FK parents a - /// detection (and, through it, a redaction) requires. + /// Seeds an account, a workspace, a pipeline, and an input file — the FK + /// parents a detection (and, through it, a redaction) requires. /// /// # Panics /// /// Panics if any insert fails. - pub async fn seed_pipeline_and_file(&self) -> (Uuid, Uuid, Uuid, Uuid) { - let (account_id, workspace_id) = self.seed_account_and_workspace().await; + pub async fn seed_pipeline_and_file(&self) -> SeededPipeline { + let SeededWorkspace { + account_id, + workspace_id, + } = self.seed_account_and_workspace().await; let mut conn = self .client .get_connection() @@ -167,10 +172,38 @@ impl TestDatabase { .await .expect("failed to seed file"); - (account_id, workspace_id, pipeline.id, file.id) + SeededPipeline { + account_id, + workspace_id, + pipeline_id: pipeline.id, + file_id: file.id, + } } } +/// An account and a workspace it owns, seeded for a test. +#[derive(Debug, Clone, Copy)] +pub struct SeededWorkspace { + /// The account that owns the workspace. + pub account_id: Uuid, + /// The workspace. + pub workspace_id: Uuid, +} + +/// An account, workspace, pipeline, and input file, seeded for a test — the FK +/// parents a detection and redaction need. +#[derive(Debug, Clone, Copy)] +pub struct SeededPipeline { + /// The account that owns everything. + pub account_id: Uuid, + /// The workspace. + pub workspace_id: Uuid, + /// The pipeline. + pub pipeline_id: Uuid, + /// The input file. + pub file_id: Uuid, +} + /// Test-only helpers that backdate a single row's timestamp column(s). /// /// Production `create_*` methods stamp timestamps like `created_at`/`started_at` diff --git a/crates/nvisy-postgres/src/types/json/pipeline_metadata.rs b/crates/nvisy-postgres/src/types/json/pipeline_metadata.rs index ffa57340..a194a6f0 100644 --- a/crates/nvisy-postgres/src/types/json/pipeline_metadata.rs +++ b/crates/nvisy-postgres/src/types/json/pipeline_metadata.rs @@ -61,8 +61,8 @@ mod tests { #[test] fn get_maps_each_scope_to_its_field_and_never_overrides_originals() { let over = RetentionOverride { - redacted_documents: Some(Retention::Forever), - audit_logs: Some(Retention::Days { days: 30 }), + redacted_documents: Some(Retention::Persistent), + audit_logs: Some(Retention::Fixed { days: 30 }), intermediates: None, }; @@ -71,11 +71,11 @@ mod tests { assert_eq!(over.get(RetentionScope::OriginalDocuments), None); assert_eq!( over.get(RetentionScope::RedactedDocuments), - Some(Retention::Forever) + Some(Retention::Persistent) ); assert_eq!( over.get(RetentionScope::AuditLogs), - Some(Retention::Days { days: 30 }) + Some(Retention::Fixed { days: 30 }) ); // A `None` field inherits the workspace baseline (no override reported). assert_eq!(over.get(RetentionScope::Intermediates), None); diff --git a/crates/nvisy-postgres/src/types/json/retention.rs b/crates/nvisy-postgres/src/types/json/retention.rs index 6fde66d3..4b04ad11 100644 --- a/crates/nvisy-postgres/src/types/json/retention.rs +++ b/crates/nvisy-postgres/src/types/json/retention.rs @@ -14,20 +14,20 @@ use super::RetentionOverride; /// How long a class of data is retained. /// -/// Wire shape is internally tagged on `mode`: `{ "mode": "forever" }`, -/// `{ "mode": "zeroDays" }`, `{ "mode": "days", "days": 30 }`. +/// Wire shape is internally tagged on `mode`: `{ "mode": "persistent" }`, +/// `{ "mode": "ephemeral" }`, `{ "mode": "fixed", "days": 30 }`. #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] #[derive(Serialize, Deserialize)] #[serde(tag = "mode", rename_all = "camelCase")] pub enum Retention { - /// Keep data indefinitely (the default). + /// Keep data indefinitely. + Persistent, + /// Delete data as soon as it has been processed (the default). #[default] - Forever, - /// Delete data as soon as it has been processed. - ZeroDays, + Ephemeral, /// Keep data for a fixed number of days, then delete it. - Days { + Fixed { /// Number of days to retain data. days: u32, }, @@ -35,19 +35,19 @@ pub enum Retention { impl Retention { /// When data written at `now` expires under this policy, or `None` when it - /// never expires ([`Retention::Forever`]). Stored as a file's `expires_at`; + /// never expires ([`Retention::Persistent`]). Stored as a file's `expires_at`; /// the retention sweep deletes rows whose `expires_at` is in the past. /// - /// [`Retention::ZeroDays`] expires immediately (`now`), so the data is + /// [`Retention::Ephemeral`] expires immediately (`now`), so the data is /// eligible for deletion as soon as it has been written. #[must_use] pub fn expires_at(self, now: Timestamp) -> Option { match self { - Self::Forever => None, - Self::ZeroDays => Some(now), + Self::Persistent => None, + Self::Ephemeral => Some(now), // `Timestamp` arithmetic only accepts uniform units (hours or // smaller), not calendar days, so express the window in hours. - Self::Days { days } => Some(now + Span::new().hours(i64::from(days) * 24)), + Self::Fixed { days } => Some(now + Span::new().hours(i64::from(days) * 24)), } } } @@ -66,8 +66,9 @@ pub enum RetentionScope { Intermediates, } -/// Retention for every scope. Missing fields default to [`Retention::Forever`], -/// so an empty settings blob keeps everything. +/// Retention for every scope. Missing fields default to [`Retention::Ephemeral`], +/// so an empty settings blob deletes each class of data as soon as it has been +/// processed. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] #[derive(Serialize, Deserialize)] @@ -95,10 +96,17 @@ impl RetentionSettings { } } - /// Whether every scope is [`Retention::Forever`] (nothing to enforce). + /// Whether every scope is [`Retention::Persistent`] (nothing to enforce). Kept + /// distinct from the type's `Default`, which now deletes by default. #[must_use] pub fn is_noop(&self) -> bool { - *self == Self::default() + let keep_everything = RetentionSettings { + original_documents: Retention::Persistent, + redacted_documents: Retention::Persistent, + audit_logs: Retention::Persistent, + intermediates: Retention::Persistent, + }; + *self == keep_everything } /// The effective retention for `scope`: a pipeline override wins over this @@ -124,12 +132,12 @@ mod tests { #[test] fn expires_at_is_none_for_forever_and_future_for_days() { let now = Timestamp::UNIX_EPOCH + Span::new().hours(100 * 24); - // Forever never expires; ZeroDays expires immediately; Days expires in - // the future (now + window), never in the past. - assert_eq!(Retention::Forever.expires_at(now), None); - assert_eq!(Retention::ZeroDays.expires_at(now), Some(now)); + // Persistent never expires; Ephemeral expires immediately; Fixed expires + // in the future (now + window), never in the past. + assert_eq!(Retention::Persistent.expires_at(now), None); + assert_eq!(Retention::Ephemeral.expires_at(now), Some(now)); assert_eq!( - Retention::Days { days: 10 }.expires_at(now), + Retention::Fixed { days: 10 }.expires_at(now), Some(now + Span::new().hours(10 * 24)), ); } @@ -137,34 +145,35 @@ mod tests { #[test] fn resolve_prefers_pipeline_override() { let workspace = RetentionSettings { - redacted_documents: Retention::Days { days: 30 }, + redacted_documents: Retention::Fixed { days: 30 }, ..Default::default() }; let over = RetentionOverride { - redacted_documents: Some(Retention::ZeroDays), + redacted_documents: Some(Retention::Ephemeral), ..Default::default() }; // Override wins when set. assert_eq!( workspace.resolve(RetentionScope::RedactedDocuments, Some(&over)), - Retention::ZeroDays, + Retention::Ephemeral, ); // Workspace baseline applies when there is no override. assert_eq!( workspace.resolve(RetentionScope::RedactedDocuments, None), - Retention::Days { days: 30 }, + Retention::Fixed { days: 30 }, ); - // A scope the override leaves unset inherits the workspace value. + // A scope the override leaves unset inherits the workspace value (here the + // default, which is Ephemeral). assert_eq!( workspace.resolve(RetentionScope::AuditLogs, Some(&over)), - Retention::Forever, + Retention::Ephemeral, ); } #[test] fn original_documents_ignore_pipeline_override() { let workspace = RetentionSettings { - original_documents: Retention::Days { days: 30 }, + original_documents: Retention::Fixed { days: 30 }, ..Default::default() }; // Original documents are ingested, not produced by a pipeline, so an @@ -174,7 +183,7 @@ mod tests { RetentionScope::OriginalDocuments, Some(&RetentionOverride::default()) ), - Retention::Days { days: 30 }, + Retention::Fixed { days: 30 }, ); } } diff --git a/crates/nvisy-postgres/src/types/json/workspace_settings.rs b/crates/nvisy-postgres/src/types/json/workspace_settings.rs index f4d8ecf9..bddcb546 100644 --- a/crates/nvisy-postgres/src/types/json/workspace_settings.rs +++ b/crates/nvisy-postgres/src/types/json/workspace_settings.rs @@ -89,14 +89,14 @@ mod tests { fn empty_settings_blob_is_default() { let settings = column(json!({})).or_default(); assert_eq!(settings.raster, RasterPolicy::Auto); - assert!(settings.retention.is_noop()); + assert_eq!(settings.retention, RetentionSettings::default()); } #[test] fn malformed_settings_blob_falls_back_to_default() { let settings = column(json!({ "retention": "nonsense" })).or_default(); assert_eq!(settings.raster, RasterPolicy::Auto); - assert!(settings.retention.is_noop()); + assert_eq!(settings.retention, RetentionSettings::default()); } #[test] diff --git a/crates/nvisy-postgres/src/types/mod.rs b/crates/nvisy-postgres/src/types/mod.rs index fc48e8bd..190aa5e1 100644 --- a/crates/nvisy-postgres/src/types/mod.rs +++ b/crates/nvisy-postgres/src/types/mod.rs @@ -42,12 +42,15 @@ pub use json::{ ThreadCommentActivityParams, WebhookActivityParams, WebhookHeaders, WorkspaceActivityParams, WorkspaceMetadata, WorkspaceSettings, }; -pub use pagination::{Cursor, CursorPage, CursorPagination, OffsetPage, OffsetPagination}; +pub(crate) use pagination::keyset; +pub use pagination::{ + Cursor, CursorKey, CursorPage, CursorPagination, OffsetPage, OffsetPagination, +}; pub use prefixed_id::{ ConnectionId, DetectionId, PrefixedIdError, ProviderId, RedactionId, WebhookId, }; pub use sorting::{ - FileSortBy, FileSortField, InviteSortBy, InviteSortField, MemberSortBy, MemberSortField, - SortBy, SortOrder, + Direction, FileSortBy, FileSortField, InviteSortBy, InviteSortField, MemberSortBy, + MemberSortField, SortBy, }; pub use utilities::{AccountRefRow, WithAccountRef, session}; diff --git a/crates/nvisy-postgres/src/types/pagination/cursor.rs b/crates/nvisy-postgres/src/types/pagination/cursor.rs index 18bca237..b283ea2c 100644 --- a/crates/nvisy-postgres/src/types/pagination/cursor.rs +++ b/crates/nvisy-postgres/src/types/pagination/cursor.rs @@ -1,190 +1,170 @@ -//! Cursor-based pagination for database queries. +//! Keyset (cursor) pagination for database queries. //! -//! Cursor pagination provides efficient, stable pagination for large datasets. -//! Unlike offset pagination, performance remains constant regardless of page depth. - -use std::fmt; +//! Cursor pagination is stable and constant-time regardless of page depth: a +//! query orders by a **keyset** — a tuple of columns whose combined value is +//! unique and monotonic (a timestamp plus a tiebreaking id) — and the next page +//! is the rows strictly after the last one seen under that same order. +//! +//! The keyset is defined once per query as a [`CursorKey`] type `K`. The same +//! `K` drives all three things that must agree — the `ORDER BY`, the keyset +//! `WHERE` comparison, and the opaque cursor the client echoes back — so they +//! cannot silently disagree. The [`keyset`](crate::keyset) macro applies the +//! order, comparison, and limit to a query from a `K`'s columns and a +//! [`Direction`](crate::types::Direction) direction, eliminating the hand-written comparison at each call site. use base64::prelude::*; -use jiff::Timestamp; -use serde::{Deserialize, Serialize}; -use uuid::Uuid; +use serde::Serialize; +use serde::de::DeserializeOwned; + +use crate::types::Direction; /// Maximum number of items per page. pub const MAX_LIMIT: i64 = 100; -/// A cursor representing a position in a paginated result set. +/// A keyset: the ordered column values that position a row in a paginated query. /// -/// The cursor encodes the last seen item's timestamp and ID, allowing -/// efficient keyset pagination. The ID serves as a tiebreaker for items -/// with identical timestamps. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] -#[serde(into = "String", try_from = "String")] -pub struct Cursor { - /// Timestamp of the last seen item. - pub timestamp: Timestamp, - /// ID of the last seen item (tiebreaker). - pub id: Uuid, -} +/// Implementors are plain, serializable structs of the ordering columns (e.g. +/// `{ created_at, id }`), most-significant field first. `Serialize` / +/// `DeserializeOwned` give the opaque wire cursor; the database does the actual +/// ordering, so no `Ord` is required here. +pub trait CursorKey: Serialize + DeserializeOwned {} -impl Cursor { - /// Creates a new cursor from a timestamp and ID. - pub fn new(timestamp: Timestamp, id: Uuid) -> Self { - Self { timestamp, id } - } +impl CursorKey for K where K: Serialize + DeserializeOwned {} - /// Encodes the cursor as a URL-safe base64 string. - pub fn encode(&self) -> String { - let data = format!("{}|{}", self.timestamp, self.id); - BASE64_URL_SAFE_NO_PAD.encode(data.as_bytes()) - } - - /// Decodes a cursor from a URL-safe base64 string. - /// - /// Returns `None` if the string is invalid or malformed. - pub fn decode(encoded: &str) -> Option { - let bytes = BASE64_URL_SAFE_NO_PAD.decode(encoded).ok()?; - let data = String::from_utf8(bytes).ok()?; - let (timestamp_str, id_str) = data.split_once('|')?; - - let timestamp = timestamp_str.parse().ok()?; - let id = id_str.parse().ok()?; - - Some(Self { timestamp, id }) - } +/// An opaque position in a keyset-paginated result set: the [`CursorKey`] of the +/// last row of the previous page. +/// +/// Serializes to and from a URL-safe base64 string of the key's JSON, so the +/// client treats it as an opaque token and the wire form is not tied to any +/// particular key shape. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Cursor { + /// The keyset of the last row seen. + pub key: K, } -impl fmt::Display for Cursor { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.encode()) +impl Cursor { + /// Wraps a keyset as a cursor. + pub fn new(key: K) -> Self { + Self { key } } -} -impl From for String { - fn from(cursor: Cursor) -> Self { - cursor.encode() + /// Encodes the cursor as a URL-safe base64 string of the key's JSON. + pub fn encode(&self) -> String { + // The key is a small fixed struct, so serialization cannot realistically + // fail; fall back to an empty token rather than panicking in a getter. + let json = serde_json::to_vec(&self.key).unwrap_or_default(); + BASE64_URL_SAFE_NO_PAD.encode(json) } -} -impl TryFrom for Cursor { - type Error = &'static str; - - fn try_from(value: String) -> Result { - Cursor::decode(&value).ok_or("invalid cursor format") + /// Decodes a cursor from a URL-safe base64 string, or `None` if it is not a + /// valid encoding of this key type. + pub fn decode(encoded: &str) -> Option { + let json = BASE64_URL_SAFE_NO_PAD.decode(encoded).ok()?; + let key = serde_json::from_slice(&json).ok()?; + Some(Self { key }) } } -/// Cursor-based pagination parameters for database queries. +/// Keyset-pagination parameters: how many rows, from where, in which direction, +/// and whether to also count the total. /// -/// This is the preferred pagination method for API endpoints. It provides: -/// - Consistent performance regardless of page depth -/// - Stable results even when items are added/removed -/// - Efficient "load more" / infinite scroll patterns -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] -pub struct CursorPagination { - /// Maximum number of records to return. +/// Generic over the query's [`CursorKey`] so `after` is a typed cursor decoded +/// against exactly that key. +#[derive(Debug, Clone)] +pub struct CursorPagination { + /// Maximum rows to return (clamped to `1..=MAX_LIMIT`). pub limit: i64, - /// Cursor pointing to the last item of the previous page. - #[serde(skip_serializing_if = "Option::is_none")] - pub after: Option, - /// Whether to include total count in the response. - /// Set to `false` to skip the count query for better performance. - #[serde(default)] + /// The cursor to continue after; `None` starts at the first page. + pub after: Option>, + /// Whether to also run the total-count query (skipped by default). pub include_count: bool, + /// The order to walk rows in. + pub direction: Direction, } -impl CursorPagination { - /// Creates a new cursor pagination with the given limit. +impl CursorPagination { + /// A first page of `limit` rows, newest first, without a count. pub fn new(limit: i64) -> Self { Self { limit: limit.clamp(1, MAX_LIMIT), after: None, include_count: false, + direction: Direction::Descending, } } - /// Creates cursor pagination starting after the given cursor. - pub fn after(limit: i64, cursor: Cursor) -> Self { - Self { - limit: limit.clamp(1, MAX_LIMIT), - after: Some(cursor), - include_count: false, - } - } - - /// Creates cursor pagination from an optional encoded cursor string. - /// - /// If the cursor string is invalid, pagination starts from the beginning. + /// Decodes an optional encoded cursor string into typed pagination. An invalid + /// cursor string is treated as no cursor (the first page). pub fn from_cursor_string(limit: i64, cursor: Option<&str>) -> Self { Self { limit: limit.clamp(1, MAX_LIMIT), after: cursor.and_then(Cursor::decode), include_count: false, + direction: Direction::Descending, } } - /// Enables including total count in the response. + /// Sets the walk direction. + #[must_use] + pub fn with_direction(mut self, direction: Direction) -> Self { + self.direction = direction; + self + } + + /// Enables the total-count query. + #[must_use] pub fn with_count(mut self) -> Self { self.include_count = true; self } - /// Returns the limit plus one for fetching to determine if there are more results. - /// - /// When querying, fetch `limit + 1` items. If you get `limit + 1` results, - /// there are more pages; return only `limit` items to the client. - pub fn fetch_limit(&self) -> i64 { - self.limit + 1 + /// The key to compare against, when continuing after a cursor. + pub fn after_key(&self) -> Option<&K> { + self.after.as_ref().map(|c| &c.key) } - /// Checks if we have a cursor to paginate from. - pub fn has_cursor(&self) -> bool { - self.after.is_some() + /// The fetch limit: one more than `limit`, so a full extra row signals that a + /// further page exists (it is dropped before the page is returned). + pub fn fetch_limit(&self) -> i64 { + self.limit + 1 } } -/// Result of a cursor-paginated query. +/// A page of keyset-paginated rows: the items, an optional total, and the opaque +/// cursor for the next page (present only when more rows exist). #[derive(Debug, Clone)] pub struct CursorPage { - /// The items in this page. + /// The rows in this page. pub items: Vec, - /// Total count of items matching the query (across all pages). - /// Only present if `include_count` was set in the pagination request. + /// Total rows matching the query, when a count was requested. pub total: Option, - /// Cursor to fetch the next page. Present only when more items exist. + /// The cursor for the next page, or `None` at the end. pub next_cursor: Option, } impl CursorPage { - /// Creates a new cursor page from query results. + /// Builds a page from a fetched batch (of up to `limit + 1` rows). /// - /// # Arguments - /// * `items` - Items fetched from the database (should be `limit + 1` if there are more) - /// * `total` - Total count of items matching the query (None if count was skipped) - /// * `limit` - The requested page size - /// * `cursor_fn` - Function to extract cursor data (timestamp, id) from an item - pub fn new(mut items: Vec, total: Option, limit: i64, cursor_fn: F) -> Self + /// If the batch holds more than `limit` rows, the extra one is dropped and its + /// predecessor's [`CursorKey`] (from `cursor_fn`) becomes the next cursor; + /// otherwise this is the last page. `total` is passed through. + pub fn new(mut items: Vec, total: Option, limit: i64, cursor_fn: F) -> Self where - F: Fn(&T) -> (Timestamp, Uuid), + K: CursorKey, + F: Fn(&T) -> K, { let has_more = items.len() as i64 > limit; - - // Remove the extra item used to detect more pages if has_more { items.pop(); } - - let next_cursor = if has_more { - items.last().map(|item| { - let (timestamp, id) = cursor_fn(item); - Cursor::new(timestamp, id).encode() + let next_cursor = has_more + .then(|| { + items + .last() + .map(|item| Cursor::new(cursor_fn(item)).encode()) }) - } else { - None - }; - + .flatten(); Self { items, total, @@ -192,7 +172,7 @@ impl CursorPage { } } - /// Creates an empty cursor page. + /// An empty page (no rows, count zero, no next cursor). pub fn empty() -> Self { Self { items: Vec::new(), @@ -201,12 +181,12 @@ impl CursorPage { } } - /// Returns true if there are more items to fetch. + /// Whether a further page exists. pub fn has_more(&self) -> bool { self.next_cursor.is_some() } - /// Maps the items to a different type. + /// Maps the items to another type, keeping the total and next cursor. pub fn map(self, f: F) -> CursorPage where F: FnMut(T) -> U, @@ -219,94 +199,127 @@ impl CursorPage { } } -#[cfg(test)] -mod tests { - use super::*; +/// Applies keyset ordering, the after-cursor comparison, and the fetch limit to a +/// boxed Diesel query, in one place, so no call site hand-writes the comparison. +/// +/// `keyset!(query, sort_col, id_col, direction, after)` orders by +/// `(sort_col, id_col)` in `direction`, filters to the rows strictly after +/// `after` (an `Option<(sort_value, id_value)>` read from the decoded cursor key), +/// and limits to `limit`. It returns the boxed query, ready for +/// `.select(...).limit(..).load(..)`. +/// +/// Generic over the sort column's type: `sort_col` may be any orderable column +/// (a timestamp, a text field, …) and `after`'s first element is its comparable +/// value — nothing here assumes a timestamp. `direction` is a +/// [`Direction`](crate::types::Direction); `after` is typically +/// `pagination.after_key().map(|k| (k.field.into(), k.id))`. +macro_rules! keyset { + ($query:expr, $sort:expr, $id:expr, $direction:expr, $after:expr) => {{ + use $crate::types::Direction; + let mut query = $query; + + // Continue strictly after the cursor's row, in the walk direction: for + // Descending, rows before the sort value, or equal on it and a smaller id; + // for Ascending, the mirror. + if let Some((after_sort, after_id)) = $after { + query = match $direction { + Direction::Descending => query.filter( + $sort + .lt(after_sort.clone()) + .or($sort.eq(after_sort).and($id.lt(after_id))), + ), + Direction::Ascending => query.filter( + $sort + .gt(after_sort.clone()) + .or($sort.eq(after_sort).and($id.gt(after_id))), + ), + }; + } - #[test] - fn cursor_encode_decode_roundtrip() { - let timestamp = Timestamp::now(); - let id = Uuid::new_v4(); - let cursor = Cursor::new(timestamp, id); + query = match $direction { + Direction::Descending => query.order(($sort.desc(), $id.desc())), + Direction::Ascending => query.order(($sort.asc(), $id.asc())), + }; - let encoded = cursor.encode(); - let decoded = Cursor::decode(&encoded).expect("decode should succeed"); + query + }}; +} - assert_eq!(cursor.timestamp, decoded.timestamp); - assert_eq!(cursor.id, decoded.id); - } +pub(crate) use keyset; - #[test] - fn cursor_decode_invalid() { - assert!(Cursor::decode("invalid").is_none()); - assert!(Cursor::decode("").is_none()); - assert!(Cursor::decode("not:valid:cursor").is_none()); - } +#[cfg(test)] +mod tests { + use jiff::Timestamp; + use serde::Deserialize; + use uuid::Uuid; - #[test] - fn cursor_pagination_defaults() { - let pagination = CursorPagination::default(); - assert_eq!(pagination.limit, 0); - assert!(pagination.after.is_none()); - assert!(!pagination.include_count); - } + use super::*; - #[test] - fn cursor_pagination_new() { - let pagination = CursorPagination::new(25); - assert_eq!(pagination.limit, 25); - assert!(pagination.after.is_none()); - assert!(!pagination.include_count); + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] + struct TimeKey { + created_at: Timestamp, + id: Uuid, } #[test] - fn cursor_pagination_with_count() { - let pagination = CursorPagination::new(25).with_count(); - assert!(pagination.include_count); + fn cursor_encode_decode_roundtrip() { + let key = TimeKey { + created_at: Timestamp::now(), + id: Uuid::new_v4(), + }; + let cursor = Cursor::new(key.clone()); + let decoded = Cursor::::decode(&cursor.encode()).expect("decode"); + assert_eq!(decoded.key, key); } #[test] - fn cursor_pagination_limit_bounds() { - let pagination = CursorPagination::new(0); - assert_eq!(pagination.limit, 1); - - let pagination = CursorPagination::new(200); - assert_eq!(pagination.limit, MAX_LIMIT); + fn cursor_decode_rejects_garbage() { + assert!(Cursor::::decode("not base64!!").is_none()); + assert!(Cursor::::decode("").is_none()); + // Valid base64 of the wrong shape does not decode to this key. + let wrong = BASE64_URL_SAFE_NO_PAD.encode(b"{}"); + assert!(Cursor::::decode(&wrong).is_none()); } #[test] - fn cursor_pagination_fetch_limit() { - let pagination = CursorPagination::new(50); - assert_eq!(pagination.fetch_limit(), 51); + fn pagination_clamps_limit_and_defaults_descending() { + assert_eq!(CursorPagination::::new(0).limit, 1); + assert_eq!(CursorPagination::::new(500).limit, MAX_LIMIT); + assert_eq!( + CursorPagination::::new(10).direction, + Direction::Descending + ); + assert_eq!(CursorPagination::::new(10).fetch_limit(), 11); } #[test] - fn cursor_page_with_more() { - let items: Vec = (1..=51).collect(); // 51 items = has more - let page = CursorPage::new(items, Some(100), 50, |_| (Timestamp::now(), Uuid::new_v4())); - - assert_eq!(page.items.len(), 50); - assert_eq!(page.total, Some(100)); - assert!(page.has_more()); + fn page_drops_the_probe_row_and_sets_a_next_cursor() { + // limit 2, three rows fetched (the probe) -> two returned + a next cursor. + let rows = vec![ + TimeKey { + created_at: Timestamp::now(), + id: Uuid::new_v4(), + }, + TimeKey { + created_at: Timestamp::now(), + id: Uuid::new_v4(), + }, + TimeKey { + created_at: Timestamp::now(), + id: Uuid::new_v4(), + }, + ]; + let page = CursorPage::new(rows, None, 2, |k: &TimeKey| k.clone()); + assert_eq!(page.items.len(), 2); assert!(page.next_cursor.is_some()); - } - - #[test] - fn cursor_page_without_more() { - let items: Vec = (1..=30).collect(); // 30 items = no more - let page = CursorPage::new(items, Some(30), 50, |_| (Timestamp::now(), Uuid::new_v4())); - - assert_eq!(page.items.len(), 30); - assert_eq!(page.total, Some(30)); - assert!(!page.has_more()); - assert!(page.next_cursor.is_none()); - } - - #[test] - fn cursor_page_without_count() { - let items: Vec = (1..=30).collect(); - let page = CursorPage::new(items, None, 50, |_| (Timestamp::now(), Uuid::new_v4())); - assert_eq!(page.total, None); + // A short batch is the last page. + let last = CursorPage::new(vec![1_i32, 2], Some(2), 5, |n: &i32| TimeKey { + created_at: Timestamp::now(), + id: Uuid::from_u128(u128::try_from(*n).unwrap()), + }); + assert_eq!(last.items.len(), 2); + assert!(last.next_cursor.is_none()); + assert_eq!(last.total, Some(2)); } } diff --git a/crates/nvisy-postgres/src/types/pagination/mod.rs b/crates/nvisy-postgres/src/types/pagination/mod.rs index d5fe9929..eee7bba4 100644 --- a/crates/nvisy-postgres/src/types/pagination/mod.rs +++ b/crates/nvisy-postgres/src/types/pagination/mod.rs @@ -6,5 +6,6 @@ mod cursor; mod offset; -pub use cursor::{Cursor, CursorPage, CursorPagination}; +pub(crate) use cursor::keyset; +pub use cursor::{Cursor, CursorKey, CursorPage, CursorPagination}; pub use offset::{OffsetPage, OffsetPagination}; diff --git a/crates/nvisy-postgres/src/types/sorting/mod.rs b/crates/nvisy-postgres/src/types/sorting/mod.rs index 02d651a3..28616a0c 100644 --- a/crates/nvisy-postgres/src/types/sorting/mod.rs +++ b/crates/nvisy-postgres/src/types/sorting/mod.rs @@ -13,12 +13,12 @@ use serde::{Deserialize, Serialize}; #[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] #[serde(rename_all = "snake_case")] -pub enum SortOrder { +pub enum Direction { /// Ascending order (A-Z, oldest first, smallest first). - Asc, + Ascending, /// Descending order (Z-A, newest first, largest first). #[default] - Desc, + Descending, } /// Generic sort specification with field and order. @@ -30,14 +30,14 @@ pub struct SortBy { pub field: F, /// The sort order direction. #[serde(default)] - pub order: SortOrder, + pub order: Direction, } impl Default for SortBy { fn default() -> Self { Self { field: F::default(), - order: SortOrder::default(), + order: Direction::default(), } } } @@ -45,7 +45,7 @@ impl Default for SortBy { impl SortBy { /// Creates a new sort specification with the given field and order. #[inline] - pub fn new(field: F, order: SortOrder) -> Self { + pub fn new(field: F, order: Direction) -> Self { Self { field, order } } @@ -54,7 +54,7 @@ impl SortBy { pub fn asc(field: F) -> Self { Self { field, - order: SortOrder::Asc, + order: Direction::Ascending, } } @@ -63,19 +63,19 @@ impl SortBy { pub fn desc(field: F) -> Self { Self { field, - order: SortOrder::Desc, + order: Direction::Descending, } } /// Returns whether the sort order is ascending. #[inline] pub fn is_asc(&self) -> bool { - matches!(self.order, SortOrder::Asc) + matches!(self.order, Direction::Ascending) } /// Returns whether the sort order is descending. #[inline] pub fn is_desc(&self) -> bool { - matches!(self.order, SortOrder::Desc) + matches!(self.order, Direction::Descending) } } diff --git a/crates/nvisy-server/src/handler/activities.rs b/crates/nvisy-server/src/handler/activities.rs index 39945b00..821acfce 100644 --- a/crates/nvisy-server/src/handler/activities.rs +++ b/crates/nvisy-server/src/handler/activities.rs @@ -137,7 +137,7 @@ async fn list_activities( } let page = conn - .cursor_list_workspace_activity(workspace.id, filter, pagination.into()) + .cursor_list_workspace_activity(workspace.id, filter, pagination.into_cursor()) .await?; let response = ActivitiesPage::from_cursor_page(page, |wc| { diff --git a/crates/nvisy-server/src/handler/assignments.rs b/crates/nvisy-server/src/handler/assignments.rs index 83a75d27..69e2d154 100644 --- a/crates/nvisy-server/src/handler/assignments.rs +++ b/crates/nvisy-server/src/handler/assignments.rs @@ -235,7 +235,7 @@ async fn list_workspace_assignments( let filter = query.into_filter(assignee_account_id); let page = conn - .cursor_list_workspace_assignments(workspace.id, pagination.into(), &filter) + .cursor_list_workspace_assignments(workspace.id, pagination.into_cursor(), &filter) .await?; let response = AssignmentsPage::from_cursor_page(page, |row| { diff --git a/crates/nvisy-server/src/handler/connection_syncs.rs b/crates/nvisy-server/src/handler/connection_syncs.rs index a60d6a28..30e9bccc 100644 --- a/crates/nvisy-server/src/handler/connection_syncs.rs +++ b/crates/nvisy-server/src/handler/connection_syncs.rs @@ -368,7 +368,7 @@ async fn list_connection_syncs( let connection = find_connection(&mut conn, workspace.id, path_params.connection_id).await?; let page = conn - .cursor_list_workspace_connection_syncs(connection.id, pagination.into(), None) + .cursor_list_workspace_connection_syncs(connection.id, pagination.into_cursor(), None) .await?; let page = Page::from_cursor_page(page, |wc| { @@ -413,7 +413,7 @@ async fn list_workspace_syncs( let page = conn .cursor_list_workspace_connection_syncs_all( workspace.id, - pagination.into(), + pagination.into_cursor(), query.status, &query.provider, ) diff --git a/crates/nvisy-server/src/handler/connections.rs b/crates/nvisy-server/src/handler/connections.rs index d216f303..bf372b5d 100644 --- a/crates/nvisy-server/src/handler/connections.rs +++ b/crates/nvisy-server/src/handler/connections.rs @@ -205,7 +205,7 @@ async fn list_connections( let mut conn = pg_client.get_connection().await?; let page = conn - .cursor_list_workspace_connections(workspace.id, pagination.into(), &query.provider) + .cursor_list_workspace_connections(workspace.id, pagination.into_cursor(), &query.provider) .await?; // One grouped query resolves last-synced for the whole page (not per row). diff --git a/crates/nvisy-server/src/handler/detections.rs b/crates/nvisy-server/src/handler/detections.rs index 16dcc688..b93742c4 100644 --- a/crates/nvisy-server/src/handler/detections.rs +++ b/crates/nvisy-server/src/handler/detections.rs @@ -260,7 +260,7 @@ async fn list_pipeline_detections( let pipeline = find_pipeline(&mut conn, workspace.id, &path_params.pipeline_slug).await?; let page = conn - .cursor_list_pipeline_detections(pipeline.id, pagination.into(), &query.into()) + .cursor_list_pipeline_detections(pipeline.id, pagination.into_cursor(), &query.into()) .await?; tracing::debug!( @@ -319,7 +319,7 @@ async fn list_workspace_detections( let mut conn = pg_client.get_connection().await?; let page = conn - .cursor_list_workspace_detections(workspace.id, pagination.into(), &query.into()) + .cursor_list_workspace_detections(workspace.id, pagination.into_cursor(), &query.into()) .await?; tracing::debug!( diff --git a/crates/nvisy-server/src/handler/files.rs b/crates/nvisy-server/src/handler/files.rs index da67f3e4..53555ce4 100644 --- a/crates/nvisy-server/src/handler/files.rs +++ b/crates/nvisy-server/src/handler/files.rs @@ -88,7 +88,7 @@ async fn list_files( })?; let page = conn - .cursor_list_workspace_files(workspace.id, cursor_pagination.into(), filter) + .cursor_list_workspace_files(workspace.id, cursor_pagination.into_cursor(), filter) .await?; let response = FilesPage::from_cursor_page(page, |wc| { diff --git a/crates/nvisy-server/src/handler/invites.rs b/crates/nvisy-server/src/handler/invites.rs index ac668d13..89917c31 100644 --- a/crates/nvisy-server/src/handler/invites.rs +++ b/crates/nvisy-server/src/handler/invites.rs @@ -217,7 +217,7 @@ async fn list_invites( let page = conn .cursor_list_workspace_invites( workspace.id, - pagination.into(), + pagination.into_cursor(), query.to_sort(), query.to_filter(), ) diff --git a/crates/nvisy-server/src/handler/members.rs b/crates/nvisy-server/src/handler/members.rs index 491c9a90..8da9af92 100644 --- a/crates/nvisy-server/src/handler/members.rs +++ b/crates/nvisy-server/src/handler/members.rs @@ -53,7 +53,7 @@ async fn list_members( let page = conn .cursor_list_workspace_members_with_accounts( workspace.id, - pagination.into(), + pagination.into_cursor(), query.to_filter(), ) .await?; diff --git a/crates/nvisy-server/src/handler/notifications.rs b/crates/nvisy-server/src/handler/notifications.rs index 004172d6..d6886d53 100644 --- a/crates/nvisy-server/src/handler/notifications.rs +++ b/crates/nvisy-server/src/handler/notifications.rs @@ -45,7 +45,7 @@ async fn list_notifications( let mut conn = pg_client.get_connection().await?; let page = conn - .cursor_list_account_notifications(auth_state.account_id, pagination.into()) + .cursor_list_account_notifications(auth_state.account_id, pagination.into_cursor()) .await?; let response = NotificationsPage::from_cursor_page(page, Notification::from_model); diff --git a/crates/nvisy-server/src/handler/pipelines.rs b/crates/nvisy-server/src/handler/pipelines.rs index 1718c412..47d8f6d1 100644 --- a/crates/nvisy-server/src/handler/pipelines.rs +++ b/crates/nvisy-server/src/handler/pipelines.rs @@ -132,7 +132,7 @@ async fn list_pipelines( let page = conn .cursor_list_workspace_pipelines( workspace.id, - pagination.into(), + pagination.into_cursor(), filter.status, filter.search.as_deref(), ) diff --git a/crates/nvisy-server/src/handler/policies.rs b/crates/nvisy-server/src/handler/policies.rs index 40016324..73df96c5 100644 --- a/crates/nvisy-server/src/handler/policies.rs +++ b/crates/nvisy-server/src/handler/policies.rs @@ -136,7 +136,7 @@ async fn list_policies( let mut conn = pg_client.get_connection().await?; let page = conn - .cursor_list_workspace_policies(workspace.id, pagination.into()) + .cursor_list_workspace_policies(workspace.id, pagination.into_cursor()) .await?; tracing::debug!( diff --git a/crates/nvisy-server/src/handler/providers.rs b/crates/nvisy-server/src/handler/providers.rs index 8faf224f..cd6593b0 100644 --- a/crates/nvisy-server/src/handler/providers.rs +++ b/crates/nvisy-server/src/handler/providers.rs @@ -150,7 +150,7 @@ async fn list_providers( let mut conn = pg_client.get_connection().await?; let page = conn - .cursor_list_workspace_providers(workspace.id, pagination.into(), &query.provider) + .cursor_list_workspace_providers(workspace.id, pagination.into_cursor(), &query.provider) .await?; tracing::debug!( diff --git a/crates/nvisy-server/src/handler/redactions.rs b/crates/nvisy-server/src/handler/redactions.rs index 07c0186d..66d7251c 100644 --- a/crates/nvisy-server/src/handler/redactions.rs +++ b/crates/nvisy-server/src/handler/redactions.rs @@ -52,7 +52,7 @@ async fn list_detection_redactions( find_detection(&mut conn, workspace.id, path_params.detection_id.as_uuid()).await?; let page = conn - .cursor_list_detection_redactions(detection.id, pagination.into()) + .cursor_list_detection_redactions(detection.id, pagination.into_cursor()) .await?; // Resolve the requesting account per row. A detection's redactions are few diff --git a/crates/nvisy-server/src/handler/request/invites.rs b/crates/nvisy-server/src/handler/request/invites.rs index b8b1963d..de61333d 100644 --- a/crates/nvisy-server/src/handler/request/invites.rs +++ b/crates/nvisy-server/src/handler/request/invites.rs @@ -3,7 +3,7 @@ use garde::Validate; use nvisy_postgres::model::NewWorkspaceInvite; use nvisy_postgres::types::{ - InviteFilter, InviteSortBy, InviteSortField, SortOrder, WorkspaceRole, + Direction, InviteFilter, InviteSortBy, InviteSortField, WorkspaceRole, }; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; @@ -123,7 +123,7 @@ pub struct ListInvites { pub sort_by: Option, /// Sort order (asc or desc). #[serde(skip_serializing_if = "Option::is_none")] - pub order: Option, + pub order: Option, } impl ListInvites { diff --git a/crates/nvisy-server/src/handler/request/members.rs b/crates/nvisy-server/src/handler/request/members.rs index ef611794..dd25ec2d 100644 --- a/crates/nvisy-server/src/handler/request/members.rs +++ b/crates/nvisy-server/src/handler/request/members.rs @@ -3,7 +3,7 @@ use garde::Validate; use nvisy_postgres::model::UpdateWorkspaceMember; use nvisy_postgres::types::{ - MemberFilter, MemberSortBy, MemberSortField, SortOrder, WorkspaceRole, + Direction, MemberFilter, MemberSortBy, MemberSortField, WorkspaceRole, }; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; @@ -43,7 +43,7 @@ pub struct ListMembers { pub sort_by: Option, /// Sort order (asc or desc). #[serde(skip_serializing_if = "Option::is_none")] - pub order: Option, + pub order: Option, } impl ListMembers { diff --git a/crates/nvisy-server/src/handler/request/paginations.rs b/crates/nvisy-server/src/handler/request/paginations.rs index 9685bb32..e30b9a00 100644 --- a/crates/nvisy-server/src/handler/request/paginations.rs +++ b/crates/nvisy-server/src/handler/request/paginations.rs @@ -87,12 +87,19 @@ impl CursorPagination { pub fn limit(&self) -> u32 { self.limit.unwrap_or(DEFAULT_LIMIT).clamp(1, MAX_LIMIT) } -} -impl From for types::CursorPagination { - fn from(query: CursorPagination) -> Self { - let pagination = Self::from_cursor_string(query.limit() as i64, query.after.as_deref()); - if query.include_count { + /// Converts to typed database pagination, decoding the opaque `after` cursor + /// against the query's keyset `K`. An `after` that does not decode to `K` is + /// treated as no cursor (a fresh first page). Defaults to descending; a caller + /// that walks ascending sets it with + /// [`with_direction`](types::CursorPagination::with_direction). + pub fn into_cursor(self) -> types::CursorPagination + where + K: types::CursorKey, + { + let pagination = + types::CursorPagination::from_cursor_string(self.limit() as i64, self.after.as_deref()); + if self.include_count { pagination.with_count() } else { pagination diff --git a/crates/nvisy-server/src/handler/response/comments.rs b/crates/nvisy-server/src/handler/response/comments.rs index 7095fafa..b1ec3519 100644 --- a/crates/nvisy-server/src/handler/response/comments.rs +++ b/crates/nvisy-server/src/handler/response/comments.rs @@ -5,6 +5,7 @@ use nvisy_postgres::model::{ WorkspaceThread as ThreadModel, WorkspaceThreadAnchor as AnchorModel, WorkspaceThreadComment as CommentModel, WorkspaceThreadEvent as EventModel, }; +use nvisy_postgres::query::{TimelineCursor, TimelineSource}; use nvisy_postgres::types::ThreadEventKind; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; @@ -108,18 +109,37 @@ pub enum ThreadEntry { } impl ThreadEntry { - /// The entry's timestamp, used to order the merged timeline. - pub fn timestamp(&self) -> Timestamp { + /// The entry's position in the merged timeline: `(created_at, source, id)`. + /// Comments sort before events at the same instant; `id` breaks a tie within + /// one stream. This is the total order the timeline is paginated by. + pub fn cursor(&self) -> TimelineCursor { match self { - ThreadEntry::Comment(c) => c.created_at, - ThreadEntry::Event(e) => e.created_at, + ThreadEntry::Comment(c) => TimelineCursor { + created_at: c.created_at, + source: TimelineSource::Comment, + id: c.id, + }, + ThreadEntry::Event(e) => TimelineCursor { + created_at: e.created_at, + source: TimelineSource::Event, + id: e.id, + }, } } + + /// The sort tuple for merging the two streams, derived from [`Self::cursor`]. + pub fn sort_key(&self) -> (Timestamp, TimelineSource, uuid::Uuid) { + let c = self.cursor(); + (c.created_at, c.source, c.id) + } } /// Paginated response for threads. pub type ThreadsPage = Page; +/// Paginated response for a thread's timeline. +pub type TimelinePage = Page; + impl Thread { /// Creates a thread response from the database model, its live anchors, and /// the resolved author reference. diff --git a/crates/nvisy-server/src/handler/threads.rs b/crates/nvisy-server/src/handler/threads.rs index 90e3e9c6..5abf2dba 100644 --- a/crates/nvisy-server/src/handler/threads.rs +++ b/crates/nvisy-server/src/handler/threads.rs @@ -22,11 +22,11 @@ use nvisy_postgres::model::{ WorkspaceThreadComment, }; use nvisy_postgres::query::{ - AssistantJobOutboxRepository, WorkspaceFileRepository, WorkspaceMemberRepository, - WorkspaceThreadAnchorRepository, WorkspaceThreadCommentRepository, + AssistantJobOutboxRepository, TimelineCursor, WorkspaceFileRepository, + WorkspaceMemberRepository, WorkspaceThreadAnchorRepository, WorkspaceThreadCommentRepository, WorkspaceThreadEventRepository, WorkspaceThreadRepository, }; -use nvisy_postgres::types::Handle; +use nvisy_postgres::types::{CursorPage, Direction, Handle}; use nvisy_postgres::{ASSISTANT_ACCOUNT_ID, ASSISTANT_HANDLE, AsyncConnection, PgClient, PgConn}; use uuid::Uuid; @@ -36,7 +36,7 @@ use crate::handler::request::{ ThreadAnchorPathParams, ThreadPathParams, WorkspaceFilePathParams, WorkspaceThreadsQuery, }; use crate::handler::response::{ - Comment, Thread, ThreadAnchor, ThreadEntry, ThreadEvent, ThreadsPage, + Comment, Thread, ThreadAnchor, ThreadEntry, ThreadEvent, ThreadsPage, TimelinePage, }; use crate::handler::utility::resolve_account_ref; use crate::response::{Error, ErrorKind, ErrorResponse, Result}; @@ -259,7 +259,7 @@ async fn list_threads( let mut conn = pg_client.get_connection().await?; let page = conn - .cursor_list_threads(workspace.id, pagination.into(), &query.into()) + .cursor_list_threads(workspace.id, pagination.into_cursor(), &query.into()) .await?; // Fetch the whole page's live anchors in one query, then group them by thread @@ -686,7 +686,8 @@ async fn list_thread_timeline( State(pg_client): State, authz: Authorized, Path(path_params): Path, -) -> Result<(StatusCode, Json>)> { + Query(pagination): Query, +) -> Result<(StatusCode, Json)> { tracing::debug!(target: TRACING_TARGET, "Listing thread timeline"); let workspace = authz.workspace; @@ -694,13 +695,24 @@ async fn list_thread_timeline( find_thread(&mut conn, workspace.id, path_params.thread_id).await?; + // The timeline reads oldest first, so it walks ascending. + let pagination = pagination + .into_cursor::() + .with_direction(Direction::Ascending); + let after = pagination.after_key(); + let fetch = pagination.fetch_limit(); + + // Fetch a bounded window from each stream (fetch = limit + 1, so a full window + // from either stream can still signal that more rows exist after the merge). let comments = conn - .list_thread_comments(workspace.id, path_params.thread_id) + .list_thread_comments_after(workspace.id, path_params.thread_id, after, fetch) + .await?; + let events = conn + .list_thread_events_after(path_params.thread_id, after, fetch) .await?; - let events = conn.list_thread_events(path_params.thread_id).await?; - // Merge the two ordered streams into one timeline, oldest first. Both are - // already `created_at`-ordered, so a single sort by timestamp suffices. + // Merge the two already-ordered windows into one ascending timeline by + // (created_at, source, id) — the same total order the cursor encodes. let mut entries: Vec = Vec::with_capacity(comments.len() + events.len()); entries.extend( comments @@ -710,18 +722,26 @@ async fn list_thread_timeline( entries.extend(events.into_iter().map(|(event, actor)| { ThreadEntry::Event(ThreadEvent::from_model(event, actor.map(Into::into))) })); - entries.sort_by_key(ThreadEntry::timestamp); + entries.sort_by_key(ThreadEntry::sort_key); - Ok((StatusCode::OK, Json(entries))) + // The merged window holds up to 2 * fetch rows; a page is the first `limit`, + // with a next cursor when a further entry exists beyond them. + let response = TimelinePage::from_cursor_page( + CursorPage::new(entries, None, pagination.limit, ThreadEntry::cursor), + |entry| entry, + ); + + Ok((StatusCode::OK, Json(response))) } fn list_thread_timeline_docs(op: TransformOperation) -> TransformOperation { op.summary("List a thread's timeline") .description( - "Returns the thread's timeline — comments and lifecycle events (closed, \ - reopened, anchor added/removed) interleaved, oldest first.", + "Returns the thread's timeline — comments and lifecycle events (opened, \ + closed, reopened, renamed, anchor added/removed) interleaved, oldest \ + first, with cursor pagination.", ) - .response::<200, Json>>() + .response::<200, Json>() .response::<401, Json>() .response::<403, Json>() .response::<404, Json>() diff --git a/crates/nvisy-server/src/handler/tokens.rs b/crates/nvisy-server/src/handler/tokens.rs index 17242750..417ac167 100644 --- a/crates/nvisy-server/src/handler/tokens.rs +++ b/crates/nvisy-server/src/handler/tokens.rs @@ -86,7 +86,7 @@ async fn list_api_tokens( let mut conn = pg_client.get_connection().await?; let page = conn - .cursor_list_account_api_tokens(auth_state.account_id, pagination.into()) + .cursor_list_account_api_tokens(auth_state.account_id, pagination.into_cursor()) .await?; tracing::debug!( diff --git a/crates/nvisy-server/src/handler/webhooks.rs b/crates/nvisy-server/src/handler/webhooks.rs index a19fe101..4a27729a 100644 --- a/crates/nvisy-server/src/handler/webhooks.rs +++ b/crates/nvisy-server/src/handler/webhooks.rs @@ -143,7 +143,7 @@ async fn list_webhooks( let mut conn = pg_client.get_connection().await?; let page = conn - .cursor_list_workspace_webhooks(workspace.id, pagination.into()) + .cursor_list_workspace_webhooks(workspace.id, pagination.into_cursor()) .await?; tracing::debug!( diff --git a/crates/nvisy-server/src/handler/workspaces.rs b/crates/nvisy-server/src/handler/workspaces.rs index 510ba634..8ebfcafb 100644 --- a/crates/nvisy-server/src/handler/workspaces.rs +++ b/crates/nvisy-server/src/handler/workspaces.rs @@ -143,7 +143,10 @@ async fn list_workspaces( ) -> Result<(StatusCode, Json)> { let mut conn = pg_client.get_connection().await?; let page = conn - .cursor_list_account_workspaces_with_details(auth_state.account_id, pagination.into()) + .cursor_list_account_workspaces_with_details( + auth_state.account_id, + pagination.into_cursor(), + ) .await?; let hard_max_upload_bytes = upload.max_file_bytes(); diff --git a/crates/nvisy-server/src/middleware/specification.rs b/crates/nvisy-server/src/middleware/specification.rs index 4c5e9b31..b4c4b250 100644 --- a/crates/nvisy-server/src/middleware/specification.rs +++ b/crates/nvisy-server/src/middleware/specification.rs @@ -376,7 +376,7 @@ mod tests { "enumeration": { "description": "field description", "anyOf": [ - { "$ref": "#/components/schemas/SortOrder" }, + { "$ref": "#/components/schemas/Direction" }, { "type": "null" } ] }, @@ -410,7 +410,7 @@ mod tests { ); assert_eq!( schema.pointer("/properties/enumeration/$ref"), - Some(&json!("#/components/schemas/SortOrder")), + Some(&json!("#/components/schemas/Direction")), "optional referenced type hoists the non-null anyOf branch" ); assert!( From 07a4c7cb259eeb23a82c574ee75b9910121dc144 Mon Sep 17 00:00:00 2001 From: Oleh Martsokha Date: Fri, 11 Sep 2026 11:38:13 +0200 Subject: [PATCH 4/5] Address review findings on threads, comments, and the assistant Thread and comment layer: - Make close/reopen atomic with a current-state predicate, so a concurrent double transition returns NotFound instead of a duplicate timeline event. - Guard update_comment_body against an all-None changeset (return the current comment) rather than emitting an empty Diesel SET. - Reject comments on a closed thread with 409. - Rename requests use Option>: an omitted displayName keeps the title, only an explicit null clears it. - Gate rename_thread behind CloseComments, matching delete/close/reopen. - Cap live anchors per thread at MAX_THREAD_ANCHORS (32), counted inside the add transaction and returned as AddAnchorOutcome::LimitReached (400). - Use the workspace_thread_comment resource identifier consistently. - Rename emit_comment_event to emit_thread_event: it carries every thread collaboration event, not only comments. Assistant subsystem: - Bound the whole drain pass: a publish timeout defers the row and stops the pass instead of burning the timeout on each remaining row. - Build the drainer's EventPublisher once per pass instead of per row. - Classify inference and provider failures as transient (nack, redeliver); only a genuinely missing provider is terminal. Add an inference timeout. - Detect an existing assistant reply by matching parent_id, not iteration order. - Hold a pooled connection only for the load and post phases, never across the model call. - Guard the reserved-assistant migration against the case-insensitive username and email unique indexes with a clear error; use assistant@nvisy.com. Ignore the quick-xml DoS advisories (RUSTSEC-2026-0194/0195): only the 0.37.5 copy pulled via little_exif is affected and the fix must land upstream in little_exif. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_018bKk1YEG4tZ69jzYVQvQL8 --- crates/nvisy-postgres/src/query/mod.rs | 4 +- .../src/query/workspace_thread.rs | 27 ++- .../src/query/workspace_thread_anchor.rs | 43 +++- .../src/query/workspace_thread_comment.rs | 13 ++ crates/nvisy-server/src/handler/comments.rs | 15 +- .../src/handler/request/comments.rs | 12 +- crates/nvisy-server/src/handler/threads.rs | 52 +++-- .../src/response/error/pg_workspace.rs | 2 +- .../src/service/assistant/drainer.rs | 88 ++++++--- .../src/service/assistant/worker.rs | 187 ++++++++++-------- deny.toml | 10 + migrations/2026-09-11-050000_assistant/up.sql | 47 ++++- 12 files changed, 341 insertions(+), 159 deletions(-) diff --git a/crates/nvisy-postgres/src/query/mod.rs b/crates/nvisy-postgres/src/query/mod.rs index cc8ce96d..83e0ec9b 100644 --- a/crates/nvisy-postgres/src/query/mod.rs +++ b/crates/nvisy-postgres/src/query/mod.rs @@ -78,7 +78,9 @@ pub use workspace_policy::{PolicyCursor, WorkspacePolicyRepository}; pub use workspace_provider::{ProviderCursor, WorkspaceProviderRepository}; pub use workspace_redaction::{RedactionCursor, WorkspaceRedactionRepository}; pub use workspace_thread::{ThreadCursor, WorkspaceThreadRepository}; -pub use workspace_thread_anchor::WorkspaceThreadAnchorRepository; +pub use workspace_thread_anchor::{ + AddAnchorOutcome, MAX_THREAD_ANCHORS, WorkspaceThreadAnchorRepository, +}; pub use workspace_thread_comment::WorkspaceThreadCommentRepository; pub use workspace_thread_event::{TimelineCursor, TimelineSource, WorkspaceThreadEventRepository}; pub use workspace_webhook::{WebhookCursor, WorkspaceWebhookRepository}; diff --git a/crates/nvisy-postgres/src/query/workspace_thread.rs b/crates/nvisy-postgres/src/query/workspace_thread.rs index 92f1cfb9..25695f61 100644 --- a/crates/nvisy-postgres/src/query/workspace_thread.rs +++ b/crates/nvisy-postgres/src/query/workspace_thread.rs @@ -264,10 +264,15 @@ impl WorkspaceThreadRepository for PgConnection { self.transaction(async |conn| { use schema::workspace_threads::{self, dsl}; + // The `closed_at IS NULL` predicate makes the transition atomic: a + // thread that is already closed matches no row, so a second concurrent + // close returns `NotFound` instead of overwriting `closed_at`/ + // `closed_by` and recording a duplicate `Closed` event. let thread = diesel::update( workspace_threads::table .filter(dsl::id.eq(thread_id)) - .filter(dsl::deleted_at.is_null()), + .filter(dsl::deleted_at.is_null()) + .filter(dsl::closed_at.is_null()), ) .set((dsl::closed_at.eq(now), dsl::closed_by.eq(actor))) .returning(WorkspaceThread::as_returning()) @@ -285,10 +290,15 @@ impl WorkspaceThreadRepository for PgConnection { self.transaction(async |conn| { use schema::workspace_threads::{self, dsl}; + // The `closed_at IS NOT NULL` predicate makes the transition atomic: a + // thread that is already open matches no row, so a second concurrent + // reopen returns `NotFound` instead of recording a duplicate + // `Reopened` event. let thread = diesel::update( workspace_threads::table .filter(dsl::id.eq(thread_id)) - .filter(dsl::deleted_at.is_null()), + .filter(dsl::deleted_at.is_null()) + .filter(dsl::closed_at.is_not_null()), ) .set(( dsl::closed_at.eq(None::), @@ -374,9 +384,9 @@ mod tests { UpdateWorkspaceThreadComment, }; use crate::query::{ - AccountRepository, TimelineCursor, TimelineSource, WorkspaceThreadAnchorRepository, - WorkspaceThreadCommentRepository, WorkspaceThreadEventRepository, - WorkspaceThreadRepository, + AccountRepository, AddAnchorOutcome, TimelineCursor, TimelineSource, + WorkspaceThreadAnchorRepository, WorkspaceThreadCommentRepository, + WorkspaceThreadEventRepository, WorkspaceThreadRepository, }; use crate::test_util::TestDatabase; use crate::types::{CursorPagination, ThreadEventKind, ThreadFilter}; @@ -512,7 +522,7 @@ mod tests { assert_eq!(opening_kinds, vec![ThreadEventKind::Opened]); // Add a second anchor -> one anchor.added event. - let added = conn + let AddAnchorOutcome::Added(added) = conn .add_thread_anchor( seeded.workspace_id, NewWorkspaceThreadAnchor { @@ -521,7 +531,10 @@ mod tests { }, seeded.account_id, ) - .await?; + .await? + else { + panic!("adding a second anchor should not hit the limit"); + }; assert_eq!(conn.list_thread_anchors(thread.id).await?.len(), 2); // Remove it -> anchor.removed event; live anchors back to one. diff --git a/crates/nvisy-postgres/src/query/workspace_thread_anchor.rs b/crates/nvisy-postgres/src/query/workspace_thread_anchor.rs index df144259..09dcb75d 100644 --- a/crates/nvisy-postgres/src/query/workspace_thread_anchor.rs +++ b/crates/nvisy-postgres/src/query/workspace_thread_anchor.rs @@ -14,16 +14,35 @@ use crate::model::{NewWorkspaceThreadAnchor, NewWorkspaceThreadEvent, WorkspaceT use crate::types::ThreadEventKind; use crate::{AsyncConnection, Error, PgConnection, Result, schema}; +/// The most live anchors one thread may carry. The initial anchors at open time +/// and each incremental add are held to the same cap so a thread's anchor set — +/// loaded and serialized on every thread response — cannot grow without bound. +pub const MAX_THREAD_ANCHORS: i64 = 32; + +/// The result of an +/// [`add_thread_anchor`](WorkspaceThreadAnchorRepository::add_thread_anchor) call. +#[derive(Debug, Clone, PartialEq)] +pub enum AddAnchorOutcome { + /// The anchor was added. + Added(WorkspaceThreadAnchor), + /// The thread already holds [`MAX_THREAD_ANCHORS`] live anchors; nothing was + /// added. The caller reports this as a client error. + LimitReached, +} + /// Read and write operations on a thread's anchors. pub trait WorkspaceThreadAnchorRepository { /// Adds an anchor to a thread, recording an `anchor.added` timeline event, in - /// one transaction. Returns the created anchor. + /// one transaction. Returns [`AddAnchorOutcome::LimitReached`] without adding + /// when the thread already holds [`MAX_THREAD_ANCHORS`] live anchors; the count + /// and the insert share the transaction so concurrent adds cannot race past + /// the cap. fn add_thread_anchor( &mut self, workspace_id: Uuid, new_anchor: NewWorkspaceThreadAnchor, actor: Uuid, - ) -> impl Future> + Send; + ) -> impl Future> + Send; /// Soft-removes an anchor, recording an `anchor.removed` timeline event, in /// one transaction. Returns the removed anchor. @@ -62,9 +81,23 @@ impl WorkspaceThreadAnchorRepository for PgConnection { workspace_id: Uuid, new_anchor: NewWorkspaceThreadAnchor, actor: Uuid, - ) -> Result { + ) -> Result { self.transaction(async |conn| { - use schema::{workspace_thread_anchors, workspace_thread_events}; + use schema::workspace_thread_anchors::{self, dsl}; + use schema::workspace_thread_events; + + // Count the thread's live anchors inside the transaction and stop at the + // cap, so concurrent adds cannot race past it. + let live_anchors: i64 = workspace_thread_anchors::table + .filter(dsl::thread_id.eq(new_anchor.thread_id)) + .filter(dsl::deleted_at.is_null()) + .count() + .get_result(conn) + .await + .map_err(Error::from)?; + if live_anchors >= MAX_THREAD_ANCHORS { + return Ok(AddAnchorOutcome::LimitReached); + } let anchor = diesel::insert_into(workspace_thread_anchors::table) .values(&new_anchor) @@ -87,7 +120,7 @@ impl WorkspaceThreadAnchorRepository for PgConnection { .await .map_err(Error::from)?; - Ok(anchor) + Ok(AddAnchorOutcome::Added(anchor)) }) .await } diff --git a/crates/nvisy-postgres/src/query/workspace_thread_comment.rs b/crates/nvisy-postgres/src/query/workspace_thread_comment.rs index fe13a92a..f01f4000 100644 --- a/crates/nvisy-postgres/src/query/workspace_thread_comment.rs +++ b/crates/nvisy-postgres/src/query/workspace_thread_comment.rs @@ -229,6 +229,19 @@ impl WorkspaceThreadCommentRepository for PgConnection { ) -> Result { use schema::workspace_thread_comments::{self, dsl}; + // An all-`None` changeset (here, no `body`) would make Diesel emit an empty + // `SET` clause and fail with a query-builder error, so treat it as a no-op + // and return the current comment unchanged. + if updates.body.is_none() { + return workspace_thread_comments::table + .filter(dsl::id.eq(comment_id)) + .filter(dsl::deleted_at.is_null()) + .select(WorkspaceThreadComment::as_select()) + .get_result(self) + .await + .map_err(Error::from); + } + diesel::update( workspace_thread_comments::table .filter(dsl::id.eq(comment_id)) diff --git a/crates/nvisy-server/src/handler/comments.rs b/crates/nvisy-server/src/handler/comments.rs index 274a98c4..babc7859 100644 --- a/crates/nvisy-server/src/handler/comments.rs +++ b/crates/nvisy-server/src/handler/comments.rs @@ -15,7 +15,7 @@ use crate::extract::{Authorized, Json, Path, SecurityContext, ValidateJson, mark use crate::handler::request::{CommentPathParams, CreateComment, ThreadPathParams, UpdateComment}; use crate::handler::response::Comment; use crate::handler::threads::{ - MentionOutcome, TRACING_TARGET, emit_comment_event, enqueue_assistant_if_addressed, + MentionOutcome, TRACING_TARGET, emit_thread_event, enqueue_assistant_if_addressed, find_comment, find_thread, resolve_mentions, workspace_origin, }; use crate::handler::utility::resolve_account_ref; @@ -50,6 +50,13 @@ async fn create_comment( // The thread must exist in the workspace (and be live). let thread = find_thread(&mut conn, workspace.id, path_params.thread_id).await?; + // A closed thread is a finished discussion: reject new comments with a 409 + // rather than appending to it. Reopen the thread to continue. + if thread.closed_at.is_some() { + return Err(ErrorKind::Conflict + .with_message("This thread is closed; reopen it before posting a comment")); + } + let MentionOutcome { recipients, addressed_assistant, @@ -73,7 +80,7 @@ async fn create_comment( }) .await?; - emit_comment_event( + emit_thread_event( conn, workspace_origin(workspace.id, authz.account_id, &security), WorkspaceEvent::ThreadCommentCreated(ThreadCommentCreated { @@ -117,13 +124,15 @@ fn create_comment_docs(op: TransformOperation) -> TransformOperation { op.summary("Post a comment") .description( "Posts a comment (message) in a thread. @username mentions notify those \ - members. Requires the Comment permission.", + members. Requires the Comment permission. Returns 409 if the thread is \ + closed.", ) .response::<201, Json>() .response::<400, Json>() .response::<401, Json>() .response::<403, Json>() .response::<404, Json>() + .response::<409, Json>() } /// Edits a comment's body. Restricted to the comment's author. diff --git a/crates/nvisy-server/src/handler/request/comments.rs b/crates/nvisy-server/src/handler/request/comments.rs index 4dc516c1..465e3b0a 100644 --- a/crates/nvisy-server/src/handler/request/comments.rs +++ b/crates/nvisy-server/src/handler/request/comments.rs @@ -82,6 +82,9 @@ pub struct OpenThread { pub body: String, /// Locations within the file the thread is pinned to. Empty for a file-level /// thread (no pin). Ignored for a workspace-level thread (no file). + /// + /// The `max = 32` limit is the same cap `add_anchor` enforces per thread + /// (`nvisy_postgres::query::MAX_THREAD_ANCHORS`); keep the two in sync. #[serde(default, skip_serializing_if = "Vec::is_empty")] #[garde(length(max = 32))] pub anchors: Vec, @@ -92,10 +95,13 @@ pub struct OpenThread { #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Validate)] #[serde(rename_all = "camelCase")] pub struct RenameThread { - /// The new title (1-255 characters), or `null` to clear it. + /// The new title (1-255 characters), or `null` to clear it. Omitting the + /// field leaves the current title unchanged; only an explicit `null` clears + /// it. The outer `Option` distinguishes "absent" (`None`) from "explicit + /// null" (`Some(None)`). #[serde(default, skip_serializing_if = "Option::is_none")] - #[garde(inner(length(chars, min = 1, max = 255), custom(validate_non_blank)))] - pub display_name: Option, + #[garde(inner(inner(length(chars, min = 1, max = 255), custom(validate_non_blank))))] + pub display_name: Option>, } /// Request payload to add an anchor (location pin) to a thread. diff --git a/crates/nvisy-server/src/handler/threads.rs b/crates/nvisy-server/src/handler/threads.rs index 5abf2dba..65b63354 100644 --- a/crates/nvisy-server/src/handler/threads.rs +++ b/crates/nvisy-server/src/handler/threads.rs @@ -22,9 +22,9 @@ use nvisy_postgres::model::{ WorkspaceThreadComment, }; use nvisy_postgres::query::{ - AssistantJobOutboxRepository, TimelineCursor, WorkspaceFileRepository, - WorkspaceMemberRepository, WorkspaceThreadAnchorRepository, WorkspaceThreadCommentRepository, - WorkspaceThreadEventRepository, WorkspaceThreadRepository, + AddAnchorOutcome, AssistantJobOutboxRepository, MAX_THREAD_ANCHORS, TimelineCursor, + WorkspaceFileRepository, WorkspaceMemberRepository, WorkspaceThreadAnchorRepository, + WorkspaceThreadCommentRepository, WorkspaceThreadEventRepository, WorkspaceThreadRepository, }; use nvisy_postgres::types::{CursorPage, Direction, Handle}; use nvisy_postgres::{ASSISTANT_ACCOUNT_ID, ASSISTANT_HANDLE, AsyncConnection, PgClient, PgConn}; @@ -200,7 +200,7 @@ async fn open_thread( .transaction(async |conn| { let (thread, opening) = conn.open_thread(new_thread, request.body, anchors).await?; - emit_comment_event( + emit_thread_event( conn, workspace_origin(workspace_id, author_id, security), WorkspaceEvent::ThreadOpened(ThreadOpened { @@ -328,7 +328,7 @@ async fn delete_thread( conn.transaction(async |conn| { conn.delete_thread(thread.id).await?; - emit_comment_event( + emit_thread_event( conn, workspace_origin(workspace.id, authz.account_id, &security), WorkspaceEvent::ThreadDeleted(ThreadDeleted { @@ -387,7 +387,7 @@ async fn close_thread( let closed = conn .transaction(async |conn| { let closed = conn.close_thread(thread.id, authz.account_id).await?; - emit_comment_event( + emit_thread_event( conn, workspace_origin(workspace.id, authz.account_id, &security), WorkspaceEvent::ThreadClosed(ThreadClosed { @@ -447,7 +447,7 @@ async fn reopen_thread( let reopened = conn .transaction(async |conn| { let reopened = conn.reopen_thread(thread.id, authz.account_id).await?; - emit_comment_event( + emit_thread_event( conn, workspace_origin(workspace.id, authz.account_id, &security), WorkspaceEvent::ThreadReopened(ThreadReopened { @@ -476,7 +476,7 @@ fn reopen_thread_docs(op: TransformOperation) -> TransformOperation { .response::<404, Json>() } -/// Renames a thread (sets or clears its title). Requires `Comment`. +/// Renames a thread (sets or clears its title). Requires `CloseComments`. #[tracing::instrument( skip_all, fields( @@ -487,7 +487,7 @@ fn reopen_thread_docs(op: TransformOperation) -> TransformOperation { )] async fn rename_thread( State(pg_client): State, - authz: Authorized, + authz: Authorized, Path(path_params): Path, security: SecurityContext, ValidateJson(request): ValidateJson, @@ -499,12 +499,20 @@ async fn rename_thread( let thread = find_thread(&mut conn, workspace.id, path_params.thread_id).await?; + // The field is `Option>`: an absent `displayName` (`None`) + // leaves the title unchanged, while an explicit `null` (`Some(None)`) clears + // it. Only an explicit value triggers the update and its timeline event. + let Some(display_name) = request.display_name else { + let response = thread_response(&mut conn, thread).await?; + return Ok((StatusCode::OK, Json(response))); + }; + let renamed = conn .transaction(async |conn| { let renamed = conn - .rename_thread(thread.id, request.display_name, authz.account_id) + .rename_thread(thread.id, display_name, authz.account_id) .await?; - emit_comment_event( + emit_thread_event( conn, workspace_origin(workspace.id, authz.account_id, &security), WorkspaceEvent::ThreadRenamed(ThreadRenamed { @@ -526,7 +534,7 @@ async fn rename_thread( fn rename_thread_docs(op: TransformOperation) -> TransformOperation { op.summary("Rename a thread") - .description("Sets or clears a thread's title. Requires the Comment permission.") + .description("Sets or clears a thread's title. Requires CloseComments.") .response::<200, Json>() .response::<400, Json>() .response::<401, Json>() @@ -567,7 +575,7 @@ async fn add_anchor( let anchor = conn .transaction(async |conn| { - let anchor = conn + let AddAnchorOutcome::Added(anchor) = conn .add_thread_anchor( workspace.id, NewWorkspaceThreadAnchor { @@ -576,8 +584,13 @@ async fn add_anchor( }, authz.account_id, ) - .await?; - emit_comment_event( + .await? + else { + return Err(ErrorKind::BadRequest.with_message(format!( + "A thread may have at most {MAX_THREAD_ANCHORS} anchors" + ))); + }; + emit_thread_event( conn, workspace_origin(workspace.id, authz.account_id, &security), WorkspaceEvent::ThreadAnchorAdded(ThreadAnchorAdded { @@ -641,7 +654,7 @@ async fn remove_anchor( let anchor = conn .remove_thread_anchor(workspace.id, path_params.anchor_id, authz.account_id) .await?; - emit_comment_event( + emit_thread_event( conn, workspace_origin(workspace.id, authz.account_id, &security), WorkspaceEvent::ThreadAnchorRemoved(ThreadAnchorRemoved { @@ -774,7 +787,7 @@ pub(crate) async fn find_comment( ) -> Result { conn.find_comment_in_workspace(workspace_id, comment_id) .await? - .ok_or_else(|| Error::not_found("workspace_comment")) + .ok_or_else(|| Error::not_found("workspace_thread_comment")) } /// Encodes one typed anchor into its stored JSON. @@ -928,8 +941,9 @@ pub(crate) fn workspace_origin<'a>( } } -/// Emits one comment event onto the outbox. -pub(crate) async fn emit_comment_event( +/// Emits one thread collaboration event (a lifecycle change, an anchor change, +/// or a new comment) onto the outbox. +pub(crate) async fn emit_thread_event( conn: &mut PgConn, origin: EventOrigin<'_>, event: WorkspaceEvent, diff --git a/crates/nvisy-server/src/response/error/pg_workspace.rs b/crates/nvisy-server/src/response/error/pg_workspace.rs index c9f248c5..2e065ea8 100644 --- a/crates/nvisy-server/src/response/error/pg_workspace.rs +++ b/crates/nvisy-server/src/response/error/pg_workspace.rs @@ -94,7 +94,7 @@ impl From for Error<'static> { .with_message("Comment body must be between 1 and 10000 characters"), }; - error.with_resource("workspace_comment") + error.with_resource("workspace_thread_comment") } } diff --git a/crates/nvisy-server/src/service/assistant/drainer.rs b/crates/nvisy-server/src/service/assistant/drainer.rs index 4526c57a..bcbd6103 100644 --- a/crates/nvisy-server/src/service/assistant/drainer.rs +++ b/crates/nvisy-server/src/service/assistant/drainer.rs @@ -9,14 +9,14 @@ use std::time::Duration; +use nvisy_nats::stream::EventPublisher; use nvisy_postgres::AsyncConnection; use nvisy_postgres::model::WorkspaceAssistantJob; use nvisy_postgres::query::AssistantJobOutboxRepository; use tokio_util::sync::CancellationToken; use super::coordinator::AssistantCoordinator; -use super::job::AssistantJob; -use super::service::AssistantQueue; +use super::job::{AssistantJob, AssistantStream}; use crate::response::{Error, Result}; use crate::service::{Infra, Worker}; @@ -52,7 +52,6 @@ const PUBLISH_TIMEOUT: Duration = Duration::from_secs(5); /// Drains the assistant-job outbox, publishing each pending job to the work-queue. pub struct AssistantOutboxDrainer { infra: Infra, - queue: AssistantQueue, coordinator: AssistantCoordinator, } @@ -98,14 +97,10 @@ impl Worker for AssistantOutboxDrainer { impl AssistantOutboxDrainer { /// Creates a new [`AssistantOutboxDrainer`]. /// - /// Shares the [`AssistantCoordinator`] with the enqueue-side [`AssistantQueue`] + /// Shares the [`AssistantCoordinator`] with the enqueue-side `AssistantQueue` /// so a job committed on this instance wakes this drainer at once. pub fn new(infra: Infra, coordinator: AssistantCoordinator) -> Self { - Self { - queue: AssistantQueue::new(infra.clone(), coordinator.clone()), - infra, - coordinator, - } + Self { infra, coordinator } } /// One drain pass: claim and publish batches until a short page signals the due @@ -155,6 +150,11 @@ impl AssistantOutboxDrainer { async fn drain_batch(&self) -> Result { let mut conn = self.infra.postgres.get_connection().await?; + // Build the stream publisher once per pass rather than per row: it runs the + // JetStream stream lookup/reconciliation on construction, which need not + // repeat for each of the (up to `DRAIN_BATCH`) rows. + let publisher = self.infra.nats.event_publisher::().await?; + conn.transaction(async |conn| { let batch = conn.claim_assistant_job_batch(DRAIN_BATCH).await?; let mut pass = DrainPass { @@ -165,8 +165,8 @@ impl AssistantOutboxDrainer { }; for row in batch { - match self.publish(&row).await { - Ok(()) => { + match publish(&publisher, &row).await { + PublishOutcome::Published => { conn.mark_assistant_job_processed(row.id).await?; pass.processed += 1; } @@ -174,15 +174,27 @@ impl AssistantOutboxDrainer { // `attempts + 1`. Once that reaches the cap, dead-letter the row // instead of deferring it forever. A dead-lettered reply job just // means the assistant never answers this message. - Err(()) if row.attempts + 1 >= MAX_ATTEMPTS => { + PublishOutcome::Failed if row.attempts + 1 >= MAX_ATTEMPTS => { tracing::error!(target: TRACING_TARGET, id = %row.id, comment_id = %row.comment_id, attempts = row.attempts + 1, "Dead-lettering assistant job after too many failed attempts"); conn.mark_assistant_job_failed(row.id).await?; pass.dead_lettered += 1; } - Err(()) => { + PublishOutcome::Failed => { + conn.defer_assistant_job_attempt(row.id, retry_backoff(row.attempts)) + .await?; + pass.deferred += 1; + } + // A publish timeout signals NATS is unavailable or hung. Do not + // burn `PUBLISH_TIMEOUT` on each remaining row — that could hold + // the batch's `FOR UPDATE SKIP LOCKED` locks and the pooled + // connection for `DRAIN_BATCH * PUBLISH_TIMEOUT`. Defer this row + // and stop the pass; the next tick retries the rest. + PublishOutcome::TimedOut => { conn.defer_assistant_job_attempt(row.id, retry_backoff(row.attempts)) .await?; pass.deferred += 1; + tracing::warn!(target: TRACING_TARGET, id = %row.id, "Assistant-job publish timed out; deferring the rest of the batch"); + break; } } } @@ -191,27 +203,39 @@ impl AssistantOutboxDrainer { }) .await } +} - /// Decodes a row's job and publishes it to the work-queue. Returns `Err` if the - /// payload cannot decode or the publish fails, so the caller defers or - /// dead-letters it. - async fn publish(&self, row: &WorkspaceAssistantJob) -> std::result::Result<(), ()> { - let job = serde_json::from_value::(row.job.clone()).map_err(|err| { - tracing::error!(target: TRACING_TARGET, error = %err, id = %row.id, "Failed to decode assistant job"); - })?; - // Bound the publish so a hung NATS cannot hold the batch transaction's locks - // open; a timeout is a failed attempt like any other. - match tokio::time::timeout(PUBLISH_TIMEOUT, self.queue.enqueue(job)).await { - Ok(Ok(())) => Ok(()), - Ok(Err(err)) => { - tracing::warn!(target: TRACING_TARGET, error = %err, id = %row.id, "Failed to publish assistant job; deferring"); - Err(()) - } - Err(_elapsed) => { - tracing::warn!(target: TRACING_TARGET, id = %row.id, "Assistant-job publish timed out; deferring"); - Err(()) - } +/// The result of one publish attempt: published, failed (decode or NATS error), +/// or timed out (NATS unavailable/hung — the caller stops the batch). +enum PublishOutcome { + /// Published to the work-queue. + Published, + /// The payload could not decode or NATS rejected the publish. + Failed, + /// The publish exceeded [`PUBLISH_TIMEOUT`]; NATS is likely down. + TimedOut, +} + +/// Decodes a row's job and publishes it to the work-queue with the shared +/// publisher. A decode error or NATS error is [`Failed`](PublishOutcome::Failed); +/// exceeding [`PUBLISH_TIMEOUT`] is [`TimedOut`](PublishOutcome::TimedOut). +async fn publish( + publisher: &EventPublisher, + row: &WorkspaceAssistantJob, +) -> PublishOutcome { + let Ok(job) = serde_json::from_value::(row.job.clone()) else { + tracing::error!(target: TRACING_TARGET, id = %row.id, "Failed to decode assistant job"); + return PublishOutcome::Failed; + }; + // Bound the publish so a hung NATS cannot hold the batch transaction's locks + // open; a timeout stops the whole pass (see the caller). + match tokio::time::timeout(PUBLISH_TIMEOUT, publisher.publish(&job)).await { + Ok(Ok(())) => PublishOutcome::Published, + Ok(Err(err)) => { + tracing::warn!(target: TRACING_TARGET, error = %err, id = %row.id, "Failed to publish assistant job; deferring"); + PublishOutcome::Failed } + Err(_elapsed) => PublishOutcome::TimedOut, } } diff --git a/crates/nvisy-server/src/service/assistant/worker.rs b/crates/nvisy-server/src/service/assistant/worker.rs index ddc7fa35..a6852588 100644 --- a/crates/nvisy-server/src/service/assistant/worker.rs +++ b/crates/nvisy-server/src/service/assistant/worker.rs @@ -46,6 +46,10 @@ const PREAMBLE: &str = "You are the assistant for a document redaction platform. /// Fallback concurrency when the runtime cannot report available parallelism. const DEFAULT_ASSISTANT_CONCURRENCY: usize = 4; +/// Upper bound on one inference call, so a hung provider cannot pin a worker +/// task indefinitely. A timeout is transient — the job is redelivered. +const INFERENCE_TIMEOUT: Duration = Duration::from_secs(120); + /// Background worker that answers assistant mentions off the request thread. /// /// Cheaply cloneable (every field is `Arc`-backed); a clone is handed to each @@ -185,15 +189,7 @@ impl AssistantWorker { /// provider configured — return [`JobOutcome::Done`]: retrying would not help. #[tracing::instrument(skip_all, fields(thread_id = %job.thread_id, comment_id = %job.comment_id, workspace_id = %job.workspace_id))] async fn run_job(&self, job: AssistantJob) -> JobOutcome { - let mut conn = match self.infra.postgres.get_connection().await { - Ok(conn) => conn, - Err(err) => { - tracing::error!(target: TRACING_TARGET, error = %err, "Failed to get connection for assistant job"); - return JobOutcome::Retry; - } - }; - - match self.reply(&mut conn, &job).await { + match self.reply(&job).await { Ok(()) => JobOutcome::Done, Err(ReplyError::Transient(err)) => { tracing::error!(target: TRACING_TARGET, error = %err, "Assistant reply failed transiently; will retry"); @@ -208,71 +204,110 @@ impl AssistantWorker { /// Loads the conversation, runs the model, and posts the reply. Distinguishes /// transient failures (worth a redelivery) from terminal ones (drop the job). - async fn reply( - &self, - conn: &mut PgConn, - job: &AssistantJob, - ) -> std::result::Result<(), ReplyError> { - // The thread must still exist and be live. - let thread = conn - .find_thread_in_workspace(job.workspace_id, job.thread_id) - .await - .map_err(ReplyError::transient)? - .ok_or_else(|| ReplyError::terminal("thread no longer exists"))?; - - // Read the conversation oldest-first. - let comments = conn - .list_thread_comments(job.workspace_id, job.thread_id) - .await - .map_err(ReplyError::transient)?; - - // Idempotency: if the assistant has already replied to (i.e. after) the - // triggering comment, this is a redelivery — do not post a second reply. - if already_replied(&comments, job.comment_id) { - return Err(ReplyError::terminal("assistant already replied")); - } + /// + /// A pooled connection is held only for the two database phases (the load and + /// the post), never across the model call in between: inference is unbounded + /// I/O against the provider, so pinning a pool connection to it would starve + /// the pool under a slow provider. The `chat` call itself carries a timeout. + async fn reply(&self, job: &AssistantJob) -> std::result::Result<(), ReplyError> { + // Load phase: read the thread, the conversation, and the model client on + // one connection, then drop it before inference. + let (thread, prompt, history, client) = { + let mut conn = self + .infra + .postgres + .get_connection() + .await + .map_err(ReplyError::transient)?; + + // The thread must still exist and be live. + let thread = conn + .find_thread_in_workspace(job.workspace_id, job.thread_id) + .await + .map_err(ReplyError::transient)? + .ok_or_else(|| ReplyError::terminal("thread no longer exists"))?; + + // Read the conversation oldest-first. + let comments = conn + .list_thread_comments(job.workspace_id, job.thread_id) + .await + .map_err(ReplyError::transient)?; + + // Idempotency: if the assistant has already replied to the triggering + // comment, this is a redelivery — do not post a second reply. + if already_replied(&comments, job.comment_id) { + return Err(ReplyError::terminal("assistant already replied")); + } - // Resolve the workspace's language-model client. A workspace with no model - // provider configured is a terminal condition — retrying will not conjure - // one — so drop the job rather than redeliver forever. - let client = self - .resolve_client(conn, job.workspace_id) - .await - .map_err(|_| ReplyError::terminal("no language model provider configured"))?; - - // Build the turn: prior comments become history, the triggering comment is - // the prompt. Skip the triggering comment in the history so it is not - // duplicated as both history and prompt. - let mut history = Vec::with_capacity(comments.len() + 1); - history.push(ChatTurn::system(PREAMBLE)); - let mut prompt = String::new(); - for row in &comments { - if row.item.id == job.comment_id { - prompt = row.item.body.clone(); - continue; + // Resolve the workspace's language-model client. Only a genuinely + // missing provider is terminal (retrying will not conjure one); a + // decryption or client-build failure is transient, so redeliver rather + // than silently dropping the job. `resolve_client` reports the + // missing-provider case as a `Conflict`; every other failure is + // treated as transient. + let client = self + .resolve_client(&mut conn, job.workspace_id) + .await + .map_err(|err| { + if err.kind() == ErrorKind::Conflict { + ReplyError::terminal("no language model provider configured") + } else { + ReplyError::transient(err) + } + })?; + + // Build the turn: prior comments become history, the triggering comment + // is the prompt. Skip the triggering comment in the history so it is not + // duplicated as both history and prompt. + let mut history = Vec::with_capacity(comments.len() + 1); + history.push(ChatTurn::system(PREAMBLE)); + let mut prompt = String::new(); + for row in &comments { + if row.item.id == job.comment_id { + prompt = row.item.body.clone(); + continue; + } + history.push(turn_for(&row.item)); } - history.push(turn_for(&row.item)); - } - if prompt.is_empty() { - // The triggering comment vanished (deleted) between enqueue and now. - return Err(ReplyError::terminal("triggering comment no longer exists")); - } + if prompt.is_empty() { + // The triggering comment vanished (deleted) between enqueue and now. + return Err(ReplyError::terminal("triggering comment no longer exists")); + } + + (thread, prompt, history, client) + // `conn` is dropped here, back to the pool, before inference runs. + }; - let answer = client - .chat(&prompt, history) + // Inference phase: no connection held. Failures — provider timeouts, rate + // limits (429), and 5xx — are transient: nack so the message is + // redelivered rather than acking and leaving the user with no reply after a + // short provider outage. A hung provider is bounded by `INFERENCE_TIMEOUT`. + let answer = tokio::time::timeout(INFERENCE_TIMEOUT, client.chat(&prompt, history)) .await - .map_err(|err| ReplyError::terminal(format!("inference failed: {err}")))?; + .map_err(|_| { + ReplyError::transient( + ErrorKind::ServiceUnavailable.with_message("Inference timed out"), + ) + })? + .map_err(ReplyError::transient)?; let answer = answer.trim(); if answer.is_empty() { return Err(ReplyError::terminal("model returned an empty reply")); } - // Post the reply. The database's partial unique index on the triggering - // comment is the airtight guard: if a live reply already exists (a - // redelivered job that raced past the `already_replied` pre-check), the - // insert is rejected and nothing is posted. + // Post phase: acquire a fresh connection for the write. The database's + // partial unique index on the triggering comment is the airtight guard: if + // a live reply already exists (a redelivered job that raced past the + // `already_replied` pre-check), the insert is rejected and nothing is + // posted. + let mut conn = self + .infra + .postgres + .get_connection() + .await + .map_err(ReplyError::transient)?; let posted = self - .post_reply(conn, &thread, job.comment_id, answer) + .post_reply(&mut conn, &thread, job.comment_id, answer) .await .map_err(ReplyError::transient)?; if !posted { @@ -369,26 +404,20 @@ impl AssistantWorker { } } -/// Whether the assistant has already posted a comment at or after `comment_id` -/// (the triggering message) in this thread — the redelivery-dedup check. +/// Whether the assistant has already replied to `comment_id` (the triggering +/// message) in this thread — the redelivery-dedup pre-check. /// -/// `comments` is oldest-first, so once the triggering comment is seen, any -/// later assistant-authored comment is a reply the worker already produced. +/// A reply is the assistant-authored comment whose `parent_id` is the triggering +/// comment (`post_reply` sets exactly that), so match it directly rather than by +/// iteration order. The database's partial unique index on `parent_id` is the +/// airtight guard; this only avoids the wasted inference of an obvious redelivery. fn already_replied( comments: &[nvisy_postgres::types::WithAccountRef], comment_id: Uuid, ) -> bool { - let mut seen_trigger = false; - for row in comments { - if row.item.id == comment_id { - seen_trigger = true; - continue; - } - if seen_trigger && row.item.author_account_id == ASSISTANT_ACCOUNT_ID { - return true; - } - } - false + comments.iter().any(|row| { + row.item.author_account_id == ASSISTANT_ACCOUNT_ID && row.item.parent_id == Some(comment_id) + }) } /// Maps one stored comment to a chat turn: the assistant's own messages are the diff --git a/deny.toml b/deny.toml index c91b5a77..648923f5 100644 --- a/deny.toml +++ b/deny.toml @@ -32,6 +32,16 @@ ignore = [ # decryption oracle), so the practical exposure is minimal, and no fixed # `rsa` release exists yet. Revisit when `rsa` ships a constant-time fix. "RUSTSEC-2023-0071", + # Two `quick-xml` DoS advisories (quadratic attribute-duplicate check; + # unbounded namespace-declaration allocation), both patched in >= 0.41.0. + # Only the 0.37.5 copy is affected, pulled transitively via `little_exif` + # 0.6.23 (through `elide-image`). `little_exif` pins `quick-xml = "^0.37"`, + # so there is no workspace-level fix: the bump must land upstream in + # `little_exif` itself, after which `elide` picks up the new release. We + # never feed attacker-controlled XML through `little_exif`'s EXIF path. + # Remove once `little_exif` moves off the affected `quick-xml`. + "RUSTSEC-2026-0194", + "RUSTSEC-2026-0195", ] [licenses] diff --git a/migrations/2026-09-11-050000_assistant/up.sql b/migrations/2026-09-11-050000_assistant/up.sql index 09a68985..797b1f89 100644 --- a/migrations/2026-09-11-050000_assistant/up.sql +++ b/migrations/2026-09-11-050000_assistant/up.sql @@ -9,15 +9,44 @@ -- it never uses the HTTP write path (the worker writes its comments -- server-internally). Its id is a fixed, well-known constant (mirrored in the -- Rust layer as ASSISTANT_ACCOUNT_ID) so code references it without a lookup. -INSERT INTO accounts (id, is_verified, username, display_name, email_address) -VALUES ( - '00000000-0000-0000-0000-000000000a11', - TRUE, - 'assistant', - 'Assistant', - 'assistant@system.nvisy.internal' -) -ON CONFLICT (id) DO NOTHING; +-- +-- The `accounts` table has case-insensitive partial unique indexes on +-- `lower(username)` and `lower(email_address)` (where `deleted_at IS NULL`), which +-- `ON CONFLICT (id)` does not cover. A live account under a *different* id already +-- holding this username or email would make a bare insert fail with an opaque +-- index violation, so guard the insert: skip it if the reserved id already exists, +-- and otherwise fail with a clear, actionable message if the reserved identifiers +-- are taken by another live account (the reserved handle/email must be free). +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM accounts WHERE id = '00000000-0000-0000-0000-000000000a11' + ) THEN + RETURN; + END IF; + + IF EXISTS ( + SELECT 1 FROM accounts + WHERE deleted_at IS NULL + AND (lower(username) = 'assistant' + OR lower(email_address) = 'assistant@nvisy.com') + ) THEN + RAISE EXCEPTION + 'Cannot create the reserved assistant account: the username ' + '"assistant" or email "assistant@nvisy.com" is already ' + 'held by another live account. Free those identifiers, then re-run.'; + END IF; + + INSERT INTO accounts (id, is_verified, username, display_name, email_address) + VALUES ( + '00000000-0000-0000-0000-000000000a11', + TRUE, + 'assistant', + 'Assistant', + 'assistant@nvisy.com' + ); +END +$$; -- Assistant-reply outbox: when a user posts a comment addressing the assistant, a -- job row is inserted in the same transaction as the comment, then relayed by the From d2fcfd0da0fe0a91bf02896663fdff1a815b37cd Mon Sep 17 00:00:00 2001 From: Oleh Martsokha Date: Fri, 11 Sep 2026 12:07:53 +0200 Subject: [PATCH 5/5] Fix timeline event/comment ordering and closed-thread comment race Order TimelineSource so an event sorts before a comment at the same instant: open_thread writes the Opened event and the opening comment in one transaction and they can share a created_at, so the event must come first for the opening to render in natural order. The variant order is the derived Ord the timeline sorts by. Make the closed-thread check atomic with comment creation: lock the thread (FOR UPDATE) and re-check closed_at inside the transaction, so a concurrent close cannot let a comment (and its ThreadCommentCreated event) land after ThreadClosed. Still returns 409 when the thread is closed. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_018bKk1YEG4tZ69jzYVQvQL8 --- .../src/query/workspace_thread.rs | 32 +++++++++++++++++++ .../src/query/workspace_thread_event.rs | 16 +++++++--- crates/nvisy-server/src/handler/comments.rs | 22 ++++++++----- 3 files changed, 57 insertions(+), 13 deletions(-) diff --git a/crates/nvisy-postgres/src/query/workspace_thread.rs b/crates/nvisy-postgres/src/query/workspace_thread.rs index 25695f61..b52109e6 100644 --- a/crates/nvisy-postgres/src/query/workspace_thread.rs +++ b/crates/nvisy-postgres/src/query/workspace_thread.rs @@ -53,6 +53,19 @@ pub trait WorkspaceThreadRepository { thread_id: Uuid, ) -> impl Future>> + Send; + /// Finds a live thread by id within a workspace, taking a row lock (`FOR + /// UPDATE`) so a concurrent close/reopen/delete serializes behind this read. + /// + /// Call inside a transaction that then acts on the thread's state (e.g. + /// posting a comment only while it is open): the lock makes the check and the + /// write atomic, closing the read-then-write race the unlocked + /// [`find_thread_in_workspace`](Self::find_thread_in_workspace) leaves open. + fn lock_thread_in_workspace( + &mut self, + workspace_id: Uuid, + thread_id: Uuid, + ) -> impl Future>> + Send; + /// Lists a workspace's live threads with cursor pagination, each paired with /// the opening author's account reference. fn cursor_list_threads( @@ -177,6 +190,25 @@ impl WorkspaceThreadRepository for PgConnection { .map_err(Error::from) } + async fn lock_thread_in_workspace( + &mut self, + workspace_id: Uuid, + thread_id: Uuid, + ) -> Result> { + use schema::workspace_threads::{self, dsl}; + + workspace_threads::table + .filter(dsl::id.eq(thread_id)) + .filter(dsl::workspace_id.eq(workspace_id)) + .filter(dsl::deleted_at.is_null()) + .select(WorkspaceThread::as_select()) + .for_update() + .first(self) + .await + .optional() + .map_err(Error::from) + } + async fn cursor_list_threads( &mut self, workspace_id: Uuid, diff --git a/crates/nvisy-postgres/src/query/workspace_thread_event.rs b/crates/nvisy-postgres/src/query/workspace_thread_event.rs index 8488b20a..e2571702 100644 --- a/crates/nvisy-postgres/src/query/workspace_thread_event.rs +++ b/crates/nvisy-postgres/src/query/workspace_thread_event.rs @@ -19,16 +19,22 @@ use crate::types::{AccountRefRow, ThreadEventKind}; use crate::{Error, PgConnection, Result, schema}; /// Which of the two timeline streams an entry came from. Its order is the -/// tiebreak between a comment and an event that share a `created_at`: a comment -/// sorts before an event at the same instant. +/// tiebreak between an event and a comment that share a `created_at`: an event +/// sorts before a comment at the same instant. This is what makes a thread's +/// opening render in the natural order — the `Opened` event and the opening +/// comment are written in the same transaction (and can share an instant), and +/// the event is the one that logically comes first. +/// +/// The variant order is significant: it is the derived `Ord` the timeline sorts +/// by, so `Event` must be declared before `Comment`. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] #[derive(Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum TimelineSource { - /// A comment (message). - Comment, /// A lifecycle event. Event, + /// A comment (message). + Comment, } /// Keyset for the merged thread timeline: comments and events ordered together by @@ -50,7 +56,7 @@ impl TimelineCursor { /// A stream's own [`TimelineSource`] decides how the cursor instant is /// treated: /// - source > cursor.source: every row at the cursor instant comes after it, - /// so include all of them (`same_instant_all = true`). + /// so include the whole instant. /// - source == cursor.source: only rows at the instant with a larger id. /// - source < cursor.source: no row at the instant qualifies; page strictly /// after the instant. diff --git a/crates/nvisy-server/src/handler/comments.rs b/crates/nvisy-server/src/handler/comments.rs index babc7859..2015dd47 100644 --- a/crates/nvisy-server/src/handler/comments.rs +++ b/crates/nvisy-server/src/handler/comments.rs @@ -8,7 +8,7 @@ use aide::transform::TransformOperation; use axum::extract::State; use axum::http::StatusCode; use nvisy_postgres::model::{NewWorkspaceThreadComment, UpdateWorkspaceThreadComment}; -use nvisy_postgres::query::WorkspaceThreadCommentRepository; +use nvisy_postgres::query::{WorkspaceThreadCommentRepository, WorkspaceThreadRepository}; use nvisy_postgres::{AsyncConnection, PgClient}; use crate::extract::{Authorized, Json, Path, SecurityContext, ValidateJson, markers}; @@ -50,13 +50,6 @@ async fn create_comment( // The thread must exist in the workspace (and be live). let thread = find_thread(&mut conn, workspace.id, path_params.thread_id).await?; - // A closed thread is a finished discussion: reject new comments with a 409 - // rather than appending to it. Reopen the thread to continue. - if thread.closed_at.is_some() { - return Err(ErrorKind::Conflict - .with_message("This thread is closed; reopen it before posting a comment")); - } - let MentionOutcome { recipients, addressed_assistant, @@ -70,6 +63,19 @@ async fn create_comment( // queue the reply job, all in one transaction so they commit together. let (comment, queued_assistant) = conn .transaction(async |conn| { + // Lock the thread and re-check its closed state inside the transaction: + // a closed thread is a finished discussion, and the row lock serializes + // against a concurrent close so a comment (and its ThreadCommentCreated + // event) can never land after ThreadClosed. Reopen to continue. + let locked = conn + .lock_thread_in_workspace(workspace.id, thread.id) + .await? + .ok_or_else(|| Error::not_found("workspace_thread"))?; + if locked.closed_at.is_some() { + return Err(ErrorKind::Conflict + .with_message("This thread is closed; reopen it before posting a comment")); + } + let comment = conn .create_comment(NewWorkspaceThreadComment { workspace_id: workspace.id,