From 15386653993858a98b7552263645a0bf49b0e05b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 17 Sep 2026 18:25:50 +0000 Subject: [PATCH 1/8] feat(initiative): store description in an initiative_description document Create one markdown description document per initiative, copy the initiative's link share exactly, and mirror grants in lockstep. Keep the description column until a later drop. Co-authored-by: Will Hutchinson --- ...4f7eb4ee08f375c95d9e40bd879d1439a8342.json | 3 +- ...948837e27fdde34109b1aac0d889f10a25eed.json | 3 +- ...d6b55a4c527fd3105bfa9cf83dbc70516011c.json | 3 +- ...d70a9dd31a02d35fa44ef3a41e8d9872ca1d.json} | 8 +- ...333f87800b8b96970638e21b3f5ce8a58511f.json | 3 +- ...0b0a8701621b42e4c57e74d2da40062d577d4.json | 3 +- ...f1591934bb55a9f2fed8e47dd4406085fa6b2.json | 3 +- ...389f7abdcc74544558d60d817f56ff0c14524.json | 3 +- ...34d0e0beb7e3cb0db7087d3835c51788e81a8.json | 18 - ...5a7a2e43c2231040cc399488418eab9f6023a.json | 3 +- ...2e36ad8035a77a7322b25ab2ee090a0bef68.json} | 4 +- ...8908fd19aa1f6dd274803963698d6b95760ce.json | 3 +- ...0de19c2d6e90cb030f64dccf965a0515b467e.json | 3 +- ...09309a8dfd144886330fcef5e6fd87e2c6f8.json} | 12 +- ...ccfc398c0116e929e921a2b0d8d78ff035823.json | 3 +- ...b46168b4bf9848d252242a06569301144a4f6.json | 3 +- ...b80eafcff34b2d5d13621a688883acc3db154.json | 3 +- ...78a1879039b7230aa83df18f583106c97a18f.json | 3 +- ...3fc086f4518149772d661222d54592df920d3.json | 3 +- ...a04c3862f32130ca31196451e48e7712f7319.json | 22 + ...423e760ba3632bfae3aef9da1f146b7a9c638.json | 3 +- ...53fddb8e06cc23840229c3f0219d41095db3d.json | 22 - ...ce983b8ac666f70adefadb95f50cf366f727f.json | 30 + ...f5793f766f458d8ca24a388db9ff5440349fd.json | 3 +- ...85fe45f4dc1e66d4b5a846c734b308c74fa70.json | 3 +- ...a7767d298b720b29e137285c705e4f3ce9484.json | 22 + ...1b038eda324b78b4c99c98ab0851b3599930.json} | 8 +- ...4379c7fdaca83a33355842cf8b0b29d25a517.json | 3 +- ...2bdafe162fb8c8fd217a8fcaf613e39525b0f.json | 3 +- ...c645a882c4f965226e71fc300d84a081ad30d.json | 3 +- ...3354c7b4bd005b9e5657a702bd39846ff0917.json | 3 +- ...46370580a768c26a4c9f6b1fe9d8244abdb36.json | 3 +- ...aa60d2eb5b7b0ff90863b0d0dc288779a5f7f.json | 3 +- ...d0bf0ad8155e25cf77aa57dafdcb133f30509.json | 3 +- ...c5934f900adbadcc5549165e709cb1cd345b5.json | 3 +- Cargo.lock | 1 + apps/web/src/features/entity/types/entity.ts | 16 + apps/web/src/lib/queries/preview/fetchers.ts | 12 +- apps/web/src/lib/queries/preview/graphql.ts | 2 + .../lib/queries/soup/transform-utils.test.ts | 37 + .../src/lib/queries/soup/transform-utils.ts | 24 +- .../generated/schemas/basicDocumentSubType.ts | 4 +- .../schemas/basicDocumentSubTypeOneOfSeven.ts | 14 + .../basicDocumentSubTypeOneOfSevenType.ts | 14 + .../schemas/createInitiativeRequest.ts | 3 +- .../createInitiativeRequestDescription.ts | 3 +- .../schemas/descriptionDocumentId.ts | 15 + .../schemas/documentPreviewDataSubType.ts | 4 +- .../documentPreviewDataSubTypeOneOfSeven.ts | 14 + ...ocumentPreviewDataSubTypeOneOfSevenType.ts | 14 + .../generated/schemas/documentSubType.ts | 4 + .../generated/schemas/index.ts | 10 +- .../generated/schemas/initiativeDetail.ts | 6 +- .../schemas/initiativeDetailDescription.ts | 11 - .../generated/schemas/initiativeSummary.ts | 7 +- .../schemas/initiativeSummaryDescription.ts | 11 - .../generated/schemas/soupDocumentSubType.ts | 4 +- .../schemas/soupDocumentSubTypeOneOfSeven.ts | 14 + .../soupDocumentSubTypeOneOfSevenType.ts | 14 + .../schemas/updateInitiativeRequest.ts | 5 +- .../updateInitiativeRequestDescription.ts | 11 - .../service-storage/generated/zod.ts | 170 ++++- .../service-storage/graphql-soup.test.ts | 41 ++ .../service-storage/graphql-soup.ts | 4 + .../graphql/generated/graphql.ts | 29 + .../service-storage/openapi.json | 65 +- .../src/outbound/notification_sender.rs | 3 + crates/document_sub_type/Cargo.toml | 3 + crates/document_sub_type/src/lib.rs | 14 +- crates/document_sub_type/src/test.rs | 31 + crates/documents/src/domain/create.rs | 81 ++- crates/documents/src/domain/models.rs | 15 + crates/documents/src/domain/service.rs | 29 +- crates/documents/src/domain/service/tests.rs | 99 +++ .../inbound/axum_router/create_document.rs | 5 +- .../inbound/axum_router/get_branch_name.rs | 11 +- .../src/inbound/toolset/read_metadata.rs | 3 +- .../src/outbound/pg_document_repo/create.rs | 1 + .../src/outbound/pg_document_repo/tests.rs | 3 + .../src/notification_event.rs | 3 + crates/graphql_soup/src/objects.rs | 12 + crates/graphql_soup_filter_input/src/lib.rs | 3 + crates/initiative/src/domain/models.rs | 122 +++- crates/initiative/src/domain/models/test.rs | 29 + crates/initiative/src/domain/ports.rs | 47 +- crates/initiative/src/domain/service.rs | 167 +++-- crates/initiative/src/domain/service/test.rs | 283 +++++++- .../src/inbound/axum_router/test.rs | 39 +- .../src/outbound/pg_initiative_repo.rs | 113 ++- .../src/outbound/pg_initiative_repo/create.rs | 148 ++-- .../src/outbound/pg_initiative_repo/list.rs | 23 +- .../outbound/pg_initiative_repo/members.rs | 48 +- .../src/outbound/pg_initiative_repo/share.rs | 119 +++- .../src/outbound/pg_initiative_repo/test.rs | 673 ++++++++++++++---- crates/item_filter_index/src/test.rs | 1 + ...d_initiative_description_document.down.sql | 3 + ...add_initiative_description_document.up.sql | 13 + crates/model/src/document.rs | 6 + crates/model_notifications/src/metadata.rs | 1 + .../src/share_permission.rs | 37 + .../src/share_permission/team_share.rs | 5 +- .../src/share_permission/team_share/test.rs | 14 + .../src/share_permission/test.rs | 58 +- .../src/shared/entity_type.rs | 7 +- crates/models_soup/src/document.rs | 14 +- crates/properties/src/domain/service_impl.rs | 6 +- .../soup/src/inbound/toolset/list_entities.rs | 3 +- crates/soup_filter_cache_adapter/src/lib.rs | 3 + crates/soup_filter_projection/src/profile.rs | 5 +- packages/sdk/generated/storage/index.ts | 2 +- packages/sdk/generated/storage/types.gen.ts | 37 +- packages/sdk/specs/storage.json | 65 +- .../src/api/annotations/create_comment.rs | 3 + .../src/api/annotations/edit_comment.rs | 3 + .../src/api/context.rs | 14 +- services/document_storage_service/src/main.rs | 36 +- .../initiative_description_documents.rs | 139 ++++ .../src/outbound/mod.rs | 1 + static_assets/schema.graphql | 20 +- 119 files changed, 2761 insertions(+), 669 deletions(-) rename .sqlx/{query-05afed4baa89c7e57afc95aaaba1e63b2cb637ac8a3d66734e0259b2314417c9.json => query-186988a35770a5c4fb4b82ff4506d70a9dd31a02d35fa44ef3a41e8d9872ca1d.json} (53%) delete mode 100644 .sqlx/query-2fac2ac9ac6e19757b822ce7b8a34d0e0beb7e3cb0db7087d3835c51788e81a8.json rename .sqlx/{query-8273c42a36b8aa48f8c4e841fb093170ea326933f7d83b1cc716111212865591.json => query-347f3a7b2fb6268b56df7ea076912e36ad8035a77a7322b25ab2ee090a0bef68.json} (52%) rename .sqlx/{query-78f336260899ad227833043c7d8934509d722f58e00963c895cde7def825233d.json => query-630a797b9b8fe15ef5a79f2368ca09309a8dfd144886330fcef5e6fd87e2c6f8.json} (53%) create mode 100644 .sqlx/query-a8106188a819184594ea60e10bba04c3862f32130ca31196451e48e7712f7319.json delete mode 100644 .sqlx/query-b8dbdf5bd764a1c7e49c5dd033f53fddb8e06cc23840229c3f0219d41095db3d.json create mode 100644 .sqlx/query-be20430fb02550589194607b27dce983b8ac666f70adefadb95f50cf366f727f.json create mode 100644 .sqlx/query-ca54e370d5330bf3655ff50e0dda7767d298b720b29e137285c705e4f3ce9484.json rename .sqlx/{query-34b287ee871701effe4febb7c5a0b336390afdd9b7421169ec40298cfc02312a.json => query-cc009247db4fe48002dfbbd29c071b038eda324b78b4c99c98ab0851b3599930.json} (52%) create mode 100644 apps/web/src/lib/service-clients/service-storage/generated/schemas/basicDocumentSubTypeOneOfSeven.ts create mode 100644 apps/web/src/lib/service-clients/service-storage/generated/schemas/basicDocumentSubTypeOneOfSevenType.ts create mode 100644 apps/web/src/lib/service-clients/service-storage/generated/schemas/descriptionDocumentId.ts create mode 100644 apps/web/src/lib/service-clients/service-storage/generated/schemas/documentPreviewDataSubTypeOneOfSeven.ts create mode 100644 apps/web/src/lib/service-clients/service-storage/generated/schemas/documentPreviewDataSubTypeOneOfSevenType.ts delete mode 100644 apps/web/src/lib/service-clients/service-storage/generated/schemas/initiativeDetailDescription.ts delete mode 100644 apps/web/src/lib/service-clients/service-storage/generated/schemas/initiativeSummaryDescription.ts create mode 100644 apps/web/src/lib/service-clients/service-storage/generated/schemas/soupDocumentSubTypeOneOfSeven.ts create mode 100644 apps/web/src/lib/service-clients/service-storage/generated/schemas/soupDocumentSubTypeOneOfSevenType.ts delete mode 100644 apps/web/src/lib/service-clients/service-storage/generated/schemas/updateInitiativeRequestDescription.ts create mode 100644 crates/document_sub_type/src/test.rs create mode 100644 crates/macro_db_client/migrations/20260917140621_add_initiative_description_document.down.sql create mode 100644 crates/macro_db_client/migrations/20260917140621_add_initiative_description_document.up.sql create mode 100644 services/document_storage_service/src/outbound/initiative_description_documents.rs diff --git a/.sqlx/query-056dbddac4957596098ed8444564f7eb4ee08f375c95d9e40bd879d1439a8342.json b/.sqlx/query-056dbddac4957596098ed8444564f7eb4ee08f375c95d9e40bd879d1439a8342.json index cd057146854..b91970910ec 100644 --- a/.sqlx/query-056dbddac4957596098ed8444564f7eb4ee08f375c95d9e40bd879d1439a8342.json +++ b/.sqlx/query-056dbddac4957596098ed8444564f7eb4ee08f375c95d9e40bd879d1439a8342.json @@ -33,7 +33,8 @@ "Enum": [ "task", "snippet", - "skill" + "skill", + "initiative_description" ] } } diff --git a/.sqlx/query-0e9b262983e40207c6c0dcce3e7948837e27fdde34109b1aac0d889f10a25eed.json b/.sqlx/query-0e9b262983e40207c6c0dcce3e7948837e27fdde34109b1aac0d889f10a25eed.json index 2f562c67f47..74b278145b3 100644 --- a/.sqlx/query-0e9b262983e40207c6c0dcce3e7948837e27fdde34109b1aac0d889f10a25eed.json +++ b/.sqlx/query-0e9b262983e40207c6c0dcce3e7948837e27fdde34109b1aac0d889f10a25eed.json @@ -88,7 +88,8 @@ "Enum": [ "task", "snippet", - "skill" + "skill", + "initiative_description" ] } } diff --git a/.sqlx/query-17b1a9529bc693aeb668fd0c432d6b55a4c527fd3105bfa9cf83dbc70516011c.json b/.sqlx/query-17b1a9529bc693aeb668fd0c432d6b55a4c527fd3105bfa9cf83dbc70516011c.json index ca9d6020a37..136d9b28a9a 100644 --- a/.sqlx/query-17b1a9529bc693aeb668fd0c432d6b55a4c527fd3105bfa9cf83dbc70516011c.json +++ b/.sqlx/query-17b1a9529bc693aeb668fd0c432d6b55a4c527fd3105bfa9cf83dbc70516011c.json @@ -48,7 +48,8 @@ "Enum": [ "task", "snippet", - "skill" + "skill", + "initiative_description" ] } } diff --git a/.sqlx/query-05afed4baa89c7e57afc95aaaba1e63b2cb637ac8a3d66734e0259b2314417c9.json b/.sqlx/query-186988a35770a5c4fb4b82ff4506d70a9dd31a02d35fa44ef3a41e8d9872ca1d.json similarity index 53% rename from .sqlx/query-05afed4baa89c7e57afc95aaaba1e63b2cb637ac8a3d66734e0259b2314417c9.json rename to .sqlx/query-186988a35770a5c4fb4b82ff4506d70a9dd31a02d35fa44ef3a41e8d9872ca1d.json index 0a29e43d723..8d9554b7ed7 100644 --- a/.sqlx/query-05afed4baa89c7e57afc95aaaba1e63b2cb637ac8a3d66734e0259b2314417c9.json +++ b/.sqlx/query-186988a35770a5c4fb4b82ff4506d70a9dd31a02d35fa44ef3a41e8d9872ca1d.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT\n i.id,\n i.name,\n i.description,\n i.owner_user_id,\n i.created_at,\n i.updated_at,\n i.share_permission_id,\n COALESCE(\n array_agg(DISTINCT m.user_id) FILTER (WHERE m.user_id IS NOT NULL),\n '{}'::text[]\n ) AS \"member_ids!\",\n COALESCE(\n array_agg(DISTINCT t.task_id) FILTER (WHERE t.task_id IS NOT NULL),\n '{}'::text[]\n ) AS \"task_ids!\",\n sp.\"linkShare\" AS \"link_share?\",\n sp.\"linkShareAccessLevel\" AS \"link_share_access_level?: AccessLevel\",\n sp.team_share_access_level AS \"team_share_access_level?: AccessLevel\",\n COALESCE(\n (\n SELECT json_agg(json_build_object(\n 'channel_id', channel.\"channel_id\",\n 'access_level', channel.\"access_level\"\n ))\n FROM \"ChannelSharePermission\" channel\n WHERE channel.\"share_permission_id\" = i.share_permission_id\n ),\n '[]'::json\n ) AS \"channel_share_permissions!\"\n FROM initiative i\n JOIN \"SharePermission\" sp ON sp.id = i.share_permission_id\n LEFT JOIN initiative_member m ON m.initiative_id = i.id\n LEFT JOIN task_initiative t ON t.initiative_id = i.id\n WHERE i.id = $1\n GROUP BY\n i.id,\n i.name,\n i.description,\n i.owner_user_id,\n i.created_at,\n i.updated_at,\n i.share_permission_id,\n sp.\"linkShare\",\n sp.\"linkShareAccessLevel\",\n sp.team_share_access_level\n ", + "query": "\n SELECT\n i.id,\n i.name,\n i.description_document_id,\n i.owner_user_id,\n i.created_at,\n i.updated_at,\n i.share_permission_id,\n COALESCE(\n array_agg(DISTINCT m.user_id) FILTER (WHERE m.user_id IS NOT NULL),\n '{}'::text[]\n ) AS \"member_ids!\",\n COALESCE(\n array_agg(DISTINCT t.task_id) FILTER (WHERE t.task_id IS NOT NULL),\n '{}'::text[]\n ) AS \"task_ids!\",\n sp.\"linkShare\" AS \"link_share?\",\n sp.\"linkShareAccessLevel\" AS \"link_share_access_level?: AccessLevel\",\n sp.team_share_access_level AS \"team_share_access_level?: AccessLevel\",\n COALESCE(\n (\n SELECT json_agg(json_build_object(\n 'channel_id', channel.\"channel_id\",\n 'access_level', channel.\"access_level\"\n ))\n FROM \"ChannelSharePermission\" channel\n WHERE channel.\"share_permission_id\" = i.share_permission_id\n ),\n '[]'::json\n ) AS \"channel_share_permissions!\"\n FROM initiative i\n JOIN \"SharePermission\" sp ON sp.id = i.share_permission_id\n LEFT JOIN initiative_member m ON m.initiative_id = i.id\n LEFT JOIN task_initiative t ON t.initiative_id = i.id\n WHERE i.id = $1\n GROUP BY\n i.id,\n i.name,\n i.description_document_id,\n i.owner_user_id,\n i.created_at,\n i.updated_at,\n i.share_permission_id,\n sp.\"linkShare\",\n sp.\"linkShareAccessLevel\",\n sp.team_share_access_level\n ", "describe": { "columns": [ { @@ -15,7 +15,7 @@ }, { "ordinal": 2, - "name": "description", + "name": "description_document_id", "type_info": "Text" }, { @@ -101,7 +101,7 @@ "nullable": [ false, false, - false, + true, false, false, false, @@ -114,5 +114,5 @@ null ] }, - "hash": "05afed4baa89c7e57afc95aaaba1e63b2cb637ac8a3d66734e0259b2314417c9" + "hash": "186988a35770a5c4fb4b82ff4506d70a9dd31a02d35fa44ef3a41e8d9872ca1d" } diff --git a/.sqlx/query-225fa012f7e8731c67488221e06333f87800b8b96970638e21b3f5ce8a58511f.json b/.sqlx/query-225fa012f7e8731c67488221e06333f87800b8b96970638e21b3f5ce8a58511f.json index 2da562626ae..3985ea209d0 100644 --- a/.sqlx/query-225fa012f7e8731c67488221e06333f87800b8b96970638e21b3f5ce8a58511f.json +++ b/.sqlx/query-225fa012f7e8731c67488221e06333f87800b8b96970638e21b3f5ce8a58511f.json @@ -88,7 +88,8 @@ "Enum": [ "task", "snippet", - "skill" + "skill", + "initiative_description" ] } } diff --git a/.sqlx/query-2611958405cf94c91ef85a09da00b0a8701621b42e4c57e74d2da40062d577d4.json b/.sqlx/query-2611958405cf94c91ef85a09da00b0a8701621b42e4c57e74d2da40062d577d4.json index 0c1488f8148..3eab78c52cf 100644 --- a/.sqlx/query-2611958405cf94c91ef85a09da00b0a8701621b42e4c57e74d2da40062d577d4.json +++ b/.sqlx/query-2611958405cf94c91ef85a09da00b0a8701621b42e4c57e74d2da40062d577d4.json @@ -83,7 +83,8 @@ "Enum": [ "task", "snippet", - "skill" + "skill", + "initiative_description" ] } } diff --git a/.sqlx/query-2930435da61e2e75a6ec3a127f3f1591934bb55a9f2fed8e47dd4406085fa6b2.json b/.sqlx/query-2930435da61e2e75a6ec3a127f3f1591934bb55a9f2fed8e47dd4406085fa6b2.json index 840ff5983d1..39ae373855a 100644 --- a/.sqlx/query-2930435da61e2e75a6ec3a127f3f1591934bb55a9f2fed8e47dd4406085fa6b2.json +++ b/.sqlx/query-2930435da61e2e75a6ec3a127f3f1591934bb55a9f2fed8e47dd4406085fa6b2.json @@ -48,7 +48,8 @@ "Enum": [ "task", "snippet", - "skill" + "skill", + "initiative_description" ] } } diff --git a/.sqlx/query-2d1112d21440f0dfdf8fcbec264389f7abdcc74544558d60d817f56ff0c14524.json b/.sqlx/query-2d1112d21440f0dfdf8fcbec264389f7abdcc74544558d60d817f56ff0c14524.json index 11eb14246f1..b93b7b33296 100644 --- a/.sqlx/query-2d1112d21440f0dfdf8fcbec264389f7abdcc74544558d60d817f56ff0c14524.json +++ b/.sqlx/query-2d1112d21440f0dfdf8fcbec264389f7abdcc74544558d60d817f56ff0c14524.json @@ -33,7 +33,8 @@ "Enum": [ "task", "snippet", - "skill" + "skill", + "initiative_description" ] } } diff --git a/.sqlx/query-2fac2ac9ac6e19757b822ce7b8a34d0e0beb7e3cb0db7087d3835c51788e81a8.json b/.sqlx/query-2fac2ac9ac6e19757b822ce7b8a34d0e0beb7e3cb0db7087d3835c51788e81a8.json deleted file mode 100644 index 0ab6cbee3b8..00000000000 --- a/.sqlx/query-2fac2ac9ac6e19757b822ce7b8a34d0e0beb7e3cb0db7087d3835c51788e81a8.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n UPDATE initiative\n SET\n name = CASE WHEN $2 THEN $3 ELSE name END,\n description = CASE WHEN $4 THEN $5 ELSE description END,\n updated_at = now()\n WHERE id = $1\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Uuid", - "Bool", - "Text", - "Bool", - "Text" - ] - }, - "nullable": [] - }, - "hash": "2fac2ac9ac6e19757b822ce7b8a34d0e0beb7e3cb0db7087d3835c51788e81a8" -} diff --git a/.sqlx/query-31dbb9af47386234da706e7c3175a7a2e43c2231040cc399488418eab9f6023a.json b/.sqlx/query-31dbb9af47386234da706e7c3175a7a2e43c2231040cc399488418eab9f6023a.json index 54f38556ec0..cbf24f25d2d 100644 --- a/.sqlx/query-31dbb9af47386234da706e7c3175a7a2e43c2231040cc399488418eab9f6023a.json +++ b/.sqlx/query-31dbb9af47386234da706e7c3175a7a2e43c2231040cc399488418eab9f6023a.json @@ -13,7 +13,8 @@ "Enum": [ "task", "snippet", - "skill" + "skill", + "initiative_description" ] } } diff --git a/.sqlx/query-8273c42a36b8aa48f8c4e841fb093170ea326933f7d83b1cc716111212865591.json b/.sqlx/query-347f3a7b2fb6268b56df7ea076912e36ad8035a77a7322b25ab2ee090a0bef68.json similarity index 52% rename from .sqlx/query-8273c42a36b8aa48f8c4e841fb093170ea326933f7d83b1cc716111212865591.json rename to .sqlx/query-347f3a7b2fb6268b56df7ea076912e36ad8035a77a7322b25ab2ee090a0bef68.json index e213d540562..a4a00f38458 100644 --- a/.sqlx/query-8273c42a36b8aa48f8c4e841fb093170ea326933f7d83b1cc716111212865591.json +++ b/.sqlx/query-347f3a7b2fb6268b56df7ea076912e36ad8035a77a7322b25ab2ee090a0bef68.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n INSERT INTO initiative (\n id,\n name,\n description,\n owner_user_id,\n share_permission_id\n )\n VALUES ($1, $2, $3, $4, $5)\n ", + "query": "\n INSERT INTO initiative (\n id,\n name,\n owner_user_id,\n share_permission_id,\n description_document_id\n )\n VALUES ($1, $2, $3, $4, $5)\n ", "describe": { "columns": [], "parameters": { @@ -14,5 +14,5 @@ }, "nullable": [] }, - "hash": "8273c42a36b8aa48f8c4e841fb093170ea326933f7d83b1cc716111212865591" + "hash": "347f3a7b2fb6268b56df7ea076912e36ad8035a77a7322b25ab2ee090a0bef68" } diff --git a/.sqlx/query-43aedaff60665c1bf3dea9dc3e58908fd19aa1f6dd274803963698d6b95760ce.json b/.sqlx/query-43aedaff60665c1bf3dea9dc3e58908fd19aa1f6dd274803963698d6b95760ce.json index 7bf7fc2688b..61633c439db 100644 --- a/.sqlx/query-43aedaff60665c1bf3dea9dc3e58908fd19aa1f6dd274803963698d6b95760ce.json +++ b/.sqlx/query-43aedaff60665c1bf3dea9dc3e58908fd19aa1f6dd274803963698d6b95760ce.json @@ -53,7 +53,8 @@ "Enum": [ "task", "snippet", - "skill" + "skill", + "initiative_description" ] } } diff --git a/.sqlx/query-5959ff0d2410e3f2599a53bc1bf0de19c2d6e90cb030f64dccf965a0515b467e.json b/.sqlx/query-5959ff0d2410e3f2599a53bc1bf0de19c2d6e90cb030f64dccf965a0515b467e.json index cf33b5b7c3b..64ebba8e470 100644 --- a/.sqlx/query-5959ff0d2410e3f2599a53bc1bf0de19c2d6e90cb030f64dccf965a0515b467e.json +++ b/.sqlx/query-5959ff0d2410e3f2599a53bc1bf0de19c2d6e90cb030f64dccf965a0515b467e.json @@ -68,7 +68,8 @@ "Enum": [ "task", "snippet", - "skill" + "skill", + "initiative_description" ] } } diff --git a/.sqlx/query-78f336260899ad227833043c7d8934509d722f58e00963c895cde7def825233d.json b/.sqlx/query-630a797b9b8fe15ef5a79f2368ca09309a8dfd144886330fcef5e6fd87e2c6f8.json similarity index 53% rename from .sqlx/query-78f336260899ad227833043c7d8934509d722f58e00963c895cde7def825233d.json rename to .sqlx/query-630a797b9b8fe15ef5a79f2368ca09309a8dfd144886330fcef5e6fd87e2c6f8.json index 07a802e25ac..4688a98fe8e 100644 --- a/.sqlx/query-78f336260899ad227833043c7d8934509d722f58e00963c895cde7def825233d.json +++ b/.sqlx/query-630a797b9b8fe15ef5a79f2368ca09309a8dfd144886330fcef5e6fd87e2c6f8.json @@ -1,12 +1,17 @@ { "db_name": "PostgreSQL", - "query": "\n DELETE FROM initiative\n WHERE id = $1\n RETURNING share_permission_id\n ", + "query": "\n DELETE FROM initiative\n WHERE id = $1\n RETURNING share_permission_id, description_document_id\n ", "describe": { "columns": [ { "ordinal": 0, "name": "share_permission_id", "type_info": "Text" + }, + { + "ordinal": 1, + "name": "description_document_id", + "type_info": "Text" } ], "parameters": { @@ -15,8 +20,9 @@ ] }, "nullable": [ - false + false, + true ] }, - "hash": "78f336260899ad227833043c7d8934509d722f58e00963c895cde7def825233d" + "hash": "630a797b9b8fe15ef5a79f2368ca09309a8dfd144886330fcef5e6fd87e2c6f8" } diff --git a/.sqlx/query-68c14c7f2106ef2b3e27c398fa1ccfc398c0116e929e921a2b0d8d78ff035823.json b/.sqlx/query-68c14c7f2106ef2b3e27c398fa1ccfc398c0116e929e921a2b0d8d78ff035823.json index ee78110d969..3fcfb4154be 100644 --- a/.sqlx/query-68c14c7f2106ef2b3e27c398fa1ccfc398c0116e929e921a2b0d8d78ff035823.json +++ b/.sqlx/query-68c14c7f2106ef2b3e27c398fa1ccfc398c0116e929e921a2b0d8d78ff035823.json @@ -18,7 +18,8 @@ "Enum": [ "task", "snippet", - "skill" + "skill", + "initiative_description" ] } } diff --git a/.sqlx/query-6ea5fe0c08693b233361460d1a1b46168b4bf9848d252242a06569301144a4f6.json b/.sqlx/query-6ea5fe0c08693b233361460d1a1b46168b4bf9848d252242a06569301144a4f6.json index 89d00a6b77b..c54732321bf 100644 --- a/.sqlx/query-6ea5fe0c08693b233361460d1a1b46168b4bf9848d252242a06569301144a4f6.json +++ b/.sqlx/query-6ea5fe0c08693b233361460d1a1b46168b4bf9848d252242a06569301144a4f6.json @@ -33,7 +33,8 @@ "Enum": [ "task", "snippet", - "skill" + "skill", + "initiative_description" ] } } diff --git a/.sqlx/query-7fc9da48b6c62ab0db6327e300eb80eafcff34b2d5d13621a688883acc3db154.json b/.sqlx/query-7fc9da48b6c62ab0db6327e300eb80eafcff34b2d5d13621a688883acc3db154.json index 4fa39ebc40d..b89ff7a4abc 100644 --- a/.sqlx/query-7fc9da48b6c62ab0db6327e300eb80eafcff34b2d5d13621a688883acc3db154.json +++ b/.sqlx/query-7fc9da48b6c62ab0db6327e300eb80eafcff34b2d5d13621a688883acc3db154.json @@ -88,7 +88,8 @@ "Enum": [ "task", "snippet", - "skill" + "skill", + "initiative_description" ] } } diff --git a/.sqlx/query-8b7e81c3b4f31c86100de517e1978a1879039b7230aa83df18f583106c97a18f.json b/.sqlx/query-8b7e81c3b4f31c86100de517e1978a1879039b7230aa83df18f583106c97a18f.json index e2da6e63838..da7a8fd54b8 100644 --- a/.sqlx/query-8b7e81c3b4f31c86100de517e1978a1879039b7230aa83df18f583106c97a18f.json +++ b/.sqlx/query-8b7e81c3b4f31c86100de517e1978a1879039b7230aa83df18f583106c97a18f.json @@ -88,7 +88,8 @@ "Enum": [ "task", "snippet", - "skill" + "skill", + "initiative_description" ] } } diff --git a/.sqlx/query-96a2c0a064e56b4fd812c2768403fc086f4518149772d661222d54592df920d3.json b/.sqlx/query-96a2c0a064e56b4fd812c2768403fc086f4518149772d661222d54592df920d3.json index 719c9beed7f..a6490eb4f2e 100644 --- a/.sqlx/query-96a2c0a064e56b4fd812c2768403fc086f4518149772d661222d54592df920d3.json +++ b/.sqlx/query-96a2c0a064e56b4fd812c2768403fc086f4518149772d661222d54592df920d3.json @@ -13,7 +13,8 @@ "Enum": [ "task", "snippet", - "skill" + "skill", + "initiative_description" ] } } diff --git a/.sqlx/query-a8106188a819184594ea60e10bba04c3862f32130ca31196451e48e7712f7319.json b/.sqlx/query-a8106188a819184594ea60e10bba04c3862f32130ca31196451e48e7712f7319.json new file mode 100644 index 00000000000..9025a6d531c --- /dev/null +++ b/.sqlx/query-a8106188a819184594ea60e10bba04c3862f32130ca31196451e48e7712f7319.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT \"sharePermissionId\"\n FROM \"DocumentPermission\"\n WHERE \"documentId\" = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "sharePermissionId", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "a8106188a819184594ea60e10bba04c3862f32130ca31196451e48e7712f7319" +} diff --git a/.sqlx/query-a9fee4b6ef042c94f5dfc2ba668423e760ba3632bfae3aef9da1f146b7a9c638.json b/.sqlx/query-a9fee4b6ef042c94f5dfc2ba668423e760ba3632bfae3aef9da1f146b7a9c638.json index aa46dbd21b3..2901f784978 100644 --- a/.sqlx/query-a9fee4b6ef042c94f5dfc2ba668423e760ba3632bfae3aef9da1f146b7a9c638.json +++ b/.sqlx/query-a9fee4b6ef042c94f5dfc2ba668423e760ba3632bfae3aef9da1f146b7a9c638.json @@ -48,7 +48,8 @@ "Enum": [ "task", "snippet", - "skill" + "skill", + "initiative_description" ] } } diff --git a/.sqlx/query-b8dbdf5bd764a1c7e49c5dd033f53fddb8e06cc23840229c3f0219d41095db3d.json b/.sqlx/query-b8dbdf5bd764a1c7e49c5dd033f53fddb8e06cc23840229c3f0219d41095db3d.json deleted file mode 100644 index 30d0b3719c8..00000000000 --- a/.sqlx/query-b8dbdf5bd764a1c7e49c5dd033f53fddb8e06cc23840229c3f0219d41095db3d.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT share_permission_id\n FROM initiative\n WHERE id = $1\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "share_permission_id", - "type_info": "Text" - } - ], - "parameters": { - "Left": [ - "Uuid" - ] - }, - "nullable": [ - false - ] - }, - "hash": "b8dbdf5bd764a1c7e49c5dd033f53fddb8e06cc23840229c3f0219d41095db3d" -} diff --git a/.sqlx/query-be20430fb02550589194607b27dce983b8ac666f70adefadb95f50cf366f727f.json b/.sqlx/query-be20430fb02550589194607b27dce983b8ac666f70adefadb95f50cf366f727f.json new file mode 100644 index 00000000000..2cdbc8a0bf4 --- /dev/null +++ b/.sqlx/query-be20430fb02550589194607b27dce983b8ac666f70adefadb95f50cf366f727f.json @@ -0,0 +1,30 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE initiative\n SET\n name = CASE WHEN $2 THEN $3 ELSE name END,\n updated_at = now()\n WHERE id = $1\n RETURNING share_permission_id, description_document_id\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "share_permission_id", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "description_document_id", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Bool", + "Text" + ] + }, + "nullable": [ + false, + true + ] + }, + "hash": "be20430fb02550589194607b27dce983b8ac666f70adefadb95f50cf366f727f" +} diff --git a/.sqlx/query-c1fc8fea65af6acbf8e00a08794f5793f766f458d8ca24a388db9ff5440349fd.json b/.sqlx/query-c1fc8fea65af6acbf8e00a08794f5793f766f458d8ca24a388db9ff5440349fd.json index 0f2695b43b0..567111b0bc1 100644 --- a/.sqlx/query-c1fc8fea65af6acbf8e00a08794f5793f766f458d8ca24a388db9ff5440349fd.json +++ b/.sqlx/query-c1fc8fea65af6acbf8e00a08794f5793f766f458d8ca24a388db9ff5440349fd.json @@ -88,7 +88,8 @@ "Enum": [ "task", "snippet", - "skill" + "skill", + "initiative_description" ] } } diff --git a/.sqlx/query-c9fce1ac31e4e4f9992809c650e85fe45f4dc1e66d4b5a846c734b308c74fa70.json b/.sqlx/query-c9fce1ac31e4e4f9992809c650e85fe45f4dc1e66d4b5a846c734b308c74fa70.json index 190c10d985f..ebeb7f50b84 100644 --- a/.sqlx/query-c9fce1ac31e4e4f9992809c650e85fe45f4dc1e66d4b5a846c734b308c74fa70.json +++ b/.sqlx/query-c9fce1ac31e4e4f9992809c650e85fe45f4dc1e66d4b5a846c734b308c74fa70.json @@ -88,7 +88,8 @@ "Enum": [ "task", "snippet", - "skill" + "skill", + "initiative_description" ] } } diff --git a/.sqlx/query-ca54e370d5330bf3655ff50e0dda7767d298b720b29e137285c705e4f3ce9484.json b/.sqlx/query-ca54e370d5330bf3655ff50e0dda7767d298b720b29e137285c705e4f3ce9484.json new file mode 100644 index 00000000000..d1b73fba058 --- /dev/null +++ b/.sqlx/query-ca54e370d5330bf3655ff50e0dda7767d298b720b29e137285c705e4f3ce9484.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT description_document_id\n FROM initiative\n WHERE id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "description_document_id", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + true + ] + }, + "hash": "ca54e370d5330bf3655ff50e0dda7767d298b720b29e137285c705e4f3ce9484" +} diff --git a/.sqlx/query-34b287ee871701effe4febb7c5a0b336390afdd9b7421169ec40298cfc02312a.json b/.sqlx/query-cc009247db4fe48002dfbbd29c071b038eda324b78b4c99c98ab0851b3599930.json similarity index 52% rename from .sqlx/query-34b287ee871701effe4febb7c5a0b336390afdd9b7421169ec40298cfc02312a.json rename to .sqlx/query-cc009247db4fe48002dfbbd29c071b038eda324b78b4c99c98ab0851b3599930.json index c37800ab6a2..fa4156bad7b 100644 --- a/.sqlx/query-34b287ee871701effe4febb7c5a0b336390afdd9b7421169ec40298cfc02312a.json +++ b/.sqlx/query-cc009247db4fe48002dfbbd29c071b038eda324b78b4c99c98ab0851b3599930.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n WITH user_source_ids AS (\n SELECT cp.channel_id::text AS source_id\n FROM comms_channel_participants cp\n WHERE cp.user_id = $1 AND cp.left_at IS NULL\n UNION ALL\n SELECT t.team_id::text\n FROM team_user t\n WHERE t.user_id = $1\n UNION ALL\n SELECT $1\n )\n SELECT\n i.id,\n i.name,\n i.description,\n i.updated_at\n FROM initiative i\n JOIN \"SharePermission\" sp ON sp.id = i.share_permission_id\n WHERE EXISTS (\n SELECT 1\n FROM entity_access ea\n WHERE ea.entity_id = i.id\n AND ea.entity_type = 'initiative'\n AND ea.source_id IN (SELECT source_id FROM user_source_ids)\n )\n OR (\n sp.\"linkShare\" = 'TEAM'\n AND EXISTS (\n SELECT 1\n FROM team_user owner_tu\n WHERE owner_tu.user_id = i.owner_user_id\n AND owner_tu.team_id::text IN (SELECT source_id FROM user_source_ids)\n )\n )\n ORDER BY i.updated_at DESC\n ", + "query": "\n WITH user_source_ids AS (\n SELECT cp.channel_id::text AS source_id\n FROM comms_channel_participants cp\n WHERE cp.user_id = $1 AND cp.left_at IS NULL\n UNION ALL\n SELECT t.team_id::text\n FROM team_user t\n WHERE t.user_id = $1\n UNION ALL\n SELECT $1\n )\n SELECT\n i.id,\n i.name,\n i.description_document_id,\n i.updated_at\n FROM initiative i\n JOIN \"SharePermission\" sp ON sp.id = i.share_permission_id\n WHERE EXISTS (\n SELECT 1\n FROM entity_access ea\n WHERE ea.entity_id = i.id\n AND ea.entity_type = 'initiative'\n AND ea.source_id IN (SELECT source_id FROM user_source_ids)\n )\n OR (\n sp.\"linkShare\" = 'TEAM'\n AND EXISTS (\n SELECT 1\n FROM team_user owner_tu\n WHERE owner_tu.user_id = i.owner_user_id\n AND owner_tu.team_id::text IN (SELECT source_id FROM user_source_ids)\n )\n )\n ORDER BY i.updated_at DESC\n ", "describe": { "columns": [ { @@ -15,7 +15,7 @@ }, { "ordinal": 2, - "name": "description", + "name": "description_document_id", "type_info": "Text" }, { @@ -32,9 +32,9 @@ "nullable": [ false, false, - false, + true, false ] }, - "hash": "34b287ee871701effe4febb7c5a0b336390afdd9b7421169ec40298cfc02312a" + "hash": "cc009247db4fe48002dfbbd29c071b038eda324b78b4c99c98ab0851b3599930" } diff --git a/.sqlx/query-cc55e105bd5f6916a779d57ed7e4379c7fdaca83a33355842cf8b0b29d25a517.json b/.sqlx/query-cc55e105bd5f6916a779d57ed7e4379c7fdaca83a33355842cf8b0b29d25a517.json index 4161f6d9089..1c5e2eed1c5 100644 --- a/.sqlx/query-cc55e105bd5f6916a779d57ed7e4379c7fdaca83a33355842cf8b0b29d25a517.json +++ b/.sqlx/query-cc55e105bd5f6916a779d57ed7e4379c7fdaca83a33355842cf8b0b29d25a517.json @@ -88,7 +88,8 @@ "Enum": [ "task", "snippet", - "skill" + "skill", + "initiative_description" ] } } diff --git a/.sqlx/query-cfbec090ff3b62415ec0c215aa52bdafe162fb8c8fd217a8fcaf613e39525b0f.json b/.sqlx/query-cfbec090ff3b62415ec0c215aa52bdafe162fb8c8fd217a8fcaf613e39525b0f.json index ee2c38dc5b0..e35cb991382 100644 --- a/.sqlx/query-cfbec090ff3b62415ec0c215aa52bdafe162fb8c8fd217a8fcaf613e39525b0f.json +++ b/.sqlx/query-cfbec090ff3b62415ec0c215aa52bdafe162fb8c8fd217a8fcaf613e39525b0f.json @@ -38,7 +38,8 @@ "Enum": [ "task", "snippet", - "skill" + "skill", + "initiative_description" ] } } diff --git a/.sqlx/query-d86be6e3a5bac69df99498b43dfc645a882c4f965226e71fc300d84a081ad30d.json b/.sqlx/query-d86be6e3a5bac69df99498b43dfc645a882c4f965226e71fc300d84a081ad30d.json index d3b429235fe..f7f24e44857 100644 --- a/.sqlx/query-d86be6e3a5bac69df99498b43dfc645a882c4f965226e71fc300d84a081ad30d.json +++ b/.sqlx/query-d86be6e3a5bac69df99498b43dfc645a882c4f965226e71fc300d84a081ad30d.json @@ -88,7 +88,8 @@ "Enum": [ "task", "snippet", - "skill" + "skill", + "initiative_description" ] } } diff --git a/.sqlx/query-da98ce3985fbf5eaed5300bb94b3354c7b4bd005b9e5657a702bd39846ff0917.json b/.sqlx/query-da98ce3985fbf5eaed5300bb94b3354c7b4bd005b9e5657a702bd39846ff0917.json index 69f2544e3c2..5af38179219 100644 --- a/.sqlx/query-da98ce3985fbf5eaed5300bb94b3354c7b4bd005b9e5657a702bd39846ff0917.json +++ b/.sqlx/query-da98ce3985fbf5eaed5300bb94b3354c7b4bd005b9e5657a702bd39846ff0917.json @@ -13,7 +13,8 @@ "Enum": [ "task", "snippet", - "skill" + "skill", + "initiative_description" ] } } diff --git a/.sqlx/query-ecd59864bf9f241755a73ed370146370580a768c26a4c9f6b1fe9d8244abdb36.json b/.sqlx/query-ecd59864bf9f241755a73ed370146370580a768c26a4c9f6b1fe9d8244abdb36.json index c67e2c1de84..76a04f378c1 100644 --- a/.sqlx/query-ecd59864bf9f241755a73ed370146370580a768c26a4c9f6b1fe9d8244abdb36.json +++ b/.sqlx/query-ecd59864bf9f241755a73ed370146370580a768c26a4c9f6b1fe9d8244abdb36.json @@ -48,7 +48,8 @@ "Enum": [ "task", "snippet", - "skill" + "skill", + "initiative_description" ] } } diff --git a/.sqlx/query-f61fae90a53682f217091f1ecbdaa60d2eb5b7b0ff90863b0d0dc288779a5f7f.json b/.sqlx/query-f61fae90a53682f217091f1ecbdaa60d2eb5b7b0ff90863b0d0dc288779a5f7f.json index ea8c6ba8add..0fabd82dac6 100644 --- a/.sqlx/query-f61fae90a53682f217091f1ecbdaa60d2eb5b7b0ff90863b0d0dc288779a5f7f.json +++ b/.sqlx/query-f61fae90a53682f217091f1ecbdaa60d2eb5b7b0ff90863b0d0dc288779a5f7f.json @@ -58,7 +58,8 @@ "Enum": [ "task", "snippet", - "skill" + "skill", + "initiative_description" ] } } diff --git a/.sqlx/query-fab6b92c5781874261b3c3ca673d0bf0ad8155e25cf77aa57dafdcb133f30509.json b/.sqlx/query-fab6b92c5781874261b3c3ca673d0bf0ad8155e25cf77aa57dafdcb133f30509.json index ba478af6f9b..938451b7cba 100644 --- a/.sqlx/query-fab6b92c5781874261b3c3ca673d0bf0ad8155e25cf77aa57dafdcb133f30509.json +++ b/.sqlx/query-fab6b92c5781874261b3c3ca673d0bf0ad8155e25cf77aa57dafdcb133f30509.json @@ -13,7 +13,8 @@ "Enum": [ "task", "snippet", - "skill" + "skill", + "initiative_description" ] } } diff --git a/.sqlx/query-fcd4b67db8a3a8de51b0bb55880c5934f900adbadcc5549165e709cb1cd345b5.json b/.sqlx/query-fcd4b67db8a3a8de51b0bb55880c5934f900adbadcc5549165e709cb1cd345b5.json index 628a760f1c5..f877ed2f7a0 100644 --- a/.sqlx/query-fcd4b67db8a3a8de51b0bb55880c5934f900adbadcc5549165e709cb1cd345b5.json +++ b/.sqlx/query-fcd4b67db8a3a8de51b0bb55880c5934f900adbadcc5549165e709cb1cd345b5.json @@ -93,7 +93,8 @@ "Enum": [ "task", "snippet", - "skill" + "skill", + "initiative_description" ] } } diff --git a/Cargo.lock b/Cargo.lock index 9c437bb6881..fb8ef776ba5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5552,6 +5552,7 @@ version = "0.1.0" dependencies = [ "schemars 1.2.1", "serde", + "serde_json", "sqlx", "strum 0.27.2", "thiserror 2.0.18", diff --git a/apps/web/src/features/entity/types/entity.ts b/apps/web/src/features/entity/types/entity.ts index 112ec85c121..a5611a2d3c2 100644 --- a/apps/web/src/features/entity/types/entity.ts +++ b/apps/web/src/features/entity/types/entity.ts @@ -181,6 +181,22 @@ export type SubType = { is_completed?: boolean; } | null; +/** + * Narrow a wire sub type to the ones the app renders specially. The backend + * also tags documents the app has no dedicated block for, such as an + * initiative's description; those come through as `null` so the document + * renders as a plain document of its file type. + */ +export const toSubType = ( + wire: { type: string; is_completed?: boolean } | null | undefined +): SubType => { + if (wire == null) return null; + const { type } = wire; + return type === 'task' || type === 'snippet' || type === 'skill' + ? { type, is_completed: wire.is_completed } + : null; +}; + export type BaseDocumentEntity = EntityBase & { type: 'document'; fileType?: string; diff --git a/apps/web/src/lib/queries/preview/fetchers.ts b/apps/web/src/lib/queries/preview/fetchers.ts index 737044057f1..2a3a8d3b0d2 100644 --- a/apps/web/src/lib/queries/preview/fetchers.ts +++ b/apps/web/src/lib/queries/preview/fetchers.ts @@ -4,6 +4,7 @@ import { isFeatureEnabled, } from '@core/constant/featureFlags'; import { DEFAULT_THREAD_MESSAGES_LIMIT } from '@core/constant/pagination'; +import { toSubType } from '@entity/types/entity'; import { cognitionApiServiceClient } from '@service-cognition/client'; import { emailClient } from '@service-email/client'; import type { ApiThread } from '@service-email/generated/schemas'; @@ -136,16 +137,7 @@ async function fetchDocumentPreviews(ids: string[]): Promise { fileType: doc.file_type as FileType, owner: doc.owner, updatedAt: doc.updated_at, - subType: - doc.sub_type === null || doc.sub_type === undefined - ? undefined - : { - type: doc.sub_type.type, - is_completed: - 'is_completed' in doc.sub_type - ? doc.sub_type.is_completed - : undefined, - }, + subType: toSubType(doc.sub_type) ?? undefined, }; case 'no_access': case 'does_not_exist': diff --git a/apps/web/src/lib/queries/preview/graphql.ts b/apps/web/src/lib/queries/preview/graphql.ts index f6665fc9f2e..a36bc538e05 100644 --- a/apps/web/src/lib/queries/preview/graphql.ts +++ b/apps/web/src/lib/queries/preview/graphql.ts @@ -148,6 +148,8 @@ function documentSubType( return { type: 'snippet' as const }; case 'GraphqlSkillSubType': return { type: 'skill' as const }; + case 'GraphqlInitiativeDescriptionSubType': + return undefined; } } diff --git a/apps/web/src/lib/queries/soup/transform-utils.test.ts b/apps/web/src/lib/queries/soup/transform-utils.test.ts index 3c253569699..d52cbfd4298 100644 --- a/apps/web/src/lib/queries/soup/transform-utils.test.ts +++ b/apps/web/src/lib/queries/soup/transform-utils.test.ts @@ -37,3 +37,40 @@ describe('chat soup entities', () => { } ); }); + +describe('document soup entities', () => { + it.each([ + [ + { type: 'task', is_completed: true }, + { type: 'task', is_completed: true }, + ], + [{ type: 'skill' }, { type: 'skill' }], + [{ type: 'initiative_description' }, undefined], + ] as const)( + 'maps the wire sub type %j to the app sub type %j', + (subType, expected) => { + const item = { + tag: 'document', + frecency_score: 0, + is_favorited: false, + data: { + id: 'doc-sub-type', + name: 'Plan', + ownerId: 'macro|owner@example.com', + fileType: 'md', + subType, + documentVersionId: 1, + properties: [], + createdAt: '2026-09-11T00:00:00Z', + updatedAt: '2026-09-11T00:00:00Z', + }, + } satisfies SoupApiItem; + + expect(mapApiSoupItemToEntity(item)).toMatchObject({ + type: 'document', + id: item.data.id, + subType: expected, + }); + } + ); +}); diff --git a/apps/web/src/lib/queries/soup/transform-utils.ts b/apps/web/src/lib/queries/soup/transform-utils.ts index 9a39407bce8..e712455429f 100644 --- a/apps/web/src/lib/queries/soup/transform-utils.ts +++ b/apps/web/src/lib/queries/soup/transform-utils.ts @@ -25,13 +25,13 @@ import type { EntityData, ForeignEntity, GithubPullRequestEntity, - NamedSubType, Notification, ProjectEntity, ReminderEntity, SearchData, WithSearch, } from '@entity'; +import { toSubType } from '@entity/types/entity'; import { resolveNotifiedAt } from '@queries/soup/normalized-cache/notified-floor'; import { resolveOwnTouch } from '@queries/soup/normalized-cache/own-touch'; import type { @@ -637,16 +637,7 @@ const resolveDocumentEntityName = ( type: 'document', name: entity.name, fileType: entity.fileType, - subType: - entity.subType == null - ? null - : { - type: entity.subType.type, - is_completed: - 'is_completed' in entity.subType - ? entity.subType.is_completed - : undefined, - }, + subType: toSubType(entity.subType), }); }; @@ -947,16 +938,7 @@ export const mapApiSoupItemToEntity = ( viewedAt: item.data.viewedAt, fileType: item.data.fileType ?? undefined, projectId: item.data.projectId ?? undefined, - subType: - item.data.subType === null || item.data.subType === undefined - ? undefined - : { - type: item.data.subType.type as NamedSubType, - is_completed: - 'is_completed' in item.data.subType - ? item.data.subType.is_completed - : undefined, - }, + subType: toSubType(item.data.subType) ?? undefined, name: resolveDocumentEntityName(item.data), })) .with({ tag: 'crmCompany' }, (item) => { diff --git a/apps/web/src/lib/service-clients/service-storage/generated/schemas/basicDocumentSubType.ts b/apps/web/src/lib/service-clients/service-storage/generated/schemas/basicDocumentSubType.ts index 4ea0dfc89df..0f1a6503253 100644 --- a/apps/web/src/lib/service-clients/service-storage/generated/schemas/basicDocumentSubType.ts +++ b/apps/web/src/lib/service-clients/service-storage/generated/schemas/basicDocumentSubType.ts @@ -6,6 +6,7 @@ */ import type { BasicDocumentSubTypeOneOf } from './basicDocumentSubTypeOneOf'; import type { BasicDocumentSubTypeOneOfFive } from './basicDocumentSubTypeOneOfFive'; +import type { BasicDocumentSubTypeOneOfSeven } from './basicDocumentSubTypeOneOfSeven'; import type { BasicDocumentSubTypeOneOfThree } from './basicDocumentSubTypeOneOfThree'; /** @@ -15,4 +16,5 @@ This ensures type-safety: task properties only exist when the document is a task export type BasicDocumentSubType = | BasicDocumentSubTypeOneOf | BasicDocumentSubTypeOneOfThree - | BasicDocumentSubTypeOneOfFive; + | BasicDocumentSubTypeOneOfFive + | BasicDocumentSubTypeOneOfSeven; diff --git a/apps/web/src/lib/service-clients/service-storage/generated/schemas/basicDocumentSubTypeOneOfSeven.ts b/apps/web/src/lib/service-clients/service-storage/generated/schemas/basicDocumentSubTypeOneOfSeven.ts new file mode 100644 index 00000000000..75ff48406ac --- /dev/null +++ b/apps/web/src/lib/service-clients/service-storage/generated/schemas/basicDocumentSubTypeOneOfSeven.ts @@ -0,0 +1,14 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * document_storage_service + * OpenAPI spec version: 0.1.0 + */ +import type { BasicDocumentSubTypeOneOfSevenType } from './basicDocumentSubTypeOneOfSevenType'; + +/** + * The description document of an initiative + */ +export type BasicDocumentSubTypeOneOfSeven = { + type: BasicDocumentSubTypeOneOfSevenType; +}; diff --git a/apps/web/src/lib/service-clients/service-storage/generated/schemas/basicDocumentSubTypeOneOfSevenType.ts b/apps/web/src/lib/service-clients/service-storage/generated/schemas/basicDocumentSubTypeOneOfSevenType.ts new file mode 100644 index 00000000000..17f1bf05a51 --- /dev/null +++ b/apps/web/src/lib/service-clients/service-storage/generated/schemas/basicDocumentSubTypeOneOfSevenType.ts @@ -0,0 +1,14 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * document_storage_service + * OpenAPI spec version: 0.1.0 + */ + +export type BasicDocumentSubTypeOneOfSevenType = + (typeof BasicDocumentSubTypeOneOfSevenType)[keyof typeof BasicDocumentSubTypeOneOfSevenType]; + +// eslint-disable-next-line @typescript-eslint/no-redeclare +export const BasicDocumentSubTypeOneOfSevenType = { + initiative_description: 'initiative_description', +} as const; diff --git a/apps/web/src/lib/service-clients/service-storage/generated/schemas/createInitiativeRequest.ts b/apps/web/src/lib/service-clients/service-storage/generated/schemas/createInitiativeRequest.ts index 332e86be004..1919398c34c 100644 --- a/apps/web/src/lib/service-clients/service-storage/generated/schemas/createInitiativeRequest.ts +++ b/apps/web/src/lib/service-clients/service-storage/generated/schemas/createInitiativeRequest.ts @@ -12,7 +12,8 @@ import type { CreateInitiativeRequestShareWithTeam } from './createInitiativeReq * Create-initiative HTTP body. */ export interface CreateInitiativeRequest { - /** Optional description. */ + /** Initial markdown for the description document. Not stored on the initiative; later +edits happen in the document editor. */ description?: CreateInitiativeRequestDescription; /** Optional member user ids. Invalid ids fail at the service boundary. */ memberIds?: CreateInitiativeRequestMemberIds; diff --git a/apps/web/src/lib/service-clients/service-storage/generated/schemas/createInitiativeRequestDescription.ts b/apps/web/src/lib/service-clients/service-storage/generated/schemas/createInitiativeRequestDescription.ts index b489e693b14..1b0ada32340 100644 --- a/apps/web/src/lib/service-clients/service-storage/generated/schemas/createInitiativeRequestDescription.ts +++ b/apps/web/src/lib/service-clients/service-storage/generated/schemas/createInitiativeRequestDescription.ts @@ -6,6 +6,7 @@ */ /** - * Optional description. + * Initial markdown for the description document. Not stored on the initiative; later +edits happen in the document editor. */ export type CreateInitiativeRequestDescription = string | null; diff --git a/apps/web/src/lib/service-clients/service-storage/generated/schemas/descriptionDocumentId.ts b/apps/web/src/lib/service-clients/service-storage/generated/schemas/descriptionDocumentId.ts new file mode 100644 index 00000000000..a1dc8507a3b --- /dev/null +++ b/apps/web/src/lib/service-clients/service-storage/generated/schemas/descriptionDocumentId.ts @@ -0,0 +1,15 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * document_storage_service + * OpenAPI spec version: 0.1.0 + */ + +/** + * Id of the markdown document that holds an initiative's description. + +The documents side mints it as a UUID; `"Document".id` and +`initiative.description_document_id` store it as TEXT, so it is parsed once at the +adapter boundary and displayed back when bound in SQL. + */ +export type DescriptionDocumentId = string; diff --git a/apps/web/src/lib/service-clients/service-storage/generated/schemas/documentPreviewDataSubType.ts b/apps/web/src/lib/service-clients/service-storage/generated/schemas/documentPreviewDataSubType.ts index d1f95b0127f..dceacf10e51 100644 --- a/apps/web/src/lib/service-clients/service-storage/generated/schemas/documentPreviewDataSubType.ts +++ b/apps/web/src/lib/service-clients/service-storage/generated/schemas/documentPreviewDataSubType.ts @@ -6,6 +6,7 @@ */ import type { DocumentPreviewDataSubTypeOneOf } from './documentPreviewDataSubTypeOneOf'; import type { DocumentPreviewDataSubTypeOneOfFive } from './documentPreviewDataSubTypeOneOfFive'; +import type { DocumentPreviewDataSubTypeOneOfSeven } from './documentPreviewDataSubTypeOneOfSeven'; import type { DocumentPreviewDataSubTypeOneOfThree } from './documentPreviewDataSubTypeOneOfThree'; /** @@ -15,4 +16,5 @@ Task-related properties are encoded within the variant to ensure valid states. export type DocumentPreviewDataSubType = | DocumentPreviewDataSubTypeOneOf | DocumentPreviewDataSubTypeOneOfThree - | DocumentPreviewDataSubTypeOneOfFive; + | DocumentPreviewDataSubTypeOneOfFive + | DocumentPreviewDataSubTypeOneOfSeven; diff --git a/apps/web/src/lib/service-clients/service-storage/generated/schemas/documentPreviewDataSubTypeOneOfSeven.ts b/apps/web/src/lib/service-clients/service-storage/generated/schemas/documentPreviewDataSubTypeOneOfSeven.ts new file mode 100644 index 00000000000..e8042ac6b5d --- /dev/null +++ b/apps/web/src/lib/service-clients/service-storage/generated/schemas/documentPreviewDataSubTypeOneOfSeven.ts @@ -0,0 +1,14 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * document_storage_service + * OpenAPI spec version: 0.1.0 + */ +import type { DocumentPreviewDataSubTypeOneOfSevenType } from './documentPreviewDataSubTypeOneOfSevenType'; + +/** + * The description document of an initiative + */ +export type DocumentPreviewDataSubTypeOneOfSeven = { + type: DocumentPreviewDataSubTypeOneOfSevenType; +}; diff --git a/apps/web/src/lib/service-clients/service-storage/generated/schemas/documentPreviewDataSubTypeOneOfSevenType.ts b/apps/web/src/lib/service-clients/service-storage/generated/schemas/documentPreviewDataSubTypeOneOfSevenType.ts new file mode 100644 index 00000000000..535df92dcbf --- /dev/null +++ b/apps/web/src/lib/service-clients/service-storage/generated/schemas/documentPreviewDataSubTypeOneOfSevenType.ts @@ -0,0 +1,14 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * document_storage_service + * OpenAPI spec version: 0.1.0 + */ + +export type DocumentPreviewDataSubTypeOneOfSevenType = + (typeof DocumentPreviewDataSubTypeOneOfSevenType)[keyof typeof DocumentPreviewDataSubTypeOneOfSevenType]; + +// eslint-disable-next-line @typescript-eslint/no-redeclare +export const DocumentPreviewDataSubTypeOneOfSevenType = { + initiative_description: 'initiative_description', +} as const; diff --git a/apps/web/src/lib/service-clients/service-storage/generated/schemas/documentSubType.ts b/apps/web/src/lib/service-clients/service-storage/generated/schemas/documentSubType.ts index 98c3e43e5f4..f8e5b490dce 100644 --- a/apps/web/src/lib/service-clients/service-storage/generated/schemas/documentSubType.ts +++ b/apps/web/src/lib/service-clients/service-storage/generated/schemas/documentSubType.ts @@ -8,6 +8,9 @@ /** * The document sub type enum represents all values of document sub types. These values should match the `document_sub_type_value` table in macrodb. + +Wire, database, and `Display` spellings are all `snake_case` so a +multi-word variant serializes identically in every system. */ export type DocumentSubType = (typeof DocumentSubType)[keyof typeof DocumentSubType]; @@ -17,4 +20,5 @@ export const DocumentSubType = { task: 'task', snippet: 'snippet', skill: 'skill', + initiative_description: 'initiative_description', } as const; diff --git a/apps/web/src/lib/service-clients/service-storage/generated/schemas/index.ts b/apps/web/src/lib/service-clients/service-storage/generated/schemas/index.ts index d0b2379796c..e6a20482362 100644 --- a/apps/web/src/lib/service-clients/service-storage/generated/schemas/index.ts +++ b/apps/web/src/lib/service-clients/service-storage/generated/schemas/index.ts @@ -178,6 +178,8 @@ export * from './basicDocumentSubType'; export * from './basicDocumentSubTypeOneOf'; export * from './basicDocumentSubTypeOneOfFive'; export * from './basicDocumentSubTypeOneOfFiveType'; +export * from './basicDocumentSubTypeOneOfSeven'; +export * from './basicDocumentSubTypeOneOfSevenType'; export * from './basicDocumentSubTypeOneOfThree'; export * from './basicDocumentSubTypeOneOfThreeType'; export * from './basicDocumentSubTypeOneOfType'; @@ -525,6 +527,7 @@ export * from './deleteUnthreadedAnchorResponseAllOfThreadId'; export * from './deleteUnthreadedPdfAnchorRequest'; export * from './deleteUnthreadedPdfAnchorRequestOneOf'; export * from './deleteUnthreadedPdfAnchorRequestOneOfAnchorType'; +export * from './descriptionDocumentId'; export * from './documentBasic'; export * from './documentBasicBranchedFromId'; export * from './documentBasicBranchedFromVersionId'; @@ -572,6 +575,8 @@ export * from './documentPreviewDataSubType'; export * from './documentPreviewDataSubTypeOneOf'; export * from './documentPreviewDataSubTypeOneOfFive'; export * from './documentPreviewDataSubTypeOneOfFiveType'; +export * from './documentPreviewDataSubTypeOneOfSeven'; +export * from './documentPreviewDataSubTypeOneOfSevenType'; export * from './documentPreviewDataSubTypeOneOfThree'; export * from './documentPreviewDataSubTypeOneOfThreeType'; export * from './documentPreviewDataSubTypeOneOfType'; @@ -850,11 +855,9 @@ export * from './inFlightTurnSummary'; export * from './inFlightTurnSummaryActor'; export * from './inFlightTurnSummaryAnnouncementMessageId'; export * from './initiativeDetail'; -export * from './initiativeDetailDescription'; export * from './initiativeId'; export * from './initiativeList'; export * from './initiativeSummary'; -export * from './initiativeSummaryDescription'; export * from './inputReceivedMetadata'; export * from './interactionReason'; export * from './item'; @@ -1285,6 +1288,8 @@ export * from './soupDocumentSubType'; export * from './soupDocumentSubTypeOneOf'; export * from './soupDocumentSubTypeOneOfFive'; export * from './soupDocumentSubTypeOneOfFiveType'; +export * from './soupDocumentSubTypeOneOfSeven'; +export * from './soupDocumentSubTypeOneOfSevenType'; export * from './soupDocumentSubTypeOneOfThree'; export * from './soupDocumentSubTypeOneOfThreeType'; export * from './soupDocumentSubTypeOneOfType'; @@ -1442,7 +1447,6 @@ export * from './updateCrmTeamSettingsRequestDeleteRecordsRole'; export * from './updateCrmTeamSettingsRequestEditStagesRole'; export * from './updateCrmTeamSettingsRequestMoveClosedDealsRole'; export * from './updateInitiativeRequest'; -export * from './updateInitiativeRequestDescription'; export * from './updateInitiativeRequestMemberIds'; export * from './updateInitiativeRequestName'; export * from './updateInitiativeRequestSharePermission'; diff --git a/apps/web/src/lib/service-clients/service-storage/generated/schemas/initiativeDetail.ts b/apps/web/src/lib/service-clients/service-storage/generated/schemas/initiativeDetail.ts index 81cb87c73bc..6ef996e865d 100644 --- a/apps/web/src/lib/service-clients/service-storage/generated/schemas/initiativeDetail.ts +++ b/apps/web/src/lib/service-clients/service-storage/generated/schemas/initiativeDetail.ts @@ -6,7 +6,7 @@ */ import type { AccessLevel } from './accessLevel'; -import type { InitiativeDetailDescription } from './initiativeDetailDescription'; +import type { DescriptionDocumentId } from './descriptionDocumentId'; import type { InitiativeId } from './initiativeId'; import type { MacroUserIdStr } from './macroUserIdStr'; import type { SharePermissionV2 } from './sharePermissionV2'; @@ -17,8 +17,8 @@ import type { SharePermissionV2 } from './sharePermissionV2'; export interface InitiativeDetail { /** When the initiative was created. */ createdAt: string; - /** Optional description. */ - description?: InitiativeDetailDescription; + /** The markdown document holding the description; open it in the editor. */ + descriptionDocumentId: DescriptionDocumentId; /** Opaque identifier. */ id: InitiativeId; /** Member user ids. The owner is never stored here. */ diff --git a/apps/web/src/lib/service-clients/service-storage/generated/schemas/initiativeDetailDescription.ts b/apps/web/src/lib/service-clients/service-storage/generated/schemas/initiativeDetailDescription.ts deleted file mode 100644 index 18fef64e5e2..00000000000 --- a/apps/web/src/lib/service-clients/service-storage/generated/schemas/initiativeDetailDescription.ts +++ /dev/null @@ -1,11 +0,0 @@ -/** - * Generated by orval v7.21.0 🍺 - * Do not edit manually. - * document_storage_service - * OpenAPI spec version: 0.1.0 - */ - -/** - * Optional description. - */ -export type InitiativeDetailDescription = string | null; diff --git a/apps/web/src/lib/service-clients/service-storage/generated/schemas/initiativeSummary.ts b/apps/web/src/lib/service-clients/service-storage/generated/schemas/initiativeSummary.ts index 3c48af62dc2..2c011e26aef 100644 --- a/apps/web/src/lib/service-clients/service-storage/generated/schemas/initiativeSummary.ts +++ b/apps/web/src/lib/service-clients/service-storage/generated/schemas/initiativeSummary.ts @@ -4,16 +4,15 @@ * document_storage_service * OpenAPI spec version: 0.1.0 */ - +import type { DescriptionDocumentId } from './descriptionDocumentId'; import type { InitiativeId } from './initiativeId'; -import type { InitiativeSummaryDescription } from './initiativeSummaryDescription'; /** * List-row view of an initiative. */ export interface InitiativeSummary { - /** Optional description. */ - description?: InitiativeSummaryDescription; + /** The markdown document holding the description; open it in the editor. */ + descriptionDocumentId: DescriptionDocumentId; /** Opaque identifier. */ id: InitiativeId; /** Display name. */ diff --git a/apps/web/src/lib/service-clients/service-storage/generated/schemas/initiativeSummaryDescription.ts b/apps/web/src/lib/service-clients/service-storage/generated/schemas/initiativeSummaryDescription.ts deleted file mode 100644 index aaad4301468..00000000000 --- a/apps/web/src/lib/service-clients/service-storage/generated/schemas/initiativeSummaryDescription.ts +++ /dev/null @@ -1,11 +0,0 @@ -/** - * Generated by orval v7.21.0 🍺 - * Do not edit manually. - * document_storage_service - * OpenAPI spec version: 0.1.0 - */ - -/** - * Optional description. - */ -export type InitiativeSummaryDescription = string | null; diff --git a/apps/web/src/lib/service-clients/service-storage/generated/schemas/soupDocumentSubType.ts b/apps/web/src/lib/service-clients/service-storage/generated/schemas/soupDocumentSubType.ts index d3c4aa1c38f..fe9dacfa693 100644 --- a/apps/web/src/lib/service-clients/service-storage/generated/schemas/soupDocumentSubType.ts +++ b/apps/web/src/lib/service-clients/service-storage/generated/schemas/soupDocumentSubType.ts @@ -6,6 +6,7 @@ */ import type { SoupDocumentSubTypeOneOf } from './soupDocumentSubTypeOneOf'; import type { SoupDocumentSubTypeOneOfFive } from './soupDocumentSubTypeOneOfFive'; +import type { SoupDocumentSubTypeOneOfSeven } from './soupDocumentSubTypeOneOfSeven'; import type { SoupDocumentSubTypeOneOfThree } from './soupDocumentSubTypeOneOfThree'; /** @@ -15,4 +16,5 @@ This ensures type-safety: task properties only exist when the document is a task export type SoupDocumentSubType = | SoupDocumentSubTypeOneOf | SoupDocumentSubTypeOneOfThree - | SoupDocumentSubTypeOneOfFive; + | SoupDocumentSubTypeOneOfFive + | SoupDocumentSubTypeOneOfSeven; diff --git a/apps/web/src/lib/service-clients/service-storage/generated/schemas/soupDocumentSubTypeOneOfSeven.ts b/apps/web/src/lib/service-clients/service-storage/generated/schemas/soupDocumentSubTypeOneOfSeven.ts new file mode 100644 index 00000000000..f81766d156f --- /dev/null +++ b/apps/web/src/lib/service-clients/service-storage/generated/schemas/soupDocumentSubTypeOneOfSeven.ts @@ -0,0 +1,14 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * document_storage_service + * OpenAPI spec version: 0.1.0 + */ +import type { SoupDocumentSubTypeOneOfSevenType } from './soupDocumentSubTypeOneOfSevenType'; + +/** + * The description document of an initiative + */ +export type SoupDocumentSubTypeOneOfSeven = { + type: SoupDocumentSubTypeOneOfSevenType; +}; diff --git a/apps/web/src/lib/service-clients/service-storage/generated/schemas/soupDocumentSubTypeOneOfSevenType.ts b/apps/web/src/lib/service-clients/service-storage/generated/schemas/soupDocumentSubTypeOneOfSevenType.ts new file mode 100644 index 00000000000..5308f0d322f --- /dev/null +++ b/apps/web/src/lib/service-clients/service-storage/generated/schemas/soupDocumentSubTypeOneOfSevenType.ts @@ -0,0 +1,14 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * document_storage_service + * OpenAPI spec version: 0.1.0 + */ + +export type SoupDocumentSubTypeOneOfSevenType = + (typeof SoupDocumentSubTypeOneOfSevenType)[keyof typeof SoupDocumentSubTypeOneOfSevenType]; + +// eslint-disable-next-line @typescript-eslint/no-redeclare +export const SoupDocumentSubTypeOneOfSevenType = { + initiative_description: 'initiative_description', +} as const; diff --git a/apps/web/src/lib/service-clients/service-storage/generated/schemas/updateInitiativeRequest.ts b/apps/web/src/lib/service-clients/service-storage/generated/schemas/updateInitiativeRequest.ts index 7cdb66d09c0..aba98c92089 100644 --- a/apps/web/src/lib/service-clients/service-storage/generated/schemas/updateInitiativeRequest.ts +++ b/apps/web/src/lib/service-clients/service-storage/generated/schemas/updateInitiativeRequest.ts @@ -4,18 +4,15 @@ * document_storage_service * OpenAPI spec version: 0.1.0 */ -import type { UpdateInitiativeRequestDescription } from './updateInitiativeRequestDescription'; import type { UpdateInitiativeRequestMemberIds } from './updateInitiativeRequestMemberIds'; import type { UpdateInitiativeRequestName } from './updateInitiativeRequestName'; import type { UpdateInitiativeRequestSharePermission } from './updateInitiativeRequestSharePermission'; /** * Update-initiative HTTP body. Absent fields are left unchanged. `member_ids` -present is a full replace. +present is a full replace. The description is edited in its document, not here. */ export interface UpdateInitiativeRequest { - /** Replacement description. `Some("")` clears it after trim. */ - description?: UpdateInitiativeRequestDescription; /** Full replacement member list when present. */ memberIds?: UpdateInitiativeRequestMemberIds; /** Replacement name. */ diff --git a/apps/web/src/lib/service-clients/service-storage/generated/schemas/updateInitiativeRequestDescription.ts b/apps/web/src/lib/service-clients/service-storage/generated/schemas/updateInitiativeRequestDescription.ts deleted file mode 100644 index 2edf04520de..00000000000 --- a/apps/web/src/lib/service-clients/service-storage/generated/schemas/updateInitiativeRequestDescription.ts +++ /dev/null @@ -1,11 +0,0 @@ -/** - * Generated by orval v7.21.0 🍺 - * Do not edit manually. - * document_storage_service - * OpenAPI spec version: 0.1.0 - */ - -/** - * Replacement description. `Some("")` clears it after trim. - */ -export type UpdateInitiativeRequestDescription = string | null; diff --git a/apps/web/src/lib/service-clients/service-storage/generated/zod.ts b/apps/web/src/lib/service-clients/service-storage/generated/zod.ts index 6f54f33f336..1f0c59feec7 100644 --- a/apps/web/src/lib/service-clients/service-storage/generated/zod.ts +++ b/apps/web/src/lib/service-clients/service-storage/generated/zod.ts @@ -806,9 +806,9 @@ export const editCommentResponse = zod .union([ zod.null(), zod - .enum(['task', 'snippet', 'skill']) + .enum(['task', 'snippet', 'skill', 'initiative_description']) .describe( - 'The document sub type enum represents all values of document sub types.\nThese values should match the `document_sub_type_value` table in macrodb.' + 'The document sub type enum represents all values of document sub types.\nThese values should match the `document_sub_type_value` table in macrodb.\n\nWire, database, and `Display` spellings are all `snake_case` so a\nmulti-word variant serializes identically in every system.' ), ]) .optional(), @@ -5549,9 +5549,9 @@ export const getUserDocumentsHandlerResponse = zod.object({ .union([ zod.null(), zod - .enum(['task', 'snippet', 'skill']) + .enum(['task', 'snippet', 'skill', 'initiative_description']) .describe( - 'The document sub type enum represents all values of document sub types.\nThese values should match the `document_sub_type_value` table in macrodb.' + 'The document sub type enum represents all values of document sub types.\nThese values should match the `document_sub_type_value` table in macrodb.\n\nWire, database, and `Display` spellings are all `snake_case` so a\nmulti-word variant serializes identically in every system.' ), ]) .optional(), @@ -5718,9 +5718,9 @@ export const createDocumentResponse = zod.object({ .union([ zod.null(), zod - .enum(['task', 'snippet', 'skill']) + .enum(['task', 'snippet', 'skill', 'initiative_description']) .describe( - 'The document sub type enum represents all values of document sub types.\nThese values should match the `document_sub_type_value` table in macrodb.' + 'The document sub type enum represents all values of document sub types.\nThese values should match the `document_sub_type_value` table in macrodb.\n\nWire, database, and `Display` spellings are all `snake_case` so a\nmulti-word variant serializes identically in every system.' ), ]) .optional(), @@ -5894,9 +5894,9 @@ export const createMarkdownHandlerResponse = zod .union([ zod.null(), zod - .enum(['task', 'snippet', 'skill']) + .enum(['task', 'snippet', 'skill', 'initiative_description']) .describe( - 'The document sub type enum represents all values of document sub types.\nThese values should match the `document_sub_type_value` table in macrodb.' + 'The document sub type enum represents all values of document sub types.\nThese values should match the `document_sub_type_value` table in macrodb.\n\nWire, database, and `Display` spellings are all `snake_case` so a\nmulti-word variant serializes identically in every system.' ), ]) .optional(), @@ -6206,9 +6206,9 @@ export const createTaskHandlerResponse = zod .union([ zod.null(), zod - .enum(['task', 'snippet', 'skill']) + .enum(['task', 'snippet', 'skill', 'initiative_description']) .describe( - 'The document sub type enum represents all values of document sub types.\nThese values should match the `document_sub_type_value` table in macrodb.' + 'The document sub type enum represents all values of document sub types.\nThese values should match the `document_sub_type_value` table in macrodb.\n\nWire, database, and `Display` spellings are all `snake_case` so a\nmulti-word variant serializes identically in every system.' ), ]) .optional(), @@ -6406,6 +6406,11 @@ export const getBatchPreviewHandlerResponse = zod.object({ .describe( 'A skill document — markdown instructions for AI' ), + zod + .object({ + type: zod.enum(['initiative_description']), + }) + .describe('The description document of an initiative'), ]) .describe( 'The sub type of a document preview with associated properties.\nTask-related properties are encoded within the variant to ensure valid states.' @@ -6536,6 +6541,11 @@ export const getDocumentByTeamSlugResponse = zod.object({ .describe( 'A skill document — markdown instructions for AI' ), + zod + .object({ + type: zod.enum(['initiative_description']), + }) + .describe('The description document of an initiative'), ]) .describe( 'Sub type of a document with associated properties encoded in each variant.\nThis ensures type-safety: task properties only exist when the document is a task.' @@ -6734,9 +6744,9 @@ export const getDocumentResponse = zod.object({ .union([ zod.null(), zod - .enum(['task', 'snippet', 'skill']) + .enum(['task', 'snippet', 'skill', 'initiative_description']) .describe( - 'The document sub type enum represents all values of document sub types.\nThese values should match the `document_sub_type_value` table in macrodb.' + 'The document sub type enum represents all values of document sub types.\nThese values should match the `document_sub_type_value` table in macrodb.\n\nWire, database, and `Display` spellings are all `snake_case` so a\nmulti-word variant serializes identically in every system.' ), ]) .optional(), @@ -6906,9 +6916,9 @@ export const saveDocumentHandlerResponse = zod.object({ .union([ zod.null(), zod - .enum(['task', 'snippet', 'skill']) + .enum(['task', 'snippet', 'skill', 'initiative_description']) .describe( - 'The document sub type enum represents all values of document sub types.\nThese values should match the `document_sub_type_value` table in macrodb.' + 'The document sub type enum represents all values of document sub types.\nThese values should match the `document_sub_type_value` table in macrodb.\n\nWire, database, and `Display` spellings are all `snake_case` so a\nmulti-word variant serializes identically in every system.' ), ]) .optional(), @@ -7594,9 +7604,9 @@ export const copyDocumentResponse = zod .union([ zod.null(), zod - .enum(['task', 'snippet', 'skill']) + .enum(['task', 'snippet', 'skill', 'initiative_description']) .describe( - 'The document sub type enum represents all values of document sub types.\nThese values should match the `document_sub_type_value` table in macrodb.' + 'The document sub type enum represents all values of document sub types.\nThese values should match the `document_sub_type_value` table in macrodb.\n\nWire, database, and `Display` spellings are all `snake_case` so a\nmulti-word variant serializes identically in every system.' ), ]) .optional(), @@ -8027,9 +8037,9 @@ export const getDocumentLocationV3Response = zod .union([ zod.null(), zod - .enum(['task', 'snippet', 'skill']) + .enum(['task', 'snippet', 'skill', 'initiative_description']) .describe( - 'The document sub type enum represents all values of document sub types.\nThese values should match the `document_sub_type_value` table in macrodb.' + 'The document sub type enum represents all values of document sub types.\nThese values should match the `document_sub_type_value` table in macrodb.\n\nWire, database, and `Display` spellings are all `snake_case` so a\nmulti-word variant serializes identically in every system.' ), ]) .optional(), @@ -8083,9 +8093,9 @@ export const getDocumentLocationV3Response = zod .union([ zod.null(), zod - .enum(['task', 'snippet', 'skill']) + .enum(['task', 'snippet', 'skill', 'initiative_description']) .describe( - 'The document sub type enum represents all values of document sub types.\nThese values should match the `document_sub_type_value` table in macrodb.' + 'The document sub type enum represents all values of document sub types.\nThese values should match the `document_sub_type_value` table in macrodb.\n\nWire, database, and `Display` spellings are all `snake_case` so a\nmulti-word variant serializes identically in every system.' ), ]) .optional(), @@ -8150,9 +8160,9 @@ export const getDocumentLocationV3Response = zod .union([ zod.null(), zod - .enum(['task', 'snippet', 'skill']) + .enum(['task', 'snippet', 'skill', 'initiative_description']) .describe( - 'The document sub type enum represents all values of document sub types.\nThese values should match the `document_sub_type_value` table in macrodb.' + 'The document sub type enum represents all values of document sub types.\nThese values should match the `document_sub_type_value` table in macrodb.\n\nWire, database, and `Display` spellings are all `snake_case` so a\nmulti-word variant serializes identically in every system.' ), ]) .optional(), @@ -8319,9 +8329,9 @@ export const simpleSaveResponse = zod.object({ .union([ zod.null(), zod - .enum(['task', 'snippet', 'skill']) + .enum(['task', 'snippet', 'skill', 'initiative_description']) .describe( - 'The document sub type enum represents all values of document sub types.\nThese values should match the `document_sub_type_value` table in macrodb.' + 'The document sub type enum represents all values of document sub types.\nThese values should match the `document_sub_type_value` table in macrodb.\n\nWire, database, and `Display` spellings are all `snake_case` so a\nmulti-word variant serializes identically in every system.' ), ]) .optional(), @@ -8504,9 +8514,9 @@ export const getDocumentVersionResponse = zod.object({ .union([ zod.null(), zod - .enum(['task', 'snippet', 'skill']) + .enum(['task', 'snippet', 'skill', 'initiative_description']) .describe( - 'The document sub type enum represents all values of document sub types.\nThese values should match the `document_sub_type_value` table in macrodb.' + 'The document sub type enum represents all values of document sub types.\nThese values should match the `document_sub_type_value` table in macrodb.\n\nWire, database, and `Display` spellings are all `snake_case` so a\nmulti-word variant serializes identically in every system.' ), ]) .optional(), @@ -9364,6 +9374,11 @@ export const getHistoryHandlerResponse = zod.object({ .describe( 'A skill document — markdown instructions for AI' ), + zod + .object({ + type: zod.enum(['initiative_description']), + }) + .describe('The description document of an initiative'), ]) .describe( 'Sub type of a document with associated properties encoded in each variant.\nThis ensures type-safety: task properties only exist when the document is a task.' @@ -9469,10 +9484,11 @@ export const listInitiativesResponse = zod .array( zod .object({ - description: zod - .string() - .nullish() - .describe('Optional description.'), + descriptionDocumentId: zod + .uuid() + .describe( + 'Id of the markdown document that holds an initiative\'s description.\n\nThe documents side mints it as a UUID; `\"Document\".id` and\n`initiative.description_document_id` store it as TEXT, so it is parsed once at the\nadapter boundary and displayed back when bound in SQL.' + ), id: zod .uuid() .describe( @@ -9494,7 +9510,12 @@ export const listInitiativesResponse = zod */ export const createInitiativeBody = zod .object({ - description: zod.string().nullish().describe('Optional description.'), + description: zod + .string() + .nullish() + .describe( + 'Initial markdown for the description document. Not stored on the initiative; later\nedits happen in the document editor.' + ), memberIds: zod .array(zod.string()) .nullish() @@ -9514,7 +9535,11 @@ export const createInitiativeResponse = zod createdAt: zod.iso .datetime({}) .describe('When the initiative was created.'), - description: zod.string().nullish().describe('Optional description.'), + descriptionDocumentId: zod + .uuid() + .describe( + 'Id of the markdown document that holds an initiative\'s description.\n\nThe documents side mints it as a UUID; `\"Document\".id` and\n`initiative.description_document_id` store it as TEXT, so it is parsed once at the\nadapter boundary and displayed back when bound in SQL.' + ), id: zod .uuid() .describe( @@ -9592,7 +9617,11 @@ export const getInitiativeResponse = zod createdAt: zod.iso .datetime({}) .describe('When the initiative was created.'), - description: zod.string().nullish().describe('Optional description.'), + descriptionDocumentId: zod + .uuid() + .describe( + 'Id of the markdown document that holds an initiative\'s description.\n\nThe documents side mints it as a UUID; `\"Document\".id` and\n`initiative.description_document_id` store it as TEXT, so it is parsed once at the\nadapter boundary and displayed back when bound in SQL.' + ), id: zod .uuid() .describe( @@ -9678,10 +9707,6 @@ export const updateInitiativeParams = zod.object({ export const updateInitiativeBody = zod .object({ - description: zod - .string() - .nullish() - .describe('Replacement description. `Some(\"\")` clears it after trim.'), memberIds: zod .array(zod.string()) .nullish() @@ -9743,7 +9768,7 @@ export const updateInitiativeBody = zod .optional(), }) .describe( - 'Update-initiative HTTP body. Absent fields are left unchanged. `member_ids`\npresent is a full replace.' + 'Update-initiative HTTP body. Absent fields are left unchanged. `member_ids`\npresent is a full replace. The description is edited in its document, not here.' ); export const updateInitiativeResponse = zod @@ -9751,7 +9776,11 @@ export const updateInitiativeResponse = zod createdAt: zod.iso .datetime({}) .describe('When the initiative was created.'), - description: zod.string().nullish().describe('Optional description.'), + descriptionDocumentId: zod + .uuid() + .describe( + 'Id of the markdown document that holds an initiative\'s description.\n\nThe documents side mints it as a UUID; `\"Document\".id` and\n`initiative.description_document_id` store it as TEXT, so it is parsed once at the\nadapter boundary and displayed back when bound in SQL.' + ), id: zod .uuid() .describe( @@ -10240,6 +10269,13 @@ export const getItemsSoupResponse = zod .describe( 'A skill document — markdown instructions for AI' ), + zod + .object({ + type: zod.enum(['initiative_description']), + }) + .describe( + 'The description document of an initiative' + ), ]) .describe( 'Sub type of a document with associated properties encoded in each variant.\nThis ensures type-safety: task properties only exist when the document is a task.' @@ -14209,6 +14245,13 @@ export const postItemsSoupResponse = zod .describe( 'A skill document — markdown instructions for AI' ), + zod + .object({ + type: zod.enum(['initiative_description']), + }) + .describe( + 'The description document of an initiative' + ), ]) .describe( 'Sub type of a document with associated properties encoded in each variant.\nThis ensures type-safety: task properties only exist when the document is a task.' @@ -17619,6 +17662,13 @@ export const postItemsSoupAstResponse = zod .describe( 'A skill document — markdown instructions for AI' ), + zod + .object({ + type: zod.enum(['initiative_description']), + }) + .describe( + 'The description document of an initiative' + ), ]) .describe( 'Sub type of a document with associated properties encoded in each variant.\nThis ensures type-safety: task properties only exist when the document is a task.' @@ -21291,6 +21341,15 @@ export const postItemsSoupAstGroupedResponse = zod .describe( 'A skill document — markdown instructions for AI' ), + zod + .object({ + type: zod.enum([ + 'initiative_description', + ]), + }) + .describe( + 'The description document of an initiative' + ), ]) .describe( 'Sub type of a document with associated properties encoded in each variant.\nThis ensures type-safety: task properties only exist when the document is a task.' @@ -24699,6 +24758,15 @@ export const postItemsSoupAstGroupedResponse = zod .describe( 'A skill document — markdown instructions for AI' ), + zod + .object({ + type: zod.enum([ + 'initiative_description', + ]), + }) + .describe( + 'The description document of an initiative' + ), ]) .describe( 'Sub type of a document with associated properties encoded in each variant.\nThis ensures type-safety: task properties only exist when the document is a task.' @@ -29879,6 +29947,13 @@ export const getPinsHandlerResponse = zod.object({ .describe( 'A skill document — markdown instructions for AI' ), + zod + .object({ + type: zod.enum(['initiative_description']), + }) + .describe( + 'The description document of an initiative' + ), ]) .describe( 'Sub type of a document with associated properties encoded in each variant.\nThis ensures type-safety: task properties only exist when the document is a task.' @@ -30024,6 +30099,13 @@ export const getPinsHandlerResponse = zod.object({ .describe( 'A skill document — markdown instructions for AI' ), + zod + .object({ + type: zod.enum(['initiative_description']), + }) + .describe( + 'The description document of an initiative' + ), ]) .describe( 'Sub type of a document with associated properties encoded in each variant.\nThis ensures type-safety: task properties only exist when the document is a task.' @@ -31430,6 +31512,11 @@ export const getProjectContentHandlerResponse = zod.object({ .describe( 'A skill document — markdown instructions for AI' ), + zod + .object({ + type: zod.enum(['initiative_description']), + }) + .describe('The description document of an initiative'), ]) .describe( 'Sub type of a document with associated properties encoded in each variant.\nThis ensures type-safety: task properties only exist when the document is a task.' @@ -31666,6 +31753,11 @@ export const recentlyDeletedResponse = zod.object({ .describe( 'A skill document — markdown instructions for AI' ), + zod + .object({ + type: zod.enum(['initiative_description']), + }) + .describe('The description document of an initiative'), ]) .describe( 'Sub type of a document with associated properties encoded in each variant.\nThis ensures type-safety: task properties only exist when the document is a task.' diff --git a/apps/web/src/lib/service-clients/service-storage/graphql-soup.test.ts b/apps/web/src/lib/service-clients/service-storage/graphql-soup.test.ts index 79ea701a27a..0a4d3a95a25 100644 --- a/apps/web/src/lib/service-clients/service-storage/graphql-soup.test.ts +++ b/apps/web/src/lib/service-clients/service-storage/graphql-soup.test.ts @@ -262,6 +262,47 @@ describe('GraphQL Soup chat models', () => { ); }); +describe('GraphQL Soup document sub types', () => { + it.each([ + [ + { __typename: 'GraphqlTaskSubType', isCompleted: true }, + { type: 'task', is_completed: true }, + ], + [{ __typename: 'GraphqlSkillSubType' }, { type: 'skill' }], + [{ __typename: 'GraphqlInitiativeDescriptionSubType' }, undefined], + ] as const)( + 'maps %j to the shared soup sub type %j', + async (subType, expected) => { + const { mapGraphqlSoupItem } = await import('./graphql-soup'); + const item = { + __typename: 'GraphqlSoupDocument', + id: 'doc-sub-type', + entityType: 'DOCUMENT' as GraphqlSoupEntityType, + displayName: 'Plan', + documentName: 'Plan', + ownerId: 'macro|owner@example.com', + fileType: 'md', + projectId: null, + viewedAt: null, + deletedAt: null, + cacheProjection: null, + frecencyScore: null, + isFavorited: false, + createdAt: '2026-09-11T00:00:00Z', + updatedAt: '2026-09-11T00:00:00Z', + subType, + properties: [], + notifications: [], + } satisfies SoupItemFieldsFragment; + + expect(mapGraphqlSoupItem(item)).toMatchObject({ + tag: 'document', + data: { id: item.id, subType: expected }, + }); + } + ); +}); + describe('GraphQL Soup browser cache session gate', () => { beforeEach(() => { vi.resetModules(); diff --git a/apps/web/src/lib/service-clients/service-storage/graphql-soup.ts b/apps/web/src/lib/service-clients/service-storage/graphql-soup.ts index 1de54e9be22..26f5bec4594 100644 --- a/apps/web/src/lib/service-clients/service-storage/graphql-soup.ts +++ b/apps/web/src/lib/service-clients/service-storage/graphql-soup.ts @@ -603,6 +603,10 @@ function mapDocumentSubType(subType: GraphqlSoupDocument['subType']) { .with({ __typename: 'GraphqlSkillSubType' }, () => ({ type: 'skill' as const, })) + .with({ __typename: 'GraphqlInitiativeDescriptionSubType' }, () => { + // No dedicated block yet; the document renders as a plain document. + return undefined; + }) .exhaustive(); } diff --git a/apps/web/src/lib/service-clients/service-storage/graphql/generated/graphql.ts b/apps/web/src/lib/service-clients/service-storage/graphql/generated/graphql.ts index 42f6af501f7..46d26d4ab01 100644 --- a/apps/web/src/lib/service-clients/service-storage/graphql/generated/graphql.ts +++ b/apps/web/src/lib/service-clients/service-storage/graphql/generated/graphql.ts @@ -434,6 +434,8 @@ export type GraphqlDocumentLiteral = /** GraphQL input representing the document sub type. */ export type GraphqlDocumentSubType = + /** The initiative description option. */ + | 'INITIATIVE_DESCRIPTION' /** The skill option. */ | 'SKILL' /** The snippet option. */ @@ -797,6 +799,8 @@ export type GraphqlNotificationChannelType = /** GraphQL document subtype used by notification metadata. */ export type GraphqlNotificationDocumentSubType = + /** The description document of an initiative. */ + | 'INITIATIVE_DESCRIPTION' /** Skill document. */ | 'SKILL' /** Snippet document. */ @@ -1946,6 +1950,7 @@ type EntityMutationResultFields_GraphqlMutationSuccess_Fragment = { __typename: | { __typename: 'GraphqlTaskAssignedMetadata', taskAssignedTaskId: string, taskAssignedTaskName: string | null, taskAssignedSubType: GraphqlNotificationDocumentSubType | null, taskAssignedAssignedBy: string, taskAssignedSenderProfilePictureUrl: string | null } }> } | { __typename: 'GraphqlSoupDocument', ownerId: string, fileType: string | null, projectId: string | null, createdAt: string, updatedAt: string, viewedAt: string | null, deletedAt: string | null, cacheProjection: string | null, frecencyScore: number | null, id: string, entityType: GraphqlSoupEntityType, displayName: string | null, isFavorited: boolean, documentName: string, subType: + | { __typename: 'GraphqlInitiativeDescriptionSubType' } | { __typename: 'GraphqlSkillSubType' } | { __typename: 'GraphqlSnippetSubType' } | { __typename: 'GraphqlTaskSubType', isCompleted: boolean } @@ -2359,6 +2364,7 @@ export type EntityMutationPayloadFieldsFragment = { results: Array< | { __typename: 'GraphqlTaskAssignedMetadata', taskAssignedTaskId: string, taskAssignedTaskName: string | null, taskAssignedSubType: GraphqlNotificationDocumentSubType | null, taskAssignedAssignedBy: string, taskAssignedSenderProfilePictureUrl: string | null } }> } | { __typename: 'GraphqlSoupDocument', ownerId: string, fileType: string | null, projectId: string | null, createdAt: string, updatedAt: string, viewedAt: string | null, deletedAt: string | null, cacheProjection: string | null, frecencyScore: number | null, id: string, entityType: GraphqlSoupEntityType, displayName: string | null, isFavorited: boolean, documentName: string, subType: + | { __typename: 'GraphqlInitiativeDescriptionSubType' } | { __typename: 'GraphqlSkillSubType' } | { __typename: 'GraphqlSnippetSubType' } | { __typename: 'GraphqlTaskSubType', isCompleted: boolean } @@ -2773,6 +2779,7 @@ export type RenameEntitiesMutation = { renameEntities: { results: Array< | { __typename: 'GraphqlTaskAssignedMetadata', taskAssignedTaskId: string, taskAssignedTaskName: string | null, taskAssignedSubType: GraphqlNotificationDocumentSubType | null, taskAssignedAssignedBy: string, taskAssignedSenderProfilePictureUrl: string | null } }> } | { __typename: 'GraphqlSoupDocument', ownerId: string, fileType: string | null, projectId: string | null, createdAt: string, updatedAt: string, viewedAt: string | null, deletedAt: string | null, cacheProjection: string | null, frecencyScore: number | null, id: string, entityType: GraphqlSoupEntityType, displayName: string | null, isFavorited: boolean, documentName: string, subType: + | { __typename: 'GraphqlInitiativeDescriptionSubType' } | { __typename: 'GraphqlSkillSubType' } | { __typename: 'GraphqlSnippetSubType' } | { __typename: 'GraphqlTaskSubType', isCompleted: boolean } @@ -3187,6 +3194,7 @@ export type MoveEntitiesMutation = { moveEntities: { results: Array< | { __typename: 'GraphqlTaskAssignedMetadata', taskAssignedTaskId: string, taskAssignedTaskName: string | null, taskAssignedSubType: GraphqlNotificationDocumentSubType | null, taskAssignedAssignedBy: string, taskAssignedSenderProfilePictureUrl: string | null } }> } | { __typename: 'GraphqlSoupDocument', ownerId: string, fileType: string | null, projectId: string | null, createdAt: string, updatedAt: string, viewedAt: string | null, deletedAt: string | null, cacheProjection: string | null, frecencyScore: number | null, id: string, entityType: GraphqlSoupEntityType, displayName: string | null, isFavorited: boolean, documentName: string, subType: + | { __typename: 'GraphqlInitiativeDescriptionSubType' } | { __typename: 'GraphqlSkillSubType' } | { __typename: 'GraphqlSnippetSubType' } | { __typename: 'GraphqlTaskSubType', isCompleted: boolean } @@ -3601,6 +3609,7 @@ export type UpdateEntitySharePoliciesMutation = { updateEntitySharePolicies: { r | { __typename: 'GraphqlTaskAssignedMetadata', taskAssignedTaskId: string, taskAssignedTaskName: string | null, taskAssignedSubType: GraphqlNotificationDocumentSubType | null, taskAssignedAssignedBy: string, taskAssignedSenderProfilePictureUrl: string | null } }> } | { __typename: 'GraphqlSoupDocument', ownerId: string, fileType: string | null, projectId: string | null, createdAt: string, updatedAt: string, viewedAt: string | null, deletedAt: string | null, cacheProjection: string | null, frecencyScore: number | null, id: string, entityType: GraphqlSoupEntityType, displayName: string | null, isFavorited: boolean, documentName: string, subType: + | { __typename: 'GraphqlInitiativeDescriptionSubType' } | { __typename: 'GraphqlSkillSubType' } | { __typename: 'GraphqlSnippetSubType' } | { __typename: 'GraphqlTaskSubType', isCompleted: boolean } @@ -4015,6 +4024,7 @@ export type TrashEntitiesMutation = { trashEntities: { results: Array< | { __typename: 'GraphqlTaskAssignedMetadata', taskAssignedTaskId: string, taskAssignedTaskName: string | null, taskAssignedSubType: GraphqlNotificationDocumentSubType | null, taskAssignedAssignedBy: string, taskAssignedSenderProfilePictureUrl: string | null } }> } | { __typename: 'GraphqlSoupDocument', ownerId: string, fileType: string | null, projectId: string | null, createdAt: string, updatedAt: string, viewedAt: string | null, deletedAt: string | null, cacheProjection: string | null, frecencyScore: number | null, id: string, entityType: GraphqlSoupEntityType, displayName: string | null, isFavorited: boolean, documentName: string, subType: + | { __typename: 'GraphqlInitiativeDescriptionSubType' } | { __typename: 'GraphqlSkillSubType' } | { __typename: 'GraphqlSnippetSubType' } | { __typename: 'GraphqlTaskSubType', isCompleted: boolean } @@ -4429,6 +4439,7 @@ export type RestoreEntitiesMutation = { restoreEntities: { results: Array< | { __typename: 'GraphqlTaskAssignedMetadata', taskAssignedTaskId: string, taskAssignedTaskName: string | null, taskAssignedSubType: GraphqlNotificationDocumentSubType | null, taskAssignedAssignedBy: string, taskAssignedSenderProfilePictureUrl: string | null } }> } | { __typename: 'GraphqlSoupDocument', ownerId: string, fileType: string | null, projectId: string | null, createdAt: string, updatedAt: string, viewedAt: string | null, deletedAt: string | null, cacheProjection: string | null, frecencyScore: number | null, id: string, entityType: GraphqlSoupEntityType, displayName: string | null, isFavorited: boolean, documentName: string, subType: + | { __typename: 'GraphqlInitiativeDescriptionSubType' } | { __typename: 'GraphqlSkillSubType' } | { __typename: 'GraphqlSnippetSubType' } | { __typename: 'GraphqlTaskSubType', isCompleted: boolean } @@ -4843,6 +4854,7 @@ export type DeleteEntitiesPermanentlyMutation = { deleteEntitiesPermanently: { r | { __typename: 'GraphqlTaskAssignedMetadata', taskAssignedTaskId: string, taskAssignedTaskName: string | null, taskAssignedSubType: GraphqlNotificationDocumentSubType | null, taskAssignedAssignedBy: string, taskAssignedSenderProfilePictureUrl: string | null } }> } | { __typename: 'GraphqlSoupDocument', ownerId: string, fileType: string | null, projectId: string | null, createdAt: string, updatedAt: string, viewedAt: string | null, deletedAt: string | null, cacheProjection: string | null, frecencyScore: number | null, id: string, entityType: GraphqlSoupEntityType, displayName: string | null, isFavorited: boolean, documentName: string, subType: + | { __typename: 'GraphqlInitiativeDescriptionSubType' } | { __typename: 'GraphqlSkillSubType' } | { __typename: 'GraphqlSnippetSubType' } | { __typename: 'GraphqlTaskSubType', isCompleted: boolean } @@ -5257,6 +5269,7 @@ export type DuplicateEntitiesMutation = { duplicateEntities: { results: Array< | { __typename: 'GraphqlTaskAssignedMetadata', taskAssignedTaskId: string, taskAssignedTaskName: string | null, taskAssignedSubType: GraphqlNotificationDocumentSubType | null, taskAssignedAssignedBy: string, taskAssignedSenderProfilePictureUrl: string | null } }> } | { __typename: 'GraphqlSoupDocument', ownerId: string, fileType: string | null, projectId: string | null, createdAt: string, updatedAt: string, viewedAt: string | null, deletedAt: string | null, cacheProjection: string | null, frecencyScore: number | null, id: string, entityType: GraphqlSoupEntityType, displayName: string | null, isFavorited: boolean, documentName: string, subType: + | { __typename: 'GraphqlInitiativeDescriptionSubType' } | { __typename: 'GraphqlSkillSubType' } | { __typename: 'GraphqlSnippetSubType' } | { __typename: 'GraphqlTaskSubType', isCompleted: boolean } @@ -5672,6 +5685,7 @@ export type SetEntityFavoriteMutation = { setEntityFavorite: | { __typename: 'GraphqlTaskAssignedMetadata', taskAssignedTaskId: string, taskAssignedTaskName: string | null, taskAssignedSubType: GraphqlNotificationDocumentSubType | null, taskAssignedAssignedBy: string, taskAssignedSenderProfilePictureUrl: string | null } }> } | { __typename: 'GraphqlSoupDocument', ownerId: string, fileType: string | null, projectId: string | null, createdAt: string, updatedAt: string, viewedAt: string | null, deletedAt: string | null, cacheProjection: string | null, frecencyScore: number | null, id: string, entityType: GraphqlSoupEntityType, displayName: string | null, isFavorited: boolean, documentName: string, subType: + | { __typename: 'GraphqlInitiativeDescriptionSubType' } | { __typename: 'GraphqlSkillSubType' } | { __typename: 'GraphqlSnippetSubType' } | { __typename: 'GraphqlTaskSubType', isCompleted: boolean } @@ -6245,6 +6259,7 @@ export type GroupSoupQuery = { user: { id: string, groupSoup: { bins: Array<{ ke | { __typename: 'GraphqlTaskAssignedMetadata', taskAssignedTaskId: string, taskAssignedTaskName: string | null, taskAssignedSubType: GraphqlNotificationDocumentSubType | null, taskAssignedAssignedBy: string, taskAssignedSenderProfilePictureUrl: string | null } }> } | { __typename: 'GraphqlSoupDocument', ownerId: string, fileType: string | null, projectId: string | null, createdAt: string, updatedAt: string, viewedAt: string | null, deletedAt: string | null, cacheProjection: string | null, frecencyScore: number | null, id: string, entityType: GraphqlSoupEntityType, displayName: string | null, isFavorited: boolean, documentName: string, subType: + | { __typename: 'GraphqlInitiativeDescriptionSubType' } | { __typename: 'GraphqlSkillSubType' } | { __typename: 'GraphqlSnippetSubType' } | { __typename: 'GraphqlTaskSubType', isCompleted: boolean } @@ -6418,6 +6433,7 @@ export type GroupSoupQuery = { user: { id: string, groupSoup: { bins: Array<{ ke > }> } } }; export type GraphqlDocumentHistoryFieldsFragment = { __typename: 'GraphqlSoupDocument', id: string, ownerId: string, fileType: string | null, createdAt: string, updatedAt: string, viewedAt: string | null, deletedAt: string | null, documentName: string, subType: + | { __typename: 'GraphqlInitiativeDescriptionSubType' } | { __typename: 'GraphqlSkillSubType' } | { __typename: 'GraphqlSnippetSubType' } | { __typename: 'GraphqlTaskSubType', isCompleted: boolean } @@ -6428,6 +6444,7 @@ export type GraphqlChatHistoryFieldsFragment = { __typename: 'GraphqlSoupChat', export type GraphqlProjectHistoryFieldsFragment = { __typename: 'GraphqlSoupProject', id: string, ownerId: string, createdAt: string, updatedAt: string, viewedAt: string | null, deletedAt: string | null, projectName: string }; export type GraphqlDocumentQuickAccessNameFragment = { __typename: 'GraphqlSoupDocument', name: string, ownerId: string, createdAt: string, subType: + | { __typename: 'GraphqlInitiativeDescriptionSubType' } | { __typename: 'GraphqlSkillSubType' } | { __typename: 'GraphqlSnippetSubType' } | { __typename: 'GraphqlTaskSubType', isCompleted: boolean } @@ -6554,6 +6571,7 @@ export type ItemPreviewQuery = { user: { id: string, soup: { items: Array< | { __typename: 'GraphqlSoupChat', id: string, displayName: string | null, chatName: string } | { __typename: 'GraphqlSoupCrmCompany', domains: Array, id: string, displayName: string | null, companyName: string | null } | { __typename: 'GraphqlSoupDocument', fileType: string | null, id: string, displayName: string | null, documentName: string, subType: + | { __typename: 'GraphqlInitiativeDescriptionSubType' } | { __typename: 'GraphqlSkillSubType' } | { __typename: 'GraphqlSnippetSubType' } | { __typename: 'GraphqlTaskSubType', isCompleted: boolean } @@ -6591,6 +6609,7 @@ export type ItemPreviewsQuery = { user: { id: string, soup: { items: Array< | { __typename: 'GraphqlChannelViewOnlyPermission' } | { __typename: 'GraphqlTeamRolePermission' } | null, subType: + | { __typename: 'GraphqlInitiativeDescriptionSubType' } | { __typename: 'GraphqlSkillSubType' } | { __typename: 'GraphqlSnippetSubType' } | { __typename: 'GraphqlTaskSubType', isCompleted: boolean } @@ -6629,6 +6648,7 @@ type ItemPreviewDetailsFields_GraphqlSoupDocument_Fragment = { __typename: 'Grap | { __typename: 'GraphqlChannelViewOnlyPermission' } | { __typename: 'GraphqlTeamRolePermission' } | null, subType: + | { __typename: 'GraphqlInitiativeDescriptionSubType' } | { __typename: 'GraphqlSkillSubType' } | { __typename: 'GraphqlSnippetSubType' } | { __typename: 'GraphqlTaskSubType', isCompleted: boolean } @@ -6672,6 +6692,7 @@ type ItemPreviewFields_GraphqlSoupChat_Fragment = { __typename: 'GraphqlSoupChat type ItemPreviewFields_GraphqlSoupCrmCompany_Fragment = { __typename: 'GraphqlSoupCrmCompany', domains: Array, id: string, displayName: string | null, companyName: string | null }; type ItemPreviewFields_GraphqlSoupDocument_Fragment = { __typename: 'GraphqlSoupDocument', fileType: string | null, id: string, displayName: string | null, documentName: string, subType: + | { __typename: 'GraphqlInitiativeDescriptionSubType' } | { __typename: 'GraphqlSkillSubType' } | { __typename: 'GraphqlSnippetSubType' } | { __typename: 'GraphqlTaskSubType', isCompleted: boolean } @@ -6968,6 +6989,7 @@ type SoupPatchFields_SoupUpdated_Fragment = { __typename: 'SoupUpdated', item: | { __typename: 'GraphqlTaskAssignedMetadata', taskAssignedTaskId: string, taskAssignedTaskName: string | null, taskAssignedSubType: GraphqlNotificationDocumentSubType | null, taskAssignedAssignedBy: string, taskAssignedSenderProfilePictureUrl: string | null } }> } | { __typename: 'GraphqlSoupDocument', ownerId: string, fileType: string | null, projectId: string | null, createdAt: string, updatedAt: string, viewedAt: string | null, deletedAt: string | null, cacheProjection: string | null, frecencyScore: number | null, id: string, entityType: GraphqlSoupEntityType, displayName: string | null, isFavorited: boolean, documentName: string, subType: + | { __typename: 'GraphqlInitiativeDescriptionSubType' } | { __typename: 'GraphqlSkillSubType' } | { __typename: 'GraphqlSnippetSubType' } | { __typename: 'GraphqlTaskSubType', isCompleted: boolean } @@ -7381,6 +7403,7 @@ export type SoupUpdatesSubscription = { soupUpdates: Array< | { __typename: 'GraphqlTaskAssignedMetadata', taskAssignedTaskId: string, taskAssignedTaskName: string | null, taskAssignedSubType: GraphqlNotificationDocumentSubType | null, taskAssignedAssignedBy: string, taskAssignedSenderProfilePictureUrl: string | null } }> } | { __typename: 'GraphqlSoupDocument', ownerId: string, fileType: string | null, projectId: string | null, createdAt: string, updatedAt: string, viewedAt: string | null, deletedAt: string | null, cacheProjection: string | null, frecencyScore: number | null, id: string, entityType: GraphqlSoupEntityType, displayName: string | null, isFavorited: boolean, documentName: string, subType: + | { __typename: 'GraphqlInitiativeDescriptionSubType' } | { __typename: 'GraphqlSkillSubType' } | { __typename: 'GraphqlSnippetSubType' } | { __typename: 'GraphqlTaskSubType', isCompleted: boolean } @@ -7790,6 +7813,7 @@ export type SoupQuery = { user: { id: string, emailLinks: Array<{ id: string, em | { __typename: 'GraphqlTaskAssignedMetadata', taskAssignedTaskId: string, taskAssignedTaskName: string | null, taskAssignedSubType: GraphqlNotificationDocumentSubType | null, taskAssignedAssignedBy: string, taskAssignedSenderProfilePictureUrl: string | null } }> } | { __typename: 'GraphqlSoupDocument', ownerId: string, fileType: string | null, projectId: string | null, createdAt: string, updatedAt: string, viewedAt: string | null, deletedAt: string | null, cacheProjection: string | null, frecencyScore: number | null, id: string, entityType: GraphqlSoupEntityType, displayName: string | null, isFavorited: boolean, documentName: string, subType: + | { __typename: 'GraphqlInitiativeDescriptionSubType' } | { __typename: 'GraphqlSkillSubType' } | { __typename: 'GraphqlSnippetSubType' } | { __typename: 'GraphqlTaskSubType', isCompleted: boolean } @@ -8198,6 +8222,7 @@ export type SoupBackfillQuery = { user: { id: string, emailLinks: Array<{ id: st | { __typename: 'GraphqlTaskAssignedMetadata', taskAssignedTaskId: string, taskAssignedTaskName: string | null, taskAssignedSubType: GraphqlNotificationDocumentSubType | null, taskAssignedAssignedBy: string, taskAssignedSenderProfilePictureUrl: string | null } }> } | { __typename: 'GraphqlSoupDocument', ownerId: string, fileType: string | null, projectId: string | null, createdAt: string, updatedAt: string, viewedAt: string | null, deletedAt: string | null, cacheProjection: string | null, frecencyScore: number | null, id: string, entityType: GraphqlSoupEntityType, displayName: string | null, isFavorited: boolean, documentName: string, subType: + | { __typename: 'GraphqlInitiativeDescriptionSubType' } | { __typename: 'GraphqlSkillSubType' } | { __typename: 'GraphqlSnippetSubType' } | { __typename: 'GraphqlTaskSubType', isCompleted: boolean } @@ -8611,6 +8636,7 @@ export type SoupMailBackfillQuery = { user: { id: string, emailLinks: Array<{ id | { __typename: 'GraphqlTaskAssignedMetadata', taskAssignedTaskId: string, taskAssignedTaskName: string | null, taskAssignedSubType: GraphqlNotificationDocumentSubType | null, taskAssignedAssignedBy: string, taskAssignedSenderProfilePictureUrl: string | null } }> } | { __typename: 'GraphqlSoupDocument', ownerId: string, fileType: string | null, projectId: string | null, createdAt: string, updatedAt: string, viewedAt: string | null, deletedAt: string | null, cacheProjection: string | null, frecencyScore: number | null, id: string, entityType: GraphqlSoupEntityType, displayName: string | null, isFavorited: boolean, documentName: string, subType: + | { __typename: 'GraphqlInitiativeDescriptionSubType' } | { __typename: 'GraphqlSkillSubType' } | { __typename: 'GraphqlSnippetSubType' } | { __typename: 'GraphqlTaskSubType', isCompleted: boolean } @@ -9019,6 +9045,7 @@ export type SoupSharedMailBackfillQuery = { user: { id: string, emailLinks: Arra | { __typename: 'GraphqlTaskAssignedMetadata', taskAssignedTaskId: string, taskAssignedTaskName: string | null, taskAssignedSubType: GraphqlNotificationDocumentSubType | null, taskAssignedAssignedBy: string, taskAssignedSenderProfilePictureUrl: string | null } }> } | { __typename: 'GraphqlSoupDocument', ownerId: string, fileType: string | null, projectId: string | null, createdAt: string, updatedAt: string, viewedAt: string | null, deletedAt: string | null, cacheProjection: string | null, frecencyScore: number | null, id: string, entityType: GraphqlSoupEntityType, displayName: string | null, isFavorited: boolean, documentName: string, subType: + | { __typename: 'GraphqlInitiativeDescriptionSubType' } | { __typename: 'GraphqlSkillSubType' } | { __typename: 'GraphqlSnippetSubType' } | { __typename: 'GraphqlTaskSubType', isCompleted: boolean } @@ -9448,6 +9475,7 @@ type MailItemFields_GraphqlSoupCrmCompany_Fragment = { __typename: 'GraphqlSoupC }> }; type MailItemFields_GraphqlSoupDocument_Fragment = { __typename: 'GraphqlSoupDocument', ownerId: string, fileType: string | null, projectId: string | null, createdAt: string, updatedAt: string, viewedAt: string | null, deletedAt: string | null, cacheProjection: string | null, frecencyScore: number | null, id: string, entityType: GraphqlSoupEntityType, displayName: string | null, isFavorited: boolean, documentName: string, subType: + | { __typename: 'GraphqlInitiativeDescriptionSubType' } | { __typename: 'GraphqlSkillSubType' } | { __typename: 'GraphqlSnippetSubType' } | { __typename: 'GraphqlTaskSubType', isCompleted: boolean } @@ -9875,6 +9903,7 @@ type SoupItemFields_GraphqlSoupCrmCompany_Fragment = { __typename: 'GraphqlSoupC }> }; type SoupItemFields_GraphqlSoupDocument_Fragment = { __typename: 'GraphqlSoupDocument', ownerId: string, fileType: string | null, projectId: string | null, createdAt: string, updatedAt: string, viewedAt: string | null, deletedAt: string | null, cacheProjection: string | null, frecencyScore: number | null, id: string, entityType: GraphqlSoupEntityType, displayName: string | null, isFavorited: boolean, documentName: string, subType: + | { __typename: 'GraphqlInitiativeDescriptionSubType' } | { __typename: 'GraphqlSkillSubType' } | { __typename: 'GraphqlSnippetSubType' } | { __typename: 'GraphqlTaskSubType', isCompleted: boolean } diff --git a/apps/web/src/lib/service-clients/service-storage/openapi.json b/apps/web/src/lib/service-clients/service-storage/openapi.json index 666555e87a4..5521d1e7448 100644 --- a/apps/web/src/lib/service-clients/service-storage/openapi.json +++ b/apps/web/src/lib/service-clients/service-storage/openapi.json @@ -16120,6 +16120,17 @@ "enum": ["skill"] } } + }, + { + "type": "object", + "description": "The description document of an initiative", + "required": ["type"], + "properties": { + "type": { + "type": "string", + "enum": ["initiative_description"] + } + } } ], "description": "Sub type of a document with associated properties encoded in each variant.\nThis ensures type-safety: task properties only exist when the document is a task." @@ -19148,7 +19159,7 @@ "properties": { "description": { "type": ["string", "null"], - "description": "Optional description." + "description": "Initial markdown for the description document. Not stored on the initiative; later\nedits happen in the document editor." }, "memberIds": { "type": ["array", "null"], @@ -20327,6 +20338,11 @@ } ] }, + "DescriptionDocumentId": { + "type": "string", + "format": "uuid", + "description": "Id of the markdown document that holds an initiative's description.\n\nThe documents side mints it as a UUID; `\"Document\".id` and\n`initiative.description_document_id` store it as TEXT, so it is parsed once at the\nadapter boundary and displayed back when bound in SQL." + }, "DocumentBasic": { "type": "object", "description": "Returns basic information of a document used for some db queries", @@ -20984,6 +21000,17 @@ "enum": ["skill"] } } + }, + { + "type": "object", + "description": "The description document of an initiative", + "required": ["type"], + "properties": { + "type": { + "type": "string", + "enum": ["initiative_description"] + } + } } ], "description": "The sub type of a document preview with associated properties.\nTask-related properties are encoded within the variant to ensure valid states." @@ -21130,8 +21157,8 @@ }, "DocumentSubType": { "type": "string", - "description": "The document sub type enum represents all values of document sub types.\nThese values should match the `document_sub_type_value` table in macrodb.", - "enum": ["task", "snippet", "skill"] + "description": "The document sub type enum represents all values of document sub types.\nThese values should match the `document_sub_type_value` table in macrodb.\n\nWire, database, and `Display` spellings are all `snake_case` so a\nmulti-word variant serializes identically in every system.", + "enum": ["task", "snippet", "skill", "initiative_description"] }, "DocumentSyncContentUpdatedMetadata": { "type": "object", @@ -24060,6 +24087,7 @@ "required": [ "id", "name", + "descriptionDocumentId", "ownerId", "memberIds", "taskIds", @@ -24074,9 +24102,9 @@ "format": "date-time", "description": "When the initiative was created." }, - "description": { - "type": ["string", "null"], - "description": "Optional description." + "descriptionDocumentId": { + "$ref": "#/components/schemas/DescriptionDocumentId", + "description": "The markdown document holding the description; open it in the editor." }, "id": { "$ref": "#/components/schemas/InitiativeId", @@ -24141,11 +24169,11 @@ "InitiativeSummary": { "type": "object", "description": "List-row view of an initiative.", - "required": ["id", "name", "updatedAt"], + "required": ["id", "name", "descriptionDocumentId", "updatedAt"], "properties": { - "description": { - "type": ["string", "null"], - "description": "Optional description." + "descriptionDocumentId": { + "$ref": "#/components/schemas/DescriptionDocumentId", + "description": "The markdown document holding the description; open it in the editor." }, "id": { "$ref": "#/components/schemas/InitiativeId", @@ -28377,6 +28405,17 @@ "enum": ["skill"] } } + }, + { + "type": "object", + "description": "The description document of an initiative", + "required": ["type"], + "properties": { + "type": { + "type": "string", + "enum": ["initiative_description"] + } + } } ], "description": "Sub type of a document with associated properties encoded in each variant.\nThis ensures type-safety: task properties only exist when the document is a task." @@ -30141,12 +30180,8 @@ }, "UpdateInitiativeRequest": { "type": "object", - "description": "Update-initiative HTTP body. Absent fields are left unchanged. `member_ids`\npresent is a full replace.", + "description": "Update-initiative HTTP body. Absent fields are left unchanged. `member_ids`\npresent is a full replace. The description is edited in its document, not here.", "properties": { - "description": { - "type": ["string", "null"], - "description": "Replacement description. `Some(\"\")` clears it after trim." - }, "memberIds": { "type": ["array", "null"], "items": { diff --git a/crates/channels/src/outbound/notification_sender.rs b/crates/channels/src/outbound/notification_sender.rs index cf9c7b01310..230bee10b96 100644 --- a/crates/channels/src/outbound/notification_sender.rs +++ b/crates/channels/src/outbound/notification_sender.rs @@ -200,6 +200,9 @@ where Some("task") => Some(NotificationDocumentSubType::Task), Some("snippet") => Some(NotificationDocumentSubType::Snippet), Some("skill") => Some(NotificationDocumentSubType::Skill), + Some("initiative_description") => { + Some(NotificationDocumentSubType::InitiativeDescription) + } _ => None, }, channel: to_channel_mention_metadata(mention), diff --git a/crates/document_sub_type/Cargo.toml b/crates/document_sub_type/Cargo.toml index 9b5227ac664..60ea872722d 100644 --- a/crates/document_sub_type/Cargo.toml +++ b/crates/document_sub_type/Cargo.toml @@ -16,3 +16,6 @@ strum = { workspace = true } thiserror = { workspace = true } utoipa = { workspace = true, optional = true } schemars = { workspace = true, optional = true } + +[dev-dependencies] +serde_json = { workspace = true } diff --git a/crates/document_sub_type/src/lib.rs b/crates/document_sub_type/src/lib.rs index 5214cd12142..f544a1a2dcd 100644 --- a/crates/document_sub_type/src/lib.rs +++ b/crates/document_sub_type/src/lib.rs @@ -1,7 +1,13 @@ //! This crate contains the document sub type enum and various logic for it +#[cfg(test)] +mod test; + /// The document sub type enum represents all values of document sub types. /// These values should match the `document_sub_type_value` table in macrodb. +/// +/// Wire, database, and `Display` spellings are all `snake_case` so a +/// multi-word variant serializes identically in every system. #[derive( serde::Serialize, serde::Deserialize, @@ -12,14 +18,15 @@ Copy, strum::EnumString, strum::Display, + strum::EnumIter, )] #[cfg_attr(feature = "schema", derive(utoipa::ToSchema, schemars::JsonSchema))] #[cfg_attr(feature = "sqlx", derive(sqlx::Type))] -#[serde(rename_all = "lowercase")] +#[serde(rename_all = "snake_case")] #[strum(serialize_all = "snake_case")] #[cfg_attr( feature = "sqlx", - sqlx(type_name = "\"document_sub_type_value\"", rename_all = "lowercase") + sqlx(type_name = "\"document_sub_type_value\"", rename_all = "snake_case") )] pub enum DocumentSubType { /// A task document @@ -30,4 +37,7 @@ pub enum DocumentSubType { /// A skill document — a markdown document containing instructions that AI /// can read and follow when referenced in an AI input Skill, + /// The description document of an initiative. Exactly one per initiative, + /// created by the initiative service rather than through `POST /documents`. + InitiativeDescription, } diff --git a/crates/document_sub_type/src/test.rs b/crates/document_sub_type/src/test.rs new file mode 100644 index 00000000000..3c54c126d60 --- /dev/null +++ b/crates/document_sub_type/src/test.rs @@ -0,0 +1,31 @@ +use std::str::FromStr; + +use strum::IntoEnumIterator; + +use super::DocumentSubType; + +#[test] +fn serde_and_strum_agree_on_snake_case_for_every_variant() { + for variant in DocumentSubType::iter() { + let display = variant.to_string(); + let json = serde_json::to_string(&variant).expect("serializes"); + assert_eq!(json, format!("\"{display}\""), "{variant:?}"); + assert_eq!( + DocumentSubType::from_str(&display).expect("parses"), + variant + ); + assert_eq!( + serde_json::from_str::(&json).expect("deserializes"), + variant + ); + } +} + +#[test] +fn every_variant_has_its_database_spelling() { + let spellings: Vec = DocumentSubType::iter().map(|v| v.to_string()).collect(); + assert_eq!( + spellings, + ["task", "snippet", "skill", "initiative_description"] + ); +} diff --git a/crates/documents/src/domain/create.rs b/crates/documents/src/domain/create.rs index 8c4c729f2bc..890d1165b8b 100644 --- a/crates/documents/src/domain/create.rs +++ b/crates/documents/src/domain/create.rs @@ -15,9 +15,12 @@ use macro_user_id::user_id::MacroUserIdStr; use model::document::FileType; use sha2::{Digest, Sha256}; +use models_permissions::share_permission::LinkShareState; + use crate::domain::content::{DocumentContent, DocumentContentLocation}; use crate::domain::models::{ - CreateDocumentRepoArgs, CreateTaskRequest, DocumentError, EMPTY_SHA256, PropertyInput, + CreateDocumentRepoArgs, CreateTaskRequest, DocumentError, EMPTY_SHA256, InitialLinkShare, + PropertyInput, }; use crate::domain::ports::create::{ DocumentBytesUpload, DocumentBytesUploadPort, DocumentCreationService, @@ -35,6 +38,7 @@ pub struct NewDocumentMetadata { created_at: Option>, skip_history: bool, attribution: Option, + initial_link_share: InitialLinkShare, } impl NewDocumentMetadata { @@ -53,6 +57,7 @@ impl NewDocumentMetadata { created_at: None, skip_history: false, attribution: None, + initial_link_share: InitialLinkShare::EntityDefault, }, } } @@ -75,6 +80,7 @@ impl NewDocumentMetadata { sub_type: kind.subtype.sub_type(), skip_history: self.skip_history, attribution: self.attribution, + initial_link_share: self.initial_link_share, } } } @@ -116,6 +122,12 @@ impl NewDocumentMetadataBuilder { self } + /// Initialize link share to exactly `state` instead of the entity-type default. + pub fn initial_link_share(mut self, state: LinkShareState) -> Self { + self.metadata.initial_link_share = InitialLinkShare::Exact(state); + self + } + /// Build the metadata value. pub fn build(self) -> NewDocumentMetadata { self.metadata @@ -135,6 +147,7 @@ enum RepoDocumentSubtype { MarkdownTask, MarkdownSnippet, MarkdownSkill, + MarkdownInitiativeDescription, } impl RepoDocumentSubtype { @@ -146,6 +159,9 @@ impl RepoDocumentSubtype { Some(document_sub_type::DocumentSubType::Snippet) } RepoDocumentSubtype::MarkdownSkill => Some(document_sub_type::DocumentSubType::Skill), + RepoDocumentSubtype::MarkdownInitiativeDescription => { + Some(document_sub_type::DocumentSubType::InitiativeDescription) + } } } } @@ -173,6 +189,10 @@ pub enum MarkdownSubtype { /// A skill document — markdown instructions that AI reads and follows when /// the skill is referenced in an AI input. Skill, + /// An initiative's description document. Created only by the initiative + /// service, which mirrors the initiative's grants onto it in its own + /// transaction; nothing is team-shared here. + InitiativeDescription, } impl MarkdownSubtype { @@ -343,6 +363,11 @@ impl NewPlainTextDocumentBuilder { "skills must be markdown documents".to_string(), )); } + MarkdownSubtype::InitiativeDescription => { + return Err(DocumentError::BadRequest( + "initiative descriptions must be markdown documents".to_string(), + )); + } MarkdownSubtype::Note => {} } PlainTextDocumentKind::Text(NonMarkdownFileType::new(self.file_type)?) @@ -578,7 +603,10 @@ where subtype, } = document; let task = match &subtype { - MarkdownSubtype::Note | MarkdownSubtype::Snippet | MarkdownSubtype::Skill => None, + MarkdownSubtype::Note + | MarkdownSubtype::Snippet + | MarkdownSubtype::Skill + | MarkdownSubtype::InitiativeDescription => None, MarkdownSubtype::Task { property_values, share_with_team, @@ -604,6 +632,9 @@ where MarkdownSubtype::Task { .. } => RepoDocumentSubtype::MarkdownTask, MarkdownSubtype::Snippet => RepoDocumentSubtype::MarkdownSnippet, MarkdownSubtype::Skill => RepoDocumentSubtype::MarkdownSkill, + MarkdownSubtype::InitiativeDescription => { + RepoDocumentSubtype::MarkdownInitiativeDescription + } }, team_id, share_with_team: task.as_ref().is_some_and(|(_, share, _)| *share), @@ -787,7 +818,7 @@ mod tests { MarkdownSubtype, NewDocumentMetadata, NewPlainTextDocument, RepoDocumentKind, RepoDocumentSubtype, file_shas, }; - use crate::domain::models::ImportEmailAttachmentRepoArgs; + use crate::domain::models::{ImportEmailAttachmentRepoArgs, InitialLinkShare}; use activity::{Actor, Attribution}; use macro_user_id::user_id::MacroUserIdStr; use model::document::FileType; @@ -847,6 +878,50 @@ mod tests { assert_eq!(args.resolved_attribution(), attribution); } + #[test] + fn link_share_defaults_to_the_entity_default_unless_set_exactly() { + use models_permissions::share_permission::access_level::AccessLevel; + use models_permissions::share_permission::{LinkShare, LinkShareState}; + + let args = metadata().into_repo_args(owner(), repo_kind()); + assert_eq!(args.initial_link_share, InitialLinkShare::EntityDefault); + + let state = LinkShareState::On { + scope: LinkShare::Team, + level: AccessLevel::View, + }; + let args = NewDocumentMetadata::builder("description") + .initial_link_share(state) + .build() + .into_repo_args(owner(), repo_kind()); + assert_eq!(args.initial_link_share, InitialLinkShare::Exact(state)); + } + + #[test] + fn initiative_description_is_a_markdown_only_subtype_without_task_properties() { + let err = NewPlainTextDocument::builder(metadata()) + .file_type(FileType::Txt) + .text("hello") + .markdown_subtype(MarkdownSubtype::InitiativeDescription) + .build() + .unwrap_err(); + assert_eq!( + err.to_string(), + "bad request: initiative descriptions must be markdown documents" + ); + + NewPlainTextDocument::builder(metadata()) + .file_type(FileType::Md) + .text("# goals") + .markdown_subtype(MarkdownSubtype::InitiativeDescription) + .build() + .unwrap(); + assert_eq!( + RepoDocumentSubtype::MarkdownInitiativeDescription.sub_type(), + Some(document_sub_type::DocumentSubType::InitiativeDescription) + ); + } + #[test] fn new_plain_text_rejects_non_markdown_task() { let err = NewPlainTextDocument::builder(metadata()) diff --git a/crates/documents/src/domain/models.rs b/crates/documents/src/domain/models.rs index 89a9faf93a3..bf928d259a3 100644 --- a/crates/documents/src/domain/models.rs +++ b/crates/documents/src/domain/models.rs @@ -5,6 +5,7 @@ use chrono::{DateTime, Utc}; use macro_user_id::user_id::MacroUserIdStr; use model::document::response::DocumentResponseMetadata; use model::document::{DocumentMetadata, FileType}; +use models_permissions::share_permission::LinkShareState; use super::response::DocumentResponse; use model::sync_service::SyncServiceVersionID; @@ -306,6 +307,17 @@ pub struct CopyDocumentQueryParams { pub version_id: Option, } +/// How a new document's link share is initialized. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum InitialLinkShare { + /// The entity-type default (md is PUBLIC/Edit), overridden only by the owner's team default. + #[default] + EntityDefault, + /// Exactly this state, already resolved by the caller. The document never carries the + /// entity-type default, not even between creation and a later update. + Exact(LinkShareState), +} + /// Arguments for creating a document in the repository. pub struct CreateDocumentRepoArgs { /// Optional user-provided document ID. @@ -333,6 +345,9 @@ pub struct CreateDocumentRepoArgs { pub skip_history: bool, /// Explicit activity attribution. Unset uses [`Self::resolved_attribution`]. pub attribution: Option, + /// Honored by [`super::ports::DocumentService::create_document`]. Copies and email imports + /// resolve their own permission and ignore it. + pub initial_link_share: InitialLinkShare, } impl CreateDocumentRepoArgs { diff --git a/crates/documents/src/domain/service.rs b/crates/documents/src/domain/service.rs index c02217decc5..81198f37f2c 100644 --- a/crates/documents/src/domain/service.rs +++ b/crates/documents/src/domain/service.rs @@ -46,7 +46,8 @@ use s3_key::{ use tracing; use crate::domain::models::{ - ASSIGNEES_PROPERTY_ID, NOT_STARTED_STATUS_OPTION_ID, PropertyInput, STATUS_PROPERTY_ID, + ASSIGNEES_PROPERTY_ID, InitialLinkShare, NOT_STARTED_STATUS_OPTION_ID, PropertyInput, + STATUS_PROPERTY_ID, }; use super::branch_name::{build_task_branch_name, user_branch_prefix}; @@ -1332,18 +1333,30 @@ impl< }); } + if args.sub_type == Some(DocumentSubType::InitiativeDescription) + && matches!(args.initial_link_share, InitialLinkShare::EntityDefault) + { + return Err(DocumentError::BadRequest( + "initiative descriptions must set an exact initial link share".to_string(), + )); + } + let file_type = args.file_type; let project_id = args.project_id; let sha = args.sha.clone(); let attribution = args.resolved_attribution(); - let team_default = self - .repo - .get_team_default_link_share(args.user_id.as_ref()) - .await - .map_err(|e| DocumentError::Internal(e.into()))?; - let share_permission = - SharePermissionV2::new_document_share_permission(file_type, team_default); + let share_permission = match args.initial_link_share { + InitialLinkShare::EntityDefault => { + let team_default = self + .repo + .get_team_default_link_share(args.user_id.as_ref()) + .await + .map_err(|e| DocumentError::Internal(e.into()))?; + SharePermissionV2::new_document_share_permission(file_type, team_default) + } + InitialLinkShare::Exact(state) => SharePermissionV2::from_link_share_state(state), + }; let document_metadata = self.repo.create_document(args, share_permission).await?; diff --git a/crates/documents/src/domain/service/tests.rs b/crates/documents/src/domain/service/tests.rs index 0ef67c5be53..badde7970e6 100644 --- a/crates/documents/src/domain/service/tests.rs +++ b/crates/documents/src/domain/service/tests.rs @@ -2265,6 +2265,7 @@ fn create_document_repo_args(file_type: FileType) -> CreateDocumentRepoArgs { sub_type: None, skip_history: false, attribution: None, + initial_link_share: InitialLinkShare::EntityDefault, } } @@ -2352,6 +2353,104 @@ async fn create_document_repo_receives_disabled_share_when_team_turned_link_shar .await; } +#[tokio::test] +async fn exact_initial_link_share_bypasses_md_public_edit_default() { + use models_permissions::share_permission::access_level::AccessLevel; + use models_permissions::share_permission::{LinkShare, LinkShareState}; + + for state in [ + LinkShareState::Off, + LinkShareState::On { + scope: LinkShare::Team, + level: AccessLevel::View, + }, + ] { + // No `expect_get_team_default_link_share`: an exact state never consults the team. + let mut repo = make_mock_repo(); + let created_metadata = make_test_metadata(); + repo.expect_create_document() + .withf(move |args, share_permission| { + args.initial_link_share == InitialLinkShare::Exact(state) + && share_permission.link_share_state() == state + && share_permission.team_share_access_level.is_none() + }) + .times(1) + .returning(move |_, _| Box::pin(std::future::ready(Ok(created_metadata.clone())))); + repo.expect_set_document_content() + .returning(|_, _| Box::pin(std::future::ready(Ok(())))); + repo.expect_get_team_task_metadata() + .returning(|_| Box::pin(std::future::ready(Ok(None)))); + let (service, _event_broker) = make_test_service_with_event_broker(repo); + + let mut args = create_document_repo_args(FileType::Md); + args.initial_link_share = InitialLinkShare::Exact(state); + crate::domain::ports::DocumentService::create_document( + &service, + args.user_id.clone(), + args, + None, + ) + .await + .unwrap(); + } +} + +#[tokio::test] +async fn initiative_description_rejects_entity_default_link_share() { + let repo = make_mock_repo(); + let (service, event_broker) = make_test_service_with_event_broker(repo); + let mut args = create_document_repo_args(FileType::Md); + args.sub_type = Some(document_sub_type::DocumentSubType::InitiativeDescription); + + let err = crate::domain::ports::DocumentService::create_document( + &service, + args.user_id.clone(), + args, + None, + ) + .await + .unwrap_err(); + + assert_eq!( + err.to_string(), + "bad request: initiative descriptions must set an exact initial link share" + ); + assert!(event_broker.published().lock().unwrap().is_empty()); +} + +#[tokio::test] +async fn initiative_description_accepts_exact_link_share() { + use models_permissions::share_permission::LinkShareState; + + let mut repo = make_mock_repo(); + let created_metadata = make_test_metadata(); + repo.expect_create_document() + .withf(|args, share_permission| { + args.sub_type == Some(document_sub_type::DocumentSubType::InitiativeDescription) + && args.initial_link_share == InitialLinkShare::Exact(LinkShareState::Off) + && share_permission.link_share_state() == LinkShareState::Off + }) + .times(1) + .returning(move |_, _| Box::pin(std::future::ready(Ok(created_metadata.clone())))); + repo.expect_set_document_content() + .returning(|_, _| Box::pin(std::future::ready(Ok(())))); + repo.expect_get_team_task_metadata() + .returning(|_| Box::pin(std::future::ready(Ok(None)))); + let (service, _event_broker) = make_test_service_with_event_broker(repo); + + let mut args = create_document_repo_args(FileType::Md); + args.sub_type = Some(document_sub_type::DocumentSubType::InitiativeDescription); + args.initial_link_share = InitialLinkShare::Exact(LinkShareState::Off); + crate::domain::ports::DocumentService::create_document( + &service, + args.user_id.clone(), + args, + None, + ) + .await + .unwrap(); +} + #[tokio::test] async fn create_document_publishes_resolved_attribution() { let mut repo = make_mock_repo(); diff --git a/crates/documents/src/inbound/axum_router/create_document.rs b/crates/documents/src/inbound/axum_router/create_document.rs index b1ddcf1256b..165051db295 100644 --- a/crates/documents/src/inbound/axum_router/create_document.rs +++ b/crates/documents/src/inbound/axum_router/create_document.rs @@ -13,7 +13,9 @@ use model::document::{FileType, FileTypeExt}; use models_permissions::share_permission::access_level::EditAccessLevel; use super::DocumentRouterState; -use crate::domain::models::{CreateDocumentRepoArgs, DocumentError, ImportEmailAttachmentRepoArgs}; +use crate::domain::models::{ + CreateDocumentRepoArgs, DocumentError, ImportEmailAttachmentRepoArgs, InitialLinkShare, +}; use crate::domain::ports::DocumentService; use crate::domain::response::CreateDocumentResponse; @@ -103,6 +105,7 @@ pub async fn create_document_handler< .then_some(document_sub_type::DocumentSubType::Task), skip_history: req.skip_history, attribution: None, + initial_link_share: InitialLinkShare::EntityDefault, }; let user_id = user.authorization.user.macro_user_id.clone(); diff --git a/crates/documents/src/inbound/axum_router/get_branch_name.rs b/crates/documents/src/inbound/axum_router/get_branch_name.rs index 3c4c3d0696f..c467d95ce18 100644 --- a/crates/documents/src/inbound/axum_router/get_branch_name.rs +++ b/crates/documents/src/inbound/axum_router/get_branch_name.rs @@ -72,8 +72,13 @@ pub async fn get_branch_name_handler< branch_name: task_branch_name.branch_name, })) } - Some(DocumentSubType::Snippet | DocumentSubType::Skill) | None => Err( - DocumentError::BadRequest(format!("document {document_id} is not a task")), - ), + Some( + DocumentSubType::Snippet + | DocumentSubType::Skill + | DocumentSubType::InitiativeDescription, + ) + | None => Err(DocumentError::BadRequest(format!( + "document {document_id} is not a task" + ))), } } diff --git a/crates/documents/src/inbound/toolset/read_metadata.rs b/crates/documents/src/inbound/toolset/read_metadata.rs index 94a19546b97..f8f77999394 100644 --- a/crates/documents/src/inbound/toolset/read_metadata.rs +++ b/crates/documents/src/inbound/toolset/read_metadata.rs @@ -108,7 +108,8 @@ where Some(task_branch_name.branch_name) } document_sub_type::DocumentSubType::Snippet - | document_sub_type::DocumentSubType::Skill => None, + | document_sub_type::DocumentSubType::Skill + | document_sub_type::DocumentSubType::InitiativeDescription => None, } } else { None diff --git a/crates/documents/src/outbound/pg_document_repo/create.rs b/crates/documents/src/outbound/pg_document_repo/create.rs index 777b84a1947..d3ed3877672 100644 --- a/crates/documents/src/outbound/pg_document_repo/create.rs +++ b/crates/documents/src/outbound/pg_document_repo/create.rs @@ -399,6 +399,7 @@ pub async fn insert_new_document( sub_type: requested_sub_type, skip_history, attribution: _, + initial_link_share: _, } = args; let now = chrono::Utc::now(); diff --git a/crates/documents/src/outbound/pg_document_repo/tests.rs b/crates/documents/src/outbound/pg_document_repo/tests.rs index 600f2d7fa9b..3303b946e50 100644 --- a/crates/documents/src/outbound/pg_document_repo/tests.rs +++ b/crates/documents/src/outbound/pg_document_repo/tests.rs @@ -13,6 +13,7 @@ use sqlx::{Pool, Postgres, Row}; use crate::domain::models::{ CopyDocumentRepoArgs, CreateDocumentRepoArgs, EditDocumentRepoArgs, EmailImportRepoOutcome, FileTypeUpdate, GithubPullRequest, GithubPullRequestsResponse, ImportEmailAttachmentRepoArgs, + InitialLinkShare, }; use crate::domain::ports::DocumentRepo; use crate::outbound::pg_document_repo::PgDocumentRepo; @@ -491,6 +492,7 @@ fn create_document_args( sub_type: is_task.then_some(document_sub_type::DocumentSubType::Task), skip_history: false, attribution: None, + initial_link_share: InitialLinkShare::EntityDefault, } } @@ -2459,6 +2461,7 @@ fn import_email_document_args( sub_type: None, skip_history: true, attribution: None, + initial_link_share: InitialLinkShare::EntityDefault, }, } } diff --git a/crates/graphql_notification/src/notification_event.rs b/crates/graphql_notification/src/notification_event.rs index 16b5dafdfa3..336f812bdf0 100644 --- a/crates/graphql_notification/src/notification_event.rs +++ b/crates/graphql_notification/src/notification_event.rs @@ -47,6 +47,8 @@ pub enum GraphqlNotificationDocumentSubType { Snippet, /// Skill document. Skill, + /// The description document of an initiative. + InitiativeDescription, } impl From for GraphqlNotificationDocumentSubType { @@ -55,6 +57,7 @@ impl From for GraphqlNotificationDocumentSubType { NotificationDocumentSubType::Task => Self::Task, NotificationDocumentSubType::Snippet => Self::Snippet, NotificationDocumentSubType::Skill => Self::Skill, + NotificationDocumentSubType::InitiativeDescription => Self::InitiativeDescription, } } } diff --git a/crates/graphql_soup/src/objects.rs b/crates/graphql_soup/src/objects.rs index 98c6784a4c5..e8dddbbcfc0 100644 --- a/crates/graphql_soup/src/objects.rs +++ b/crates/graphql_soup/src/objects.rs @@ -789,6 +789,13 @@ pub struct GraphqlSkillSubType { nothing: bool, } +/// represents the initiative description subtype fields +#[derive(SimpleObject)] +pub struct GraphqlInitiativeDescriptionSubType { + /// this object has nothing as a field but we need at least 1 field + nothing: bool, +} + /// GraphQL representation of the soup document sub type. #[derive(Union)] pub enum GraphqlSoupDocumentSubType { @@ -798,6 +805,8 @@ pub enum GraphqlSoupDocumentSubType { Snippet(GraphqlSnippetSubType), /// the sub type is a skill Skill(GraphqlSkillSubType), + /// the sub type is an initiative description + InitiativeDescription(GraphqlInitiativeDescriptionSubType), } impl GraphqlSoupDocumentSubType { @@ -811,6 +820,9 @@ impl GraphqlSoupDocumentSubType { Self::Snippet(GraphqlSnippetSubType { nothing: false }) } SoupDocumentSubType::Skill {} => Self::Skill(GraphqlSkillSubType { nothing: false }), + SoupDocumentSubType::InitiativeDescription {} => { + Self::InitiativeDescription(GraphqlInitiativeDescriptionSubType { nothing: false }) + } } } } diff --git a/crates/graphql_soup_filter_input/src/lib.rs b/crates/graphql_soup_filter_input/src/lib.rs index a2e1018a4d5..583bc462282 100644 --- a/crates/graphql_soup_filter_input/src/lib.rs +++ b/crates/graphql_soup_filter_input/src/lib.rs @@ -671,6 +671,8 @@ enum GraphqlDocumentSubType { Snippet, /// The skill option. Skill, + /// The initiative description option. + InitiativeDescription, } impl GraphqlDocumentSubType { @@ -680,6 +682,7 @@ impl GraphqlDocumentSubType { Self::Task => DocumentSubType::Task, Self::Snippet => DocumentSubType::Snippet, Self::Skill => DocumentSubType::Skill, + Self::InitiativeDescription => DocumentSubType::InitiativeDescription, } } } diff --git a/crates/initiative/src/domain/models.rs b/crates/initiative/src/domain/models.rs index 93473ea2107..78c651f2d08 100644 --- a/crates/initiative/src/domain/models.rs +++ b/crates/initiative/src/domain/models.rs @@ -9,15 +9,19 @@ use std::str::FromStr; use chrono::{DateTime, Utc}; use macro_user_id::user_id::MacroUserIdStr; use models_permissions::share_permission::access_level::AccessLevel; -use models_permissions::share_permission::team_share::AuthorizedTeamShareCommand; -use models_permissions::share_permission::{SharePermissionV2, UpdateSharePermissionRequestV2}; +use models_permissions::share_permission::team_share::{ + AuthorizedTeamShareCommand, TeamShareFacts, +}; +use models_permissions::share_permission::{ + LinkShareState, SharePermissionV2, UpdateSharePermissionRequestV2, +}; use serde::{Deserialize, Serialize}; use uuid::Uuid; /// Maximum initiative name length, counted in Unicode grapheme clusters. pub const MAX_INITIATIVE_NAME_GRAPHEMES: usize = 100; -/// Maximum initiative description length, counted in Unicode grapheme clusters. +/// Maximum length of the create-time description prefill, counted in Unicode grapheme clusters. pub const MAX_INITIATIVE_DESCRIPTION_GRAPHEMES: usize = 2_000; /// Maximum number of tasks accepted in one assign call. @@ -60,24 +64,55 @@ impl FromStr for InitiativeId { } } -/// Persisted initiative row without members, tasks, or share state. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +/// Id of the markdown document that holds an initiative's description. +/// +/// The documents side mints it as a UUID; `"Document".id` and +/// `initiative.description_document_id` store it as TEXT, so it is parsed once at the +/// adapter boundary and displayed back when bound in SQL. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[cfg_attr(feature = "inbound", derive(utoipa::ToSchema))] -#[serde(rename_all = "camelCase")] -pub struct Initiative { - /// Opaque identifier. - pub id: InitiativeId, - /// Display name. +#[serde(transparent)] +pub struct DescriptionDocumentId(Uuid); + +impl DescriptionDocumentId { + /// Wrap an already-persisted id. + pub fn from_uuid(id: Uuid) -> Self { + Self(id) + } + + /// The inner UUID. + pub fn as_uuid(&self) -> Uuid { + self.0 + } +} + +impl fmt::Display for DescriptionDocumentId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +impl FromStr for DescriptionDocumentId { + type Err = uuid::Error; + + fn from_str(s: &str) -> Result { + Ok(Self(Uuid::parse_str(s)?)) + } +} + +/// The description document the service asks the documents side to create. Every field is +/// already validated by the initiative domain. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NewDescriptionDocument { + /// Document owner; the initiative owner, so both entities agree on who may team-share. + pub owner: MacroUserIdStr<'static>, + /// The initiative's name at create time. Renames are not mirrored. pub name: String, - /// Optional description. - #[serde(skip_serializing_if = "Option::is_none")] - pub description: Option, - /// Owner of the initiative. - pub owner_id: MacroUserIdStr<'static>, - /// When the initiative was created. - pub created_at: DateTime, - /// When the initiative was last updated. - pub updated_at: DateTime, + /// Trimmed initial markdown; empty when the request had none. + pub prefill_markdown: String, + /// The initiative's resolved link share, applied verbatim so the markdown default + /// (PUBLIC/Edit) never exists for this document. + pub link_share: LinkShareState, } /// Minimal initiative identity used by access checks and internal lookups. @@ -102,9 +137,8 @@ pub struct InitiativeSummary { pub id: InitiativeId, /// Display name. pub name: String, - /// Optional description. - #[serde(skip_serializing_if = "Option::is_none")] - pub description: Option, + /// The markdown document holding the description; open it in the editor. + pub description_document_id: DescriptionDocumentId, /// When the initiative was last updated. pub updated_at: DateTime, } @@ -118,9 +152,8 @@ pub struct InitiativeDetail { pub id: InitiativeId, /// Display name. pub name: String, - /// Optional description. - #[serde(skip_serializing_if = "Option::is_none")] - pub description: Option, + /// The markdown document holding the description; open it in the editor. + pub description_document_id: DescriptionDocumentId, /// Owner of the initiative. pub owner_id: MacroUserIdStr<'static>, /// Member user ids. The owner is never stored here. @@ -144,7 +177,8 @@ pub struct InitiativeDetail { pub struct CreateInitiativeRequest { /// Display name. pub name: String, - /// Optional description. + /// Initial markdown for the description document. Not stored on the initiative; later + /// edits happen in the document editor. #[serde(skip_serializing_if = "Option::is_none")] pub description: Option, /// Optional member user ids. Invalid ids fail at the service boundary. @@ -156,7 +190,7 @@ pub struct CreateInitiativeRequest { } /// Update-initiative HTTP body. Absent fields are left unchanged. `member_ids` -/// present is a full replace. +/// present is a full replace. The description is edited in its document, not here. #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "inbound", derive(utoipa::ToSchema))] #[serde(rename_all = "camelCase")] @@ -164,9 +198,6 @@ pub struct UpdateInitiativeRequest { /// Replacement name. #[serde(skip_serializing_if = "Option::is_none")] pub name: Option, - /// Replacement description. `Some("")` clears it after trim. - #[serde(skip_serializing_if = "Option::is_none")] - pub description: Option, /// Full replacement member list when present. #[serde(skip_serializing_if = "Option::is_none")] pub member_ids: Option>, @@ -270,8 +301,8 @@ pub struct CreateInitiativeRepoArgs { pub owner_id: MacroUserIdStr<'static>, /// Validated name. pub name: String, - /// Validated description. - pub description: Option, + /// The description document, already committed by the documents side. + pub description_document_id: DescriptionDocumentId, /// Member ids with the owner removed and duplicates dropped. pub member_ids: Vec>, } @@ -283,16 +314,35 @@ pub struct UpdateInitiativeRepoArgs { pub id: InitiativeId, /// Replacement name when present. pub name: Option, - /// Replacement description when present. `Some(None)` clears it. - pub description: Option>, /// Members to add when `member_ids` was present on the request. pub member_ids_added: Vec>, /// Members to remove when `member_ids` was present on the request. pub member_ids_removed: Vec>, /// Share permission patch when the owner sent one. pub share_permission: Option, - /// Authorized team-share write, if any. - pub team_share: Option, + /// Authorized team-share writes for both entities, if any. + pub team_share: Option, +} + +/// Team-share commands for an initiative and its description document, authorized by the +/// owner against one snapshot of facts. The repository applies both in one guarded +/// transaction, so the document's team grant can never drift from the initiative's. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LockstepTeamShare { + /// Command whose expected facts name the initiative. + pub initiative: AuthorizedTeamShareCommand, + /// Command whose expected facts name the description document. + pub description: AuthorizedTeamShareCommand, +} + +/// Team-share facts for an initiative and its description document, read in one guarded +/// transaction so the service authorizes both against the same snapshot. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LockstepTeamShareFacts { + /// Facts for the initiative entity. + pub initiative: TeamShareFacts, + /// Facts for the description document entity. + pub description: TeamShareFacts, } /// Errors returned by the initiative service. diff --git a/crates/initiative/src/domain/models/test.rs b/crates/initiative/src/domain/models/test.rs index afcd2f89441..6f364a9218f 100644 --- a/crates/initiative/src/domain/models/test.rs +++ b/crates/initiative/src/domain/models/test.rs @@ -28,6 +28,35 @@ fn wire_types_serialize_camel_case() { assert_eq!(skipped, serde_json::json!("skippedNoPermission")); } +#[test] +fn description_document_id_round_trips_uuid_strings() { + let id = DescriptionDocumentId::from_uuid(Uuid::from_u128(2)); + let parsed = DescriptionDocumentId::from_str(&id.to_string()).expect("uuid string"); + assert_eq!(parsed, id); + assert_eq!( + serde_json::to_value(id).expect("json"), + serde_json::json!("00000000-0000-0000-0000-000000000002") + ); +} + +#[test] +fn update_request_has_no_description_field() { + let request: UpdateInitiativeRequest = serde_json::from_value(serde_json::json!({ + "name": "Renamed", + "description": "edited in the document instead" + })) + .expect("unknown fields are ignored"); + assert_eq!( + request, + UpdateInitiativeRequest { + name: Some("Renamed".into()), + ..Default::default() + } + ); + let json = serde_json::to_value(UpdateInitiativeRequest::default()).expect("json"); + assert_eq!(json, serde_json::json!({})); +} + #[test] fn create_request_deserializes_camel_case() { let request: CreateInitiativeRequest = serde_json::from_value(serde_json::json!({ diff --git a/crates/initiative/src/domain/ports.rs b/crates/initiative/src/domain/ports.rs index b84480e4f00..e7b26f3ba5e 100644 --- a/crates/initiative/src/domain/ports.rs +++ b/crates/initiative/src/domain/ports.rs @@ -4,22 +4,44 @@ use entity_access::domain::models::{ EditAccessLevel, EntityAccessReceipt, OwnerAccessLevel, ViewAccessLevel, }; use macro_user_id::user_id::MacroUserIdStr; -use models_permissions::share_permission::team_share::{TeamShareCreation, TeamShareFacts}; +use models_permissions::share_permission::team_share::TeamShareCreation; use models_permissions::share_permission::{SharePermissionV2, TeamLinkShareDefault}; use crate::domain::models::{ AssignTasksResponse, AssignTasksResult, CreateInitiativeRepoArgs, CreateInitiativeRequest, - InitiativeBasic, InitiativeDetail, InitiativeError, InitiativeId, InitiativeList, - TaskAssignment, UpdateInitiativeRepoArgs, UpdateInitiativeRequest, + DescriptionDocumentId, InitiativeBasic, InitiativeDetail, InitiativeError, InitiativeId, + InitiativeList, LockstepTeamShareFacts, NewDescriptionDocument, TaskAssignment, + UpdateInitiativeRepoArgs, UpdateInitiativeRequest, }; +/// Outbound port for the description document's lifecycle. The initiative domain's whole view +/// of the documents domain: no read, rename, or thin delete. +#[cfg_attr(test, mockall::automock)] +pub trait InitiativeDescriptionDocuments: Send + Sync + 'static { + /// Create the `initiative_description` markdown document, editor-ready, owned by + /// `document.owner` with link share exactly `document.link_share`. Commits before returning. + fn create( + &self, + document: NewDescriptionDocument, + ) -> impl Future> + Send; + + /// Remove the document and everything that hangs off it: rows, grants, sync-service state, + /// mentions, and the events downstream consumers need to forget it. Purging an id that is + /// already gone succeeds, so a failed purge can be retried. + fn purge( + &self, + id: DescriptionDocumentId, + ) -> impl Future> + Send; +} + /// Outbound persistence port for initiatives. #[cfg_attr(test, mockall::automock(type Err = InitiativeError;))] pub trait InitiativeRepo: Send + Sync + 'static { /// The error type returned by repository operations. type Err: Into + Send + std::fmt::Debug; - /// Persist a new initiative, its members, and initial share state. + /// Persist a new initiative, its members, and initial share state, mirroring the member + /// and team grants onto `args.description_document_id` in the same transaction. fn create( &self, args: CreateInitiativeRepoArgs, @@ -45,17 +67,19 @@ pub trait InitiativeRepo: Send + Sync + 'static { user_id: &MacroUserIdStr<'static>, ) -> impl Future> + Send; - /// Apply an update, including member add/remove sets and optional team share. + /// Apply an update, including member add/remove sets, share patch, and optional team + /// share, to the initiative and its description document in one transaction. fn update( &self, args: UpdateInitiativeRepoArgs, ) -> impl Future> + Send; - /// Load canonical team-share facts for an initiative. + /// Load canonical team-share facts for an initiative and its description document from + /// one guarded read. fn get_team_share_facts( &self, id: InitiativeId, - ) -> impl Future> + Send; + ) -> impl Future> + Send; /// Load the owner's team default link-share preference, if any. fn get_team_default_link_share( @@ -77,8 +101,13 @@ pub trait InitiativeRepo: Send + Sync + 'static { task_id: &str, ) -> impl Future> + Send; - /// Delete the initiative and clean up related rows in one transaction. - fn delete(&self, id: InitiativeId) -> impl Future> + Send; + /// Delete the initiative and clean up its own rows in one transaction, returning the + /// description document id for the caller to purge afterwards. `None` only for a row + /// written before the column existed. + fn delete( + &self, + id: InitiativeId, + ) -> impl Future, Self::Err>> + Send; } /// Inbound service port: the initiative API used by drivers (HTTP). diff --git a/crates/initiative/src/domain/service.rs b/crates/initiative/src/domain/service.rs index fc73218ed30..2d880f51236 100644 --- a/crates/initiative/src/domain/service.rs +++ b/crates/initiative/src/domain/service.rs @@ -14,39 +14,48 @@ use macro_user_id::cowlike::CowLike; use macro_user_id::user_id::MacroUserIdStr; use models_permissions::share_permission::SharePermissionV2; use models_permissions::share_permission::team_share::{ - AuthorizedTeamShareCommand, TeamShareCreation, TeamShareLevel, TeamSharePolicyError, - TeamShareRequest, authorize_team_share, + TeamShareCreation, TeamShareLevel, TeamSharePolicyError, TeamShareRequest, authorize_team_share, }; use unicode_segmentation::UnicodeSegmentation; use crate::domain::models::{ AssignTaskStatus, AssignTasksResponse, AssignTasksResult, CreateInitiativeRepoArgs, CreateInitiativeRequest, InitiativeBasic, InitiativeDetail, InitiativeError, InitiativeId, - InitiativeList, MAX_INITIATIVE_DESCRIPTION_GRAPHEMES, MAX_INITIATIVE_NAME_GRAPHEMES, - MAX_TASKS_PER_ASSIGN, TaskAssignment, UpdateInitiativeRepoArgs, UpdateInitiativeRequest, + InitiativeList, LockstepTeamShare, MAX_INITIATIVE_DESCRIPTION_GRAPHEMES, + MAX_INITIATIVE_NAME_GRAPHEMES, MAX_TASKS_PER_ASSIGN, NewDescriptionDocument, TaskAssignment, + UpdateInitiativeRepoArgs, UpdateInitiativeRequest, }; -use crate::domain::ports::{InitiativeRepo, InitiativeService}; +use crate::domain::ports::{InitiativeDescriptionDocuments, InitiativeRepo, InitiativeService}; -/// Concrete initiative service backed by an [`InitiativeRepo`]. +/// Concrete initiative service backed by an [`InitiativeRepo`] and the description document +/// port. #[derive(Debug, Clone)] -pub struct InitiativeServiceImpl { +pub struct InitiativeServiceImpl { repo: R, + description_documents: D, } -impl InitiativeServiceImpl +impl InitiativeServiceImpl where R: InitiativeRepo, + D: InitiativeDescriptionDocuments, { - /// Create an initiative service backed by the provided repository. - pub fn new(repo: R) -> Self { - Self { repo } + /// Create an initiative service backed by the provided repository and document port. + pub fn new(repo: R, description_documents: D) -> Self { + Self { + repo, + description_documents, + } } - async fn authorize_initiative_team_share( + /// Authorize one team-share edit against both entities from a single snapshot of facts. + /// The description document is owned by the initiative owner, so a `NotOwner` on it means + /// the two have drifted apart rather than that the caller lacks authority. + async fn authorize_lockstep_team_share( &self, receipt: &EntityAccessReceipt, request: TeamShareRequest, - ) -> Result, InitiativeError> { + ) -> Result, InitiativeError> { if request == TeamShareRequest::default() { return Ok(None); } @@ -56,27 +65,39 @@ where .get_team_share_facts(id) .await .map_err(Into::into)?; - authorize_team_share( - receipt.acting_user_id(), - &facts, - request, - TeamShareLevel::Edit, - ) - .map_err(|error| match error { - TeamSharePolicyError::MissingActor | TeamSharePolicyError::NotOwner => { - InitiativeError::Unauthorized - } - TeamSharePolicyError::InvalidRevision => InitiativeError::Conflict(error.to_string()), - _ => InitiativeError::BadRequest(error.to_string()), - }) + let actor = receipt.acting_user_id(); + let initiative = + authorize_team_share(actor, &facts.initiative, request, TeamShareLevel::Edit) + .map_err(team_share_error)?; + let description = + authorize_team_share(actor, &facts.description, request, TeamShareLevel::Edit) + .map_err(|error| match error { + TeamSharePolicyError::NotOwner => InitiativeError::Conflict( + "description document is not owned by the initiative owner".to_string(), + ), + other => team_share_error(other), + })?; + let (Some(initiative), Some(description)) = (initiative, description) else { + return Err(InitiativeError::Internal(rootcause::report!( + "team-share authorization produced no command for a supplied request" + ))); + }; + Ok(Some(LockstepTeamShare { + initiative, + description, + })) } } -impl InitiativeService for InitiativeServiceImpl +impl InitiativeService for InitiativeServiceImpl where R: InitiativeRepo, R::Err: Into, + D: InitiativeDescriptionDocuments, { + /// Two commits with compensation: the documents side commits the description document + /// first, then the initiative transaction links it and mirrors the grants. A failed + /// initiative write purges the document so nothing orphaned survives a `Err`. #[tracing::instrument(err, skip_all)] async fn create( &self, @@ -84,7 +105,7 @@ where request: CreateInitiativeRequest, ) -> Result { let name = normalize_name(&request.name)?; - let description = normalize_description(request.description)?; + let prefill_markdown = normalize_description(request.description)?; let owner_id = user_id.clone().into_owned(); let member_ids = parse_member_ids(request.member_ids.unwrap_or_default(), &owner_id)?; let team_default = self @@ -98,20 +119,50 @@ where } else { TeamShareCreation::Unshared }; - self.repo + + let description_document_id = self + .description_documents + .create(NewDescriptionDocument { + owner: owner_id.clone(), + name: name.clone(), + prefill_markdown, + link_share: share_permission.link_share_state(), + }) + .await?; + + let id = InitiativeId::generate(); + let created = self + .repo .create( CreateInitiativeRepoArgs { - id: InitiativeId::generate(), + id, owner_id, name, - description, + description_document_id, member_ids, }, share_permission, team_share, ) - .await - .map_err(Into::into) + .await; + match created { + Ok(detail) => Ok(detail), + Err(error) => { + if let Err(purge_error) = self + .description_documents + .purge(description_document_id) + .await + { + tracing::error!( + error = ?purge_error, + %description_document_id, + %id, + "description document orphaned after failed initiative create" + ); + } + Err(error.into()) + } + } } #[tracing::instrument(err, skip_all)] @@ -159,10 +210,6 @@ where } let name = request.name.as_deref().map(normalize_name).transpose()?; - let description = match request.description { - None => None, - Some(value) => Some(normalize_description(Some(value))?), - }; let id = initiative_id_from_receipt(&receipt)?; let (member_ids_added, member_ids_removed) = if let Some(member_ids) = request.member_ids { @@ -179,7 +226,7 @@ where }; let team_share = if let Some(share_permission) = request.share_permission.as_ref() { - self.authorize_initiative_team_share( + self.authorize_lockstep_team_share( &receipt, TeamShareRequest { access_level: share_permission.team_share_access_level, @@ -195,7 +242,6 @@ where .update(UpdateInitiativeRepoArgs { id, name, - description, member_ids_added, member_ids_removed, share_permission: request.share_permission, @@ -262,13 +308,42 @@ where .map_err(Into::into) } + /// Initiative rows first, document second. The FK's `ON DELETE SET NULL` means the other + /// order could leave an initiative with no description after a half-failure, which is + /// worse for readers than an orphaned document. #[tracing::instrument(err, skip_all)] async fn delete( &self, receipt: EntityAccessReceipt, ) -> Result<(), InitiativeError> { let id = initiative_id_from_receipt(&receipt)?; - self.repo.delete(id).await.map_err(Into::into) + let Some(description_document_id) = self.repo.delete(id).await.map_err(Into::into)? else { + return Ok(()); + }; + self.description_documents + .purge(description_document_id) + .await + .inspect_err(|_| { + tracing::error!( + %description_document_id, + %id, + "description document orphaned after initiative delete" + ); + }) + } +} + +fn team_share_error(error: TeamSharePolicyError) -> InitiativeError { + match error { + TeamSharePolicyError::MissingActor | TeamSharePolicyError::NotOwner => { + InitiativeError::Unauthorized + } + TeamSharePolicyError::InvalidRevision => InitiativeError::Conflict(error.to_string()), + TeamSharePolicyError::MissingTeam + | TeamSharePolicyError::InvalidLevel + | TeamSharePolicyError::ContradictoryInputs => { + InitiativeError::BadRequest(error.to_string()) + } } } @@ -305,20 +380,14 @@ fn normalize_name(name: &str) -> Result { Ok(name.to_string()) } -fn normalize_description(description: Option) -> Result, InitiativeError> { - let Some(description) = description else { - return Ok(None); - }; - let description = description.trim(); - if description.is_empty() { - return Ok(None); - } +fn normalize_description(description: Option) -> Result { + let description = description.as_deref().unwrap_or_default().trim(); if description.graphemes(true).count() > MAX_INITIATIVE_DESCRIPTION_GRAPHEMES { return Err(InitiativeError::BadRequest(format!( "description must be at most {MAX_INITIATIVE_DESCRIPTION_GRAPHEMES} graphemes" ))); } - Ok(Some(description.to_string())) + Ok(description.to_string()) } fn parse_member_ids( diff --git a/crates/initiative/src/domain/service/test.rs b/crates/initiative/src/domain/service/test.rs index 640b651582b..c63a6717c4a 100644 --- a/crates/initiative/src/domain/service/test.rs +++ b/crates/initiative/src/domain/service/test.rs @@ -5,18 +5,24 @@ use entity_access::domain::models::{ }; use macro_user_id::cowlike::CowLike; use macro_user_id::user_id::MacroUserIdStr; +use mockall::Sequence; use models_permissions::share_permission::access_level::AccessLevel as ShareAccessLevel; use models_permissions::share_permission::team_share::{TeamShareCreation, TeamShareFacts}; -use models_permissions::share_permission::{SharePermissionV2, UpdateSharePermissionRequestV2}; +use models_permissions::share_permission::{ + LinkShare, LinkShareState, SharePermissionV2, TeamLinkShareDefault, + UpdateSharePermissionRequestV2, +}; use super::InitiativeServiceImpl; use crate::domain::models::{ - AssignTaskStatus, AssignTasksResult, CreateInitiativeRequest, InitiativeBasic, - InitiativeDetail, InitiativeError, InitiativeId, InitiativeList, InitiativeSummary, - MAX_INITIATIVE_DESCRIPTION_GRAPHEMES, MAX_INITIATIVE_NAME_GRAPHEMES, MAX_TASKS_PER_ASSIGN, - TaskAssignment, UpdateInitiativeRequest, + AssignTaskStatus, AssignTasksResult, CreateInitiativeRequest, DescriptionDocumentId, + InitiativeBasic, InitiativeDetail, InitiativeError, InitiativeId, InitiativeList, + InitiativeSummary, LockstepTeamShareFacts, MAX_INITIATIVE_DESCRIPTION_GRAPHEMES, + MAX_INITIATIVE_NAME_GRAPHEMES, MAX_TASKS_PER_ASSIGN, TaskAssignment, UpdateInitiativeRequest, +}; +use crate::domain::ports::{ + InitiativeService, MockInitiativeDescriptionDocuments, MockInitiativeRepo, }; -use crate::domain::ports::{InitiativeService, MockInitiativeRepo}; const OWNER: &str = "macro|owner@macro.com"; const MEMBER: &str = "macro|member@macro.com"; @@ -38,6 +44,10 @@ fn initiative_id() -> InitiativeId { InitiativeId::from_uuid(uuid::Uuid::from_u128(1)) } +fn description_document_id() -> DescriptionDocumentId { + DescriptionDocumentId::from_uuid(uuid::Uuid::from_u128(2)) +} + fn share_permission() -> SharePermissionV2 { SharePermissionV2::new_initiative_share_permission(None) } @@ -46,7 +56,7 @@ fn detail(member_ids: Vec>) -> InitiativeDetail { InitiativeDetail { id: initiative_id(), name: "Launch".to_string(), - description: None, + description_document_id: description_document_id(), owner_id: user(OWNER), member_ids, task_ids: Vec::new(), @@ -89,8 +99,18 @@ fn owner_receipt() -> EntityAccessReceipt { receipt(OWNER, EntityType::Initiative, AccessLevel::Owner) } -fn service(repo: MockInitiativeRepo) -> InitiativeServiceImpl { - InitiativeServiceImpl::new(repo) +/// A service whose document port panics on any call. +fn service( + repo: MockInitiativeRepo, +) -> InitiativeServiceImpl { + service_with_documents(repo, MockInitiativeDescriptionDocuments::new()) +} + +fn service_with_documents( + repo: MockInitiativeRepo, + documents: MockInitiativeDescriptionDocuments, +) -> InitiativeServiceImpl { + InitiativeServiceImpl::new(repo, documents) } fn share_update() -> UpdateSharePermissionRequestV2 { @@ -102,16 +122,27 @@ fn share_update() -> UpdateSharePermissionRequestV2 { } } -fn team_facts() -> TeamShareFacts { +fn team_facts(entity_type: EntityType, entity_id: String, owner: &str) -> TeamShareFacts { TeamShareFacts { - entity: EntityType::Initiative.with_entity_string(initiative_id().to_string()), - owner: user(OWNER), + entity: entity_type.with_entity_string(entity_id), + owner: user(owner), owner_team_id: Some(uuid::Uuid::from_u128(7)), current: None, revision: 0, } } +fn lockstep_facts(description_owner: &str) -> LockstepTeamShareFacts { + LockstepTeamShareFacts { + initiative: team_facts(EntityType::Initiative, initiative_id().to_string(), OWNER), + description: team_facts( + EntityType::Document, + description_document_id().to_string(), + description_owner, + ), + } +} + #[tokio::test] async fn create_rejects_empty_and_too_long_names() { let repo = MockInitiativeRepo::new(); @@ -145,9 +176,9 @@ async fn create_rejects_empty_and_too_long_names() { } #[tokio::test] -async fn create_rejects_too_long_description() { - let repo = MockInitiativeRepo::new(); - let result = service(repo) +async fn create_rejects_too_long_description_before_creating_any_document() { + // No expectations on either port: a call to `create` on the document port would panic. + let result = service(MockInitiativeRepo::new()) .create( &user(OWNER), CreateInitiativeRequest { @@ -170,10 +201,15 @@ async fn create_drops_owner_from_members_and_shares_with_team() { *team_share == TeamShareCreation::Initiative && args.member_ids == vec![user(MEMBER)] && args.owner_id == user(OWNER) + && args.description_document_id == description_document_id() }) .return_once(|_, _, _| Box::pin(async { Ok(detail(vec![user(MEMBER)])) })); + let mut documents = MockInitiativeDescriptionDocuments::new(); + documents + .expect_create() + .return_once(|_| Box::pin(async { Ok(description_document_id()) })); - let created = service(repo) + let created = service_with_documents(repo, documents) .create( &user(OWNER), CreateInitiativeRequest { @@ -186,6 +222,128 @@ async fn create_drops_owner_from_members_and_shares_with_team() { .await .expect("created"); assert_eq!(created.member_ids, vec![user(MEMBER)]); + assert_eq!(created.description_document_id, description_document_id()); +} + +#[tokio::test] +async fn create_asks_for_a_description_document_named_after_the_initiative() { + let mut sequence = Sequence::new(); + let mut repo = MockInitiativeRepo::new(); + let mut documents = MockInitiativeDescriptionDocuments::new(); + repo.expect_get_team_default_link_share() + .return_once(|_| Box::pin(async { Ok(None) })); + documents + .expect_create() + .withf(|document| { + document.owner == user(OWNER) + && document.name == "Launch" + && document.prefill_markdown == "# Goals" + && document.link_share == LinkShareState::Off + }) + .times(1) + .in_sequence(&mut sequence) + .return_once(|_| Box::pin(async { Ok(description_document_id()) })); + repo.expect_create() + .withf(|args, share_permission, _| { + args.description_document_id == description_document_id() + && share_permission.link_share_state() == LinkShareState::Off + }) + .times(1) + .in_sequence(&mut sequence) + .return_once(|_, _, _| Box::pin(async { Ok(detail(Vec::new())) })); + + service_with_documents(repo, documents) + .create( + &user(OWNER), + CreateInitiativeRequest { + name: " Launch ".into(), + description: Some(" # Goals\n".into()), + ..Default::default() + }, + ) + .await + .expect("created"); +} + +#[tokio::test] +async fn create_copies_the_initiative_link_share_onto_the_document() { + let expected = LinkShareState::On { + scope: LinkShare::Team, + level: ShareAccessLevel::View, + }; + let mut repo = MockInitiativeRepo::new(); + let mut documents = MockInitiativeDescriptionDocuments::new(); + repo.expect_get_team_default_link_share() + .return_once(|_| Box::pin(async { Ok(Some(TeamLinkShareDefault(Some(LinkShare::Team)))) })); + documents + .expect_create() + .withf(move |document| { + document.link_share == expected && document.prefill_markdown.is_empty() + }) + .return_once(|_| Box::pin(async { Ok(description_document_id()) })); + repo.expect_create() + .withf(move |_, share_permission, _| share_permission.link_share_state() == expected) + .return_once(|_, _, _| Box::pin(async { Ok(detail(Vec::new())) })); + + service_with_documents(repo, documents) + .create( + &user(OWNER), + CreateInitiativeRequest { + name: "Launch".into(), + ..Default::default() + }, + ) + .await + .expect("created"); +} + +#[tokio::test] +async fn failed_initiative_write_purges_the_document_and_returns_the_original_error() { + let mut sequence = Sequence::new(); + let mut repo = MockInitiativeRepo::new(); + let mut documents = MockInitiativeDescriptionDocuments::new(); + repo.expect_get_team_default_link_share() + .return_once(|_| Box::pin(async { Ok(None) })); + documents + .expect_create() + .times(1) + .in_sequence(&mut sequence) + .return_once(|_| Box::pin(async { Ok(description_document_id()) })); + repo.expect_create() + .times(1) + .in_sequence(&mut sequence) + .return_once(|_, _, _| { + Box::pin(async { + Err(InitiativeError::Conflict( + "initiative already exists".into(), + )) + }) + }); + documents + .expect_purge() + .withf(|id| *id == description_document_id()) + .times(1) + .in_sequence(&mut sequence) + .return_once(|_| { + Box::pin(async { + Err(InitiativeError::Internal(rootcause::report!( + "purge failed" + ))) + }) + }); + + let result = service_with_documents(repo, documents) + .create( + &user(OWNER), + CreateInitiativeRequest { + name: "Launch".into(), + ..Default::default() + }, + ) + .await; + assert!( + matches!(result, Err(InitiativeError::Conflict(ref message)) if message == "initiative already exists") + ); } #[tokio::test] @@ -287,12 +445,23 @@ async fn update_rejects_bad_member_id() { } #[tokio::test] -async fn owner_can_patch_share_permission() { +async fn owner_patches_team_share_on_both_entities_from_one_snapshot() { let mut repo = MockInitiativeRepo::new(); repo.expect_get_team_share_facts() - .return_once(|_| Box::pin(async { Ok(team_facts()) })); + .withf(|id| *id == initiative_id()) + .times(1) + .return_once(|_| Box::pin(async { Ok(lockstep_facts(OWNER)) })); repo.expect_update() - .withf(|args| args.team_share.is_some()) + .withf(|args| { + let Some(team_share) = args.team_share.as_ref() else { + return false; + }; + let facts = lockstep_facts(OWNER); + team_share.initiative.expected() == &facts.initiative + && team_share.description.expected() == &facts.description + && team_share.initiative.target().map(|grant| grant.level) + == team_share.description.target().map(|grant| grant.level) + }) .return_once(|_| Box::pin(async { Ok(detail(Vec::new())) })); service(repo) @@ -307,6 +476,24 @@ async fn owner_can_patch_share_permission() { .expect("updated"); } +#[tokio::test] +async fn team_share_patch_conflicts_when_the_document_owner_drifted() { + let mut repo = MockInitiativeRepo::new(); + repo.expect_get_team_share_facts() + .return_once(|_| Box::pin(async { Ok(lockstep_facts(OTHER)) })); + + let result = service(repo) + .update( + owner_edit_receipt(), + UpdateInitiativeRequest { + share_permission: Some(share_update()), + ..Default::default() + }, + ) + .await; + assert!(matches!(result, Err(InitiativeError::Conflict(_)))); +} + #[tokio::test] async fn assign_rejects_non_initiative_receipts() { let repo = MockInitiativeRepo::new(); @@ -401,7 +588,7 @@ async fn assign_dedupes_enforces_cap_and_preserves_order() { } #[tokio::test] -async fn get_list_unassign_and_delete_call_the_repo() { +async fn get_list_and_unassign_call_the_repo() { let mut repo = MockInitiativeRepo::new(); repo.expect_get_basic().return_once(|_| { Box::pin(async { @@ -420,7 +607,7 @@ async fn get_list_unassign_and_delete_call_the_repo() { initiatives: vec![InitiativeSummary { id: initiative_id(), name: "Launch".into(), - description: None, + description_document_id: description_document_id(), updated_at: now(), }], }) @@ -428,8 +615,6 @@ async fn get_list_unassign_and_delete_call_the_repo() { }); repo.expect_unassign_task() .return_once(|_, _| Box::pin(async { Ok(()) })); - repo.expect_delete() - .return_once(|_| Box::pin(async { Ok(()) })); let svc = service(repo); svc.internal_get_basic(initiative_id()) @@ -440,5 +625,55 @@ async fn get_list_unassign_and_delete_call_the_repo() { svc.unassign_task(edit_receipt(), "task-1") .await .expect("unassign"); - svc.delete(owner_receipt()).await.expect("delete"); +} + +#[tokio::test] +async fn delete_purges_the_document_after_the_initiative_is_gone() { + let mut sequence = Sequence::new(); + let mut repo = MockInitiativeRepo::new(); + let mut documents = MockInitiativeDescriptionDocuments::new(); + repo.expect_delete() + .withf(|id| *id == initiative_id()) + .times(1) + .in_sequence(&mut sequence) + .return_once(|_| Box::pin(async { Ok(Some(description_document_id())) })); + documents + .expect_purge() + .withf(|id| *id == description_document_id()) + .times(1) + .in_sequence(&mut sequence) + .return_once(|_| Box::pin(async { Ok(()) })); + + service_with_documents(repo, documents) + .delete(owner_receipt()) + .await + .expect("deleted"); +} + +#[tokio::test] +async fn delete_skips_the_purge_for_a_row_without_a_document() { + let mut repo = MockInitiativeRepo::new(); + repo.expect_delete() + .return_once(|_| Box::pin(async { Ok(None) })); + + service(repo) + .delete(owner_receipt()) + .await + .expect("deleted"); +} + +#[tokio::test] +async fn delete_surfaces_a_failed_purge_after_the_initiative_is_gone() { + let mut repo = MockInitiativeRepo::new(); + let mut documents = MockInitiativeDescriptionDocuments::new(); + repo.expect_delete() + .return_once(|_| Box::pin(async { Ok(Some(description_document_id())) })); + documents.expect_purge().return_once(|_| { + Box::pin(async { Err(InitiativeError::Internal(rootcause::report!("sync down"))) }) + }); + + let result = service_with_documents(repo, documents) + .delete(owner_receipt()) + .await; + assert!(matches!(result, Err(InitiativeError::Internal(_)))); } diff --git a/crates/initiative/src/inbound/axum_router/test.rs b/crates/initiative/src/inbound/axum_router/test.rs index 23ea0084b10..0b990107b50 100644 --- a/crates/initiative/src/inbound/axum_router/test.rs +++ b/crates/initiative/src/inbound/axum_router/test.rs @@ -29,8 +29,9 @@ use super::*; use crate::domain::{ models::{ AssignTaskStatus, AssignTasksResponse, AssignTasksResult, CreateInitiativeRequest, - InitiativeBasic, InitiativeDetail, InitiativeError, InitiativeId, InitiativeList, - InitiativeSummary, MAX_INITIATIVE_NAME_GRAPHEMES, TaskAssignment, UpdateInitiativeRequest, + DescriptionDocumentId, InitiativeBasic, InitiativeDetail, InitiativeError, InitiativeId, + InitiativeList, InitiativeSummary, MAX_INITIATIVE_NAME_GRAPHEMES, TaskAssignment, + UpdateInitiativeRequest, }, ports::InitiativeService, }; @@ -49,6 +50,10 @@ fn unknown_id() -> InitiativeId { InitiativeId::from_uuid(Uuid::from_u128(99)) } +fn description_document_id() -> DescriptionDocumentId { + DescriptionDocumentId::from_uuid(Uuid::from_u128(2)) +} + fn now() -> DateTime { Utc.with_ymd_and_hms(2026, 9, 15, 12, 0, 0) .single() @@ -86,7 +91,7 @@ fn sample_detail() -> InitiativeDetail { InitiativeDetail { id: existing_id(), name: "Launch".to_string(), - description: None, + description_document_id: description_document_id(), owner_id: user(), member_ids: Vec::new(), task_ids: Vec::new(), @@ -102,7 +107,7 @@ fn sample_list() -> InitiativeList { initiatives: vec![InitiativeSummary { id: existing_id(), name: "Launch".to_string(), - description: None, + description_document_id: description_document_id(), updated_at: now(), }], } @@ -551,6 +556,32 @@ async fn create_returns_200() { ); } +#[tokio::test] +async fn create_response_points_at_the_description_document_instead_of_inlining_text() { + let response = send( + build_router( + FakeInitiativeService::default(), + FakeEntityAccessService::default(), + ), + authed(axum::http::Request::post("/")) + .header(header::CONTENT_TYPE, "application/json") + .body(json_body(serde_json::json!({ + "name": "Launch", + "description": "## Goals" + }))) + .expect("request should build"), + ) + .await; + + assert_eq!(response.status(), StatusCode::OK); + let body = read_json(response).await; + assert_eq!( + body["descriptionDocumentId"], + serde_json::json!(description_document_id().to_string()) + ); + assert!(body.get("description").is_none()); +} + #[tokio::test] async fn get_returns_200() { let service = FakeInitiativeService::default(); diff --git a/crates/initiative/src/outbound/pg_initiative_repo.rs b/crates/initiative/src/outbound/pg_initiative_repo.rs index 539106e153d..aaaa795bca6 100644 --- a/crates/initiative/src/outbound/pg_initiative_repo.rs +++ b/crates/initiative/src/outbound/pg_initiative_repo.rs @@ -16,7 +16,7 @@ use macro_user_id::user_id::MacroUserIdStr; use model_entity::{Entity, EntityType}; use models_permissions::share_permission::access_level::AccessLevel; use models_permissions::share_permission::channel_share_permission::ChannelSharePermission; -use models_permissions::share_permission::team_share::{TeamShareCreation, TeamShareFacts}; +use models_permissions::share_permission::team_share::TeamShareCreation; use models_permissions::share_permission::{LinkShare, SharePermissionV2, TeamLinkShareDefault}; use rootcause::prelude::*; use share_permission_db_utils::team_share::TeamShareError; @@ -24,13 +24,17 @@ use sqlx::postgres::PgDatabaseError; use sqlx::{Executor, PgPool, Postgres}; use crate::domain::models::{ - AssignTasksResult, CreateInitiativeRepoArgs, InitiativeBasic, InitiativeDetail, - InitiativeError, InitiativeId, InitiativeList, UpdateInitiativeRepoArgs, + AssignTasksResult, CreateInitiativeRepoArgs, DescriptionDocumentId, InitiativeBasic, + InitiativeDetail, InitiativeError, InitiativeId, InitiativeList, LockstepTeamShareFacts, + UpdateInitiativeRepoArgs, }; use crate::domain::ports::InitiativeRepo; const DETAIL_ACCESS_WITHOUT_ACTOR: AccessLevel = AccessLevel::View; +/// Postgres' generated name for the `UNIQUE` on `initiative.description_document_id`. +const DESCRIPTION_DOCUMENT_UNIQUE: &str = "initiative_description_document_id_key"; + /// PostgreSQL `InitiativeRepo`. One pool. One transaction per mutation. #[derive(Clone)] pub struct PgInitiativeRepo { @@ -91,8 +95,11 @@ impl InitiativeRepo for PgInitiativeRepo { } #[tracing::instrument(err, skip(self))] - async fn get_team_share_facts(&self, id: InitiativeId) -> Result { - share::get_team_share_facts(&self.pool, id).await + async fn get_team_share_facts( + &self, + id: InitiativeId, + ) -> Result { + share::get_lockstep_team_share_facts(&self.pool, id).await } #[tracing::instrument(err, skip(self))] @@ -118,15 +125,55 @@ impl InitiativeRepo for PgInitiativeRepo { } #[tracing::instrument(err, skip(self))] - async fn delete(&self, id: InitiativeId) -> Result<(), Self::Err> { + async fn delete(&self, id: InitiativeId) -> Result, Self::Err> { create::delete(&self.pool, id).await } } +/// Both entities every grant write targets. Built once per mutation and threaded through +/// every helper, so no code path can grant on the initiative and forget the document. +#[derive(Debug, Clone, Copy)] +struct GrantTargets { + initiative: uuid::Uuid, + description: uuid::Uuid, +} + +impl GrantTargets { + fn new(initiative: InitiativeId, description: DescriptionDocumentId) -> Self { + Self { + initiative: initiative.as_uuid(), + description: description.as_uuid(), + } + } + + fn initiative_id(&self) -> uuid::Uuid { + self.initiative + } + + fn description_id(&self) -> uuid::Uuid { + self.description + } + + fn initiative_entity(&self) -> Entity<'static> { + EntityType::Initiative.with_entity_string(self.initiative.to_string()) + } + + fn description_entity(&self) -> Entity<'static> { + EntityType::Document.with_entity_string(self.description.to_string()) + } + + fn each(&self) -> [(uuid::Uuid, EntityType); 2] { + [ + (self.initiative, EntityType::Initiative), + (self.description, EntityType::Document), + ] + } +} + struct InitiativeRecord { id: uuid::Uuid, name: String, - description: String, + description_document_id: Option, owner_user_id: String, created_at: chrono::DateTime, updated_at: chrono::DateTime, @@ -154,7 +201,10 @@ impl InitiativeRecord { Ok(InitiativeDetail { id: InitiativeId::from_uuid(self.id), name: self.name, - description: description_from_db(self.description), + description_document_id: require_description_document_id( + self.id, + self.description_document_id, + )?, owner_id, member_ids: parse_members(self.member_ids)?, task_ids: self.task_ids, @@ -166,17 +216,29 @@ impl InitiativeRecord { } } -fn description_from_db(value: String) -> Option { - let trimmed = value.trim(); - if trimmed.is_empty() { - None - } else { - Some(trimmed.to_string()) - } +/// A NULL is a deploy-window artifact, not a valid state; failing loudly here keeps +/// `description_document_id` non-optional for every caller instead of forever. +fn require_description_document_id( + initiative: uuid::Uuid, + raw: Option, +) -> Result { + let raw = raw.ok_or_else(|| { + InitiativeError::Internal(report!( + "initiative {initiative} has no description document; backfill required" + )) + })?; + parse_description_document_id(initiative, &raw) } -fn description_to_db(value: Option<&str>) -> &str { - value.unwrap_or("") +fn parse_description_document_id( + initiative: uuid::Uuid, + raw: &str, +) -> Result { + DescriptionDocumentId::from_str(raw).map_err(|error| { + InitiativeError::Internal(report!( + "initiative {initiative} has a non-UUID description document id {raw:?}: {error}" + )) + }) } fn parse_owner(raw: &str) -> Result, InitiativeError> { @@ -244,6 +306,13 @@ fn map_sqlx(error: AdapterError) -> InitiativeError { fn classify_sqlx(error: sqlx::Error) -> InitiativeError { if let Some(db) = error.as_database_error() { if db.is_unique_violation() { + // The service mints a fresh document per create, so a second initiative on the + // same document is a bug in this code path, not a request the caller can fix. + if db.constraint() == Some(DESCRIPTION_DOCUMENT_UNIQUE) { + return InitiativeError::Internal(report!( + "description document already linked to another initiative: {error}" + )); + } return InitiativeError::Conflict("initiative already exists".to_string()); } if is_initiative_member_fk(db) { @@ -278,10 +347,6 @@ fn classify_team_share( } } -fn entity_of(id: InitiativeId) -> Entity<'static> { - EntityType::Initiative.with_entity_string(id.as_uuid().to_string()) -} - async fn load_record( executor: impl Executor<'_, Database = Postgres>, id: InitiativeId, @@ -292,7 +357,7 @@ async fn load_record( SELECT i.id, i.name, - i.description, + i.description_document_id, i.owner_user_id, i.created_at, i.updated_at, @@ -327,7 +392,7 @@ async fn load_record( GROUP BY i.id, i.name, - i.description, + i.description_document_id, i.owner_user_id, i.created_at, i.updated_at, @@ -344,7 +409,7 @@ async fn load_record( Ok(row.map(|row| InitiativeRecord { id: row.id, name: row.name, - description: row.description, + description_document_id: row.description_document_id, owner_user_id: row.owner_user_id, created_at: row.created_at, updated_at: row.updated_at, diff --git a/crates/initiative/src/outbound/pg_initiative_repo/create.rs b/crates/initiative/src/outbound/pg_initiative_repo/create.rs index 020f0dee8da..3afccc46cbd 100644 --- a/crates/initiative/src/outbound/pg_initiative_repo/create.rs +++ b/crates/initiative/src/outbound/pg_initiative_repo/create.rs @@ -2,15 +2,21 @@ use entity_access_db_utils::{ AccessLevel, EntityAccessSourceType, EntityType, delete_entity_access_rows, insert_entity_access_row, }; +use model_entity::Entity; use models_permissions::share_permission::SharePermissionV2; -use models_permissions::share_permission::team_share::TeamShareCreation; +use models_permissions::share_permission::team_share::{ + AuthorizedTeamShareCommand, TeamShareCreation, +}; use share_permission_db_utils::team_share; use sqlx::{PgPool, Postgres, Transaction}; -use super::{AdapterError, description_to_db, entity_of, map_sqlx, require_detail}; +use super::{ + AdapterError, GrantTargets, map_sqlx, parse_description_document_id, + require_description_document_id, require_detail, +}; use crate::domain::models::{ - CreateInitiativeRepoArgs, InitiativeDetail, InitiativeError, InitiativeId, - UpdateInitiativeRepoArgs, + CreateInitiativeRepoArgs, DescriptionDocumentId, InitiativeDetail, InitiativeError, + InitiativeId, UpdateInitiativeRepoArgs, }; pub(super) async fn create( @@ -25,7 +31,7 @@ pub(super) async fn create( .map_err(AdapterError::Sqlx) .map_err(map_sqlx)?; let id = args.id.as_uuid(); - let entity = entity_of(args.id); + let targets = GrantTargets::new(args.id, args.description_document_id); team_share::acquire_guard(&mut tx) .await @@ -40,17 +46,17 @@ pub(super) async fn create( INSERT INTO initiative ( id, name, - description, owner_user_id, - share_permission_id + share_permission_id, + description_document_id ) VALUES ($1, $2, $3, $4, $5) "#, id, args.name, - description_to_db(args.description.as_deref()), args.owner_id.as_ref(), share_permission_id, + args.description_document_id.to_string(), ) .execute(tx.as_mut()) .await @@ -59,6 +65,7 @@ pub(super) async fn create( super::members::insert_members(&mut tx, id, &args.member_ids).await?; + // The document's Owner row was written when the documents side created it. insert_entity_access_row( &mut tx, &id, @@ -71,13 +78,23 @@ pub(super) async fn create( .map_err(AdapterError::Sqlx) .map_err(map_sqlx)?; - super::members::grant_members_edit(&mut tx, id, &args.member_ids).await?; + super::members::grant_members_edit(&mut tx, &targets, &args.member_ids).await?; - team_share::initialize(&mut tx, &entity, team_share) + team_share::initialize(&mut tx, &targets.initiative_entity(), team_share) .await .map_err(AdapterError::TeamShareCreate) .map_err(map_sqlx)?; + // The document was minted for this owner moments ago and nobody else can see it yet, so + // any failure here is an invariant violation rather than something the caller can fix. + team_share::initialize( + &mut tx, + &targets.description_entity(), + description_team_share(team_share), + ) + .await + .map_err(|error| InitiativeError::Internal(error.into()))?; + tx.commit() .await .map_err(AdapterError::Sqlx) @@ -86,6 +103,15 @@ pub(super) async fn create( require_detail(pool, args.id).await } +/// Team share on the document follows the initiative's create-time intent, under the variant +/// that names the document so the write is visibly deliberate. +fn description_team_share(intent: TeamShareCreation) -> TeamShareCreation { + match intent { + TeamShareCreation::Initiative => TeamShareCreation::InitiativeDescription, + other => other, + } +} + pub(super) async fn update( pool: &PgPool, args: UpdateInitiativeRepoArgs, @@ -95,7 +121,6 @@ pub(super) async fn update( .await .map_err(AdapterError::Sqlx) .map_err(map_sqlx)?; - let id = args.id.as_uuid(); if args.team_share.is_some() { team_share::acquire_guard(&mut tx) @@ -104,33 +129,56 @@ pub(super) async fn update( .map_err(map_sqlx)?; } - patch_initiative_row(&mut tx, &args).await?; + let patched = patch_initiative_row(&mut tx, &args).await?; + let targets = GrantTargets::new(args.id, patched.description_document_id); + super::members::apply_member_diff( &mut tx, - id, + &targets, &args.member_ids_added, &args.member_ids_removed, ) .await?; if let Some(update) = &args.share_permission { - super::share::patch_share_permission(&mut tx, id, update).await?; + let description_share_permission_id = + super::share::description_share_permission_id(&mut tx, patched.description_document_id) + .await?; + super::share::apply_share_patch( + &mut tx, + super::share::ShareTarget::initiative(&targets, &patched.share_permission_id), + update, + ) + .await?; + super::share::apply_share_patch( + &mut tx, + super::share::ShareTarget::description(&targets, &description_share_permission_id), + update, + ) + .await?; } - if let Some(command) = &args.team_share { - if command.expected().entity.entity_type != model_entity::EntityType::Initiative - || command.expected().entity.entity_id != args.id.to_string() - || args - .share_permission - .as_ref() - .and_then(|permission| permission.team_share_access_level) - != Some(command.target().map(|grant| grant.level.into())) + if let Some(lockstep) = &args.team_share { + let requested = args + .share_permission + .as_ref() + .and_then(|permission| permission.team_share_access_level); + let matches_edit = |command: &AuthorizedTeamShareCommand, entity: Entity<'static>| { + command.expected().entity == entity + && requested == Some(command.target().map(|grant| grant.level.into())) + }; + if !matches_edit(&lockstep.initiative, targets.initiative_entity()) + || !matches_edit(&lockstep.description, targets.description_entity()) { return Err(InitiativeError::BadRequest( "team-share command does not match edit".to_string(), )); } - team_share::apply(&mut tx, command) + team_share::apply(&mut tx, &lockstep.initiative) + .await + .map_err(AdapterError::TeamShare) + .map_err(map_sqlx)?; + team_share::apply(&mut tx, &lockstep.description) .await .map_err(AdapterError::TeamShare) .map_err(map_sqlx)?; @@ -144,7 +192,11 @@ pub(super) async fn update( require_detail(pool, args.id).await } -pub(super) async fn delete(pool: &PgPool, id: InitiativeId) -> Result<(), InitiativeError> { +/// The document's rows are untouched here; the service purges them after this commits. +pub(super) async fn delete( + pool: &PgPool, + id: InitiativeId, +) -> Result, InitiativeError> { let mut tx = pool .begin() .await @@ -157,11 +209,11 @@ pub(super) async fn delete(pool: &PgPool, id: InitiativeId) -> Result<(), Initia .map_err(AdapterError::Sqlx) .map_err(map_sqlx)?; - let share_permission_id = sqlx::query_scalar!( + let deleted = sqlx::query!( r#" DELETE FROM initiative WHERE id = $1 - RETURNING share_permission_id + RETURNING share_permission_id, description_document_id "#, uuid, ) @@ -176,7 +228,7 @@ pub(super) async fn delete(pool: &PgPool, id: InitiativeId) -> Result<(), Initia DELETE FROM "SharePermission" WHERE id = $1 "#, - share_permission_id, + deleted.share_permission_id, ) .execute(tx.as_mut()) .await @@ -187,39 +239,47 @@ pub(super) async fn delete(pool: &PgPool, id: InitiativeId) -> Result<(), Initia .await .map_err(AdapterError::Sqlx) .map_err(map_sqlx)?; - Ok(()) + + deleted + .description_document_id + .as_deref() + .map(|raw| parse_description_document_id(uuid, raw)) + .transpose() +} + +/// Row values the rest of the update transaction needs after patching. +struct PatchedRow { + share_permission_id: String, + description_document_id: DescriptionDocumentId, } async fn patch_initiative_row( tx: &mut Transaction<'_, Postgres>, args: &UpdateInitiativeRepoArgs, -) -> Result<(), InitiativeError> { - let description_value = args - .description - .as_ref() - .map(|description| description_to_db(description.as_deref())) - .unwrap_or(""); - let result = sqlx::query!( +) -> Result { + let row = sqlx::query!( r#" UPDATE initiative SET name = CASE WHEN $2 THEN $3 ELSE name END, - description = CASE WHEN $4 THEN $5 ELSE description END, updated_at = now() WHERE id = $1 + RETURNING share_permission_id, description_document_id "#, args.id.as_uuid(), args.name.is_some(), args.name.as_deref(), - args.description.is_some(), - description_value, ) - .execute(tx.as_mut()) + .fetch_optional(tx.as_mut()) .await .map_err(AdapterError::Sqlx) - .map_err(map_sqlx)?; - if result.rows_affected() == 0 { - return Err(InitiativeError::NotFound); - } - Ok(()) + .map_err(map_sqlx)? + .ok_or(InitiativeError::NotFound)?; + Ok(PatchedRow { + share_permission_id: row.share_permission_id, + description_document_id: require_description_document_id( + args.id.as_uuid(), + row.description_document_id, + )?, + }) } diff --git a/crates/initiative/src/outbound/pg_initiative_repo/list.rs b/crates/initiative/src/outbound/pg_initiative_repo/list.rs index 36a432daa9d..fa9d2422b7d 100644 --- a/crates/initiative/src/outbound/pg_initiative_repo/list.rs +++ b/crates/initiative/src/outbound/pg_initiative_repo/list.rs @@ -1,7 +1,7 @@ use macro_user_id::user_id::MacroUserIdStr; use sqlx::PgPool; -use super::{AdapterError, description_from_db, map_sqlx}; +use super::{AdapterError, map_sqlx, require_description_document_id}; use crate::domain::models::{InitiativeError, InitiativeId, InitiativeList, InitiativeSummary}; pub(super) async fn list_accessible( @@ -24,7 +24,7 @@ pub(super) async fn list_accessible( SELECT i.id, i.name, - i.description, + i.description_document_id, i.updated_at FROM initiative i JOIN "SharePermission" sp ON sp.id = i.share_permission_id @@ -53,15 +53,20 @@ pub(super) async fn list_accessible( .map_err(AdapterError::Sqlx) .map_err(map_sqlx)?; - Ok(InitiativeList { - initiatives: rows - .into_iter() - .map(|row| InitiativeSummary { + let initiatives = rows + .into_iter() + .map(|row| { + Ok(InitiativeSummary { id: InitiativeId::from_uuid(row.id), name: row.name, - description: description_from_db(row.description), + description_document_id: require_description_document_id( + row.id, + row.description_document_id, + )?, updated_at: row.updated_at, }) - .collect(), - }) + }) + .collect::, InitiativeError>>()?; + + Ok(InitiativeList { initiatives }) } diff --git a/crates/initiative/src/outbound/pg_initiative_repo/members.rs b/crates/initiative/src/outbound/pg_initiative_repo/members.rs index 287a5209628..17ea42cb802 100644 --- a/crates/initiative/src/outbound/pg_initiative_repo/members.rs +++ b/crates/initiative/src/outbound/pg_initiative_repo/members.rs @@ -1,11 +1,11 @@ use entity_access_db_utils::{ - AccessLevel, EntityType, delete_user_entity_access_rows, upsert_user_entity_access_bulk, + AccessLevel, delete_user_entity_access_rows, upsert_user_entity_access_bulk, }; use macro_user_id::user_id::MacroUserIdStr; use sqlx::{Postgres, Transaction}; use uuid::Uuid; -use super::{AdapterError, map_sqlx}; +use super::{AdapterError, GrantTargets, map_sqlx}; use crate::domain::models::InitiativeError; pub(super) async fn insert_members( @@ -32,28 +32,34 @@ pub(super) async fn insert_members( Ok(()) } +/// Edit on both entities. The upsert's owner guard keeps each Owner row intact when the +/// owner is also listed. pub(super) async fn grant_members_edit( tx: &mut Transaction<'_, Postgres>, - id: Uuid, + targets: &GrantTargets, member_ids: &[MacroUserIdStr<'static>], ) -> Result<(), InitiativeError> { - upsert_user_entity_access_bulk( - tx.as_mut(), - member_ids, - &id, - EntityType::Initiative, - AccessLevel::Edit, - ) - .await - .map_err(|error| InitiativeError::Internal(rootcause::report!("{error}"))) + for (entity_id, entity_type) in targets.each() { + upsert_user_entity_access_bulk( + tx.as_mut(), + member_ids, + &entity_id, + entity_type, + AccessLevel::Edit, + ) + .await + .map_err(|error| InitiativeError::Internal(rootcause::report!("{error}")))?; + } + Ok(()) } pub(super) async fn apply_member_diff( tx: &mut Transaction<'_, Postgres>, - id: Uuid, + targets: &GrantTargets, added: &[MacroUserIdStr<'static>], removed: &[MacroUserIdStr<'static>], ) -> Result<(), InitiativeError> { + let initiative_id = targets.initiative_id(); if !removed.is_empty() { let removed_ids: Vec = removed.iter().map(|id| id.to_string()).collect(); sqlx::query!( @@ -61,17 +67,19 @@ pub(super) async fn apply_member_diff( DELETE FROM initiative_member WHERE initiative_id = $1 AND user_id = ANY($2) "#, - id, + initiative_id, &removed_ids, ) .execute(tx.as_mut()) .await .map_err(AdapterError::Sqlx) .map_err(map_sqlx)?; - delete_user_entity_access_rows(tx, &id, EntityType::Initiative, removed) - .await - .map_err(AdapterError::Sqlx) - .map_err(map_sqlx)?; + for (entity_id, entity_type) in targets.each() { + delete_user_entity_access_rows(tx, &entity_id, entity_type, removed) + .await + .map_err(AdapterError::Sqlx) + .map_err(map_sqlx)?; + } } if !added.is_empty() { @@ -82,14 +90,14 @@ pub(super) async fn apply_member_diff( SELECT $1, UNNEST($2::text[]) ON CONFLICT DO NOTHING "#, - id, + initiative_id, &added_ids, ) .execute(tx.as_mut()) .await .map_err(AdapterError::Sqlx) .map_err(map_sqlx)?; - grant_members_edit(tx, id, added).await?; + grant_members_edit(tx, targets, added).await?; } Ok(()) diff --git a/crates/initiative/src/outbound/pg_initiative_repo/share.rs b/crates/initiative/src/outbound/pg_initiative_repo/share.rs index 82929866841..f42e6e4420a 100644 --- a/crates/initiative/src/outbound/pg_initiative_repo/share.rs +++ b/crates/initiative/src/outbound/pg_initiative_repo/share.rs @@ -1,16 +1,44 @@ use entity_access_db_utils::EntityType; use models_permissions::share_permission::channel_share_permission::UpdateOperation; -use models_permissions::share_permission::team_share::TeamShareFacts; use models_permissions::share_permission::{ LinkShare, SharePermissionV2, TeamLinkShareDefault, UpdateSharePermissionRequestV2, access_level::AccessLevel, }; +use rootcause::prelude::*; use share_permission_db_utils::{InsertChannelSharePermissionResult, team_share}; use sqlx::{PgPool, Postgres, Transaction}; use uuid::Uuid; -use super::{AdapterError, entity_of, map_sqlx}; -use crate::domain::models::{InitiativeError, InitiativeId}; +use super::{AdapterError, GrantTargets, map_sqlx, require_description_document_id}; +use crate::domain::models::{ + DescriptionDocumentId, InitiativeError, InitiativeId, LockstepTeamShareFacts, +}; + +/// One `SharePermission` row plus the `entity_access` entity it governs. Built only from +/// [`GrantTargets`] so the two halves cannot be mixed up. +pub(super) struct ShareTarget<'a> { + entity_id: Uuid, + entity_type: EntityType, + share_permission_id: &'a str, +} + +impl<'a> ShareTarget<'a> { + pub(super) fn initiative(targets: &GrantTargets, share_permission_id: &'a str) -> Self { + Self { + entity_id: targets.initiative_id(), + entity_type: EntityType::Initiative, + share_permission_id, + } + } + + pub(super) fn description(targets: &GrantTargets, share_permission_id: &'a str) -> Self { + Self { + entity_id: targets.description_id(), + entity_type: EntityType::Document, + share_permission_id, + } + } +} pub(super) async fn insert_share_permission( tx: &mut Transaction<'_, Postgres>, @@ -55,25 +83,35 @@ pub(super) async fn insert_share_permission( Ok(row.id) } -pub(super) async fn patch_share_permission( +/// Every document the documents side creates has a permission row, so a miss is corruption. +pub(super) async fn description_share_permission_id( tx: &mut Transaction<'_, Postgres>, - initiative_id: Uuid, - update: &UpdateSharePermissionRequestV2, -) -> Result<(), InitiativeError> { - let share_permission_id = sqlx::query_scalar!( + id: DescriptionDocumentId, +) -> Result { + sqlx::query_scalar!( r#" - SELECT share_permission_id - FROM initiative - WHERE id = $1 + SELECT "sharePermissionId" + FROM "DocumentPermission" + WHERE "documentId" = $1 "#, - initiative_id, + id.to_string(), ) .fetch_optional(tx.as_mut()) .await .map_err(AdapterError::Sqlx) .map_err(map_sqlx)? - .ok_or(InitiativeError::NotFound)?; + .ok_or_else(|| { + InitiativeError::Internal(report!("description document {id} has no share permission")) + }) +} +/// Link columns, channel `entity_access` grants, and `ChannelSharePermission` rows for one +/// target. Called once per target so both entities end identical. +pub(super) async fn apply_share_patch( + tx: &mut Transaction<'_, Postgres>, + target: ShareTarget<'_>, + update: &UpdateSharePermissionRequestV2, +) -> Result<(), InitiativeError> { let update_link_share = update.link_share.is_some(); let link_share = update.link_share.flatten(); let (update_link_share_access_level, link_share_access_level) = match update.link_share { @@ -106,7 +144,7 @@ pub(super) async fn patch_share_permission( "updatedAt" = NOW() WHERE id = $1 "#, - share_permission_id, + target.share_permission_id, update_link_share, link_share, update_link_share_access_level, @@ -123,8 +161,8 @@ pub(super) async fn patch_share_permission( entity_access_db_utils::update_entity_access_channel_share_permissions( tx, - &initiative_id, - EntityType::Initiative, + &target.entity_id, + target.entity_type, channel_updates, ) .await @@ -136,7 +174,7 @@ pub(super) async fn patch_share_permission( UpdateOperation::Add => { let insert = share_permission_db_utils::insert_channel_share_permission( tx.as_mut(), - &share_permission_id, + target.share_permission_id, &channel.channel_id, channel.access_level.unwrap_or(AccessLevel::View), ) @@ -146,7 +184,7 @@ pub(super) async fn patch_share_permission( if insert == InsertChannelSharePermissionResult::AlreadyExists { update_channel_share_access( tx, - &share_permission_id, + target.share_permission_id, &channel.channel_id, channel.access_level.unwrap_or(AccessLevel::View), ) @@ -159,7 +197,7 @@ pub(super) async fn patch_share_permission( DELETE FROM "ChannelSharePermission" WHERE share_permission_id = $1 AND channel_id = $2 "#, - share_permission_id, + target.share_permission_id, channel.channel_id, ) .execute(tx.as_mut()) @@ -170,7 +208,7 @@ pub(super) async fn patch_share_permission( UpdateOperation::Replace => { let updated = update_channel_share_access( tx, - &share_permission_id, + target.share_permission_id, &channel.channel_id, channel.access_level.unwrap_or(AccessLevel::View), ) @@ -178,7 +216,7 @@ pub(super) async fn patch_share_permission( if !updated { share_permission_db_utils::insert_channel_share_permission( tx.as_mut(), - &share_permission_id, + target.share_permission_id, &channel.channel_id, channel.access_level.unwrap_or(AccessLevel::View), ) @@ -215,26 +253,49 @@ async fn update_channel_share_access( Ok(result.rows_affected() > 0) } -pub(super) async fn get_team_share_facts( +/// One guarded read for both entities, so the service authorizes them against one snapshot. +pub(super) async fn get_lockstep_team_share_facts( pool: &PgPool, id: InitiativeId, -) -> Result { - let entity = entity_of(id); - let mut transaction = pool +) -> Result { + let mut tx = pool .begin() .await .map_err(AdapterError::Sqlx) .map_err(map_sqlx)?; - let facts = team_share::load_facts(&mut transaction, &entity) + let description_document_id = sqlx::query_scalar!( + r#" + SELECT description_document_id + FROM initiative + WHERE id = $1 + "#, + id.as_uuid(), + ) + .fetch_optional(tx.as_mut()) + .await + .map_err(AdapterError::Sqlx) + .map_err(map_sqlx)? + .ok_or(InitiativeError::NotFound)?; + let targets = GrantTargets::new( + id, + require_description_document_id(id.as_uuid(), description_document_id)?, + ); + let initiative = team_share::load_facts(&mut tx, &targets.initiative_entity()) + .await + .map_err(AdapterError::TeamShare) + .map_err(map_sqlx)?; + let description = team_share::load_facts(&mut tx, &targets.description_entity()) .await .map_err(AdapterError::TeamShare) .map_err(map_sqlx)?; - transaction - .commit() + tx.commit() .await .map_err(AdapterError::Sqlx) .map_err(map_sqlx)?; - Ok(facts) + Ok(LockstepTeamShareFacts { + initiative, + description, + }) } pub(super) async fn get_team_default_link_share( diff --git a/crates/initiative/src/outbound/pg_initiative_repo/test.rs b/crates/initiative/src/outbound/pg_initiative_repo/test.rs index d2d26eb98ca..dd3bb5b9782 100644 --- a/crates/initiative/src/outbound/pg_initiative_repo/test.rs +++ b/crates/initiative/src/outbound/pg_initiative_repo/test.rs @@ -5,7 +5,9 @@ use macro_user_id::user_id::MacroUserIdStr; use models_permissions::share_permission::channel_share_permission::{ ChannelSharePermission, UpdateChannelSharePermission, UpdateOperation, }; -use models_permissions::share_permission::team_share::TeamShareCreation; +use models_permissions::share_permission::team_share::{ + TeamShareCreation, TeamShareGrant, TeamShareLevel, TeamShareRequest, authorize_team_share, +}; use models_permissions::share_permission::{ LinkShare, SharePermissionV2, UpdateSharePermissionRequestV2, }; @@ -14,8 +16,8 @@ use uuid::Uuid; use super::PgInitiativeRepo; use crate::domain::models::{ - AssignTaskStatus, CreateInitiativeRepoArgs, InitiativeError, InitiativeId, - UpdateInitiativeRepoArgs, + AssignTaskStatus, CreateInitiativeRepoArgs, DescriptionDocumentId, InitiativeError, + InitiativeId, LockstepTeamShare, UpdateInitiativeRepoArgs, }; use crate::domain::ports::InitiativeRepo; @@ -26,6 +28,9 @@ const CHANNEL_USER: &str = "macro|initiative-repo-channel@corp.test"; const STRANGER: &str = "macro|initiative-repo-stranger@corp.test"; const OTHER_OWNER: &str = "macro|initiative-repo-other-owner@corp.test"; +const INITIATIVE: &str = "initiative"; +const DOCUMENT: &str = "document"; + fn user(id: &str) -> MacroUserIdStr<'static> { MacroUserIdStr::parse_from_str(id) .expect("valid user id") @@ -47,14 +52,105 @@ fn share_link(link: LinkShare) -> SharePermissionV2 { permission } -fn create_args(owner: &str, name: &str, members: &[&str]) -> CreateInitiativeRepoArgs { - CreateInitiativeRepoArgs { +fn update_args(id: InitiativeId) -> UpdateInitiativeRepoArgs { + UpdateInitiativeRepoArgs { + id, + name: None, + member_ids_added: Vec::new(), + member_ids_removed: Vec::new(), + share_permission: None, + team_share: None, + } +} + +fn add_channel(channel_id: Uuid) -> UpdateSharePermissionRequestV2 { + UpdateSharePermissionRequestV2 { + link_share: None, + link_share_access_level: None, + team_share_access_level: None, + channel_share_permissions: Some(vec![UpdateChannelSharePermission { + operation: UpdateOperation::Add, + channel_id: channel_id.to_string(), + access_level: Some(AccessLevel::View), + }]), + } +} + +fn set_team_share(level: AccessLevel) -> UpdateSharePermissionRequestV2 { + UpdateSharePermissionRequestV2 { + link_share: None, + link_share_access_level: None, + team_share_access_level: Some(Some(level)), + channel_share_permissions: None, + } +} + +/// The rows `insert_new_document` writes for a description document, minus history and the +/// entity registry, which team sharing and access checks never read. +async fn seed_description_document( + pool: &PgPool, + owner: &str, +) -> anyhow::Result { + let id = Uuid::now_v7(); + let id_text = id.to_string(); + sqlx::query!( + r#"INSERT INTO "Document" (id, name, "fileType", owner) VALUES ($1, 'Launch', 'md', $2)"#, + id_text, + owner, + ) + .execute(pool) + .await?; + sqlx::query!( + r#" + INSERT INTO document_sub_type (document_id, sub_type) + VALUES ($1, 'initiative_description') + "#, + id_text, + ) + .execute(pool) + .await?; + let share_permission_id = sqlx::query_scalar!( + r#" + INSERT INTO "SharePermission" ("createdAt", "updatedAt") + VALUES (NOW(), NOW()) + RETURNING id + "#, + ) + .fetch_one(pool) + .await?; + sqlx::query!( + r#"INSERT INTO "DocumentPermission" ("documentId", "sharePermissionId") VALUES ($1, $2)"#, + id_text, + share_permission_id, + ) + .execute(pool) + .await?; + sqlx::query!( + r#" + INSERT INTO entity_access (entity_id, entity_type, source_id, source_type, access_level) + VALUES ($1, 'document', $2, 'user', 'owner') + "#, + id, + owner, + ) + .execute(pool) + .await?; + Ok(DescriptionDocumentId::from_uuid(id)) +} + +async fn create_args( + pool: &PgPool, + owner: &str, + name: &str, + members: &[&str], +) -> anyhow::Result { + Ok(CreateInitiativeRepoArgs { id: InitiativeId::generate(), owner_id: user(owner), name: name.to_string(), - description: None, + description_document_id: seed_description_document(pool, owner).await?, member_ids: members.iter().copied().map(user).collect(), - } + }) } async fn insert_user(pool: &PgPool, user_id: &str) -> anyhow::Result<()> { @@ -113,6 +209,15 @@ async fn add_team_user( Ok(()) } +/// Owner plus a team the owner belongs to; returns the team id. +async fn seed_owner_with_team(pool: &PgPool) -> anyhow::Result { + insert_user(pool, OWNER).await?; + let team_id = Uuid::now_v7(); + insert_team(pool, team_id, OWNER).await?; + add_team_user(pool, team_id, OWNER, "owner").await?; + Ok(team_id) +} + async fn insert_channel( pool: &PgPool, channel_id: Uuid, @@ -164,7 +269,8 @@ async fn insert_document(pool: &PgPool, id: &str, owner: &str, task: bool) -> an async fn access_level( pool: &PgPool, - initiative_id: Uuid, + entity_id: Uuid, + entity_type: &str, source_id: &str, ) -> anyhow::Result> { let row = sqlx::query_scalar!( @@ -172,11 +278,12 @@ async fn access_level( SELECT access_level::text FROM entity_access WHERE entity_id = $1 - AND entity_type = 'initiative' - AND source_id = $2 + AND entity_type = $2 + AND source_id = $3 AND granted_from_project_id IS NULL "#, - initiative_id, + entity_id, + entity_type, source_id, ) .fetch_optional(pool) @@ -184,6 +291,80 @@ async fn access_level( Ok(row.flatten()) } +/// `(access level, source id)` for every grant on both entities, so a test can assert the +/// document mirrors the initiative in one comparison. +async fn mirrored_access( + pool: &PgPool, + initiative_id: InitiativeId, + description_document_id: DescriptionDocumentId, + source_id: &str, +) -> anyhow::Result<(Option, Option)> { + Ok(( + access_level(pool, initiative_id.as_uuid(), INITIATIVE, source_id).await?, + access_level(pool, description_document_id.as_uuid(), DOCUMENT, source_id).await?, + )) +} + +struct StoredSharePermission { + link_share: Option, + link_share_access_level: Option, + channel_ids: Vec, +} + +async fn document_share_permission( + pool: &PgPool, + id: DescriptionDocumentId, +) -> anyhow::Result { + let row = sqlx::query!( + r#" + SELECT + sp."linkShare" AS link_share, + sp."linkShareAccessLevel"::text AS link_share_access_level, + COALESCE( + array_agg(csp.channel_id ORDER BY csp.channel_id) + FILTER (WHERE csp.channel_id IS NOT NULL), + '{}'::text[] + ) AS "channel_ids!" + FROM "DocumentPermission" dp + JOIN "SharePermission" sp ON sp.id = dp."sharePermissionId" + LEFT JOIN "ChannelSharePermission" csp ON csp.share_permission_id = sp.id + WHERE dp."documentId" = $1 + GROUP BY sp."linkShare", sp."linkShareAccessLevel" + "#, + id.to_string(), + ) + .fetch_one(pool) + .await?; + Ok(StoredSharePermission { + link_share: row.link_share, + link_share_access_level: row.link_share_access_level, + channel_ids: row.channel_ids, + }) +} + +/// What the service does before calling `update` with a team-share patch. +async fn lockstep_team_share( + repo: &PgInitiativeRepo, + id: InitiativeId, + level: AccessLevel, +) -> anyhow::Result { + let facts = repo.get_team_share_facts(id).await?; + let request = TeamShareRequest { + access_level: Some(Some(level)), + legacy_enabled: None, + }; + let owner = user(OWNER); + let authorize = |entity_facts| { + authorize_team_share(Some(&owner), entity_facts, request, TeamShareLevel::Edit) + .map_err(|error| anyhow::anyhow!("{error}"))? + .ok_or_else(|| anyhow::anyhow!("a supplied level always yields a command")) + }; + Ok(LockstepTeamShare { + initiative: authorize(&facts.initiative)?, + description: authorize(&facts.description)?, + }) +} + fn ids(list: &crate::domain::models::InitiativeList) -> Vec { list.initiatives .iter() @@ -192,28 +373,23 @@ fn ids(list: &crate::domain::models::InitiativeList) -> Vec { } #[sqlx::test(migrator = "MACRO_DB_MIGRATIONS")] -async fn create_writes_initiative_share_owner_members_and_optional_team_grant( +async fn create_links_description_document_and_mirrors_member_and_team_grants_as_tracked( pool: PgPool, ) -> anyhow::Result<()> { - insert_user(&pool, OWNER).await?; + let team_id = seed_owner_with_team(&pool).await?; insert_user(&pool, MEMBER).await?; - let team_id = Uuid::now_v7(); - insert_team(&pool, team_id, OWNER).await?; - add_team_user(&pool, team_id, OWNER, "owner").await?; add_team_user(&pool, team_id, MEMBER, "member").await?; let repo = repo(pool.clone()); - let args = CreateInitiativeRepoArgs { - description: Some("Ship it".to_string()), - ..create_args(OWNER, "Launch", &[MEMBER]) - }; + let args = create_args(&pool, OWNER, "Launch", &[MEMBER]).await?; let id = args.id; + let document_id = args.description_document_id; let detail = repo .create(args, share_off(), TeamShareCreation::Initiative) .await?; assert_eq!(detail.name, "Launch"); - assert_eq!(detail.description.as_deref(), Some("Ship it")); + assert_eq!(detail.description_document_id, document_id); assert_eq!(detail.owner_id.as_ref(), OWNER); assert_eq!( detail @@ -223,39 +399,98 @@ async fn create_writes_initiative_share_owner_members_and_optional_team_grant( .collect::>(), vec![MEMBER] ); - assert!(!detail.share_permission.id.is_empty()); assert_eq!( detail.share_permission.team_share_access_level, Some(AccessLevel::Edit) ); assert_eq!(detail.user_access_level, AccessLevel::View); - let share_exists = sqlx::query_scalar!( - r#"SELECT EXISTS(SELECT 1 FROM "SharePermission" WHERE id = $1) AS "exists!""#, - detail.share_permission.id, + let still_empty = sqlx::query_scalar!( + r#"SELECT description FROM initiative WHERE id = $1"#, + id.as_uuid(), ) .fetch_one(&pool) .await?; - assert!(share_exists); + assert_eq!(still_empty, ""); + let owner = Some("owner".to_string()); + let edit = Some("edit".to_string()); assert_eq!( - access_level(&pool, id.as_uuid(), OWNER).await?.as_deref(), - Some("owner") + mirrored_access(&pool, id, document_id, OWNER).await?, + (owner.clone(), owner) ); assert_eq!( - access_level(&pool, id.as_uuid(), MEMBER).await?.as_deref(), - Some("edit") + mirrored_access(&pool, id, document_id, MEMBER).await?, + (edit.clone(), edit.clone()) ); assert_eq!( - access_level(&pool, id.as_uuid(), &team_id.to_string()) - .await? - .as_deref(), - Some("edit") + mirrored_access(&pool, id, document_id, &team_id.to_string()).await?, + (edit.clone(), edit) ); + let grant = Some(TeamShareGrant { + team_id, + level: TeamShareLevel::Edit, + }); + let facts = repo.get_team_share_facts(id).await?; + assert_eq!(facts.initiative.current, grant); + assert_eq!(facts.initiative.revision, 1); + assert_eq!(facts.description.current, grant); + assert_eq!(facts.description.revision, 1); + assert_eq!(facts.description.owner.as_ref(), OWNER); + let basic = repo.get_basic(id).await?.expect("created initiative"); assert_eq!(basic.name, "Launch"); - assert_eq!(basic.owner_id.as_ref(), OWNER); + let listed = repo.list_accessible(&user(MEMBER)).await?; + assert_eq!( + listed + .initiatives + .iter() + .map(|summary| summary.description_document_id) + .collect::>(), + vec![document_id] + ); + Ok(()) +} + +#[sqlx::test(migrator = "MACRO_DB_MIGRATIONS")] +async fn create_with_unshared_team_leaves_the_document_untracked_and_unshared( + pool: PgPool, +) -> anyhow::Result<()> { + seed_owner_with_team(&pool).await?; + let repo = repo(pool.clone()); + let args = create_args(&pool, OWNER, "Private", &[]).await?; + let id = args.id; + repo.create(args, share_off(), TeamShareCreation::Unshared) + .await?; + + let facts = repo.get_team_share_facts(id).await?; + assert_eq!(facts.initiative.current, None); + assert_eq!(facts.description.current, None); + assert_eq!(facts.description.revision, 0); + Ok(()) +} + +#[sqlx::test(migrator = "MACRO_DB_MIGRATIONS")] +async fn create_rejects_second_initiative_for_same_document(pool: PgPool) -> anyhow::Result<()> { + insert_user(&pool, OWNER).await?; + let repo = repo(pool.clone()); + let first = create_args(&pool, OWNER, "First", &[]).await?; + let document_id = first.description_document_id; + repo.create(first, share_off(), TeamShareCreation::Unshared) + .await?; + + let second = CreateInitiativeRepoArgs { + description_document_id: document_id, + ..create_args(&pool, OWNER, "Second", &[]).await? + }; + let second_id = second.id; + let error = repo + .create(second, share_off(), TeamShareCreation::Unshared) + .await + .expect_err("one document belongs to one initiative"); + assert!(matches!(error, InitiativeError::Internal(_)), "{error:?}"); + assert!(repo.get_basic(second_id).await?.is_none()); Ok(()) } @@ -271,31 +506,17 @@ async fn get_detail_reports_each_channel_grant_once(pool: PgPool) -> anyhow::Res insert_document(&pool, &task_a, OWNER, true).await?; insert_document(&pool, &task_b, OWNER, true).await?; - let repo = repo(pool); + let repo = repo(pool.clone()); let created = repo .create( - create_args(OWNER, "Busy", &[MEMBER, TEAMMATE]), + create_args(&pool, OWNER, "Busy", &[MEMBER, TEAMMATE]).await?, share_off(), TeamShareCreation::Unshared, ) .await?; repo.update(UpdateInitiativeRepoArgs { - id: created.id, - name: None, - description: None, - member_ids_added: Vec::new(), - member_ids_removed: Vec::new(), - share_permission: Some(UpdateSharePermissionRequestV2 { - link_share: None, - link_share_access_level: None, - team_share_access_level: None, - channel_share_permissions: Some(vec![UpdateChannelSharePermission { - operation: UpdateOperation::Add, - channel_id: channel_id.to_string(), - access_level: Some(AccessLevel::View), - }]), - }), - team_share: None, + share_permission: Some(add_channel(channel_id)), + ..update_args(created.id) }) .await?; repo.assign_tasks(created.id, vec![task_a.clone(), task_b.clone()]) @@ -317,10 +538,10 @@ async fn create_share_with_team_without_owner_team_is_bad_request( pool: PgPool, ) -> anyhow::Result<()> { insert_user(&pool, OWNER).await?; - let repo = repo(pool); + let repo = repo(pool.clone()); let error = repo .create( - create_args(OWNER, "Solo", &[]), + create_args(&pool, OWNER, "Solo", &[]).await?, share_off(), TeamShareCreation::Initiative, ) @@ -334,13 +555,10 @@ async fn create_share_with_team_without_owner_team_is_bad_request( async fn list_accessible_includes_owner_member_team_channel_and_team_link( pool: PgPool, ) -> anyhow::Result<()> { - insert_user(&pool, OWNER).await?; + let team_id = seed_owner_with_team(&pool).await?; insert_user(&pool, MEMBER).await?; insert_user(&pool, TEAMMATE).await?; insert_user(&pool, CHANNEL_USER).await?; - let team_id = Uuid::now_v7(); - insert_team(&pool, team_id, OWNER).await?; - add_team_user(&pool, team_id, OWNER, "owner").await?; add_team_user(&pool, team_id, TEAMMATE, "member").await?; let channel_id = Uuid::now_v7(); insert_channel(&pool, channel_id, OWNER, CHANNEL_USER).await?; @@ -348,49 +566,35 @@ async fn list_accessible_includes_owner_member_team_channel_and_team_link( let repo = repo(pool.clone()); let owned = repo .create( - create_args(OWNER, "Owned", &[MEMBER]), + create_args(&pool, OWNER, "Owned", &[MEMBER]).await?, share_off(), TeamShareCreation::Unshared, ) .await?; let team_granted = repo .create( - create_args(OWNER, "Team granted", &[]), + create_args(&pool, OWNER, "Team granted", &[]).await?, share_off(), TeamShareCreation::Initiative, ) .await?; let team_link = repo .create( - create_args(OWNER, "Team link", &[]), + create_args(&pool, OWNER, "Team link", &[]).await?, share_link(LinkShare::Team), TeamShareCreation::Unshared, ) .await?; let channel_shared = repo .create( - create_args(OWNER, "Channel", &[]), + create_args(&pool, OWNER, "Channel", &[]).await?, share_off(), TeamShareCreation::Unshared, ) .await?; repo.update(UpdateInitiativeRepoArgs { - id: channel_shared.id, - name: None, - description: None, - member_ids_added: Vec::new(), - member_ids_removed: Vec::new(), - share_permission: Some(UpdateSharePermissionRequestV2 { - link_share: None, - link_share_access_level: None, - team_share_access_level: None, - channel_share_permissions: Some(vec![UpdateChannelSharePermission { - operation: UpdateOperation::Add, - channel_id: channel_id.to_string(), - access_level: Some(AccessLevel::View), - }]), - }), - team_share: None, + share_permission: Some(add_channel(channel_id)), + ..update_args(channel_shared.id) }) .await?; @@ -413,17 +617,17 @@ async fn list_accessible_excludes_public_only_and_unrelated(pool: PgPool) -> any insert_user(&pool, STRANGER).await?; insert_user(&pool, OTHER_OWNER).await?; - let repo = repo(pool); + let repo = repo(pool.clone()); let public_only = repo .create( - create_args(OWNER, "Public only", &[]), + create_args(&pool, OWNER, "Public only", &[]).await?, share_link(LinkShare::Public), TeamShareCreation::Unshared, ) .await?; let unrelated = repo .create( - create_args(OTHER_OWNER, "Unrelated", &[]), + create_args(&pool, OTHER_OWNER, "Unrelated", &[]).await?, share_off(), TeamShareCreation::Unshared, ) @@ -436,58 +640,266 @@ async fn list_accessible_excludes_public_only_and_unrelated(pool: PgPool) -> any } #[sqlx::test(migrator = "MACRO_DB_MIGRATIONS")] -async fn update_removing_member_deletes_only_that_grant(pool: PgPool) -> anyhow::Result<()> { +async fn update_member_diff_mirrors_document_grants(pool: PgPool) -> anyhow::Result<()> { insert_user(&pool, OWNER).await?; insert_user(&pool, MEMBER).await?; insert_user(&pool, TEAMMATE).await?; + insert_user(&pool, STRANGER).await?; let repo = repo(pool.clone()); let created = repo .create( - create_args(OWNER, "Members", &[MEMBER, TEAMMATE]), + create_args(&pool, OWNER, "Members", &[MEMBER, TEAMMATE]).await?, share_off(), TeamShareCreation::Unshared, ) .await?; + let document_id = created.description_document_id; let updated = repo .update(UpdateInitiativeRepoArgs { - id: created.id, name: Some("Renamed".to_string()), - description: Some(None), - member_ids_added: Vec::new(), + member_ids_added: vec![user(STRANGER)], member_ids_removed: vec![user(MEMBER)], - share_permission: None, - team_share: None, + ..update_args(created.id) }) .await?; assert_eq!(updated.name, "Renamed"); - assert_eq!(updated.description, None); + assert_eq!(updated.description_document_id, document_id); + let mut members: Vec<&str> = updated.member_ids.iter().map(|id| id.as_ref()).collect(); + members.sort_unstable(); + assert_eq!(members, vec![STRANGER, TEAMMATE]); + let owner = Some("owner".to_string()); + let edit = Some("edit".to_string()); assert_eq!( - updated - .member_ids - .iter() - .map(|id| id.as_ref()) - .collect::>(), - vec![TEAMMATE] + mirrored_access(&pool, created.id, document_id, OWNER).await?, + (owner.clone(), owner) ); assert_eq!( - access_level(&pool, created.id.as_uuid(), OWNER) - .await? - .as_deref(), - Some("owner") + mirrored_access(&pool, created.id, document_id, MEMBER).await?, + (None, None) ); assert_eq!( - access_level(&pool, created.id.as_uuid(), MEMBER).await?, - None + mirrored_access(&pool, created.id, document_id, TEAMMATE).await?, + (edit.clone(), edit.clone()) ); assert_eq!( - access_level(&pool, created.id.as_uuid(), TEAMMATE) - .await? - .as_deref(), - Some("edit") + mirrored_access(&pool, created.id, document_id, STRANGER).await?, + (edit.clone(), edit) ); + + let document_name = sqlx::query_scalar!( + r#"SELECT name FROM "Document" WHERE id = $1"#, + document_id.to_string(), + ) + .fetch_one(&pool) + .await?; + assert_eq!(document_name, "Launch", "renames are not mirrored"); + Ok(()) +} + +#[sqlx::test(migrator = "MACRO_DB_MIGRATIONS")] +async fn update_share_patch_mirrors_link_columns_and_channel_grants_onto_document( + pool: PgPool, +) -> anyhow::Result<()> { + insert_user(&pool, OWNER).await?; + insert_user(&pool, CHANNEL_USER).await?; + let channel_id = Uuid::now_v7(); + insert_channel(&pool, channel_id, OWNER, CHANNEL_USER).await?; + + let repo = repo(pool.clone()); + let created = repo + .create( + create_args(&pool, OWNER, "Shared", &[]).await?, + share_off(), + TeamShareCreation::Unshared, + ) + .await?; + let document_id = created.description_document_id; + + let updated = repo + .update(UpdateInitiativeRepoArgs { + share_permission: Some(UpdateSharePermissionRequestV2 { + link_share: Some(Some(LinkShare::Team)), + link_share_access_level: Some(Some(AccessLevel::View)), + ..add_channel(channel_id) + }), + ..update_args(created.id) + }) + .await?; + + assert_eq!(updated.share_permission.link_share, Some(LinkShare::Team)); + assert_eq!( + updated.share_permission.link_share_access_level, + Some(AccessLevel::View) + ); + let document = document_share_permission(&pool, document_id).await?; + assert_eq!(document.link_share.as_deref(), Some("TEAM")); + assert_eq!(document.link_share_access_level.as_deref(), Some("view")); + assert_eq!(document.channel_ids, vec![channel_id.to_string()]); + let view = Some("view".to_string()); + assert_eq!( + mirrored_access(&pool, created.id, document_id, &channel_id.to_string()).await?, + (view.clone(), view) + ); + + repo.update(UpdateInitiativeRepoArgs { + share_permission: Some(UpdateSharePermissionRequestV2 { + link_share: Some(None), + link_share_access_level: None, + team_share_access_level: None, + channel_share_permissions: Some(vec![UpdateChannelSharePermission { + operation: UpdateOperation::Remove, + channel_id: channel_id.to_string(), + access_level: None, + }]), + }), + ..update_args(created.id) + }) + .await?; + let document = document_share_permission(&pool, document_id).await?; + assert_eq!(document.link_share, None); + assert_eq!(document.link_share_access_level, None); + assert!(document.channel_ids.is_empty()); + assert_eq!( + mirrored_access(&pool, created.id, document_id, &channel_id.to_string()).await?, + (None, None) + ); + Ok(()) +} + +#[sqlx::test(migrator = "MACRO_DB_MIGRATIONS")] +async fn update_team_share_applies_both_commands_and_a_second_apply_is_not_untracked( + pool: PgPool, +) -> anyhow::Result<()> { + let team_id = seed_owner_with_team(&pool).await?; + let repo = repo(pool.clone()); + let created = repo + .create( + create_args(&pool, OWNER, "Later shared", &[]).await?, + share_off(), + TeamShareCreation::Unshared, + ) + .await?; + let document_id = created.description_document_id; + let team = team_id.to_string(); + + let updated = repo + .update(UpdateInitiativeRepoArgs { + share_permission: Some(set_team_share(AccessLevel::Edit)), + team_share: Some(lockstep_team_share(&repo, created.id, AccessLevel::Edit).await?), + ..update_args(created.id) + }) + .await?; + assert_eq!( + updated.share_permission.team_share_access_level, + Some(AccessLevel::Edit) + ); + let edit = Some("edit".to_string()); + assert_eq!( + mirrored_access(&pool, created.id, document_id, &team).await?, + (edit.clone(), edit) + ); + let facts = repo.get_team_share_facts(created.id).await?; + let grant = Some(TeamShareGrant { + team_id, + level: TeamShareLevel::Edit, + }); + assert_eq!(facts.initiative.current, grant); + assert_eq!(facts.description.current, grant); + assert_eq!(facts.description.revision, 1); + + // The document's team row is tracked, so changing the level is an ordinary apply. + repo.update(UpdateInitiativeRepoArgs { + share_permission: Some(set_team_share(AccessLevel::View)), + team_share: Some(lockstep_team_share(&repo, created.id, AccessLevel::View).await?), + ..update_args(created.id) + }) + .await?; + let view = Some("view".to_string()); + assert_eq!( + mirrored_access(&pool, created.id, document_id, &team).await?, + (view.clone(), view) + ); + let facts = repo.get_team_share_facts(created.id).await?; + assert_eq!( + facts.description.current, + Some(TeamShareGrant { + team_id, + level: TeamShareLevel::View, + }) + ); + assert_eq!(facts.description.revision, 2); + Ok(()) +} + +#[sqlx::test(migrator = "MACRO_DB_MIGRATIONS")] +async fn update_rejects_lockstep_commands_naming_another_initiative( + pool: PgPool, +) -> anyhow::Result<()> { + seed_owner_with_team(&pool).await?; + let repo = repo(pool.clone()); + let target = repo + .create( + create_args(&pool, OWNER, "Target", &[]).await?, + share_off(), + TeamShareCreation::Unshared, + ) + .await?; + let other = repo + .create( + create_args(&pool, OWNER, "Other", &[]).await?, + share_off(), + TeamShareCreation::Unshared, + ) + .await?; + + let error = repo + .update(UpdateInitiativeRepoArgs { + share_permission: Some(set_team_share(AccessLevel::Edit)), + team_share: Some(lockstep_team_share(&repo, other.id, AccessLevel::Edit).await?), + ..update_args(target.id) + }) + .await + .expect_err("commands must name the initiative being edited"); + assert!(matches!(error, InitiativeError::BadRequest(_)), "{error:?}"); + let facts = repo.get_team_share_facts(target.id).await?; + assert_eq!(facts.initiative.current, None); + assert_eq!(facts.description.current, None); + Ok(()) +} + +#[sqlx::test(migrator = "MACRO_DB_MIGRATIONS")] +async fn reads_fail_loudly_when_description_document_id_is_null( + pool: PgPool, +) -> anyhow::Result<()> { + insert_user(&pool, OWNER).await?; + let repo = repo(pool.clone()); + let created = repo + .create( + create_args(&pool, OWNER, "Pre-expand", &[]).await?, + share_off(), + TeamShareCreation::Unshared, + ) + .await?; + sqlx::query!( + r#"UPDATE initiative SET description_document_id = NULL WHERE id = $1"#, + created.id.as_uuid(), + ) + .execute(&pool) + .await?; + + assert!(matches!( + repo.get_detail(created.id).await, + Err(InitiativeError::Internal(_)) + )); + assert!(matches!( + repo.list_accessible(&user(OWNER)).await, + Err(InitiativeError::Internal(_)) + )); + assert!(repo.get_basic(created.id).await?.is_some()); + assert_eq!(repo.delete(created.id).await?, None); Ok(()) } @@ -502,17 +914,17 @@ async fn assign_tasks_moves_and_reports_non_tasks(pool: PgPool) -> anyhow::Resul insert_document(&pool, &task_b, OWNER, true).await?; insert_document(&pool, ¬_a_task, OWNER, false).await?; - let repo = repo(pool); + let repo = repo(pool.clone()); let first = repo .create( - create_args(OWNER, "First", &[]), + create_args(&pool, OWNER, "First", &[]).await?, share_off(), TeamShareCreation::Unshared, ) .await?; let second = repo .create( - create_args(OWNER, "Second", &[]), + create_args(&pool, OWNER, "Second", &[]).await?, share_off(), TeamShareCreation::Unshared, ) @@ -560,17 +972,17 @@ async fn unassign_task_ignores_links_owned_by_other_initiatives( let task_id = Uuid::now_v7().to_string(); insert_document(&pool, &task_id, OWNER, true).await?; - let repo = repo(pool); + let repo = repo(pool.clone()); let first = repo .create( - create_args(OWNER, "Keeper", &[]), + create_args(&pool, OWNER, "Keeper", &[]).await?, share_off(), TeamShareCreation::Unshared, ) .await?; let second = repo .create( - create_args(OWNER, "Other", &[]), + create_args(&pool, OWNER, "Other", &[]).await?, share_off(), TeamShareCreation::Unshared, ) @@ -591,7 +1003,7 @@ async fn unassign_task_ignores_links_owned_by_other_initiatives( } #[sqlx::test(migrator = "MACRO_DB_MIGRATIONS")] -async fn delete_leaves_no_share_permission_channel_share_entity_access_member_or_task_rows( +async fn delete_returns_the_document_id_and_leaves_no_initiative_rows( pool: PgPool, ) -> anyhow::Result<()> { insert_user(&pool, OWNER).await?; @@ -604,35 +1016,22 @@ async fn delete_leaves_no_share_permission_channel_share_entity_access_member_or let repo = repo(pool.clone()); let created = repo .create( - create_args(OWNER, "To delete", &[MEMBER]), + create_args(&pool, OWNER, "To delete", &[MEMBER]).await?, share_off(), TeamShareCreation::Unshared, ) .await?; repo.update(UpdateInitiativeRepoArgs { - id: created.id, - name: None, - description: None, - member_ids_added: Vec::new(), - member_ids_removed: Vec::new(), - share_permission: Some(UpdateSharePermissionRequestV2 { - link_share: None, - link_share_access_level: None, - team_share_access_level: None, - channel_share_permissions: Some(vec![UpdateChannelSharePermission { - operation: UpdateOperation::Add, - channel_id: channel_id.to_string(), - access_level: Some(AccessLevel::View), - }]), - }), - team_share: None, + share_permission: Some(add_channel(channel_id)), + ..update_args(created.id) }) .await?; repo.assign_tasks(created.id, vec![task_id.clone()]).await?; let share_id = created.share_permission.id.clone(); let initiative_id = created.id.as_uuid(); + let document_id = created.description_document_id; - repo.delete(created.id).await?; + assert_eq!(repo.delete(created.id).await?, Some(document_id)); let leftover_share = sqlx::query_scalar!( r#"SELECT EXISTS(SELECT 1 FROM "SharePermission" WHERE id = $1) AS "exists!""#, @@ -673,12 +1072,24 @@ async fn delete_leaves_no_share_permission_channel_share_entity_access_member_or ) .fetch_one(&pool) .await?; + // The document itself is the service's to purge after this commit. + let document_remains = sqlx::query_scalar!( + r#"SELECT EXISTS(SELECT 1 FROM "Document" WHERE id = $1) AS "exists!""#, + document_id.to_string(), + ) + .fetch_one(&pool) + .await?; assert!(!leftover_share); assert!(!leftover_channel); assert!(!leftover_access); assert!(!leftover_member); assert!(!leftover_task); + assert!(document_remains); assert!(repo.get_detail(created.id).await?.is_none()); + assert!(matches!( + repo.delete(created.id).await, + Err(InitiativeError::NotFound) + )); Ok(()) } diff --git a/crates/item_filter_index/src/test.rs b/crates/item_filter_index/src/test.rs index 1869da36ba5..47f84128e07 100644 --- a/crates/item_filter_index/src/test.rs +++ b/crates/item_filter_index/src/test.rs @@ -96,6 +96,7 @@ fn production_documents_membership_literals_require_and_compile_in_v2() { DocumentLiteral::SubType(DocumentSubType::Task), DocumentLiteral::SubType(DocumentSubType::Snippet), DocumentLiteral::SubType(DocumentSubType::Skill), + DocumentLiteral::SubType(DocumentSubType::InitiativeDescription), ] { let mut ast = excluded_deferred_partitions(); ast.document_filter = Some(Arc::new(Expr::val(literal))); diff --git a/crates/macro_db_client/migrations/20260917140621_add_initiative_description_document.down.sql b/crates/macro_db_client/migrations/20260917140621_add_initiative_description_document.down.sql new file mode 100644 index 00000000000..0784bb1f653 --- /dev/null +++ b/crates/macro_db_client/migrations/20260917140621_add_initiative_description_document.down.sql @@ -0,0 +1,3 @@ +-- Postgres cannot drop an enum value. Roll back the column only. +ALTER TABLE initiative + DROP COLUMN IF EXISTS description_document_id; diff --git a/crates/macro_db_client/migrations/20260917140621_add_initiative_description_document.up.sql b/crates/macro_db_client/migrations/20260917140621_add_initiative_description_document.up.sql new file mode 100644 index 00000000000..5dc79a4408d --- /dev/null +++ b/crates/macro_db_client/migrations/20260917140621_add_initiative_description_document.up.sql @@ -0,0 +1,13 @@ +-- Expand step only. Currently deployed initiative writers still INSERT +-- `description` and omit this column, so `description_document_id` is nullable +-- and `description` stays. A later PR after deploy drops `description` and +-- may SET NOT NULL on the document id. +-- +-- ADD VALUE is safe in the same transaction as the column add because this +-- migration never uses the new enum value. +ALTER TYPE document_sub_type_value ADD VALUE IF NOT EXISTS 'initiative_description'; + +ALTER TABLE initiative + ADD COLUMN IF NOT EXISTS description_document_id TEXT + UNIQUE + REFERENCES "Document" (id) ON DELETE SET NULL; diff --git a/crates/model/src/document.rs b/crates/model/src/document.rs index 84edc00c57e..f8eefbac888 100644 --- a/crates/model/src/document.rs +++ b/crates/model/src/document.rs @@ -52,6 +52,8 @@ pub enum BasicDocumentSubType { Snippet {}, /// A skill document — markdown instructions for AI Skill {}, + /// The description document of an initiative + InitiativeDescription {}, } impl BasicDocumentSubType { @@ -64,6 +66,7 @@ impl BasicDocumentSubType { }), DocumentSubType::Snippet => Some(Self::Snippet {}), DocumentSubType::Skill => Some(Self::Skill {}), + DocumentSubType::InitiativeDescription => Some(Self::InitiativeDescription {}), } } } @@ -326,6 +329,8 @@ pub enum DocumentPreviewDataSubType { Snippet {}, /// A skill document — markdown instructions for AI Skill {}, + /// The description document of an initiative + InitiativeDescription {}, } impl DocumentPreviewDataSubType { @@ -338,6 +343,7 @@ impl DocumentPreviewDataSubType { }), DocumentSubType::Snippet => Some(Self::Snippet {}), DocumentSubType::Skill => Some(Self::Skill {}), + DocumentSubType::InitiativeDescription => Some(Self::InitiativeDescription {}), } } } diff --git a/crates/model_notifications/src/metadata.rs b/crates/model_notifications/src/metadata.rs index e48a0690598..3f9441f5d5d 100644 --- a/crates/model_notifications/src/metadata.rs +++ b/crates/model_notifications/src/metadata.rs @@ -631,6 +631,7 @@ pub enum NotificationDocumentSubType { Task, Snippet, Skill, + InitiativeDescription, } /// Someone mentioned a document in a channel diff --git a/crates/models_permissions/src/share_permission.rs b/crates/models_permissions/src/share_permission.rs index d71de8ee39f..2147bc7cb89 100644 --- a/crates/models_permissions/src/share_permission.rs +++ b/crates/models_permissions/src/share_permission.rs @@ -38,6 +38,22 @@ pub struct SharePermissionV2 { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct TeamLinkShareDefault(pub Option); +/// A fully resolved link-share setting, so a scope without a level (or the reverse) cannot be +/// copied from one entity onto another. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum LinkShareState { + /// Link sharing is disabled. + #[default] + Off, + /// Anyone matching `scope` who has the link gets `level`. + On { + /// Who the link admits. + scope: LinkShare, + /// What the link grants. + level: AccessLevel, + }, +} + impl SharePermissionV2 { fn new(link_share: Option, link_share_access_level: Option) -> Self { SharePermissionV2 { @@ -50,6 +66,27 @@ impl SharePermissionV2 { } } + /// The link share this permission carries. A stored scope without a level reads as `View`, + /// matching what the repositories write for that pair. + pub fn link_share_state(&self) -> LinkShareState { + match self.link_share { + Some(scope) => LinkShareState::On { + scope, + level: self.link_share_access_level.unwrap_or(AccessLevel::View), + }, + None => LinkShareState::Off, + } + } + + /// Exactly `state`, consulting neither the entity-type default nor the owner's team default. + /// For a document created alongside another entity whose link share is already resolved. + pub fn from_link_share_state(state: LinkShareState) -> Self { + match state { + LinkShareState::Off => Self::new(None, None), + LinkShareState::On { scope, level } => Self::new(Some(scope), Some(level)), + } + } + /// Resolves the initial link share for a new item: no team → the entity-type default; a team /// scope → that scope with the entity's default level (else `View`); a team that turned link /// sharing off → off. diff --git a/crates/models_permissions/src/share_permission/team_share.rs b/crates/models_permissions/src/share_permission/team_share.rs index 623304574c3..d5fdd4c95c7 100644 --- a/crates/models_permissions/src/share_permission/team_share.rs +++ b/crates/models_permissions/src/share_permission/team_share.rs @@ -214,6 +214,9 @@ pub enum TeamShareCreation { Call, /// A new initiative initializes Edit; missing membership is an error. Initiative, + /// The description document of a new initiative. Resolves like `Initiative`; a distinct + /// variant so an `initialize` on a document entity is visibly intentional. + InitiativeDescription, } impl TeamShareCreation { @@ -232,7 +235,7 @@ impl TeamShareCreation { team_id, level: TeamShareLevel::View, })), - Self::Initiative => Ok(Some(TeamShareGrant { + Self::Initiative | Self::InitiativeDescription => Ok(Some(TeamShareGrant { team_id: owner_team_id.ok_or(TeamSharePolicyError::MissingTeam)?, level: TeamShareLevel::Edit, })), diff --git a/crates/models_permissions/src/share_permission/team_share/test.rs b/crates/models_permissions/src/share_permission/team_share/test.rs index c5cfdf51756..1525518aad2 100644 --- a/crates/models_permissions/src/share_permission/team_share/test.rs +++ b/crates/models_permissions/src/share_permission/team_share/test.rs @@ -299,6 +299,20 @@ fn creation_contract_distinguishes_ordinary_task_call_and_initiative_defaults() ); } +#[test] +fn initiative_description_resolves_like_initiative() { + for team in [None, Some(Uuid::from_u128(1))] { + assert_eq!( + TeamShareCreation::InitiativeDescription.resolve(team), + TeamShareCreation::Initiative.resolve(team) + ); + } + assert_eq!( + TeamShareCreation::InitiativeDescription.resolve(None), + Err(TeamSharePolicyError::MissingTeam) + ); +} + #[test] fn lifecycle_clear_needs_no_actor_or_current_membership() { let mut facts = facts(); diff --git a/crates/models_permissions/src/share_permission/test.rs b/crates/models_permissions/src/share_permission/test.rs index 04268e97185..269dcb82453 100644 --- a/crates/models_permissions/src/share_permission/test.rs +++ b/crates/models_permissions/src/share_permission/test.rs @@ -2,8 +2,8 @@ use model_file_type::FileType; use serde_json::json; use super::{ - LinkShare, SharePermissionV2, TeamLinkShareDefault, UpdateSharePermissionRequestV2, - access_level::AccessLevel, + LinkShare, LinkShareState, SharePermissionV2, TeamLinkShareDefault, + UpdateSharePermissionRequestV2, access_level::AccessLevel, }; fn share( @@ -165,6 +165,60 @@ fn resolved_permissions_never_have_a_level_without_a_scope() { } } +#[test] +fn link_share_state_round_trips_through_a_permission() { + for state in [ + LinkShareState::Off, + LinkShareState::On { + scope: LinkShare::Public, + level: AccessLevel::View, + }, + LinkShareState::On { + scope: LinkShare::Team, + level: AccessLevel::Edit, + }, + ] { + let permission = SharePermissionV2::from_link_share_state(state); + assert_eq!(permission.link_share_state(), state); + assert_eq!(permission.team_share_access_level, None); + assert_eq!( + permission.link_share.is_some(), + state != LinkShareState::Off + ); + } +} + +#[test] +fn from_link_share_state_ignores_entity_and_team_defaults() { + // An md document would default to PUBLIC/Edit; an exact state never consults that. + assert_eq!( + SharePermissionV2::from_link_share_state(LinkShareState::Off), + share(None, None) + ); + assert_eq!( + SharePermissionV2::new_initiative_share_permission(TEAM_TEAM).link_share_state(), + LinkShareState::On { + scope: LinkShare::Team, + level: AccessLevel::View, + } + ); +} + +#[test] +fn link_share_state_treats_a_scope_without_a_level_as_view() { + assert_eq!( + share(Some(LinkShare::Public), None).link_share_state(), + LinkShareState::On { + scope: LinkShare::Public, + level: AccessLevel::View, + } + ); + assert_eq!( + share(None, Some(AccessLevel::Edit)).link_share_state(), + LinkShareState::Off + ); +} + #[test] fn update_request_round_trip_preserves_omitted_fields() { let request = UpdateSharePermissionRequestV2 { diff --git a/crates/models_properties/src/shared/entity_type.rs b/crates/models_properties/src/shared/entity_type.rs index 94710bcc5ff..f432e5cf7b1 100644 --- a/crates/models_properties/src/shared/entity_type.rs +++ b/crates/models_properties/src/shared/entity_type.rs @@ -69,8 +69,11 @@ impl From for EntityType { fn from(sub_type: DocumentSubType) -> Self { match sub_type { DocumentSubType::Task => EntityType::Task, - // No dedicated property entity type for snippets or skills; they key under Document. - DocumentSubType::Snippet | DocumentSubType::Skill => EntityType::Document, + // No dedicated property entity type for snippets, skills, or initiative + // descriptions; they key under Document. + DocumentSubType::Snippet + | DocumentSubType::Skill + | DocumentSubType::InitiativeDescription => EntityType::Document, } } } diff --git a/crates/models_soup/src/document.rs b/crates/models_soup/src/document.rs index fe6679bfaa3..9259b0c9fc2 100644 --- a/crates/models_soup/src/document.rs +++ b/crates/models_soup/src/document.rs @@ -21,6 +21,8 @@ pub enum SoupDocumentSubType { Snippet {}, /// A skill document — markdown instructions for AI Skill {}, + /// The description document of an initiative + InitiativeDescription {}, } impl SoupDocumentSubType { @@ -33,6 +35,7 @@ impl SoupDocumentSubType { }), DocumentSubType::Snippet => Some(Self::Snippet {}), DocumentSubType::Skill => Some(Self::Skill {}), + DocumentSubType::InitiativeDescription => Some(Self::InitiativeDescription {}), } } @@ -40,7 +43,7 @@ impl SoupDocumentSubType { pub fn is_task_completed(&self) -> Option { match self { Self::Task { is_completed } => Some(*is_completed), - Self::Snippet {} | Self::Skill {} => None, + Self::Snippet {} | Self::Skill {} | Self::InitiativeDescription {} => None, } } } @@ -122,9 +125,12 @@ impl SoupDocument { pub fn entity_type(&self) -> EntityType { match &self.sub_type { Some(SoupDocumentSubType::Task { .. }) => EntityType::Task, - Some(SoupDocumentSubType::Snippet {} | SoupDocumentSubType::Skill {}) | None => { - EntityType::Document - } + Some( + SoupDocumentSubType::Snippet {} + | SoupDocumentSubType::Skill {} + | SoupDocumentSubType::InitiativeDescription {}, + ) + | None => EntityType::Document, } } } diff --git a/crates/properties/src/domain/service_impl.rs b/crates/properties/src/domain/service_impl.rs index c0923f55602..29463c92f95 100644 --- a/crates/properties/src/domain/service_impl.rs +++ b/crates/properties/src/domain/service_impl.rs @@ -299,9 +299,9 @@ where .and_then(|document_id| document_sub_types.get(&document_id)) .map_or(EntityType::Document, |sub_type| match sub_type { DocumentSubType::Task => EntityType::Task, - DocumentSubType::Snippet | DocumentSubType::Skill => { - EntityType::Document - } + DocumentSubType::Snippet + | DocumentSubType::Skill + | DocumentSubType::InitiativeDescription => EntityType::Document, }), other => super::model::storage_entity_type(other).ok_or_else(|| { PropertiesErr::Validation(format!( diff --git a/crates/soup/src/inbound/toolset/list_entities.rs b/crates/soup/src/inbound/toolset/list_entities.rs index ce0fa07be23..d64c2ad5362 100644 --- a/crates/soup/src/inbound/toolset/list_entities.rs +++ b/crates/soup/src/inbound/toolset/list_entities.rs @@ -146,7 +146,7 @@ pub enum EntityItem { #[serde(skip_serializing_if = "Option::is_none")] file_type: Option, /// The document's sub type: "task" for Macro tasks, "snippet" for snippets, - /// "skill" for skills. + /// "skill" for skills, "initiative_description" for an initiative's description. #[serde(skip_serializing_if = "Option::is_none")] sub_type: Option, /// Tags on the document visible to the user. @@ -262,6 +262,7 @@ impl EntityItem { SoupDocumentSubType::Task { .. } => "task", SoupDocumentSubType::Snippet {} => "snippet", SoupDocumentSubType::Skill {} => "skill", + SoupDocumentSubType::InitiativeDescription {} => "initiative_description", } .to_string() }), diff --git a/crates/soup_filter_cache_adapter/src/lib.rs b/crates/soup_filter_cache_adapter/src/lib.rs index f24a188db4c..ac86f68cc81 100644 --- a/crates/soup_filter_cache_adapter/src/lib.rs +++ b/crates/soup_filter_cache_adapter/src/lib.rs @@ -719,6 +719,9 @@ fn document_sub_type(value: &serde_json::Value) -> Result Ok(Some(DocumentSubType::Task)), Some("GraphqlSnippetSubType") => Ok(Some(DocumentSubType::Snippet)), Some("GraphqlSkillSubType") => Ok(Some(DocumentSubType::Skill)), + Some("GraphqlInitiativeDescriptionSubType") => { + Ok(Some(DocumentSubType::InitiativeDescription)) + } _ => Err(()), } } diff --git a/crates/soup_filter_projection/src/profile.rs b/crates/soup_filter_projection/src/profile.rs index 1416e5f63b8..d6e7f246e58 100644 --- a/crates/soup_filter_projection/src/profile.rs +++ b/crates/soup_filter_projection/src/profile.rs @@ -154,7 +154,10 @@ fn validate_exact_facts( .map_err(|_| ProfileValidationError::InvalidValue("file-type"))?; } else if attribute == &vocabulary::document_sub_type() && kind == PartitionKind::Document { sub_type += 1; - if !matches!(value, b"task" | b"snippet" | b"skill") { + if !matches!( + value, + b"task" | b"snippet" | b"skill" | b"initiative_description" + ) { return Err(ProfileValidationError::InvalidValue("document-sub-type")); } } else if attribute == &vocabulary::email_attachment() && kind == PartitionKind::Document { diff --git a/packages/sdk/generated/storage/index.ts b/packages/sdk/generated/storage/index.ts index 434a4ed0a0a..043fd879794 100644 --- a/packages/sdk/generated/storage/index.ts +++ b/packages/sdk/generated/storage/index.ts @@ -1,4 +1,4 @@ // This file is auto-generated by @hey-api/openapi-ts export { type Options, Sdk } from './sdk.gen'; -export type { AccessLevel, ActiveCallsResponse, ActiveCallSummary, ActivityType, AddFavoriteData, AddFavoriteError, AddFavoriteErrors, AddFavoriteRequest, AddFavoriteResponse, AddFavoriteResponses, AddParticipantsData, AddParticipantsError, AddParticipantsErrors, AddParticipantsRequest, AddParticipantsResponses, AddPinHandlerData, AddPinHandlerError, AddPinHandlerErrors, AddPinHandlerResponse, AddPinHandlerResponses, AddPinRequest, Agent, AgentActionId, AgentChannelScope, AgentMcpServer, AgentMcpServers, AgentSessionFilters, AgentSessionLifecycleEvent, Anchor, AnchorId, AnchorRequest, AnchorResponse, AnnotationIncrementalUpdate, ApiActivity, ApiAttachmentChannelReference, ApiAttachmentEntityReference, ApiAttachmentGenericReference, ApiChannelAttachment, ApiChannelAttachmentsPage, ApiChannelContextMessage, ApiChannelDetail, ApiChannelListMessage, ApiChannelListPage, ApiChannelListParticipant, ApiChannelListType, ApiChannelMessage, ApiChannelMessageKind, ApiChannelMessagesPage, ApiChannelParticipant, ApiChannelWithLatest, ApiCountedReaction, ApiEntityFilterAst, ApiGroupByField, ApiGroupMeta, ApiMessageAttachment, ApiMessageSender, ApiMessageSenderType, ApiParticipantListRole, ApiParticipantRole, ApiPropertyEntityType, ApiResolvedChannelMessage, ApiThreadInfo, ApiThreadReply, ApproveHarnessPairingData, ApproveHarnessPairingError, ApproveHarnessPairingErrors, ApproveHarnessPairingResponse, ApproveHarnessPairingResponses, ApprovePairingRequest, AssignInitiativeTasksData, AssignInitiativeTasksError, AssignInitiativeTasksErrors, AssignInitiativeTasksResponse, AssignInitiativeTasksResponses, AssignTasksRequest, AssignTasksResponse, AssignTasksResult, AssignTaskStatus, AttachmentChange, AttendeeResponseStatus, BasicDocument, BasicDocumentSubType, BomPart, Bot, BotChannel, BotChannelType, BotId, BotKind, BotOwner, BotSenderProfile, BotToken, BranchNameResponse, BTreeMap, BulkWakeupRequest, BulkWakeupResponse, BulkWakeupSyncServiceDocumentsData, BulkWakeupSyncServiceDocumentsErrors, BulkWakeupSyncServiceDocumentsResponse, BulkWakeupSyncServiceDocumentsResponses, CalendarAttendee, CalendarEvent, CalendarEventFilters, CalendarEventSourceContent, CalendarMentionEvent, CalendarMentionPreviewItem, CalendarMentionPreviewKind, CalendarMentionPreviewRequest, CalendarMentionPreviewRequestItem, CalendarMentionPreviewResponse, CalendarOccurrence, CalendarOccurrenceItem, CalendarOccurrenceResponse, CalendarSyncStatus, CallActiveResponse, CallFilters, CallRecord, CallRecordParticipant, CallRecordPreview, CallRecordPreviewData, CallRecordTranscriptSegment, CallStatus, CallTokenResponse, CallWebhookData, CallWebhookError, CallWebhookErrors, CallWebhookResponses, Channel, ChannelCreatedMetadata, ChannelDeletedMetadata, ChannelEventAttachment, ChannelFilters, ChannelJoinCodeResponse, ChannelMentionedMetadata, ChannelMessage, ChannelMessageAttachmentCreatedMetadata, ChannelMessageAttachmentRemovedMetadata, ChannelMessageDeletedMetadata, ChannelMessageFilters, ChannelMessagePatchedMetadata, ChannelMessagePostedMetadata, ChannelParticipant, ChannelParticipantAddedMetadata, ChannelParticipantRemovedMetadata, ChannelPreview, ChannelPreviewData, ChannelSender, ChannelSharePermission, ChannelThreadFilters, ChannelTopicEvent, ChannelType, ChannelUpdatedMetadata, ChannelWebhookRequest, ChannelWebhookResponse, ChannelWithParticipants, Chat, ChatFilters, CheckActiveCallData, CheckActiveCallError, CheckActiveCallErrors, CheckActiveCallResponse, CheckActiveCallResponses, ClaimedPairing, ClaimHarnessPairingData, ClaimHarnessPairingError, ClaimHarnessPairingErrors, ClaimHarnessPairingResponse, ClaimHarnessPairingResponses, ClaimPairingRequest, ClientOptions, CloudStorageItemType, CollabSurfaceResponse, CollabSurfaceTokenResponse, Comment, CommentThread, ConferenceProvider, CopyDocumentData, CopyDocumentError, CopyDocumentErrors, CopyDocumentQueryParams, CopyDocumentRequest, CopyDocumentResponse, CopyDocumentResponse2, CopyDocumentResponses, CountedReaction, CreateAgentData, CreateAgentError, CreateAgentErrors, CreateAgentRequest, CreateAgentResponse, CreateAgentResponses, CreateAnchorData, CreateAnchorError, CreateAnchorErrors, CreateAnchorResponse, CreateAnchorResponses, CreateBotRequest, CreateBulkDocumentResponse, CreateBulkDocumentResponseData, CreateChannelData, CreateChannelError, CreateChannelErrors, CreateChannelRequest, CreateChannelResponse, CreateChannelResponse2, CreateChannelResponses, CreateChannelScopedBotData, CreateChannelScopedBotError, CreateChannelScopedBotErrors, CreateChannelScopedBotRequest, CreateChannelScopedBotResponse, CreateChannelScopedBotResponse2, CreateChannelScopedBotResponses, CreateCollabSurfaceTokenData, CreateCollabSurfaceTokenError, CreateCollabSurfaceTokenErrors, CreateCollabSurfaceTokenResponse, CreateCollabSurfaceTokenResponses, CreateCommentData, CreateCommentError, CreateCommentErrors, CreateCommentRequest, CreateCommentResponse, CreateCommentResponse2, CreateCommentResponses, CreateCrmCommentData, CreateCrmCommentError, CreateCrmCommentErrors, CreateCrmCommentRequest, CreateCrmCommentResponse, CreateCrmCommentResponses, CreateCrmCompanyData, CreateCrmCompanyError, CreateCrmCompanyErrors, CreateCrmCompanyRequest, CreateCrmCompanyResponse, CreateCrmCompanyResponses, CreateCrmContactData, CreateCrmContactError, CreateCrmContactErrors, CreateCrmContactRequest, CreateCrmContactResponse, CreateCrmContactResponses, CreateDocumentData, CreateDocumentError, CreateDocumentErrors, CreateDocumentRequest, CreateDocumentResponse, CreateDocumentResponseData, CreateDocumentResponses, CreatedPairing, CreatedUserApiKey, CreateEntityMentionData, CreateEntityMentionError, CreateEntityMentionErrors, CreateEntityMentionRequest, CreateEntityMentionResponse, CreateEntityMentionResponse2, CreateEntityMentionResponses, CreateHarnessPairingData, CreateHarnessPairingError, CreateHarnessPairingErrors, CreateHarnessPairingResponse, CreateHarnessPairingResponses, CreateInitiativeData, CreateInitiativeError, CreateInitiativeErrors, CreateInitiativeRequest, CreateInitiativeResponse, CreateInitiativeResponses, CreateInstructionsDocumentResponse, CreateInstructionsHandlerData, CreateInstructionsHandlerError, CreateInstructionsHandlerErrors, CreateInstructionsHandlerResponse, CreateInstructionsHandlerResponses, CreateMarkdownDocumentRequest, CreateMarkdownDocumentResponse, CreateMarkdownHandlerData, CreateMarkdownHandlerError, CreateMarkdownHandlerErrors, CreateMarkdownHandlerResponse, CreateMarkdownHandlerResponses, CreatePairingRequest, CreateProjectHandlerData, CreateProjectHandlerError, CreateProjectHandlerErrors, CreateProjectHandlerResponse, CreateProjectHandlerResponses, CreateProjectRequest, CreateProjectResponse, CreateReminderData, CreateReminderError, CreateReminderErrors, CreateReminderRequest, CreateReminderResponse, CreateReminderResponses, CreateSkillHandlerData, CreateSkillHandlerError, CreateSkillHandlerErrors, CreateSkillHandlerResponse, CreateSkillHandlerResponses, CreateSkillRequest, CreateSkillResponse, CreateSnippetHandlerData, CreateSnippetHandlerError, CreateSnippetHandlerErrors, CreateSnippetHandlerResponse, CreateSnippetHandlerResponses, CreateSnippetRequest, CreateSnippetResponse, CreateTaskHandlerData, CreateTaskHandlerError, CreateTaskHandlerErrors, CreateTaskHandlerResponse, CreateTaskHandlerResponses, CreateTaskRequest, CreateTaskResponse, CreateUnthreadedAnchorRequest, CreateUnthreadedAnchorResponse, CreateUnthreadedPdfAnchorRequest, CreateUserApiKeyData, CreateUserApiKeyError, CreateUserApiKeyErrors, CreateUserApiKeyRequest, CreateUserApiKeyResponse, CreateUserApiKeyResponses, CreateViewHandlerData, CreateViewHandlerError, CreateViewHandlerErrors, CreateViewHandlerResponse, CreateViewHandlerResponses, CreateViewRequest, CreateWebhookData, CreateWebhookError, CreateWebhookErrors, CreateWebhookRequest, CreateWebhookResponse, CreateWebhookResponse2, CreateWebhookResponses, CrmComment, CrmCommentEntityType, CrmCommentThread, CrmCompanyFilters, CrmCompanyResponse, CrmContactResponse, CrmDomainResponse, CrmPermissionRole, CrmStageInput, CrmStageResponse, CrmStagesResponse, CrmTeamSettingsResponse, CrmThread, CustomSpeakerAssignment, DataType, DeleteAnchorData, DeleteAnchorError, DeleteAnchorErrors, DeleteAnchorInfo, DeleteAnchorResponse, DeleteAnchorResponses, DeleteCallRecordData, DeleteCallRecordError, DeleteCallRecordErrors, DeleteCallRecordResponse, DeleteCallRecordResponses, DeleteChannelData, DeleteChannelError, DeleteChannelErrors, DeleteChannelResponse, DeleteChannelResponses, DeleteCollabSurfaceData, DeleteCollabSurfaceError, DeleteCollabSurfaceErrors, DeleteCollabSurfaceResponse, DeleteCollabSurfaceResponses, DeleteCommentData, DeleteCommentError, DeleteCommentErrors, DeleteCommentRequest, DeleteCommentResponse, DeleteCommentResponse2, DeleteCommentResponses, DeleteCrmCommentData, DeleteCrmCommentError, DeleteCrmCommentErrors, DeleteCrmCommentResponse, DeleteCrmCommentResponses, DeleteCrmCommentResult, DeleteDocumentData, DeleteDocumentError, DeleteDocumentErrors, DeleteDocumentResponse, DeleteDocumentResponses, DeleteEntityMentionData, DeleteEntityMentionError, DeleteEntityMentionErrors, DeleteEntityMentionResponse, DeleteEntityMentionResponse2, DeleteEntityMentionResponses, DeleteHarnessData, DeleteHarnessError, DeleteHarnessErrors, DeleteHarnessResponse, DeleteHarnessResponses, DeleteHistoryHandlerData, DeleteHistoryHandlerError, DeleteHistoryHandlerErrors, DeleteHistoryHandlerResponse, DeleteHistoryHandlerResponses, DeleteInitiativeData, DeleteInitiativeError, DeleteInitiativeErrors, DeleteInitiativeResponse, DeleteInitiativeResponses, DeleteMessageData, DeleteMessageError, DeleteMessageErrors, DeleteMessageQuery, DeleteMessageResponse, DeleteMessageResponses, DeleteProjectHandlerData, DeleteProjectHandlerError, DeleteProjectHandlerErrors, DeleteProjectHandlerResponse, DeleteProjectHandlerResponses, DeleteReminderData, DeleteReminderError, DeleteReminderErrors, DeleteReminderResponse, DeleteReminderResponses, DeleteSelfHarnessData, DeleteSelfHarnessError, DeleteSelfHarnessErrors, DeleteSelfHarnessResponse, DeleteSelfHarnessResponses, DeleteThreadInfo, DeleteUnthreadedAnchorRequest, DeleteUnthreadedAnchorResponse, DeleteUnthreadedPdfAnchorRequest, DeleteUserApiKeyData, DeleteUserApiKeyError, DeleteUserApiKeyErrors, DeleteUserApiKeyResponse, DeleteUserApiKeyResponses, DeleteUserDocumentViewLocationData, DeleteUserDocumentViewLocationError, DeleteUserDocumentViewLocationErrors, DeleteUserDocumentViewLocationResponse, DeleteUserDocumentViewLocationResponses, DeleteViewHandlerData, DeleteViewHandlerError, DeleteViewHandlerErrors, DeleteViewHandlerResponses, DeleteWebhookData, DeleteWebhookError, DeleteWebhookErrors, DeleteWebhookResponse, DeleteWebhookResponses, DocumentBasic, DocumentContent, DocumentContentLocation, DocumentContentState, DocumentContentUploadedMetadata, DocumentCopiedMetadata, DocumentCreatedMetadata, DocumentDeletedMetadata, DocumentFilters, DocumentId, DocumentInteractionMetadata, DocumentMetadata, DocumentMetadataWithContent, DocumentPermissionsToken, DocumentPermissionsTokenRequest, DocumentPermissionsTokenResponse, DocumentPreview, DocumentPreviewData, DocumentPreviewDataSubType, DocumentPurgedMetadata, DocumentResponse, DocumentResponseMetadata, DocumentResponseMetadataWithContent, DocumentStorageServiceApiVersion, DocumentSubType, DocumentSyncContentUpdatedMetadata, DocumentTeamShareResponse, DocumentTopicEvent, DocumentUpdatedMetadata, EditAnchorData, EditAnchorError, EditAnchorErrors, EditAnchorRequest, EditAnchorResponse, EditAnchorResponse2, EditAnchorResponses, EditCallRecordData, EditCallRecordError, EditCallRecordErrors, EditCallRecordRequest, EditCallRecordResponse, EditCallRecordResponses, EditCallTranscriptData, EditCallTranscriptError, EditCallTranscriptErrors, EditCallTranscriptRequest, EditCallTranscriptResponse, EditCallTranscriptResponses, EditCommentData, EditCommentError, EditCommentErrors, EditCommentRequest, EditCommentResponse, EditCommentResponse2, EditCommentResponses, EditCrmCommentData, EditCrmCommentError, EditCrmCommentErrors, EditCrmCommentRequest, EditCrmCommentResponse, EditCrmCommentResponses, EditDocumentData, EditDocumentError, EditDocumentErrors, EditDocumentResponse, EditDocumentResponse2, EditDocumentResponses, EditDocumentServiceArgs, EditPdfAnchorRequest, EditPdfPlaceableCommentAnchorRequest, EditProjectV2Data, EditProjectV2Error, EditProjectV2Errors, EditProjectV2Response, EditProjectV2Responses, EditThreadV2Data, EditThreadV2Error, EditThreadV2Errors, EditThreadV2Response, EditThreadV2Responses, EmailFilters, EmptyResponse, EnsureCollabSurfaceData, EnsureCollabSurfaceError, EnsureCollabSurfaceErrors, EnsureCollabSurfaceRequest, EnsureCollabSurfaceResponse, EnsureCollabSurfaceResponses, EntityFilters, EntityMessageCreateData, EntityMessageCreateResponse, EntityMessageCreateResponses, EntityMessageDeleteMessageData, EntityMessageDeleteMessageResponse, EntityMessageDeleteMessageResponses, EntityMessageDeleteThreadData, EntityMessageDeleteThreadResponse, EntityMessageDeleteThreadResponses, EntityMessageEditData, EntityMessageEditResponse, EntityMessageEditResponses, EntityMessageGetMessageData, EntityMessageGetMessageResponse, EntityMessageGetMessageResponses, EntityMessageGetThreadData, EntityMessageGetThreadResponse, EntityMessageGetThreadResponses, EntityMessageLegacyData, EntityMessageLegacyResponse, EntityMessageLegacyResponses, EntityMessagePatchThreadData, EntityMessagePatchThreadResponse, EntityMessagePatchThreadResponses, EntityMessageReactData, EntityMessageReactResponse, EntityMessageReactResponses, EntityMessageReferencesData, EntityMessageReferencesResponse, EntityMessageReferencesResponses, EntityMessageTypingData, EntityMessageTypingResponse, EntityMessageTypingResponses, EntityPermission, EntityPermissionResponse, EntityReference, EntityType, ErrorResponse, EventReminderOverride, EventReminders, EventStatus, EventTime, EventTransparency, EventType, EventVisibility, ExcludedDefaultView, ExcludeDefaultViewHandlerData, ExcludeDefaultViewHandlerError, ExcludeDefaultViewHandlerErrors, ExcludeDefaultViewHandlerResponses, ExcludeDefaultViewRequest, ExportDocumentData, ExportDocumentError, ExportDocumentErrors, ExportDocumentResponse, ExportDocumentResponse2, ExportDocumentResponses, Favorite, FavoriteEntityRef, FavoritesList, FileSystemNodeWithIds, FileType, FileTypeUpdate, FolderItem, ForeignEntity, ForeignEntityFilters, GenericErrorResponse, GenericResponse, GenericSuccessResponse, GetActiveCallsData, GetActiveCallsError, GetActiveCallsErrors, GetActiveCallsResponse, GetActiveCallsResponses, GetActivityData, GetActivityError, GetActivityErrors, GetActivityResponse, GetActivityResponses, GetAttachmentReferencesData, GetAttachmentReferencesError, GetAttachmentReferencesErrors, GetAttachmentReferencesResponse, GetAttachmentReferencesResponse2, GetAttachmentReferencesResponses, GetBatchCallRecordPreviewData, GetBatchCallRecordPreviewError, GetBatchCallRecordPreviewErrors, GetBatchCallRecordPreviewRequest, GetBatchCallRecordPreviewResponse, GetBatchCallRecordPreviewResponse2, GetBatchCallRecordPreviewResponses, GetBatchChannelPreviewData, GetBatchChannelPreviewError, GetBatchChannelPreviewErrors, GetBatchChannelPreviewRequest, GetBatchChannelPreviewResponse, GetBatchChannelPreviewResponse2, GetBatchChannelPreviewResponses, GetBatchPreviewHandlerData, GetBatchPreviewHandlerError, GetBatchPreviewHandlerErrors, GetBatchPreviewHandlerResponse, GetBatchPreviewHandlerResponses, GetBatchPreviewRequest, GetBatchPreviewResponse, GetBatchProjectPreviewData, GetBatchProjectPreviewError, GetBatchProjectPreviewErrors, GetBatchProjectPreviewRequest, GetBatchProjectPreviewResponse, GetBatchProjectPreviewResponse2, GetBatchProjectPreviewResponses, GetCallRecordData, GetCallRecordError, GetCallRecordErrors, GetCallRecordResponse, GetCallRecordResponses, GetChannelAttachmentsData, GetChannelAttachmentsError, GetChannelAttachmentsErrors, GetChannelAttachmentsResponse, GetChannelAttachmentsResponses, GetChannelData, GetChannelError, GetChannelErrors, GetChannelJoinLinkData, GetChannelJoinLinkError, GetChannelJoinLinkErrors, GetChannelJoinLinkResponse, GetChannelJoinLinkResponses, GetChannelMessagesCatchUpData, GetChannelMessagesCatchUpError, GetChannelMessagesCatchUpErrors, GetChannelMessagesCatchUpResponse, GetChannelMessagesCatchUpResponses, GetChannelMessagesData, GetChannelMessagesError, GetChannelMessagesErrors, GetChannelMessagesResponse, GetChannelMessagesResponses, GetChannelParticipantsData, GetChannelParticipantsError, GetChannelParticipantsErrors, GetChannelParticipantsResponse, GetChannelParticipantsResponses, GetChannelResponse, GetChannelResponses, GetChannelsData, GetChannelsError, GetChannelsErrors, GetChannelsResponse, GetChannelsResponses, GetCollabSurfaceData, GetCollabSurfaceError, GetCollabSurfaceErrors, GetCollabSurfaceResponse, GetCollabSurfaceResponses, GetCompanyData, GetCompanyError, GetCompanyErrors, GetCompanyResponse, GetCompanyResponses, GetContactByEmailData, GetContactByEmailError, GetContactByEmailErrors, GetContactByEmailResponse, GetContactByEmailResponse2, GetContactByEmailResponses, GetContactData, GetContactError, GetContactErrors, GetContactResponse, GetContactResponses, GetCrmTeamSettingsData, GetCrmTeamSettingsError, GetCrmTeamSettingsErrors, GetCrmTeamSettingsResponse, GetCrmTeamSettingsResponses, GetDocumentAnchorsData, GetDocumentAnchorsError, GetDocumentAnchorsErrors, GetDocumentAnchorsResponse, GetDocumentAnchorsResponses, GetDocumentBranchNameData, GetDocumentBranchNameError, GetDocumentBranchNameErrors, GetDocumentBranchNameResponse, GetDocumentBranchNameResponses, GetDocumentByTeamSlugData, GetDocumentByTeamSlugError, GetDocumentByTeamSlugErrors, GetDocumentByTeamSlugResponse, GetDocumentByTeamSlugResponses, GetDocumentCommentsData, GetDocumentCommentsError, GetDocumentCommentsErrors, GetDocumentCommentsResponse, GetDocumentCommentsResponses, GetDocumentData, GetDocumentError, GetDocumentErrors, GetDocumentGithubPullRequestsData, GetDocumentGithubPullRequestsError, GetDocumentGithubPullRequestsErrors, GetDocumentGithubPullRequestsResponse, GetDocumentGithubPullRequestsResponses, GetDocumentKeyResponse, GetDocumentKeyResponseData, GetDocumentListHandlerData, GetDocumentListHandlerError, GetDocumentListHandlerErrors, GetDocumentListHandlerResponse, GetDocumentListHandlerResponses, GetDocumentListResult, GetDocumentLocationV3Data, GetDocumentLocationV3Error, GetDocumentLocationV3Errors, GetDocumentLocationV3Response, GetDocumentLocationV3Responses, GetDocumentPermissionsResponseDataV2, GetDocumentPermissionsTokenData, GetDocumentPermissionsTokenError, GetDocumentPermissionsTokenErrors, GetDocumentPermissionsTokenResponse, GetDocumentPermissionsTokenResponses, GetDocumentPermissionsV2Data, GetDocumentPermissionsV2Error, GetDocumentPermissionsV2Errors, GetDocumentPermissionsV2Response, GetDocumentPermissionsV2Responses, GetDocumentProcessingResult, GetDocumentProcessingResultData, GetDocumentProcessingResultError, GetDocumentProcessingResultErrors, GetDocumentProcessingResultResponse, GetDocumentProcessingResultResponse2, GetDocumentProcessingResultResponses, GetDocumentResponse, GetDocumentResponse2, GetDocumentResponseData, GetDocumentResponses, GetDocumentSearchResponse, GetDocumentShortIdData, GetDocumentShortIdError, GetDocumentShortIdErrors, GetDocumentShortIdResponse, GetDocumentShortIdResponses, GetDocumentsResponse, GetDocumentTeamShareData, GetDocumentTeamShareError, GetDocumentTeamShareErrors, GetDocumentTeamShareResponse, GetDocumentTeamShareResponses, GetDocumentUserAccessLevelResponse, GetDocumentVersionData, GetDocumentVersionError, GetDocumentVersionErrors, GetDocumentVersionResponse, GetDocumentVersionResponses, GetDocumentViewsHandlerData, GetDocumentViewsHandlerError, GetDocumentViewsHandlerErrors, GetDocumentViewsHandlerResponse, GetDocumentViewsHandlerResponses, GetEntityPermissionData, GetEntityPermissionErrors, GetEntityPermissionResponse, GetEntityPermissionResponses, GetForeignEntityBySourceData, GetForeignEntityBySourceError, GetForeignEntityBySourceErrors, GetForeignEntityBySourceResponse, GetForeignEntityBySourceResponses, GetForeignEntityData, GetForeignEntityError, GetForeignEntityErrors, GetForeignEntityResponse, GetForeignEntityResponses, GetHarnessPairingData, GetHarnessPairingError, GetHarnessPairingErrors, GetHarnessPairingResponse, GetHarnessPairingResponses, GetHistoryHandlerData, GetHistoryHandlerError, GetHistoryHandlerErrors, GetHistoryHandlerResponse, GetHistoryHandlerResponses, GetInitiativeData, GetInitiativeError, GetInitiativeErrors, GetInitiativeResponse, GetInitiativeResponses, GetInstructionsDocumentResponse, GetInstructionsHandlerData, GetInstructionsHandlerError, GetInstructionsHandlerErrors, GetInstructionsHandlerResponse, GetInstructionsHandlerResponses, GetItemsSoupData, GetItemsSoupError, GetItemsSoupErrors, GetItemsSoupResponse, GetItemsSoupResponses, GetLocationHandlerData, GetLocationHandlerError, GetLocationHandlerErrors, GetLocationHandlerResponse, GetLocationHandlerResponses, GetMessageWithContextData, GetMessageWithContextError, GetMessageWithContextErrors, GetMessageWithContextResponse, GetMessageWithContextResponse2, GetMessageWithContextResponses, GetOrCreateAction, GetOrCreateCallData, GetOrCreateCallError, GetOrCreateCallErrors, GetOrCreateCallResponse, GetOrCreateCallResponses, GetOrCreateChannelResponse, GetOrCreateDmData, GetOrCreateDmError, GetOrCreateDmErrors, GetOrCreateDmRequest, GetOrCreateDmResponse, GetOrCreateDmResponses, GetOrCreatePrivateData, GetOrCreatePrivateError, GetOrCreatePrivateErrors, GetOrCreatePrivateRequest, GetOrCreatePrivateResponse, GetOrCreatePrivateResponses, GetPendingProjectsHandlerData, GetPendingProjectsHandlerError, GetPendingProjectsHandlerErrors, GetPendingProjectsHandlerResponse, GetPendingProjectsHandlerResponses, GetPinsHandlerData, GetPinsHandlerError, GetPinsHandlerErrors, GetPinsHandlerResponse, GetPinsHandlerResponses, GetPinsResponse, GetProjectContentHandlerData, GetProjectContentHandlerError, GetProjectContentHandlerErrors, GetProjectContentHandlerResponse, GetProjectContentHandlerResponses, GetProjectContentResponse, GetProjectHandlerData, GetProjectHandlerError, GetProjectHandlerErrors, GetProjectHandlerResponse, GetProjectHandlerResponses, GetProjectPermissionsV2Data, GetProjectPermissionsV2Error, GetProjectPermissionsV2Errors, GetProjectPermissionsV2Response, GetProjectPermissionsV2Responses, GetProjectResponse, GetProjectResponseData, GetProjectsHandlerData, GetProjectsHandlerError, GetProjectsHandlerErrors, GetProjectsHandlerResponse, GetProjectsHandlerResponses, GetProjectsResponse, GetProjectUserAccessLevelData, GetProjectUserAccessLevelError, GetProjectUserAccessLevelErrors, GetProjectUserAccessLevelResponse, GetProjectUserAccessLevelResponses, GetReminderData, GetReminderError, GetReminderErrors, GetReminderResponse, GetReminderResponses, GetRingStatusData, GetRingStatusError, GetRingStatusErrors, GetRingStatusResponse, GetRingStatusResponses, GetSelfBotData, GetSelfBotError, GetSelfBotErrors, GetSelfBotResponse, GetSelfBotResponses, GetSelfHarnessData, GetSelfHarnessError, GetSelfHarnessErrors, GetSelfHarnessResponse, GetSelfHarnessResponses, GetSystemSkillsHandlerData, GetSystemSkillsHandlerError, GetSystemSkillsHandlerErrors, GetSystemSkillsHandlerResponse, GetSystemSkillsHandlerResponses, GetThreadRepliesData, GetThreadRepliesError, GetThreadRepliesErrors, GetThreadRepliesResponse, GetThreadRepliesResponses, GetUserDocumentsHandlerData, GetUserDocumentsHandlerError, GetUserDocumentsHandlerErrors, GetUserDocumentsHandlerResponse, GetUserDocumentsHandlerResponses, GetUserDocumentViewLocationData, GetUserDocumentViewLocationError, GetUserDocumentViewLocationErrors, GetUserDocumentViewLocationResponse, GetUserDocumentViewLocationResponses, GetUserHistoryResponse, GetViewsHandlerData, GetViewsHandlerError, GetViewsHandlerErrors, GetViewsHandlerResponse, GetViewsHandlerResponses, GetWebhookData, GetWebhookError, GetWebhookErrors, GetWebhookResponse, GetWebhookResponses, GithubPullRequest, GithubPullRequestCheckRun, GithubPullRequestComment, GithubPullRequestsResponse, GroupedInitialParams, GroupedPageParams, GroupedSoupGroupPage, GroupedSoupInitialPage, GroupedSoupPage, GroupedSoupSort, HandlerData, HandlerError, HandlerErrors, HandlerResponse, HandlerResponses, Harness, HarnessAgent, HarnessId, HarnessOwner, HarnessSession, HashMap, HealthHandlerData, HealthHandlerResponse, HealthHandlerResponses, HighlightType, ImportedAuthor, InFlightTurnSummary, IngestTranscriptData, IngestTranscriptError, IngestTranscriptErrors, IngestTranscriptResponses, InitializeUserDocumentsData, InitializeUserDocumentsError, InitializeUserDocumentsErrors, InitializeUserDocumentsResponse, InitializeUserDocumentsResponses, InitiativeDetail, InitiativeId, InitiativeList, InitiativeSummary, InputReceivedMetadata, InstallSyncData, InstallSyncErrors, InteractionReason, Item, ItemWithUserAccessLevel, JobProcessingResultHandlerData, JobProcessingResultHandlerError, JobProcessingResultHandlerErrors, JobProcessingResultHandlerResponse, JobProcessingResultHandlerResponses, JoinChannelByCodeData, JoinChannelByCodeError, JoinChannelByCodeErrors, JoinChannelByCodeResponses, JoinChannelData, JoinChannelError, JoinChannelErrors, JoinChannelResponses, LatestMessage, LeaveCallResponse, LeaveChannelData, LeaveChannelError, LeaveChannelErrors, LeaveChannelResponses, LeaveOrEndCallData, LeaveOrEndCallError, LeaveOrEndCallErrors, LeaveOrEndCallResponse, LeaveOrEndCallResponses, LinkShare, ListAgentsData, ListAgentsError, ListAgentsErrors, ListAgentsResponse, ListAgentsResponses, ListBotChannelsData, ListBotChannelsError, ListBotChannelsErrors, ListBotChannelsResponse, ListBotChannelsResponses, ListCompanyContactsData, ListCompanyContactsError, ListCompanyContactsErrors, ListCompanyContactsResponse, ListCompanyContactsResponses, ListCrmCommentsData, ListCrmCommentsError, ListCrmCommentsErrors, ListCrmCommentsResponse, ListCrmCommentsResponses, ListFavoritesData, ListFavoritesError, ListFavoritesErrors, ListFavoritesResponse, ListFavoritesResponses, ListHarnessAgentsData, ListHarnessAgentsError, ListHarnessAgentsErrors, ListHarnessAgentsResponse, ListHarnessAgentsResponses, ListHarnessesData, ListHarnessesError, ListHarnessesErrors, ListHarnessesResponse, ListHarnessesResponses, ListHarnessSessionsData, ListHarnessSessionsError, ListHarnessSessionsErrors, ListHarnessSessionsResponse, ListHarnessSessionsResponses, ListInitiativesData, ListInitiativesError, ListInitiativesErrors, ListInitiativesResponse, ListInitiativesResponses, ListOccurrencesData, ListOccurrencesErrors, ListOccurrencesResponse, ListOccurrencesResponses, ListRemindersData, ListRemindersError, ListRemindersErrors, ListRemindersResponse, ListRemindersResponses, ListTeamOutOfOfficeData, ListTeamOutOfOfficeErrors, ListTeamOutOfOfficeResponse, ListTeamOutOfOfficeResponses, ListUserApiKeysData, ListUserApiKeysError, ListUserApiKeysErrors, ListUserApiKeysResponse, ListUserApiKeysResponses, ListWebhooksData, ListWebhooksError, ListWebhooksErrors, ListWebhooksResponse, ListWebhooksResponse2, ListWebhooksResponses, LocationResponseData, LocationResponseV3, MacroUserIdStr, MentionPreviewsData, MentionPreviewsErrors, MentionPreviewsResponse, MentionPreviewsResponses, Mentions, Message, MessageAttachment, MessageChange, MessageCursor, MessageDirection, MessageEvent, MessageListItem, MessagePage, MessageParent, MessagePatch, MessageThread, MessageThreadPreview, MessageTimelineData, MessageTimelineQuery, MessageTimelineResponse, MessageTimelineResponses, NewAttachment, NewChannelAttachment, NewThreadAnchor, NotificationFilters, NotificationState, PairingDetails, Params, ParticipantRole, PatchBotRequest, PatchChannelData, PatchChannelError, PatchChannelErrors, PatchChannelRequest, PatchChannelResponse, PatchChannelResponses, PatchMessageData, PatchMessageError, PatchMessageErrors, PatchMessageNotificationPolicy, PatchMessageRequest, PatchMessageResponse, PatchMessageResponses, PatchProjectRequestV2, PatchThreadRequestV2, PatchViewHandlerData, PatchViewHandlerError, PatchViewHandlerErrors, PatchViewHandlerResponses, PatchWebhookData, PatchWebhookError, PatchWebhookErrors, PatchWebhookRequest, PatchWebhookResponse, PatchWebhookResponses, PdfAnchor, PdfAnchorId, PdfAnchorRequest, PdfHighlightAnchor, PdfHighlightAnchorRequest, PdfHighlightRect, PdfHighlightRectAnchorRequest, PdfPlaceableCommentAnchor, PdfPlaceableCommentAnchorRequest, PendingClaimResponse, PermanentlyDeleteDocumentData, PermanentlyDeleteDocumentError, PermanentlyDeleteDocumentErrors, PermanentlyDeleteDocumentResponse, PermanentlyDeleteDocumentResponses, PermanentlyDeleteProjectData, PermanentlyDeleteProjectError, PermanentlyDeleteProjectErrors, PermanentlyDeleteProjectResponse, PermanentlyDeleteProjectResponses, PinnedItem, PinRequest, PostActivityData, PostActivityError, PostActivityErrors, PostActivityRequest, PostActivityResponse, PostActivityResponses, PostChannelBotWebhookData, PostChannelBotWebhookError, PostChannelBotWebhookErrors, PostChannelBotWebhookResponse, PostChannelBotWebhookResponses, PostChannelMessagesData, PostChannelMessagesError, PostChannelMessagesErrors, PostChannelMessagesResponse, PostChannelMessagesResponses, PostGroupedSoupAstGroupPageRequest, PostGroupedSoupAstInitialRequest, PostGroupedSoupAstRequest, PostItemsSoupAstData, PostItemsSoupAstError, PostItemsSoupAstErrors, PostItemsSoupAstGroupedData, PostItemsSoupAstGroupedError, PostItemsSoupAstGroupedErrors, PostItemsSoupAstGroupedResponse, PostItemsSoupAstGroupedResponses, PostItemsSoupAstResponse, PostItemsSoupAstResponses, PostItemsSoupData, PostItemsSoupError, PostItemsSoupErrors, PostItemsSoupResponse, PostItemsSoupResponses, PostMessage, PostMessageData, PostMessageError, PostMessageErrors, PostMessageNotificationPolicy, PostMessageRequest, PostMessageResponse, PostMessageResponse2, PostMessageResponses, PostReactionData, PostReactionError, PostReactionErrors, PostReactionRequest, PostReactionResponse, PostReactionResponses, PostSoupAstRequest, PostSoupRequest, PostTypingData, PostTypingError, PostTypingErrors, PostTypingRequest, PostTypingResponse, PostTypingResponses, PresaveDocumentHandlerData, PresaveDocumentHandlerError, PresaveDocumentHandlerErrors, PresaveDocumentHandlerResponse, PresaveDocumentHandlerResponses, PreSaveDocumentRequest, PreSaveDocumentResponse, PreSaveDocumentResponseData, PresignedUrl, Project, ProjectDeleteResponseData, ProjectDocumentStatus, ProjectFilters, ProjectPreview, ProjectPreviewData, PropertyDefinition, PropertyFilter, PropertyInput, PropertyOwner, PropertyValue, PutCrmTeamSettingsData, PutCrmTeamSettingsError, PutCrmTeamSettingsErrors, PutCrmTeamSettingsResponse, PutCrmTeamSettingsResponses, PutCrmTeamStagesData, PutCrmTeamStagesError, PutCrmTeamStagesErrors, PutCrmTeamStagesResponse, PutCrmTeamStagesResponses, ReactionAction, ReactionInput, RecentlyDeletedData, RecentlyDeletedError, RecentlyDeletedErrors, RecentlyDeletedResponse, RecentlyDeletedResponseData, RecentlyDeletedResponses, ReferencedThread, ReferencedThreadPage, Reminder, ReminderFilters, ReminderSchedule, RemindersList, RemoveBotFromChannelByBotData, RemoveBotFromChannelByBotError, RemoveBotFromChannelByBotErrors, RemoveBotFromChannelByBotResponse, RemoveBotFromChannelByBotResponses, RemoveFavoriteByEntityData, RemoveFavoriteByEntityError, RemoveFavoriteByEntityErrors, RemoveFavoriteByEntityResponses, RemoveParticipantsData, RemoveParticipantsError, RemoveParticipantsErrors, RemoveParticipantsRequest, RemoveParticipantsResponses, RemovePinHandlerData, RemovePinHandlerError, RemovePinHandlerErrors, RemovePinHandlerResponse, RemovePinHandlerResponses, ReorderFavoritesData, ReorderFavoritesError, ReorderFavoritesErrors, ReorderFavoritesRequest, ReorderFavoritesResponses, ReorderPinRequest, ReorderPinsHandlerData, ReorderPinsHandlerError, ReorderPinsHandlerErrors, ReorderPinsHandlerResponse, ReorderPinsHandlerResponses, ReplaceCrmStagesRequest, RequestedHarnessScope, ResetCrmTeamStagesData, ResetCrmTeamStagesError, ResetCrmTeamStagesErrors, ResetCrmTeamStagesResponse, ResetCrmTeamStagesResponses, ResolveChannelMessageData, ResolveChannelMessageError, ResolveChannelMessageErrors, ResolveChannelMessageResponse, ResolveChannelMessageResponses, RevertDeleteDocumentData, RevertDeleteDocumentError, RevertDeleteDocumentErrors, RevertDeleteDocumentResponse, RevertDeleteDocumentResponses, RevertDeleteProjectData, RevertDeleteProjectError, RevertDeleteProjectErrors, RevertDeleteProjectResponse, RevertDeleteProjectResponses, RingStatus, RingStatusResponse, S3ObjectInfo, SaveBomPart, SaveDocumentHandlerData, SaveDocumentHandlerError, SaveDocumentHandlerErrors, SaveDocumentHandlerResponse, SaveDocumentHandlerResponses, SaveDocumentRequest, SaveDocumentResponse, SaveDocumentResponseData, SessionDeletedMetadata, SessionIdentity, SessionMentionedMetadata, SessionOpenedMetadata, SessionRenamedMetadata, SessionSettledMetadata, SessionStoppedMetadata, SetChannelPictureData, SetChannelPictureError, SetChannelPictureErrors, SetChannelPictureRequest, SetChannelPictureResponse, SetChannelPictureResponses, SetCompanyHiddenData, SetCompanyHiddenError, SetCompanyHiddenErrors, SetCompanyHiddenRequest, SetCompanyHiddenResponse, SetCompanyHiddenResponses, SetCompanyNameRequest, SetContactHiddenData, SetContactHiddenError, SetContactHiddenErrors, SetContactHiddenRequest, SetContactHiddenResponse, SetContactHiddenResponses, SetContactNameRequest, SetCrmCompanyNameData, SetCrmCompanyNameError, SetCrmCompanyNameErrors, SetCrmCompanyNameResponse, SetCrmCompanyNameResponses, SetCrmContactNameData, SetCrmContactNameError, SetCrmContactNameErrors, SetCrmContactNameResponse, SetCrmContactNameResponses, SetDocumentTeamShareData, SetDocumentTeamShareError, SetDocumentTeamShareErrors, SetDocumentTeamShareRequest, SetDocumentTeamShareResponse, SetDocumentTeamShareResponses, SetEmailSyncData, SetEmailSyncError, SetEmailSyncErrors, SetEmailSyncRequest, SetEmailSyncResponse, SetEmailSyncResponses, SetPropertyValue, SharedEmailFilter, SharePermissionV2, ShortIdResponse, SimpleMention, SimpleSaveData, SimpleSaveError, SimpleSaveErrors, SimpleSaveResponse, SimpleSaveResponses, SoupAgentSessionSoupPropertiesField, SoupApiItem, SoupApiSort, SoupApiSortDirection, SoupAttachment, SoupCalendarEventSoupPropertiesField, SoupCalendarEventTime, SoupCallRecordParticipant, SoupCallRecordSoupPropertiesField, SoupChannel, SoupChannelThread, SoupChatSoupPropertiesField, SoupContact, SoupCountedReaction, SoupCrmCompanySoupPropertiesField, SoupCrmDomain, SoupDocumentSoupPropertiesField, SoupDocumentSubType, SoupEmailThreadPreview, SoupEnrichedEmailThreadPreviewSoupPropertiesField, SoupForeignEntity, SoupItem, SoupLabel, SoupLabelListVisibility, SoupLabelType, SoupMessageAttachment, SoupMessageListVisibility, SoupMessageSender, SoupMessageSenderType, SoupPage, SoupProjectSoupPropertiesField, SoupPropertiesField, SoupProperty, SoupReminderReference, SoupReminderSchedule, SoupReminderSoupPropertiesField, SoupThreadInfo, SoupThreadReply, StarterDocumentsResponse, StreamEventsData, StreamEventsError, StreamEventsErrors, StreamEventsResponse, StreamEventsResponses, String, SuccessResponse, SurfaceState, SyncServiceVersionId, SystemSkillSummary, TagFilterMode, TaskFilters, TeamOutOfOfficeItem, TeamOutOfOfficeResponse, TeamRole, Thread, ThreadAnchor, ThreadOrigin, ThreadPatch, ThreadResponse, ThreadState, ToggleShareWithTeamData, ToggleShareWithTeamError, ToggleShareWithTeamErrors, ToggleShareWithTeamResponse, ToggleShareWithTeamResponses, TranscriptSegmentRequest, TurnEndedMetadata, TurnStartedMetadata, TurnSummary, TypedSuccessResponse, TypedSuccessResponseGetDocumentResponseData, TypingAction, TypingInput, UnassignInitiativeTaskData, UnassignInitiativeTaskError, UnassignInitiativeTaskErrors, UnassignInitiativeTaskResponse, UnassignInitiativeTaskResponses, UnthreadedPdfUuidRequest, UpdateAgentData, UpdateAgentError, UpdateAgentErrors, UpdateAgentRequest, UpdateAgentResponse, UpdateAgentResponses, UpdateChannelSharePermission, UpdateCrmTeamSettingsRequest, UpdateInitiativeData, UpdateInitiativeError, UpdateInitiativeErrors, UpdateInitiativeRequest, UpdateInitiativeResponse, UpdateInitiativeResponses, UpdateOperation, UpdateReminderData, UpdateReminderError, UpdateReminderErrors, UpdateReminderRequest, UpdateReminderResponse, UpdateReminderResponses, UpdateSharePermissionRequestV2, UploadDocumentStatus, UploadExtractFolderHandlerData, UploadExtractFolderHandlerError, UploadExtractFolderHandlerErrors, UploadExtractFolderHandlerResponse, UploadExtractFolderHandlerResponses, UploadExtractFolderRequest, UploadFolderHandlerData, UploadFolderHandlerError, UploadFolderHandlerErrors, UploadFolderHandlerResponse, UploadFolderHandlerResponses, UploadFolderRequest, UpsertHistoryHandlerData, UpsertHistoryHandlerError, UpsertHistoryHandlerErrors, UpsertHistoryHandlerResponse, UpsertHistoryHandlerResponses, UpsertUserDocumentViewLocationData, UpsertUserDocumentViewLocationError, UpsertUserDocumentViewLocationErrors, UpsertUserDocumentViewLocationRequest, UpsertUserDocumentViewLocationResponse, UpsertUserDocumentViewLocationResponses, UserApiKeyId, UserApiKeyInfo, UserApiKeysList, UserDocumentsResponse, UserDocumentViewLocation, UserDocumentViewLocationResponse, UserPinsResponse, UserViewsResponse, ValidateDocumentPermissionsTokenData, ValidateDocumentPermissionsTokenError, ValidateDocumentPermissionsTokenErrors, ValidateDocumentPermissionsTokenResponse, ValidateDocumentPermissionsTokenResponses, ValidateWebhookData, ValidateWebhookError, ValidateWebhookErrors, ValidateWebhookResponse, ValidateWebhookResponse2, ValidateWebhookResponses, Vec, View, ViewPatch, ViewsResponse, WaitingForInputMetadata, Webhook, WebhookEvent, WebhookFilter, WebhookScope, WebhookStatus, WebhookValidationTestEvent, WithCallId, WithChannelId, WithDocumentId, WithProjectId } from './types.gen'; +export type { AccessLevel, ActiveCallsResponse, ActiveCallSummary, ActivityType, AddFavoriteData, AddFavoriteError, AddFavoriteErrors, AddFavoriteRequest, AddFavoriteResponse, AddFavoriteResponses, AddParticipantsData, AddParticipantsError, AddParticipantsErrors, AddParticipantsRequest, AddParticipantsResponses, AddPinHandlerData, AddPinHandlerError, AddPinHandlerErrors, AddPinHandlerResponse, AddPinHandlerResponses, AddPinRequest, Agent, AgentActionId, AgentChannelScope, AgentMcpServer, AgentMcpServers, AgentSessionFilters, AgentSessionLifecycleEvent, Anchor, AnchorId, AnchorRequest, AnchorResponse, AnnotationIncrementalUpdate, ApiActivity, ApiAttachmentChannelReference, ApiAttachmentEntityReference, ApiAttachmentGenericReference, ApiChannelAttachment, ApiChannelAttachmentsPage, ApiChannelContextMessage, ApiChannelDetail, ApiChannelListMessage, ApiChannelListPage, ApiChannelListParticipant, ApiChannelListType, ApiChannelMessage, ApiChannelMessageKind, ApiChannelMessagesPage, ApiChannelParticipant, ApiChannelWithLatest, ApiCountedReaction, ApiEntityFilterAst, ApiGroupByField, ApiGroupMeta, ApiMessageAttachment, ApiMessageSender, ApiMessageSenderType, ApiParticipantListRole, ApiParticipantRole, ApiPropertyEntityType, ApiResolvedChannelMessage, ApiThreadInfo, ApiThreadReply, ApproveHarnessPairingData, ApproveHarnessPairingError, ApproveHarnessPairingErrors, ApproveHarnessPairingResponse, ApproveHarnessPairingResponses, ApprovePairingRequest, AssignInitiativeTasksData, AssignInitiativeTasksError, AssignInitiativeTasksErrors, AssignInitiativeTasksResponse, AssignInitiativeTasksResponses, AssignTasksRequest, AssignTasksResponse, AssignTasksResult, AssignTaskStatus, AttachmentChange, AttendeeResponseStatus, BasicDocument, BasicDocumentSubType, BomPart, Bot, BotChannel, BotChannelType, BotId, BotKind, BotOwner, BotSenderProfile, BotToken, BranchNameResponse, BTreeMap, BulkWakeupRequest, BulkWakeupResponse, BulkWakeupSyncServiceDocumentsData, BulkWakeupSyncServiceDocumentsErrors, BulkWakeupSyncServiceDocumentsResponse, BulkWakeupSyncServiceDocumentsResponses, CalendarAttendee, CalendarEvent, CalendarEventFilters, CalendarEventSourceContent, CalendarMentionEvent, CalendarMentionPreviewItem, CalendarMentionPreviewKind, CalendarMentionPreviewRequest, CalendarMentionPreviewRequestItem, CalendarMentionPreviewResponse, CalendarOccurrence, CalendarOccurrenceItem, CalendarOccurrenceResponse, CalendarSyncStatus, CallActiveResponse, CallFilters, CallRecord, CallRecordParticipant, CallRecordPreview, CallRecordPreviewData, CallRecordTranscriptSegment, CallStatus, CallTokenResponse, CallWebhookData, CallWebhookError, CallWebhookErrors, CallWebhookResponses, Channel, ChannelCreatedMetadata, ChannelDeletedMetadata, ChannelEventAttachment, ChannelFilters, ChannelJoinCodeResponse, ChannelMentionedMetadata, ChannelMessage, ChannelMessageAttachmentCreatedMetadata, ChannelMessageAttachmentRemovedMetadata, ChannelMessageDeletedMetadata, ChannelMessageFilters, ChannelMessagePatchedMetadata, ChannelMessagePostedMetadata, ChannelParticipant, ChannelParticipantAddedMetadata, ChannelParticipantRemovedMetadata, ChannelPreview, ChannelPreviewData, ChannelSender, ChannelSharePermission, ChannelThreadFilters, ChannelTopicEvent, ChannelType, ChannelUpdatedMetadata, ChannelWebhookRequest, ChannelWebhookResponse, ChannelWithParticipants, Chat, ChatFilters, CheckActiveCallData, CheckActiveCallError, CheckActiveCallErrors, CheckActiveCallResponse, CheckActiveCallResponses, ClaimedPairing, ClaimHarnessPairingData, ClaimHarnessPairingError, ClaimHarnessPairingErrors, ClaimHarnessPairingResponse, ClaimHarnessPairingResponses, ClaimPairingRequest, ClientOptions, CloudStorageItemType, CollabSurfaceResponse, CollabSurfaceTokenResponse, Comment, CommentThread, ConferenceProvider, CopyDocumentData, CopyDocumentError, CopyDocumentErrors, CopyDocumentQueryParams, CopyDocumentRequest, CopyDocumentResponse, CopyDocumentResponse2, CopyDocumentResponses, CountedReaction, CreateAgentData, CreateAgentError, CreateAgentErrors, CreateAgentRequest, CreateAgentResponse, CreateAgentResponses, CreateAnchorData, CreateAnchorError, CreateAnchorErrors, CreateAnchorResponse, CreateAnchorResponses, CreateBotRequest, CreateBulkDocumentResponse, CreateBulkDocumentResponseData, CreateChannelData, CreateChannelError, CreateChannelErrors, CreateChannelRequest, CreateChannelResponse, CreateChannelResponse2, CreateChannelResponses, CreateChannelScopedBotData, CreateChannelScopedBotError, CreateChannelScopedBotErrors, CreateChannelScopedBotRequest, CreateChannelScopedBotResponse, CreateChannelScopedBotResponse2, CreateChannelScopedBotResponses, CreateCollabSurfaceTokenData, CreateCollabSurfaceTokenError, CreateCollabSurfaceTokenErrors, CreateCollabSurfaceTokenResponse, CreateCollabSurfaceTokenResponses, CreateCommentData, CreateCommentError, CreateCommentErrors, CreateCommentRequest, CreateCommentResponse, CreateCommentResponse2, CreateCommentResponses, CreateCrmCommentData, CreateCrmCommentError, CreateCrmCommentErrors, CreateCrmCommentRequest, CreateCrmCommentResponse, CreateCrmCommentResponses, CreateCrmCompanyData, CreateCrmCompanyError, CreateCrmCompanyErrors, CreateCrmCompanyRequest, CreateCrmCompanyResponse, CreateCrmCompanyResponses, CreateCrmContactData, CreateCrmContactError, CreateCrmContactErrors, CreateCrmContactRequest, CreateCrmContactResponse, CreateCrmContactResponses, CreateDocumentData, CreateDocumentError, CreateDocumentErrors, CreateDocumentRequest, CreateDocumentResponse, CreateDocumentResponseData, CreateDocumentResponses, CreatedPairing, CreatedUserApiKey, CreateEntityMentionData, CreateEntityMentionError, CreateEntityMentionErrors, CreateEntityMentionRequest, CreateEntityMentionResponse, CreateEntityMentionResponse2, CreateEntityMentionResponses, CreateHarnessPairingData, CreateHarnessPairingError, CreateHarnessPairingErrors, CreateHarnessPairingResponse, CreateHarnessPairingResponses, CreateInitiativeData, CreateInitiativeError, CreateInitiativeErrors, CreateInitiativeRequest, CreateInitiativeResponse, CreateInitiativeResponses, CreateInstructionsDocumentResponse, CreateInstructionsHandlerData, CreateInstructionsHandlerError, CreateInstructionsHandlerErrors, CreateInstructionsHandlerResponse, CreateInstructionsHandlerResponses, CreateMarkdownDocumentRequest, CreateMarkdownDocumentResponse, CreateMarkdownHandlerData, CreateMarkdownHandlerError, CreateMarkdownHandlerErrors, CreateMarkdownHandlerResponse, CreateMarkdownHandlerResponses, CreatePairingRequest, CreateProjectHandlerData, CreateProjectHandlerError, CreateProjectHandlerErrors, CreateProjectHandlerResponse, CreateProjectHandlerResponses, CreateProjectRequest, CreateProjectResponse, CreateReminderData, CreateReminderError, CreateReminderErrors, CreateReminderRequest, CreateReminderResponse, CreateReminderResponses, CreateSkillHandlerData, CreateSkillHandlerError, CreateSkillHandlerErrors, CreateSkillHandlerResponse, CreateSkillHandlerResponses, CreateSkillRequest, CreateSkillResponse, CreateSnippetHandlerData, CreateSnippetHandlerError, CreateSnippetHandlerErrors, CreateSnippetHandlerResponse, CreateSnippetHandlerResponses, CreateSnippetRequest, CreateSnippetResponse, CreateTaskHandlerData, CreateTaskHandlerError, CreateTaskHandlerErrors, CreateTaskHandlerResponse, CreateTaskHandlerResponses, CreateTaskRequest, CreateTaskResponse, CreateUnthreadedAnchorRequest, CreateUnthreadedAnchorResponse, CreateUnthreadedPdfAnchorRequest, CreateUserApiKeyData, CreateUserApiKeyError, CreateUserApiKeyErrors, CreateUserApiKeyRequest, CreateUserApiKeyResponse, CreateUserApiKeyResponses, CreateViewHandlerData, CreateViewHandlerError, CreateViewHandlerErrors, CreateViewHandlerResponse, CreateViewHandlerResponses, CreateViewRequest, CreateWebhookData, CreateWebhookError, CreateWebhookErrors, CreateWebhookRequest, CreateWebhookResponse, CreateWebhookResponse2, CreateWebhookResponses, CrmComment, CrmCommentEntityType, CrmCommentThread, CrmCompanyFilters, CrmCompanyResponse, CrmContactResponse, CrmDomainResponse, CrmPermissionRole, CrmStageInput, CrmStageResponse, CrmStagesResponse, CrmTeamSettingsResponse, CrmThread, CustomSpeakerAssignment, DataType, DeleteAnchorData, DeleteAnchorError, DeleteAnchorErrors, DeleteAnchorInfo, DeleteAnchorResponse, DeleteAnchorResponses, DeleteCallRecordData, DeleteCallRecordError, DeleteCallRecordErrors, DeleteCallRecordResponse, DeleteCallRecordResponses, DeleteChannelData, DeleteChannelError, DeleteChannelErrors, DeleteChannelResponse, DeleteChannelResponses, DeleteCollabSurfaceData, DeleteCollabSurfaceError, DeleteCollabSurfaceErrors, DeleteCollabSurfaceResponse, DeleteCollabSurfaceResponses, DeleteCommentData, DeleteCommentError, DeleteCommentErrors, DeleteCommentRequest, DeleteCommentResponse, DeleteCommentResponse2, DeleteCommentResponses, DeleteCrmCommentData, DeleteCrmCommentError, DeleteCrmCommentErrors, DeleteCrmCommentResponse, DeleteCrmCommentResponses, DeleteCrmCommentResult, DeleteDocumentData, DeleteDocumentError, DeleteDocumentErrors, DeleteDocumentResponse, DeleteDocumentResponses, DeleteEntityMentionData, DeleteEntityMentionError, DeleteEntityMentionErrors, DeleteEntityMentionResponse, DeleteEntityMentionResponse2, DeleteEntityMentionResponses, DeleteHarnessData, DeleteHarnessError, DeleteHarnessErrors, DeleteHarnessResponse, DeleteHarnessResponses, DeleteHistoryHandlerData, DeleteHistoryHandlerError, DeleteHistoryHandlerErrors, DeleteHistoryHandlerResponse, DeleteHistoryHandlerResponses, DeleteInitiativeData, DeleteInitiativeError, DeleteInitiativeErrors, DeleteInitiativeResponse, DeleteInitiativeResponses, DeleteMessageData, DeleteMessageError, DeleteMessageErrors, DeleteMessageQuery, DeleteMessageResponse, DeleteMessageResponses, DeleteProjectHandlerData, DeleteProjectHandlerError, DeleteProjectHandlerErrors, DeleteProjectHandlerResponse, DeleteProjectHandlerResponses, DeleteReminderData, DeleteReminderError, DeleteReminderErrors, DeleteReminderResponse, DeleteReminderResponses, DeleteSelfHarnessData, DeleteSelfHarnessError, DeleteSelfHarnessErrors, DeleteSelfHarnessResponse, DeleteSelfHarnessResponses, DeleteThreadInfo, DeleteUnthreadedAnchorRequest, DeleteUnthreadedAnchorResponse, DeleteUnthreadedPdfAnchorRequest, DeleteUserApiKeyData, DeleteUserApiKeyError, DeleteUserApiKeyErrors, DeleteUserApiKeyResponse, DeleteUserApiKeyResponses, DeleteUserDocumentViewLocationData, DeleteUserDocumentViewLocationError, DeleteUserDocumentViewLocationErrors, DeleteUserDocumentViewLocationResponse, DeleteUserDocumentViewLocationResponses, DeleteViewHandlerData, DeleteViewHandlerError, DeleteViewHandlerErrors, DeleteViewHandlerResponses, DeleteWebhookData, DeleteWebhookError, DeleteWebhookErrors, DeleteWebhookResponse, DeleteWebhookResponses, DescriptionDocumentId, DocumentBasic, DocumentContent, DocumentContentLocation, DocumentContentState, DocumentContentUploadedMetadata, DocumentCopiedMetadata, DocumentCreatedMetadata, DocumentDeletedMetadata, DocumentFilters, DocumentId, DocumentInteractionMetadata, DocumentMetadata, DocumentMetadataWithContent, DocumentPermissionsToken, DocumentPermissionsTokenRequest, DocumentPermissionsTokenResponse, DocumentPreview, DocumentPreviewData, DocumentPreviewDataSubType, DocumentPurgedMetadata, DocumentResponse, DocumentResponseMetadata, DocumentResponseMetadataWithContent, DocumentStorageServiceApiVersion, DocumentSubType, DocumentSyncContentUpdatedMetadata, DocumentTeamShareResponse, DocumentTopicEvent, DocumentUpdatedMetadata, EditAnchorData, EditAnchorError, EditAnchorErrors, EditAnchorRequest, EditAnchorResponse, EditAnchorResponse2, EditAnchorResponses, EditCallRecordData, EditCallRecordError, EditCallRecordErrors, EditCallRecordRequest, EditCallRecordResponse, EditCallRecordResponses, EditCallTranscriptData, EditCallTranscriptError, EditCallTranscriptErrors, EditCallTranscriptRequest, EditCallTranscriptResponse, EditCallTranscriptResponses, EditCommentData, EditCommentError, EditCommentErrors, EditCommentRequest, EditCommentResponse, EditCommentResponse2, EditCommentResponses, EditCrmCommentData, EditCrmCommentError, EditCrmCommentErrors, EditCrmCommentRequest, EditCrmCommentResponse, EditCrmCommentResponses, EditDocumentData, EditDocumentError, EditDocumentErrors, EditDocumentResponse, EditDocumentResponse2, EditDocumentResponses, EditDocumentServiceArgs, EditPdfAnchorRequest, EditPdfPlaceableCommentAnchorRequest, EditProjectV2Data, EditProjectV2Error, EditProjectV2Errors, EditProjectV2Response, EditProjectV2Responses, EditThreadV2Data, EditThreadV2Error, EditThreadV2Errors, EditThreadV2Response, EditThreadV2Responses, EmailFilters, EmptyResponse, EnsureCollabSurfaceData, EnsureCollabSurfaceError, EnsureCollabSurfaceErrors, EnsureCollabSurfaceRequest, EnsureCollabSurfaceResponse, EnsureCollabSurfaceResponses, EntityFilters, EntityMessageCreateData, EntityMessageCreateResponse, EntityMessageCreateResponses, EntityMessageDeleteMessageData, EntityMessageDeleteMessageResponse, EntityMessageDeleteMessageResponses, EntityMessageDeleteThreadData, EntityMessageDeleteThreadResponse, EntityMessageDeleteThreadResponses, EntityMessageEditData, EntityMessageEditResponse, EntityMessageEditResponses, EntityMessageGetMessageData, EntityMessageGetMessageResponse, EntityMessageGetMessageResponses, EntityMessageGetThreadData, EntityMessageGetThreadResponse, EntityMessageGetThreadResponses, EntityMessageLegacyData, EntityMessageLegacyResponse, EntityMessageLegacyResponses, EntityMessagePatchThreadData, EntityMessagePatchThreadResponse, EntityMessagePatchThreadResponses, EntityMessageReactData, EntityMessageReactResponse, EntityMessageReactResponses, EntityMessageReferencesData, EntityMessageReferencesResponse, EntityMessageReferencesResponses, EntityMessageTypingData, EntityMessageTypingResponse, EntityMessageTypingResponses, EntityPermission, EntityPermissionResponse, EntityReference, EntityType, ErrorResponse, EventReminderOverride, EventReminders, EventStatus, EventTime, EventTransparency, EventType, EventVisibility, ExcludedDefaultView, ExcludeDefaultViewHandlerData, ExcludeDefaultViewHandlerError, ExcludeDefaultViewHandlerErrors, ExcludeDefaultViewHandlerResponses, ExcludeDefaultViewRequest, ExportDocumentData, ExportDocumentError, ExportDocumentErrors, ExportDocumentResponse, ExportDocumentResponse2, ExportDocumentResponses, Favorite, FavoriteEntityRef, FavoritesList, FileSystemNodeWithIds, FileType, FileTypeUpdate, FolderItem, ForeignEntity, ForeignEntityFilters, GenericErrorResponse, GenericResponse, GenericSuccessResponse, GetActiveCallsData, GetActiveCallsError, GetActiveCallsErrors, GetActiveCallsResponse, GetActiveCallsResponses, GetActivityData, GetActivityError, GetActivityErrors, GetActivityResponse, GetActivityResponses, GetAttachmentReferencesData, GetAttachmentReferencesError, GetAttachmentReferencesErrors, GetAttachmentReferencesResponse, GetAttachmentReferencesResponse2, GetAttachmentReferencesResponses, GetBatchCallRecordPreviewData, GetBatchCallRecordPreviewError, GetBatchCallRecordPreviewErrors, GetBatchCallRecordPreviewRequest, GetBatchCallRecordPreviewResponse, GetBatchCallRecordPreviewResponse2, GetBatchCallRecordPreviewResponses, GetBatchChannelPreviewData, GetBatchChannelPreviewError, GetBatchChannelPreviewErrors, GetBatchChannelPreviewRequest, GetBatchChannelPreviewResponse, GetBatchChannelPreviewResponse2, GetBatchChannelPreviewResponses, GetBatchPreviewHandlerData, GetBatchPreviewHandlerError, GetBatchPreviewHandlerErrors, GetBatchPreviewHandlerResponse, GetBatchPreviewHandlerResponses, GetBatchPreviewRequest, GetBatchPreviewResponse, GetBatchProjectPreviewData, GetBatchProjectPreviewError, GetBatchProjectPreviewErrors, GetBatchProjectPreviewRequest, GetBatchProjectPreviewResponse, GetBatchProjectPreviewResponse2, GetBatchProjectPreviewResponses, GetCallRecordData, GetCallRecordError, GetCallRecordErrors, GetCallRecordResponse, GetCallRecordResponses, GetChannelAttachmentsData, GetChannelAttachmentsError, GetChannelAttachmentsErrors, GetChannelAttachmentsResponse, GetChannelAttachmentsResponses, GetChannelData, GetChannelError, GetChannelErrors, GetChannelJoinLinkData, GetChannelJoinLinkError, GetChannelJoinLinkErrors, GetChannelJoinLinkResponse, GetChannelJoinLinkResponses, GetChannelMessagesCatchUpData, GetChannelMessagesCatchUpError, GetChannelMessagesCatchUpErrors, GetChannelMessagesCatchUpResponse, GetChannelMessagesCatchUpResponses, GetChannelMessagesData, GetChannelMessagesError, GetChannelMessagesErrors, GetChannelMessagesResponse, GetChannelMessagesResponses, GetChannelParticipantsData, GetChannelParticipantsError, GetChannelParticipantsErrors, GetChannelParticipantsResponse, GetChannelParticipantsResponses, GetChannelResponse, GetChannelResponses, GetChannelsData, GetChannelsError, GetChannelsErrors, GetChannelsResponse, GetChannelsResponses, GetCollabSurfaceData, GetCollabSurfaceError, GetCollabSurfaceErrors, GetCollabSurfaceResponse, GetCollabSurfaceResponses, GetCompanyData, GetCompanyError, GetCompanyErrors, GetCompanyResponse, GetCompanyResponses, GetContactByEmailData, GetContactByEmailError, GetContactByEmailErrors, GetContactByEmailResponse, GetContactByEmailResponse2, GetContactByEmailResponses, GetContactData, GetContactError, GetContactErrors, GetContactResponse, GetContactResponses, GetCrmTeamSettingsData, GetCrmTeamSettingsError, GetCrmTeamSettingsErrors, GetCrmTeamSettingsResponse, GetCrmTeamSettingsResponses, GetDocumentAnchorsData, GetDocumentAnchorsError, GetDocumentAnchorsErrors, GetDocumentAnchorsResponse, GetDocumentAnchorsResponses, GetDocumentBranchNameData, GetDocumentBranchNameError, GetDocumentBranchNameErrors, GetDocumentBranchNameResponse, GetDocumentBranchNameResponses, GetDocumentByTeamSlugData, GetDocumentByTeamSlugError, GetDocumentByTeamSlugErrors, GetDocumentByTeamSlugResponse, GetDocumentByTeamSlugResponses, GetDocumentCommentsData, GetDocumentCommentsError, GetDocumentCommentsErrors, GetDocumentCommentsResponse, GetDocumentCommentsResponses, GetDocumentData, GetDocumentError, GetDocumentErrors, GetDocumentGithubPullRequestsData, GetDocumentGithubPullRequestsError, GetDocumentGithubPullRequestsErrors, GetDocumentGithubPullRequestsResponse, GetDocumentGithubPullRequestsResponses, GetDocumentKeyResponse, GetDocumentKeyResponseData, GetDocumentListHandlerData, GetDocumentListHandlerError, GetDocumentListHandlerErrors, GetDocumentListHandlerResponse, GetDocumentListHandlerResponses, GetDocumentListResult, GetDocumentLocationV3Data, GetDocumentLocationV3Error, GetDocumentLocationV3Errors, GetDocumentLocationV3Response, GetDocumentLocationV3Responses, GetDocumentPermissionsResponseDataV2, GetDocumentPermissionsTokenData, GetDocumentPermissionsTokenError, GetDocumentPermissionsTokenErrors, GetDocumentPermissionsTokenResponse, GetDocumentPermissionsTokenResponses, GetDocumentPermissionsV2Data, GetDocumentPermissionsV2Error, GetDocumentPermissionsV2Errors, GetDocumentPermissionsV2Response, GetDocumentPermissionsV2Responses, GetDocumentProcessingResult, GetDocumentProcessingResultData, GetDocumentProcessingResultError, GetDocumentProcessingResultErrors, GetDocumentProcessingResultResponse, GetDocumentProcessingResultResponse2, GetDocumentProcessingResultResponses, GetDocumentResponse, GetDocumentResponse2, GetDocumentResponseData, GetDocumentResponses, GetDocumentSearchResponse, GetDocumentShortIdData, GetDocumentShortIdError, GetDocumentShortIdErrors, GetDocumentShortIdResponse, GetDocumentShortIdResponses, GetDocumentsResponse, GetDocumentTeamShareData, GetDocumentTeamShareError, GetDocumentTeamShareErrors, GetDocumentTeamShareResponse, GetDocumentTeamShareResponses, GetDocumentUserAccessLevelResponse, GetDocumentVersionData, GetDocumentVersionError, GetDocumentVersionErrors, GetDocumentVersionResponse, GetDocumentVersionResponses, GetDocumentViewsHandlerData, GetDocumentViewsHandlerError, GetDocumentViewsHandlerErrors, GetDocumentViewsHandlerResponse, GetDocumentViewsHandlerResponses, GetEntityPermissionData, GetEntityPermissionErrors, GetEntityPermissionResponse, GetEntityPermissionResponses, GetForeignEntityBySourceData, GetForeignEntityBySourceError, GetForeignEntityBySourceErrors, GetForeignEntityBySourceResponse, GetForeignEntityBySourceResponses, GetForeignEntityData, GetForeignEntityError, GetForeignEntityErrors, GetForeignEntityResponse, GetForeignEntityResponses, GetHarnessPairingData, GetHarnessPairingError, GetHarnessPairingErrors, GetHarnessPairingResponse, GetHarnessPairingResponses, GetHistoryHandlerData, GetHistoryHandlerError, GetHistoryHandlerErrors, GetHistoryHandlerResponse, GetHistoryHandlerResponses, GetInitiativeData, GetInitiativeError, GetInitiativeErrors, GetInitiativeResponse, GetInitiativeResponses, GetInstructionsDocumentResponse, GetInstructionsHandlerData, GetInstructionsHandlerError, GetInstructionsHandlerErrors, GetInstructionsHandlerResponse, GetInstructionsHandlerResponses, GetItemsSoupData, GetItemsSoupError, GetItemsSoupErrors, GetItemsSoupResponse, GetItemsSoupResponses, GetLocationHandlerData, GetLocationHandlerError, GetLocationHandlerErrors, GetLocationHandlerResponse, GetLocationHandlerResponses, GetMessageWithContextData, GetMessageWithContextError, GetMessageWithContextErrors, GetMessageWithContextResponse, GetMessageWithContextResponse2, GetMessageWithContextResponses, GetOrCreateAction, GetOrCreateCallData, GetOrCreateCallError, GetOrCreateCallErrors, GetOrCreateCallResponse, GetOrCreateCallResponses, GetOrCreateChannelResponse, GetOrCreateDmData, GetOrCreateDmError, GetOrCreateDmErrors, GetOrCreateDmRequest, GetOrCreateDmResponse, GetOrCreateDmResponses, GetOrCreatePrivateData, GetOrCreatePrivateError, GetOrCreatePrivateErrors, GetOrCreatePrivateRequest, GetOrCreatePrivateResponse, GetOrCreatePrivateResponses, GetPendingProjectsHandlerData, GetPendingProjectsHandlerError, GetPendingProjectsHandlerErrors, GetPendingProjectsHandlerResponse, GetPendingProjectsHandlerResponses, GetPinsHandlerData, GetPinsHandlerError, GetPinsHandlerErrors, GetPinsHandlerResponse, GetPinsHandlerResponses, GetPinsResponse, GetProjectContentHandlerData, GetProjectContentHandlerError, GetProjectContentHandlerErrors, GetProjectContentHandlerResponse, GetProjectContentHandlerResponses, GetProjectContentResponse, GetProjectHandlerData, GetProjectHandlerError, GetProjectHandlerErrors, GetProjectHandlerResponse, GetProjectHandlerResponses, GetProjectPermissionsV2Data, GetProjectPermissionsV2Error, GetProjectPermissionsV2Errors, GetProjectPermissionsV2Response, GetProjectPermissionsV2Responses, GetProjectResponse, GetProjectResponseData, GetProjectsHandlerData, GetProjectsHandlerError, GetProjectsHandlerErrors, GetProjectsHandlerResponse, GetProjectsHandlerResponses, GetProjectsResponse, GetProjectUserAccessLevelData, GetProjectUserAccessLevelError, GetProjectUserAccessLevelErrors, GetProjectUserAccessLevelResponse, GetProjectUserAccessLevelResponses, GetReminderData, GetReminderError, GetReminderErrors, GetReminderResponse, GetReminderResponses, GetRingStatusData, GetRingStatusError, GetRingStatusErrors, GetRingStatusResponse, GetRingStatusResponses, GetSelfBotData, GetSelfBotError, GetSelfBotErrors, GetSelfBotResponse, GetSelfBotResponses, GetSelfHarnessData, GetSelfHarnessError, GetSelfHarnessErrors, GetSelfHarnessResponse, GetSelfHarnessResponses, GetSystemSkillsHandlerData, GetSystemSkillsHandlerError, GetSystemSkillsHandlerErrors, GetSystemSkillsHandlerResponse, GetSystemSkillsHandlerResponses, GetThreadRepliesData, GetThreadRepliesError, GetThreadRepliesErrors, GetThreadRepliesResponse, GetThreadRepliesResponses, GetUserDocumentsHandlerData, GetUserDocumentsHandlerError, GetUserDocumentsHandlerErrors, GetUserDocumentsHandlerResponse, GetUserDocumentsHandlerResponses, GetUserDocumentViewLocationData, GetUserDocumentViewLocationError, GetUserDocumentViewLocationErrors, GetUserDocumentViewLocationResponse, GetUserDocumentViewLocationResponses, GetUserHistoryResponse, GetViewsHandlerData, GetViewsHandlerError, GetViewsHandlerErrors, GetViewsHandlerResponse, GetViewsHandlerResponses, GetWebhookData, GetWebhookError, GetWebhookErrors, GetWebhookResponse, GetWebhookResponses, GithubPullRequest, GithubPullRequestCheckRun, GithubPullRequestComment, GithubPullRequestsResponse, GroupedInitialParams, GroupedPageParams, GroupedSoupGroupPage, GroupedSoupInitialPage, GroupedSoupPage, GroupedSoupSort, HandlerData, HandlerError, HandlerErrors, HandlerResponse, HandlerResponses, Harness, HarnessAgent, HarnessId, HarnessOwner, HarnessSession, HashMap, HealthHandlerData, HealthHandlerResponse, HealthHandlerResponses, HighlightType, ImportedAuthor, InFlightTurnSummary, IngestTranscriptData, IngestTranscriptError, IngestTranscriptErrors, IngestTranscriptResponses, InitializeUserDocumentsData, InitializeUserDocumentsError, InitializeUserDocumentsErrors, InitializeUserDocumentsResponse, InitializeUserDocumentsResponses, InitiativeDetail, InitiativeId, InitiativeList, InitiativeSummary, InputReceivedMetadata, InstallSyncData, InstallSyncErrors, InteractionReason, Item, ItemWithUserAccessLevel, JobProcessingResultHandlerData, JobProcessingResultHandlerError, JobProcessingResultHandlerErrors, JobProcessingResultHandlerResponse, JobProcessingResultHandlerResponses, JoinChannelByCodeData, JoinChannelByCodeError, JoinChannelByCodeErrors, JoinChannelByCodeResponses, JoinChannelData, JoinChannelError, JoinChannelErrors, JoinChannelResponses, LatestMessage, LeaveCallResponse, LeaveChannelData, LeaveChannelError, LeaveChannelErrors, LeaveChannelResponses, LeaveOrEndCallData, LeaveOrEndCallError, LeaveOrEndCallErrors, LeaveOrEndCallResponse, LeaveOrEndCallResponses, LinkShare, ListAgentsData, ListAgentsError, ListAgentsErrors, ListAgentsResponse, ListAgentsResponses, ListBotChannelsData, ListBotChannelsError, ListBotChannelsErrors, ListBotChannelsResponse, ListBotChannelsResponses, ListCompanyContactsData, ListCompanyContactsError, ListCompanyContactsErrors, ListCompanyContactsResponse, ListCompanyContactsResponses, ListCrmCommentsData, ListCrmCommentsError, ListCrmCommentsErrors, ListCrmCommentsResponse, ListCrmCommentsResponses, ListFavoritesData, ListFavoritesError, ListFavoritesErrors, ListFavoritesResponse, ListFavoritesResponses, ListHarnessAgentsData, ListHarnessAgentsError, ListHarnessAgentsErrors, ListHarnessAgentsResponse, ListHarnessAgentsResponses, ListHarnessesData, ListHarnessesError, ListHarnessesErrors, ListHarnessesResponse, ListHarnessesResponses, ListHarnessSessionsData, ListHarnessSessionsError, ListHarnessSessionsErrors, ListHarnessSessionsResponse, ListHarnessSessionsResponses, ListInitiativesData, ListInitiativesError, ListInitiativesErrors, ListInitiativesResponse, ListInitiativesResponses, ListOccurrencesData, ListOccurrencesErrors, ListOccurrencesResponse, ListOccurrencesResponses, ListRemindersData, ListRemindersError, ListRemindersErrors, ListRemindersResponse, ListRemindersResponses, ListTeamOutOfOfficeData, ListTeamOutOfOfficeErrors, ListTeamOutOfOfficeResponse, ListTeamOutOfOfficeResponses, ListUserApiKeysData, ListUserApiKeysError, ListUserApiKeysErrors, ListUserApiKeysResponse, ListUserApiKeysResponses, ListWebhooksData, ListWebhooksError, ListWebhooksErrors, ListWebhooksResponse, ListWebhooksResponse2, ListWebhooksResponses, LocationResponseData, LocationResponseV3, MacroUserIdStr, MentionPreviewsData, MentionPreviewsErrors, MentionPreviewsResponse, MentionPreviewsResponses, Mentions, Message, MessageAttachment, MessageChange, MessageCursor, MessageDirection, MessageEvent, MessageListItem, MessagePage, MessageParent, MessagePatch, MessageThread, MessageThreadPreview, MessageTimelineData, MessageTimelineQuery, MessageTimelineResponse, MessageTimelineResponses, NewAttachment, NewChannelAttachment, NewThreadAnchor, NotificationFilters, NotificationState, PairingDetails, Params, ParticipantRole, PatchBotRequest, PatchChannelData, PatchChannelError, PatchChannelErrors, PatchChannelRequest, PatchChannelResponse, PatchChannelResponses, PatchMessageData, PatchMessageError, PatchMessageErrors, PatchMessageNotificationPolicy, PatchMessageRequest, PatchMessageResponse, PatchMessageResponses, PatchProjectRequestV2, PatchThreadRequestV2, PatchViewHandlerData, PatchViewHandlerError, PatchViewHandlerErrors, PatchViewHandlerResponses, PatchWebhookData, PatchWebhookError, PatchWebhookErrors, PatchWebhookRequest, PatchWebhookResponse, PatchWebhookResponses, PdfAnchor, PdfAnchorId, PdfAnchorRequest, PdfHighlightAnchor, PdfHighlightAnchorRequest, PdfHighlightRect, PdfHighlightRectAnchorRequest, PdfPlaceableCommentAnchor, PdfPlaceableCommentAnchorRequest, PendingClaimResponse, PermanentlyDeleteDocumentData, PermanentlyDeleteDocumentError, PermanentlyDeleteDocumentErrors, PermanentlyDeleteDocumentResponse, PermanentlyDeleteDocumentResponses, PermanentlyDeleteProjectData, PermanentlyDeleteProjectError, PermanentlyDeleteProjectErrors, PermanentlyDeleteProjectResponse, PermanentlyDeleteProjectResponses, PinnedItem, PinRequest, PostActivityData, PostActivityError, PostActivityErrors, PostActivityRequest, PostActivityResponse, PostActivityResponses, PostChannelBotWebhookData, PostChannelBotWebhookError, PostChannelBotWebhookErrors, PostChannelBotWebhookResponse, PostChannelBotWebhookResponses, PostChannelMessagesData, PostChannelMessagesError, PostChannelMessagesErrors, PostChannelMessagesResponse, PostChannelMessagesResponses, PostGroupedSoupAstGroupPageRequest, PostGroupedSoupAstInitialRequest, PostGroupedSoupAstRequest, PostItemsSoupAstData, PostItemsSoupAstError, PostItemsSoupAstErrors, PostItemsSoupAstGroupedData, PostItemsSoupAstGroupedError, PostItemsSoupAstGroupedErrors, PostItemsSoupAstGroupedResponse, PostItemsSoupAstGroupedResponses, PostItemsSoupAstResponse, PostItemsSoupAstResponses, PostItemsSoupData, PostItemsSoupError, PostItemsSoupErrors, PostItemsSoupResponse, PostItemsSoupResponses, PostMessage, PostMessageData, PostMessageError, PostMessageErrors, PostMessageNotificationPolicy, PostMessageRequest, PostMessageResponse, PostMessageResponse2, PostMessageResponses, PostReactionData, PostReactionError, PostReactionErrors, PostReactionRequest, PostReactionResponse, PostReactionResponses, PostSoupAstRequest, PostSoupRequest, PostTypingData, PostTypingError, PostTypingErrors, PostTypingRequest, PostTypingResponse, PostTypingResponses, PresaveDocumentHandlerData, PresaveDocumentHandlerError, PresaveDocumentHandlerErrors, PresaveDocumentHandlerResponse, PresaveDocumentHandlerResponses, PreSaveDocumentRequest, PreSaveDocumentResponse, PreSaveDocumentResponseData, PresignedUrl, Project, ProjectDeleteResponseData, ProjectDocumentStatus, ProjectFilters, ProjectPreview, ProjectPreviewData, PropertyDefinition, PropertyFilter, PropertyInput, PropertyOwner, PropertyValue, PutCrmTeamSettingsData, PutCrmTeamSettingsError, PutCrmTeamSettingsErrors, PutCrmTeamSettingsResponse, PutCrmTeamSettingsResponses, PutCrmTeamStagesData, PutCrmTeamStagesError, PutCrmTeamStagesErrors, PutCrmTeamStagesResponse, PutCrmTeamStagesResponses, ReactionAction, ReactionInput, RecentlyDeletedData, RecentlyDeletedError, RecentlyDeletedErrors, RecentlyDeletedResponse, RecentlyDeletedResponseData, RecentlyDeletedResponses, ReferencedThread, ReferencedThreadPage, Reminder, ReminderFilters, ReminderSchedule, RemindersList, RemoveBotFromChannelByBotData, RemoveBotFromChannelByBotError, RemoveBotFromChannelByBotErrors, RemoveBotFromChannelByBotResponse, RemoveBotFromChannelByBotResponses, RemoveFavoriteByEntityData, RemoveFavoriteByEntityError, RemoveFavoriteByEntityErrors, RemoveFavoriteByEntityResponses, RemoveParticipantsData, RemoveParticipantsError, RemoveParticipantsErrors, RemoveParticipantsRequest, RemoveParticipantsResponses, RemovePinHandlerData, RemovePinHandlerError, RemovePinHandlerErrors, RemovePinHandlerResponse, RemovePinHandlerResponses, ReorderFavoritesData, ReorderFavoritesError, ReorderFavoritesErrors, ReorderFavoritesRequest, ReorderFavoritesResponses, ReorderPinRequest, ReorderPinsHandlerData, ReorderPinsHandlerError, ReorderPinsHandlerErrors, ReorderPinsHandlerResponse, ReorderPinsHandlerResponses, ReplaceCrmStagesRequest, RequestedHarnessScope, ResetCrmTeamStagesData, ResetCrmTeamStagesError, ResetCrmTeamStagesErrors, ResetCrmTeamStagesResponse, ResetCrmTeamStagesResponses, ResolveChannelMessageData, ResolveChannelMessageError, ResolveChannelMessageErrors, ResolveChannelMessageResponse, ResolveChannelMessageResponses, RevertDeleteDocumentData, RevertDeleteDocumentError, RevertDeleteDocumentErrors, RevertDeleteDocumentResponse, RevertDeleteDocumentResponses, RevertDeleteProjectData, RevertDeleteProjectError, RevertDeleteProjectErrors, RevertDeleteProjectResponse, RevertDeleteProjectResponses, RingStatus, RingStatusResponse, S3ObjectInfo, SaveBomPart, SaveDocumentHandlerData, SaveDocumentHandlerError, SaveDocumentHandlerErrors, SaveDocumentHandlerResponse, SaveDocumentHandlerResponses, SaveDocumentRequest, SaveDocumentResponse, SaveDocumentResponseData, SessionDeletedMetadata, SessionIdentity, SessionMentionedMetadata, SessionOpenedMetadata, SessionRenamedMetadata, SessionSettledMetadata, SessionStoppedMetadata, SetChannelPictureData, SetChannelPictureError, SetChannelPictureErrors, SetChannelPictureRequest, SetChannelPictureResponse, SetChannelPictureResponses, SetCompanyHiddenData, SetCompanyHiddenError, SetCompanyHiddenErrors, SetCompanyHiddenRequest, SetCompanyHiddenResponse, SetCompanyHiddenResponses, SetCompanyNameRequest, SetContactHiddenData, SetContactHiddenError, SetContactHiddenErrors, SetContactHiddenRequest, SetContactHiddenResponse, SetContactHiddenResponses, SetContactNameRequest, SetCrmCompanyNameData, SetCrmCompanyNameError, SetCrmCompanyNameErrors, SetCrmCompanyNameResponse, SetCrmCompanyNameResponses, SetCrmContactNameData, SetCrmContactNameError, SetCrmContactNameErrors, SetCrmContactNameResponse, SetCrmContactNameResponses, SetDocumentTeamShareData, SetDocumentTeamShareError, SetDocumentTeamShareErrors, SetDocumentTeamShareRequest, SetDocumentTeamShareResponse, SetDocumentTeamShareResponses, SetEmailSyncData, SetEmailSyncError, SetEmailSyncErrors, SetEmailSyncRequest, SetEmailSyncResponse, SetEmailSyncResponses, SetPropertyValue, SharedEmailFilter, SharePermissionV2, ShortIdResponse, SimpleMention, SimpleSaveData, SimpleSaveError, SimpleSaveErrors, SimpleSaveResponse, SimpleSaveResponses, SoupAgentSessionSoupPropertiesField, SoupApiItem, SoupApiSort, SoupApiSortDirection, SoupAttachment, SoupCalendarEventSoupPropertiesField, SoupCalendarEventTime, SoupCallRecordParticipant, SoupCallRecordSoupPropertiesField, SoupChannel, SoupChannelThread, SoupChatSoupPropertiesField, SoupContact, SoupCountedReaction, SoupCrmCompanySoupPropertiesField, SoupCrmDomain, SoupDocumentSoupPropertiesField, SoupDocumentSubType, SoupEmailThreadPreview, SoupEnrichedEmailThreadPreviewSoupPropertiesField, SoupForeignEntity, SoupItem, SoupLabel, SoupLabelListVisibility, SoupLabelType, SoupMessageAttachment, SoupMessageListVisibility, SoupMessageSender, SoupMessageSenderType, SoupPage, SoupProjectSoupPropertiesField, SoupPropertiesField, SoupProperty, SoupReminderReference, SoupReminderSchedule, SoupReminderSoupPropertiesField, SoupThreadInfo, SoupThreadReply, StarterDocumentsResponse, StreamEventsData, StreamEventsError, StreamEventsErrors, StreamEventsResponse, StreamEventsResponses, String, SuccessResponse, SurfaceState, SyncServiceVersionId, SystemSkillSummary, TagFilterMode, TaskFilters, TeamOutOfOfficeItem, TeamOutOfOfficeResponse, TeamRole, Thread, ThreadAnchor, ThreadOrigin, ThreadPatch, ThreadResponse, ThreadState, ToggleShareWithTeamData, ToggleShareWithTeamError, ToggleShareWithTeamErrors, ToggleShareWithTeamResponse, ToggleShareWithTeamResponses, TranscriptSegmentRequest, TurnEndedMetadata, TurnStartedMetadata, TurnSummary, TypedSuccessResponse, TypedSuccessResponseGetDocumentResponseData, TypingAction, TypingInput, UnassignInitiativeTaskData, UnassignInitiativeTaskError, UnassignInitiativeTaskErrors, UnassignInitiativeTaskResponse, UnassignInitiativeTaskResponses, UnthreadedPdfUuidRequest, UpdateAgentData, UpdateAgentError, UpdateAgentErrors, UpdateAgentRequest, UpdateAgentResponse, UpdateAgentResponses, UpdateChannelSharePermission, UpdateCrmTeamSettingsRequest, UpdateInitiativeData, UpdateInitiativeError, UpdateInitiativeErrors, UpdateInitiativeRequest, UpdateInitiativeResponse, UpdateInitiativeResponses, UpdateOperation, UpdateReminderData, UpdateReminderError, UpdateReminderErrors, UpdateReminderRequest, UpdateReminderResponse, UpdateReminderResponses, UpdateSharePermissionRequestV2, UploadDocumentStatus, UploadExtractFolderHandlerData, UploadExtractFolderHandlerError, UploadExtractFolderHandlerErrors, UploadExtractFolderHandlerResponse, UploadExtractFolderHandlerResponses, UploadExtractFolderRequest, UploadFolderHandlerData, UploadFolderHandlerError, UploadFolderHandlerErrors, UploadFolderHandlerResponse, UploadFolderHandlerResponses, UploadFolderRequest, UpsertHistoryHandlerData, UpsertHistoryHandlerError, UpsertHistoryHandlerErrors, UpsertHistoryHandlerResponse, UpsertHistoryHandlerResponses, UpsertUserDocumentViewLocationData, UpsertUserDocumentViewLocationError, UpsertUserDocumentViewLocationErrors, UpsertUserDocumentViewLocationRequest, UpsertUserDocumentViewLocationResponse, UpsertUserDocumentViewLocationResponses, UserApiKeyId, UserApiKeyInfo, UserApiKeysList, UserDocumentsResponse, UserDocumentViewLocation, UserDocumentViewLocationResponse, UserPinsResponse, UserViewsResponse, ValidateDocumentPermissionsTokenData, ValidateDocumentPermissionsTokenError, ValidateDocumentPermissionsTokenErrors, ValidateDocumentPermissionsTokenResponse, ValidateDocumentPermissionsTokenResponses, ValidateWebhookData, ValidateWebhookError, ValidateWebhookErrors, ValidateWebhookResponse, ValidateWebhookResponse2, ValidateWebhookResponses, Vec, View, ViewPatch, ViewsResponse, WaitingForInputMetadata, Webhook, WebhookEvent, WebhookFilter, WebhookScope, WebhookStatus, WebhookValidationTestEvent, WithCallId, WithChannelId, WithDocumentId, WithProjectId } from './types.gen'; diff --git a/packages/sdk/generated/storage/types.gen.ts b/packages/sdk/generated/storage/types.gen.ts index 697726eca61..794adcd1f4f 100644 --- a/packages/sdk/generated/storage/types.gen.ts +++ b/packages/sdk/generated/storage/types.gen.ts @@ -1283,6 +1283,8 @@ export type BasicDocumentSubType = { type: 'snippet'; } | { type: 'skill'; +} | { + type: 'initiative_description'; }; export type BomPart = { @@ -3469,7 +3471,8 @@ export type CreateEntityMentionResponse = { */ export type CreateInitiativeRequest = { /** - * Optional description. + * Initial markdown for the description document. Not stored on the initiative; later + * edits happen in the document editor. */ description?: string | null; /** @@ -4328,6 +4331,15 @@ export type DeleteUnthreadedPdfAnchorRequest = { uuid: string; }; +/** + * Id of the markdown document that holds an initiative's description. + * + * The documents side mints it as a UUID; `"Document".id` and + * `initiative.description_document_id` store it as TEXT, so it is parsed once at the + * adapter boundary and displayed back when bound in SQL. + */ +export type DescriptionDocumentId = string; + /** * Returns basic information of a document used for some db queries */ @@ -4712,6 +4724,8 @@ export type DocumentPreviewDataSubType = { type: 'snippet'; } | { type: 'skill'; +} | { + type: 'initiative_description'; }; /** @@ -4823,8 +4837,11 @@ export type DocumentStorageServiceApiVersion = 'v1' | 'v2'; /** * The document sub type enum represents all values of document sub types. * These values should match the `document_sub_type_value` table in macrodb. + * + * Wire, database, and `Display` spellings are all `snake_case` so a + * multi-word variant serializes identically in every system. */ -export type DocumentSubType = 'task' | 'snippet' | 'skill'; +export type DocumentSubType = 'task' | 'snippet' | 'skill' | 'initiative_description'; /** * Metadata for [`DocumentTopicEvent::SyncContentUpdated`]. @@ -6428,9 +6445,9 @@ export type InitiativeDetail = { */ createdAt: string; /** - * Optional description. + * The markdown document holding the description; open it in the editor. */ - description?: string | null; + descriptionDocumentId: DescriptionDocumentId; /** * Opaque identifier. */ @@ -6485,9 +6502,9 @@ export type InitiativeList = { */ export type InitiativeSummary = { /** - * Optional description. + * The markdown document holding the description; open it in the editor. */ - description?: string | null; + descriptionDocumentId: DescriptionDocumentId; /** * Opaque identifier. */ @@ -9005,6 +9022,8 @@ export type SoupDocumentSubType = { type: 'snippet'; } | { type: 'skill'; +} | { + type: 'initiative_description'; }; /** @@ -10161,13 +10180,9 @@ export type UpdateCrmTeamSettingsRequest = { /** * Update-initiative HTTP body. Absent fields are left unchanged. `member_ids` - * present is a full replace. + * present is a full replace. The description is edited in its document, not here. */ export type UpdateInitiativeRequest = { - /** - * Replacement description. `Some("")` clears it after trim. - */ - description?: string | null; /** * Full replacement member list when present. */ diff --git a/packages/sdk/specs/storage.json b/packages/sdk/specs/storage.json index 666555e87a4..5521d1e7448 100644 --- a/packages/sdk/specs/storage.json +++ b/packages/sdk/specs/storage.json @@ -16120,6 +16120,17 @@ "enum": ["skill"] } } + }, + { + "type": "object", + "description": "The description document of an initiative", + "required": ["type"], + "properties": { + "type": { + "type": "string", + "enum": ["initiative_description"] + } + } } ], "description": "Sub type of a document with associated properties encoded in each variant.\nThis ensures type-safety: task properties only exist when the document is a task." @@ -19148,7 +19159,7 @@ "properties": { "description": { "type": ["string", "null"], - "description": "Optional description." + "description": "Initial markdown for the description document. Not stored on the initiative; later\nedits happen in the document editor." }, "memberIds": { "type": ["array", "null"], @@ -20327,6 +20338,11 @@ } ] }, + "DescriptionDocumentId": { + "type": "string", + "format": "uuid", + "description": "Id of the markdown document that holds an initiative's description.\n\nThe documents side mints it as a UUID; `\"Document\".id` and\n`initiative.description_document_id` store it as TEXT, so it is parsed once at the\nadapter boundary and displayed back when bound in SQL." + }, "DocumentBasic": { "type": "object", "description": "Returns basic information of a document used for some db queries", @@ -20984,6 +21000,17 @@ "enum": ["skill"] } } + }, + { + "type": "object", + "description": "The description document of an initiative", + "required": ["type"], + "properties": { + "type": { + "type": "string", + "enum": ["initiative_description"] + } + } } ], "description": "The sub type of a document preview with associated properties.\nTask-related properties are encoded within the variant to ensure valid states." @@ -21130,8 +21157,8 @@ }, "DocumentSubType": { "type": "string", - "description": "The document sub type enum represents all values of document sub types.\nThese values should match the `document_sub_type_value` table in macrodb.", - "enum": ["task", "snippet", "skill"] + "description": "The document sub type enum represents all values of document sub types.\nThese values should match the `document_sub_type_value` table in macrodb.\n\nWire, database, and `Display` spellings are all `snake_case` so a\nmulti-word variant serializes identically in every system.", + "enum": ["task", "snippet", "skill", "initiative_description"] }, "DocumentSyncContentUpdatedMetadata": { "type": "object", @@ -24060,6 +24087,7 @@ "required": [ "id", "name", + "descriptionDocumentId", "ownerId", "memberIds", "taskIds", @@ -24074,9 +24102,9 @@ "format": "date-time", "description": "When the initiative was created." }, - "description": { - "type": ["string", "null"], - "description": "Optional description." + "descriptionDocumentId": { + "$ref": "#/components/schemas/DescriptionDocumentId", + "description": "The markdown document holding the description; open it in the editor." }, "id": { "$ref": "#/components/schemas/InitiativeId", @@ -24141,11 +24169,11 @@ "InitiativeSummary": { "type": "object", "description": "List-row view of an initiative.", - "required": ["id", "name", "updatedAt"], + "required": ["id", "name", "descriptionDocumentId", "updatedAt"], "properties": { - "description": { - "type": ["string", "null"], - "description": "Optional description." + "descriptionDocumentId": { + "$ref": "#/components/schemas/DescriptionDocumentId", + "description": "The markdown document holding the description; open it in the editor." }, "id": { "$ref": "#/components/schemas/InitiativeId", @@ -28377,6 +28405,17 @@ "enum": ["skill"] } } + }, + { + "type": "object", + "description": "The description document of an initiative", + "required": ["type"], + "properties": { + "type": { + "type": "string", + "enum": ["initiative_description"] + } + } } ], "description": "Sub type of a document with associated properties encoded in each variant.\nThis ensures type-safety: task properties only exist when the document is a task." @@ -30141,12 +30180,8 @@ }, "UpdateInitiativeRequest": { "type": "object", - "description": "Update-initiative HTTP body. Absent fields are left unchanged. `member_ids`\npresent is a full replace.", + "description": "Update-initiative HTTP body. Absent fields are left unchanged. `member_ids`\npresent is a full replace. The description is edited in its document, not here.", "properties": { - "description": { - "type": ["string", "null"], - "description": "Replacement description. `Some(\"\")` clears it after trim." - }, "memberIds": { "type": ["array", "null"], "items": { diff --git a/services/document_storage_service/src/api/annotations/create_comment.rs b/services/document_storage_service/src/api/annotations/create_comment.rs index 95b54d80875..986edfb9671 100644 --- a/services/document_storage_service/src/api/annotations/create_comment.rs +++ b/services/document_storage_service/src/api/annotations/create_comment.rs @@ -159,6 +159,9 @@ pub async fn create_comment_handler( document_sub_type::DocumentSubType::Skill => { NotificationDocumentSubType::Skill } + document_sub_type::DocumentSubType::InitiativeDescription => { + NotificationDocumentSubType::InitiativeDescription + } }), sender_id: sender_id.clone(), sender_profile_picture_url, diff --git a/services/document_storage_service/src/api/annotations/edit_comment.rs b/services/document_storage_service/src/api/annotations/edit_comment.rs index 3a66df5da89..a685ceaf5cd 100644 --- a/services/document_storage_service/src/api/annotations/edit_comment.rs +++ b/services/document_storage_service/src/api/annotations/edit_comment.rs @@ -89,6 +89,9 @@ pub async fn edit_comment_handler( document_sub_type::DocumentSubType::Skill => { NotificationDocumentSubType::Skill } + document_sub_type::DocumentSubType::InitiativeDescription => { + NotificationDocumentSubType::InitiativeDescription + } }), sender_id: user_id.clone().try_into().ok(), sender_profile_picture_url, diff --git a/services/document_storage_service/src/api/context.rs b/services/document_storage_service/src/api/context.rs index 8fc59b01ef8..2040e47ffab 100644 --- a/services/document_storage_service/src/api/context.rs +++ b/services/document_storage_service/src/api/context.rs @@ -481,8 +481,20 @@ pub(crate) type RemindersServiceType = RemindersServiceImpl; pub(crate) type DssRemindersState = RemindersRouterState; +/// Type alias for the description-document adapter behind the initiative service. Its +/// creator parameters match [`documents_hex::inbound::axum_router::DefaultDocumentCreator`]. +pub(crate) type InitiativeDescriptionDocumentsType = + crate::outbound::initiative_description_documents::InitiativeDescriptionDocumentsAdapter< + Arc, + documents_hex::outbound::markdown_init::LexicalSyncMarkdownInitializer, + documents_hex::outbound::document_bytes_upload::ReqwestDocumentBytesUploader, + documents_hex::outbound::mention_tracker::LexicalCommsMentionTracker, + DssEventBroker, + >; + /// Type alias for the initiative service. -pub(crate) type InitiativeServiceType = InitiativeServiceImpl; +pub(crate) type InitiativeServiceType = + InitiativeServiceImpl; /// Type alias for the initiative router state. pub(crate) type DssInitiativeState = diff --git a/services/document_storage_service/src/main.rs b/services/document_storage_service/src/main.rs index 426a269a842..20a3e50eaf4 100644 --- a/services/document_storage_service/src/main.rs +++ b/services/document_storage_service/src/main.rs @@ -1154,9 +1154,27 @@ async fn run() -> anyhow::Result<()> { // Held by value here and behind an `Arc` in the router state: the impl is a // pool handle, so cloning is cheap and `SoupImpl` needs an owned service. let reminders_service = RemindersServiceImpl::new(PgRemindersRepo::new(db.clone())); - let initiative_service = Arc::new(InitiativeServiceImpl::new(PgInitiativeRepo::new( - db.clone(), - ))); + + // Shared by the document router and the initiative service, which creates each + // initiative's description document through the same editor-ready path. + let document_creator = documents_hex::domain::create::DocumentCreator::new( + document_service.clone(), + markdown_initializer, + documents_hex::outbound::document_bytes_upload::ReqwestDocumentBytesUploader::default(), + documents_hex::outbound::mention_tracker::LexicalCommsMentionTracker::new( + db.clone(), + lexical_client.clone(), + ), + ); + let initiative_service = Arc::new(InitiativeServiceImpl::new( + PgInitiativeRepo::new(db.clone()), + outbound::initiative_description_documents::InitiativeDescriptionDocumentsAdapter::new( + document_creator.clone(), + db.clone(), + sqs_client.clone(), + macro_event_broker.clone(), + ), + )); let collab_surface_service = CollabSurfaceServiceImpl::new( Arc::new(PgCollabSurfaceRepo::new(db.clone())), @@ -1489,21 +1507,13 @@ async fn run() -> anyhow::Result<()> { authorization_state: authorization_state.clone(), }, documents_state: DocumentRouterState { - service: document_service.clone(), + service: document_service, access_service: entity_access_service.clone(), authorization_state: authorization_state.clone(), pool: db.clone(), task_dedup_service, lexical_client: lexical_client.clone(), - creator: documents_hex::domain::create::DocumentCreator::new( - document_service, - markdown_initializer, - documents_hex::outbound::document_bytes_upload::ReqwestDocumentBytesUploader::default(), - documents_hex::outbound::mention_tracker::LexicalCommsMentionTracker::new( - db.clone(), - lexical_client.clone(), - ), - ), + creator: document_creator, document_permission_jwt_secret: config.document_permission_jwt.as_ref().to_string(), }, config: Arc::new(config), diff --git a/services/document_storage_service/src/outbound/initiative_description_documents.rs b/services/document_storage_service/src/outbound/initiative_description_documents.rs new file mode 100644 index 00000000000..ebe5328a288 --- /dev/null +++ b/services/document_storage_service/src/outbound/initiative_description_documents.rs @@ -0,0 +1,139 @@ +//! Adapter that gives the initiative domain its description documents. Creation goes +//! through the documents domain's [`DocumentCreator`] so the document opens in the +//! editor; purge runs the same sequence a permanent document delete runs elsewhere in +//! this service. + +use std::str::FromStr; +use std::sync::Arc; + +use documents_hex::domain::create::{ + DocumentCreator, MarkdownSubtype, NewDocumentMetadata, NewMarkdownTextDocument, +}; +use documents_hex::domain::models::DocumentError; +use documents_hex::domain::ports::create::{DocumentBytesUploadPort, DocumentCreationService}; +use documents_hex::domain::ports::markdown::MarkdownInitializationPort; +use documents_hex::domain::ports::mentions::DocumentMentionTrackingPort; +use initiative::domain::models::{DescriptionDocumentId, InitiativeError, NewDescriptionDocument}; +use initiative::domain::ports::InitiativeDescriptionDocuments; +use macro_event_broker::MacroEventBroker; +use sqlx::PgPool; + +use crate::service::document_event_publisher::publish_document_purged_event; + +/// Wrap a documents-side failure as an internal initiative error. +macro_rules! internal { + ($error:expr) => { + InitiativeError::Internal(rootcause::report!($error).into()) + }; +} + +/// Production adapter backed by the documents domain creator and the legacy purge clients. +pub struct InitiativeDescriptionDocumentsAdapter +{ + creator: DocumentCreator, + db: PgPool, + sqs: Arc, + event_broker: B, +} + +impl + InitiativeDescriptionDocumentsAdapter +{ + /// Construct the adapter from concrete outbound dependencies. + pub fn new( + creator: DocumentCreator, + db: PgPool, + sqs: Arc, + event_broker: B, + ) -> Self { + Self { + creator, + db, + sqs, + event_broker, + } + } +} + +impl InitiativeDescriptionDocuments + for InitiativeDescriptionDocumentsAdapter +where + Svc: DocumentCreationService + Send + Sync + 'static, + MarkdownInit: MarkdownInitializationPort + Send + Sync + 'static, + BytesUpload: DocumentBytesUploadPort + Send + Sync + 'static, + MentionTracker: DocumentMentionTrackingPort + Send + Sync + 'static, + B: MacroEventBroker + Send + Sync + 'static, +{ + #[tracing::instrument(skip_all, err)] + async fn create( + &self, + document: NewDescriptionDocument, + ) -> Result { + let NewDescriptionDocument { + owner, + name, + prefill_markdown, + link_share, + } = document; + // Not in recents: the initiative is what people open, and the editor reaches the + // document by id. + let metadata = NewDocumentMetadata::builder(name) + .skip_history() + .initial_link_share(link_share) + .build(); + let created = self + .creator + .create_markdown_text( + owner, + NewMarkdownTextDocument { + metadata, + markdown: prefill_markdown, + subtype: MarkdownSubtype::InitiativeDescription, + }, + ) + .await + .map_err(map_document_error)?; + let document_id = created.document_id(); + DescriptionDocumentId::from_str(document_id).map_err(|error| { + InitiativeError::Internal(rootcause::report!( + "created document {document_id} does not have a uuid id: {error}" + )) + }) + } + + /// Every step is keyed by the document id and tolerates an id that is already gone, so a + /// failed purge can be re-run from the top. + #[tracing::instrument(skip(self), err)] + async fn purge(&self, id: DescriptionDocumentId) -> Result<(), InitiativeError> { + let document_id = id.to_string(); + macro_db_client::document::delete_document(&self.db, &document_id) + .await + .map_err(|error| internal!(error))?; + comms_db_client::entity_mentions::delete_entity_mentions_by_source( + &self.db, + vec![document_id.clone()], + ) + .await + .inspect_err(|error| { + tracing::error!(error = ?error, %document_id, "unable to delete entity mentions") + }) + .ok(); + self.sqs + .bulk_enqueue_document_delete(vec![document_id.clone()]) + .await + .map_err(|error| internal!(error))?; + publish_document_purged_event(&self.event_broker, &document_id) + .map_err(|error| internal!(error)) + } +} + +/// Keep the caller-facing classes; everything else is a failure of this service, since the +/// initiative domain already validated every field it sent. +fn map_document_error(error: DocumentError) -> InitiativeError { + match error { + DocumentError::BadRequest(message) => InitiativeError::BadRequest(message), + DocumentError::Conflict(message) => InitiativeError::Conflict(message), + DocumentError::Unauthorized => InitiativeError::Unauthorized, + other => internal!(other), + } +} diff --git a/services/document_storage_service/src/outbound/mod.rs b/services/document_storage_service/src/outbound/mod.rs index da4a69ca4f9..3bb1c4c4623 100644 --- a/services/document_storage_service/src/outbound/mod.rs +++ b/services/document_storage_service/src/outbound/mod.rs @@ -1,3 +1,4 @@ //! Outbound adapters owned by document storage service composition. pub mod entity_mutation; +pub mod initiative_description_documents; diff --git a/static_assets/schema.graphql b/static_assets/schema.graphql index 9775a5a3b32..424858a86ac 100644 --- a/static_assets/schema.graphql +++ b/static_assets/schema.graphql @@ -1680,6 +1680,10 @@ enum GraphqlDocumentSubType { The skill option. """ SKILL + """ + The initiative description option. + """ + INITIATIVE_DESCRIPTION } """ @@ -2969,6 +2973,16 @@ type GraphqlInboxReauthRequiredMetadata { emailAddress: String! } +""" +represents the initiative description subtype fields +""" +type GraphqlInitiativeDescriptionSubType { + """ + this object has nothing as a field but we need at least 1 field + """ + nothing: Boolean! +} + """ Metadata for a team invitation. """ @@ -3259,6 +3273,10 @@ enum GraphqlNotificationDocumentSubType { Skill document. """ SKILL + """ + The description document of an initiative. + """ + INITIATIVE_DESCRIPTION } """ @@ -4716,7 +4734,7 @@ type GraphqlSoupDocument implements GraphqlSoupEntity { """ GraphQL representation of the soup document sub type. """ -union GraphqlSoupDocumentSubType = GraphqlTaskSubType | GraphqlSnippetSubType | GraphqlSkillSubType +union GraphqlSoupDocumentSubType = GraphqlTaskSubType | GraphqlSnippetSubType | GraphqlSkillSubType | GraphqlInitiativeDescriptionSubType """ GraphQL representation of the soup email attachment. From e17a18236224df32d73bb6cecd8577d38dac3eac Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 17 Sep 2026 18:51:03 +0000 Subject: [PATCH 2/8] docs(initiative): keep why comments on lockstep create and grants Drop restated comments. Keep the ones that explain create compensation, delete order, GrantTargets, and why Exact link share is required. Co-authored-by: Will Hutchinson --- apps/web/src/features/entity/types/entity.ts | 7 +------ .../service-storage/graphql-soup.ts | 1 - crates/document_sub_type/src/lib.rs | 3 +-- crates/documents/src/domain/create.rs | 4 +--- crates/documents/src/domain/models.rs | 3 +-- crates/documents/src/domain/service/tests.rs | 1 - crates/initiative/src/domain/models.rs | 7 +------ crates/initiative/src/domain/ports.rs | 3 +-- crates/initiative/src/domain/service.rs | 13 +++++-------- crates/initiative/src/domain/service/test.rs | 2 -- .../initiative/src/outbound/pg_initiative_repo.rs | 10 ++++------ .../src/outbound/pg_initiative_repo/create.rs | 10 +++------- .../src/outbound/pg_initiative_repo/members.rs | 2 -- .../src/outbound/pg_initiative_repo/share.rs | 6 ------ .../src/outbound/pg_initiative_repo/test.rs | 8 -------- crates/models_permissions/src/share_permission.rs | 1 - .../src/share_permission/team_share.rs | 3 +-- .../src/share_permission/test.rs | 1 - .../models_properties/src/shared/entity_type.rs | 2 -- .../document_storage_service/src/api/context.rs | 2 -- services/document_storage_service/src/main.rs | 2 -- .../outbound/initiative_description_documents.rs | 15 ++------------- 22 files changed, 21 insertions(+), 85 deletions(-) diff --git a/apps/web/src/features/entity/types/entity.ts b/apps/web/src/features/entity/types/entity.ts index a5611a2d3c2..0c80959019c 100644 --- a/apps/web/src/features/entity/types/entity.ts +++ b/apps/web/src/features/entity/types/entity.ts @@ -181,12 +181,7 @@ export type SubType = { is_completed?: boolean; } | null; -/** - * Narrow a wire sub type to the ones the app renders specially. The backend - * also tags documents the app has no dedicated block for, such as an - * initiative's description; those come through as `null` so the document - * renders as a plain document of its file type. - */ +/** Wire subtypes without a dedicated block, including initiative_description, become null. */ export const toSubType = ( wire: { type: string; is_completed?: boolean } | null | undefined ): SubType => { diff --git a/apps/web/src/lib/service-clients/service-storage/graphql-soup.ts b/apps/web/src/lib/service-clients/service-storage/graphql-soup.ts index 26f5bec4594..3eb0febd15b 100644 --- a/apps/web/src/lib/service-clients/service-storage/graphql-soup.ts +++ b/apps/web/src/lib/service-clients/service-storage/graphql-soup.ts @@ -604,7 +604,6 @@ function mapDocumentSubType(subType: GraphqlSoupDocument['subType']) { type: 'skill' as const, })) .with({ __typename: 'GraphqlInitiativeDescriptionSubType' }, () => { - // No dedicated block yet; the document renders as a plain document. return undefined; }) .exhaustive(); diff --git a/crates/document_sub_type/src/lib.rs b/crates/document_sub_type/src/lib.rs index f544a1a2dcd..3e2a819ebf8 100644 --- a/crates/document_sub_type/src/lib.rs +++ b/crates/document_sub_type/src/lib.rs @@ -37,7 +37,6 @@ pub enum DocumentSubType { /// A skill document — a markdown document containing instructions that AI /// can read and follow when referenced in an AI input Skill, - /// The description document of an initiative. Exactly one per initiative, - /// created by the initiative service rather than through `POST /documents`. + /// The description document of an initiative. InitiativeDescription, } diff --git a/crates/documents/src/domain/create.rs b/crates/documents/src/domain/create.rs index 890d1165b8b..e5760ff4d7d 100644 --- a/crates/documents/src/domain/create.rs +++ b/crates/documents/src/domain/create.rs @@ -189,9 +189,7 @@ pub enum MarkdownSubtype { /// A skill document — markdown instructions that AI reads and follows when /// the skill is referenced in an AI input. Skill, - /// An initiative's description document. Created only by the initiative - /// service, which mirrors the initiative's grants onto it in its own - /// transaction; nothing is team-shared here. + /// An initiative's description document. InitiativeDescription, } diff --git a/crates/documents/src/domain/models.rs b/crates/documents/src/domain/models.rs index bf928d259a3..09c021b58e6 100644 --- a/crates/documents/src/domain/models.rs +++ b/crates/documents/src/domain/models.rs @@ -345,8 +345,7 @@ pub struct CreateDocumentRepoArgs { pub skip_history: bool, /// Explicit activity attribution. Unset uses [`Self::resolved_attribution`]. pub attribution: Option, - /// Honored by [`super::ports::DocumentService::create_document`]. Copies and email imports - /// resolve their own permission and ignore it. + /// How the new document's link share is initialized. pub initial_link_share: InitialLinkShare, } diff --git a/crates/documents/src/domain/service/tests.rs b/crates/documents/src/domain/service/tests.rs index badde7970e6..b65b456c6be 100644 --- a/crates/documents/src/domain/service/tests.rs +++ b/crates/documents/src/domain/service/tests.rs @@ -2365,7 +2365,6 @@ async fn exact_initial_link_share_bypasses_md_public_edit_default() { level: AccessLevel::View, }, ] { - // No `expect_get_team_default_link_share`: an exact state never consults the team. let mut repo = make_mock_repo(); let created_metadata = make_test_metadata(); repo.expect_create_document() diff --git a/crates/initiative/src/domain/models.rs b/crates/initiative/src/domain/models.rs index 78c651f2d08..1dbd4e7dfe3 100644 --- a/crates/initiative/src/domain/models.rs +++ b/crates/initiative/src/domain/models.rs @@ -65,10 +65,6 @@ impl FromStr for InitiativeId { } /// Id of the markdown document that holds an initiative's description. -/// -/// The documents side mints it as a UUID; `"Document".id` and -/// `initiative.description_document_id` store it as TEXT, so it is parsed once at the -/// adapter boundary and displayed back when bound in SQL. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[cfg_attr(feature = "inbound", derive(utoipa::ToSchema))] #[serde(transparent)] @@ -325,8 +321,7 @@ pub struct UpdateInitiativeRepoArgs { } /// Team-share commands for an initiative and its description document, authorized by the -/// owner against one snapshot of facts. The repository applies both in one guarded -/// transaction, so the document's team grant can never drift from the initiative's. +/// owner against one snapshot of facts. #[derive(Debug, Clone, PartialEq, Eq)] pub struct LockstepTeamShare { /// Command whose expected facts name the initiative. diff --git a/crates/initiative/src/domain/ports.rs b/crates/initiative/src/domain/ports.rs index e7b26f3ba5e..c02905e959f 100644 --- a/crates/initiative/src/domain/ports.rs +++ b/crates/initiative/src/domain/ports.rs @@ -14,8 +14,7 @@ use crate::domain::models::{ UpdateInitiativeRepoArgs, UpdateInitiativeRequest, }; -/// Outbound port for the description document's lifecycle. The initiative domain's whole view -/// of the documents domain: no read, rename, or thin delete. +/// Outbound port for the description document's lifecycle. #[cfg_attr(test, mockall::automock)] pub trait InitiativeDescriptionDocuments: Send + Sync + 'static { /// Create the `initiative_description` markdown document, editor-ready, owned by diff --git a/crates/initiative/src/domain/service.rs b/crates/initiative/src/domain/service.rs index 2d880f51236..c69b77ace43 100644 --- a/crates/initiative/src/domain/service.rs +++ b/crates/initiative/src/domain/service.rs @@ -49,8 +49,7 @@ where } /// Authorize one team-share edit against both entities from a single snapshot of facts. - /// The description document is owned by the initiative owner, so a `NotOwner` on it means - /// the two have drifted apart rather than that the caller lacks authority. + /// A `NotOwner` on the description document means the two owners have drifted apart. async fn authorize_lockstep_team_share( &self, receipt: &EntityAccessReceipt, @@ -95,9 +94,8 @@ where R::Err: Into, D: InitiativeDescriptionDocuments, { - /// Two commits with compensation: the documents side commits the description document - /// first, then the initiative transaction links it and mirrors the grants. A failed - /// initiative write purges the document so nothing orphaned survives a `Err`. + /// Two commits with compensation. The documents side commits first. A failed + /// initiative write purges the document so nothing orphaned survives an `Err`. #[tracing::instrument(err, skip_all)] async fn create( &self, @@ -308,9 +306,8 @@ where .map_err(Into::into) } - /// Initiative rows first, document second. The FK's `ON DELETE SET NULL` means the other - /// order could leave an initiative with no description after a half-failure, which is - /// worse for readers than an orphaned document. + /// Initiative rows first, then the document. The FK's `ON DELETE SET NULL` means + /// the other order can leave an initiative with no description after a half-failure. #[tracing::instrument(err, skip_all)] async fn delete( &self, diff --git a/crates/initiative/src/domain/service/test.rs b/crates/initiative/src/domain/service/test.rs index c63a6717c4a..295604646d8 100644 --- a/crates/initiative/src/domain/service/test.rs +++ b/crates/initiative/src/domain/service/test.rs @@ -99,7 +99,6 @@ fn owner_receipt() -> EntityAccessReceipt { receipt(OWNER, EntityType::Initiative, AccessLevel::Owner) } -/// A service whose document port panics on any call. fn service( repo: MockInitiativeRepo, ) -> InitiativeServiceImpl { @@ -177,7 +176,6 @@ async fn create_rejects_empty_and_too_long_names() { #[tokio::test] async fn create_rejects_too_long_description_before_creating_any_document() { - // No expectations on either port: a call to `create` on the document port would panic. let result = service(MockInitiativeRepo::new()) .create( &user(OWNER), diff --git a/crates/initiative/src/outbound/pg_initiative_repo.rs b/crates/initiative/src/outbound/pg_initiative_repo.rs index aaaa795bca6..775b5c50c94 100644 --- a/crates/initiative/src/outbound/pg_initiative_repo.rs +++ b/crates/initiative/src/outbound/pg_initiative_repo.rs @@ -130,8 +130,8 @@ impl InitiativeRepo for PgInitiativeRepo { } } -/// Both entities every grant write targets. Built once per mutation and threaded through -/// every helper, so no code path can grant on the initiative and forget the document. +/// Both entities every grant write targets. Built once per mutation so no path +/// can grant on the initiative and forget the document. #[derive(Debug, Clone, Copy)] struct GrantTargets { initiative: uuid::Uuid, @@ -216,8 +216,7 @@ impl InitiativeRecord { } } -/// A NULL is a deploy-window artifact, not a valid state; failing loudly here keeps -/// `description_document_id` non-optional for every caller instead of forever. +/// A NULL is a deploy-window artifact. Fail here so callers never treat it as optional. fn require_description_document_id( initiative: uuid::Uuid, raw: Option, @@ -306,8 +305,7 @@ fn map_sqlx(error: AdapterError) -> InitiativeError { fn classify_sqlx(error: sqlx::Error) -> InitiativeError { if let Some(db) = error.as_database_error() { if db.is_unique_violation() { - // The service mints a fresh document per create, so a second initiative on the - // same document is a bug in this code path, not a request the caller can fix. + // A second initiative on the same document is a bug in this path, not a caller error. if db.constraint() == Some(DESCRIPTION_DOCUMENT_UNIQUE) { return InitiativeError::Internal(report!( "description document already linked to another initiative: {error}" diff --git a/crates/initiative/src/outbound/pg_initiative_repo/create.rs b/crates/initiative/src/outbound/pg_initiative_repo/create.rs index 3afccc46cbd..bc649784d60 100644 --- a/crates/initiative/src/outbound/pg_initiative_repo/create.rs +++ b/crates/initiative/src/outbound/pg_initiative_repo/create.rs @@ -65,7 +65,7 @@ pub(super) async fn create( super::members::insert_members(&mut tx, id, &args.member_ids).await?; - // The document's Owner row was written when the documents side created it. + // Owner on the document was written when the documents side created it. insert_entity_access_row( &mut tx, &id, @@ -85,8 +85,6 @@ pub(super) async fn create( .map_err(AdapterError::TeamShareCreate) .map_err(map_sqlx)?; - // The document was minted for this owner moments ago and nobody else can see it yet, so - // any failure here is an invariant violation rather than something the caller can fix. team_share::initialize( &mut tx, &targets.description_entity(), @@ -103,8 +101,7 @@ pub(super) async fn create( require_detail(pool, args.id).await } -/// Team share on the document follows the initiative's create-time intent, under the variant -/// that names the document so the write is visibly deliberate. +/// Team share on the document follows the initiative's create-time intent. fn description_team_share(intent: TeamShareCreation) -> TeamShareCreation { match intent { TeamShareCreation::Initiative => TeamShareCreation::InitiativeDescription, @@ -192,7 +189,7 @@ pub(super) async fn update( require_detail(pool, args.id).await } -/// The document's rows are untouched here; the service purges them after this commits. +/// The document's rows are untouched here. The service purges them after this commits. pub(super) async fn delete( pool: &PgPool, id: InitiativeId, @@ -247,7 +244,6 @@ pub(super) async fn delete( .transpose() } -/// Row values the rest of the update transaction needs after patching. struct PatchedRow { share_permission_id: String, description_document_id: DescriptionDocumentId, diff --git a/crates/initiative/src/outbound/pg_initiative_repo/members.rs b/crates/initiative/src/outbound/pg_initiative_repo/members.rs index 17ea42cb802..d7ffe561de0 100644 --- a/crates/initiative/src/outbound/pg_initiative_repo/members.rs +++ b/crates/initiative/src/outbound/pg_initiative_repo/members.rs @@ -32,8 +32,6 @@ pub(super) async fn insert_members( Ok(()) } -/// Edit on both entities. The upsert's owner guard keeps each Owner row intact when the -/// owner is also listed. pub(super) async fn grant_members_edit( tx: &mut Transaction<'_, Postgres>, targets: &GrantTargets, diff --git a/crates/initiative/src/outbound/pg_initiative_repo/share.rs b/crates/initiative/src/outbound/pg_initiative_repo/share.rs index f42e6e4420a..a4fcda6cb88 100644 --- a/crates/initiative/src/outbound/pg_initiative_repo/share.rs +++ b/crates/initiative/src/outbound/pg_initiative_repo/share.rs @@ -14,8 +14,6 @@ use crate::domain::models::{ DescriptionDocumentId, InitiativeError, InitiativeId, LockstepTeamShareFacts, }; -/// One `SharePermission` row plus the `entity_access` entity it governs. Built only from -/// [`GrantTargets`] so the two halves cannot be mixed up. pub(super) struct ShareTarget<'a> { entity_id: Uuid, entity_type: EntityType, @@ -83,7 +81,6 @@ pub(super) async fn insert_share_permission( Ok(row.id) } -/// Every document the documents side creates has a permission row, so a miss is corruption. pub(super) async fn description_share_permission_id( tx: &mut Transaction<'_, Postgres>, id: DescriptionDocumentId, @@ -105,8 +102,6 @@ pub(super) async fn description_share_permission_id( }) } -/// Link columns, channel `entity_access` grants, and `ChannelSharePermission` rows for one -/// target. Called once per target so both entities end identical. pub(super) async fn apply_share_patch( tx: &mut Transaction<'_, Postgres>, target: ShareTarget<'_>, @@ -253,7 +248,6 @@ async fn update_channel_share_access( Ok(result.rows_affected() > 0) } -/// One guarded read for both entities, so the service authorizes them against one snapshot. pub(super) async fn get_lockstep_team_share_facts( pool: &PgPool, id: InitiativeId, diff --git a/crates/initiative/src/outbound/pg_initiative_repo/test.rs b/crates/initiative/src/outbound/pg_initiative_repo/test.rs index dd3bb5b9782..e1fb95bce51 100644 --- a/crates/initiative/src/outbound/pg_initiative_repo/test.rs +++ b/crates/initiative/src/outbound/pg_initiative_repo/test.rs @@ -85,8 +85,6 @@ fn set_team_share(level: AccessLevel) -> UpdateSharePermissionRequestV2 { } } -/// The rows `insert_new_document` writes for a description document, minus history and the -/// entity registry, which team sharing and access checks never read. async fn seed_description_document( pool: &PgPool, owner: &str, @@ -209,7 +207,6 @@ async fn add_team_user( Ok(()) } -/// Owner plus a team the owner belongs to; returns the team id. async fn seed_owner_with_team(pool: &PgPool) -> anyhow::Result { insert_user(pool, OWNER).await?; let team_id = Uuid::now_v7(); @@ -291,8 +288,6 @@ async fn access_level( Ok(row.flatten()) } -/// `(access level, source id)` for every grant on both entities, so a test can assert the -/// document mirrors the initiative in one comparison. async fn mirrored_access( pool: &PgPool, initiative_id: InitiativeId, @@ -342,7 +337,6 @@ async fn document_share_permission( }) } -/// What the service does before calling `update` with a team-share patch. async fn lockstep_team_share( repo: &PgInitiativeRepo, id: InitiativeId, @@ -810,7 +804,6 @@ async fn update_team_share_applies_both_commands_and_a_second_apply_is_not_untra assert_eq!(facts.description.current, grant); assert_eq!(facts.description.revision, 1); - // The document's team row is tracked, so changing the level is an ordinary apply. repo.update(UpdateInitiativeRepoArgs { share_permission: Some(set_team_share(AccessLevel::View)), team_share: Some(lockstep_team_share(&repo, created.id, AccessLevel::View).await?), @@ -1072,7 +1065,6 @@ async fn delete_returns_the_document_id_and_leaves_no_initiative_rows( ) .fetch_one(&pool) .await?; - // The document itself is the service's to purge after this commit. let document_remains = sqlx::query_scalar!( r#"SELECT EXISTS(SELECT 1 FROM "Document" WHERE id = $1) AS "exists!""#, document_id.to_string(), diff --git a/crates/models_permissions/src/share_permission.rs b/crates/models_permissions/src/share_permission.rs index 2147bc7cb89..718e7cd037b 100644 --- a/crates/models_permissions/src/share_permission.rs +++ b/crates/models_permissions/src/share_permission.rs @@ -79,7 +79,6 @@ impl SharePermissionV2 { } /// Exactly `state`, consulting neither the entity-type default nor the owner's team default. - /// For a document created alongside another entity whose link share is already resolved. pub fn from_link_share_state(state: LinkShareState) -> Self { match state { LinkShareState::Off => Self::new(None, None), diff --git a/crates/models_permissions/src/share_permission/team_share.rs b/crates/models_permissions/src/share_permission/team_share.rs index d5fdd4c95c7..b21969e6e73 100644 --- a/crates/models_permissions/src/share_permission/team_share.rs +++ b/crates/models_permissions/src/share_permission/team_share.rs @@ -214,8 +214,7 @@ pub enum TeamShareCreation { Call, /// A new initiative initializes Edit; missing membership is an error. Initiative, - /// The description document of a new initiative. Resolves like `Initiative`; a distinct - /// variant so an `initialize` on a document entity is visibly intentional. + /// The description document of a new initiative. Resolves like `Initiative`. InitiativeDescription, } diff --git a/crates/models_permissions/src/share_permission/test.rs b/crates/models_permissions/src/share_permission/test.rs index 269dcb82453..9bf345fee1c 100644 --- a/crates/models_permissions/src/share_permission/test.rs +++ b/crates/models_permissions/src/share_permission/test.rs @@ -190,7 +190,6 @@ fn link_share_state_round_trips_through_a_permission() { #[test] fn from_link_share_state_ignores_entity_and_team_defaults() { - // An md document would default to PUBLIC/Edit; an exact state never consults that. assert_eq!( SharePermissionV2::from_link_share_state(LinkShareState::Off), share(None, None) diff --git a/crates/models_properties/src/shared/entity_type.rs b/crates/models_properties/src/shared/entity_type.rs index f432e5cf7b1..18a0657f147 100644 --- a/crates/models_properties/src/shared/entity_type.rs +++ b/crates/models_properties/src/shared/entity_type.rs @@ -69,8 +69,6 @@ impl From for EntityType { fn from(sub_type: DocumentSubType) -> Self { match sub_type { DocumentSubType::Task => EntityType::Task, - // No dedicated property entity type for snippets, skills, or initiative - // descriptions; they key under Document. DocumentSubType::Snippet | DocumentSubType::Skill | DocumentSubType::InitiativeDescription => EntityType::Document, diff --git a/services/document_storage_service/src/api/context.rs b/services/document_storage_service/src/api/context.rs index 2040e47ffab..3e6cd3d8530 100644 --- a/services/document_storage_service/src/api/context.rs +++ b/services/document_storage_service/src/api/context.rs @@ -481,8 +481,6 @@ pub(crate) type RemindersServiceType = RemindersServiceImpl; pub(crate) type DssRemindersState = RemindersRouterState; -/// Type alias for the description-document adapter behind the initiative service. Its -/// creator parameters match [`documents_hex::inbound::axum_router::DefaultDocumentCreator`]. pub(crate) type InitiativeDescriptionDocumentsType = crate::outbound::initiative_description_documents::InitiativeDescriptionDocumentsAdapter< Arc, diff --git a/services/document_storage_service/src/main.rs b/services/document_storage_service/src/main.rs index 20a3e50eaf4..e71141a618e 100644 --- a/services/document_storage_service/src/main.rs +++ b/services/document_storage_service/src/main.rs @@ -1155,8 +1155,6 @@ async fn run() -> anyhow::Result<()> { // pool handle, so cloning is cheap and `SoupImpl` needs an owned service. let reminders_service = RemindersServiceImpl::new(PgRemindersRepo::new(db.clone())); - // Shared by the document router and the initiative service, which creates each - // initiative's description document through the same editor-ready path. let document_creator = documents_hex::domain::create::DocumentCreator::new( document_service.clone(), markdown_initializer, diff --git a/services/document_storage_service/src/outbound/initiative_description_documents.rs b/services/document_storage_service/src/outbound/initiative_description_documents.rs index ebe5328a288..e4971ea8332 100644 --- a/services/document_storage_service/src/outbound/initiative_description_documents.rs +++ b/services/document_storage_service/src/outbound/initiative_description_documents.rs @@ -1,7 +1,4 @@ -//! Adapter that gives the initiative domain its description documents. Creation goes -//! through the documents domain's [`DocumentCreator`] so the document opens in the -//! editor; purge runs the same sequence a permanent document delete runs elsewhere in -//! this service. +//! Adapter that gives the initiative domain its description documents. use std::str::FromStr; use std::sync::Arc; @@ -20,14 +17,12 @@ use sqlx::PgPool; use crate::service::document_event_publisher::publish_document_purged_event; -/// Wrap a documents-side failure as an internal initiative error. macro_rules! internal { ($error:expr) => { InitiativeError::Internal(rootcause::report!($error).into()) }; } -/// Production adapter backed by the documents domain creator and the legacy purge clients. pub struct InitiativeDescriptionDocumentsAdapter { creator: DocumentCreator, @@ -39,7 +34,6 @@ pub struct InitiativeDescriptionDocumentsAdapter InitiativeDescriptionDocumentsAdapter { - /// Construct the adapter from concrete outbound dependencies. pub fn new( creator: DocumentCreator, db: PgPool, @@ -75,8 +69,7 @@ where prefill_markdown, link_share, } = document; - // Not in recents: the initiative is what people open, and the editor reaches the - // document by id. + // Recents list the initiative. The editor opens this document by id. let metadata = NewDocumentMetadata::builder(name) .skip_history() .initial_link_share(link_share) @@ -101,8 +94,6 @@ where }) } - /// Every step is keyed by the document id and tolerates an id that is already gone, so a - /// failed purge can be re-run from the top. #[tracing::instrument(skip(self), err)] async fn purge(&self, id: DescriptionDocumentId) -> Result<(), InitiativeError> { let document_id = id.to_string(); @@ -127,8 +118,6 @@ where } } -/// Keep the caller-facing classes; everything else is a failure of this service, since the -/// initiative domain already validated every field it sent. fn map_document_error(error: DocumentError) -> InitiativeError { match error { DocumentError::BadRequest(message) => InitiativeError::BadRequest(message), From 874fc9f0345f8e06660dcd90de814e02e18fc723 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 17 Sep 2026 19:04:34 +0000 Subject: [PATCH 3/8] chore(api): drop leaked DescriptionDocumentId storage paragraph The rustdoc is already one sentence. Match generated OpenAPI, SDK, and zod so gen-api --check passes. Co-authored-by: Will Hutchinson --- .../generated/schemas/descriptionDocumentId.ts | 4 ---- .../lib/service-clients/service-storage/generated/zod.ts | 8 ++++---- .../src/lib/service-clients/service-storage/openapi.json | 2 +- packages/sdk/generated/storage/types.gen.ts | 4 ---- packages/sdk/specs/storage.json | 2 +- 5 files changed, 6 insertions(+), 14 deletions(-) diff --git a/apps/web/src/lib/service-clients/service-storage/generated/schemas/descriptionDocumentId.ts b/apps/web/src/lib/service-clients/service-storage/generated/schemas/descriptionDocumentId.ts index a1dc8507a3b..905fd518807 100644 --- a/apps/web/src/lib/service-clients/service-storage/generated/schemas/descriptionDocumentId.ts +++ b/apps/web/src/lib/service-clients/service-storage/generated/schemas/descriptionDocumentId.ts @@ -7,9 +7,5 @@ /** * Id of the markdown document that holds an initiative's description. - -The documents side mints it as a UUID; `"Document".id` and -`initiative.description_document_id` store it as TEXT, so it is parsed once at the -adapter boundary and displayed back when bound in SQL. */ export type DescriptionDocumentId = string; diff --git a/apps/web/src/lib/service-clients/service-storage/generated/zod.ts b/apps/web/src/lib/service-clients/service-storage/generated/zod.ts index 1f0c59feec7..dd1560523f3 100644 --- a/apps/web/src/lib/service-clients/service-storage/generated/zod.ts +++ b/apps/web/src/lib/service-clients/service-storage/generated/zod.ts @@ -9487,7 +9487,7 @@ export const listInitiativesResponse = zod descriptionDocumentId: zod .uuid() .describe( - 'Id of the markdown document that holds an initiative\'s description.\n\nThe documents side mints it as a UUID; `\"Document\".id` and\n`initiative.description_document_id` store it as TEXT, so it is parsed once at the\nadapter boundary and displayed back when bound in SQL.' + "Id of the markdown document that holds an initiative's description." ), id: zod .uuid() @@ -9538,7 +9538,7 @@ export const createInitiativeResponse = zod descriptionDocumentId: zod .uuid() .describe( - 'Id of the markdown document that holds an initiative\'s description.\n\nThe documents side mints it as a UUID; `\"Document\".id` and\n`initiative.description_document_id` store it as TEXT, so it is parsed once at the\nadapter boundary and displayed back when bound in SQL.' + "Id of the markdown document that holds an initiative's description." ), id: zod .uuid() @@ -9620,7 +9620,7 @@ export const getInitiativeResponse = zod descriptionDocumentId: zod .uuid() .describe( - 'Id of the markdown document that holds an initiative\'s description.\n\nThe documents side mints it as a UUID; `\"Document\".id` and\n`initiative.description_document_id` store it as TEXT, so it is parsed once at the\nadapter boundary and displayed back when bound in SQL.' + "Id of the markdown document that holds an initiative's description." ), id: zod .uuid() @@ -9779,7 +9779,7 @@ export const updateInitiativeResponse = zod descriptionDocumentId: zod .uuid() .describe( - 'Id of the markdown document that holds an initiative\'s description.\n\nThe documents side mints it as a UUID; `\"Document\".id` and\n`initiative.description_document_id` store it as TEXT, so it is parsed once at the\nadapter boundary and displayed back when bound in SQL.' + "Id of the markdown document that holds an initiative's description." ), id: zod .uuid() diff --git a/apps/web/src/lib/service-clients/service-storage/openapi.json b/apps/web/src/lib/service-clients/service-storage/openapi.json index 5521d1e7448..9fdae3135c4 100644 --- a/apps/web/src/lib/service-clients/service-storage/openapi.json +++ b/apps/web/src/lib/service-clients/service-storage/openapi.json @@ -20341,7 +20341,7 @@ "DescriptionDocumentId": { "type": "string", "format": "uuid", - "description": "Id of the markdown document that holds an initiative's description.\n\nThe documents side mints it as a UUID; `\"Document\".id` and\n`initiative.description_document_id` store it as TEXT, so it is parsed once at the\nadapter boundary and displayed back when bound in SQL." + "description": "Id of the markdown document that holds an initiative's description." }, "DocumentBasic": { "type": "object", diff --git a/packages/sdk/generated/storage/types.gen.ts b/packages/sdk/generated/storage/types.gen.ts index 794adcd1f4f..2a178e660f8 100644 --- a/packages/sdk/generated/storage/types.gen.ts +++ b/packages/sdk/generated/storage/types.gen.ts @@ -4333,10 +4333,6 @@ export type DeleteUnthreadedPdfAnchorRequest = { /** * Id of the markdown document that holds an initiative's description. - * - * The documents side mints it as a UUID; `"Document".id` and - * `initiative.description_document_id` store it as TEXT, so it is parsed once at the - * adapter boundary and displayed back when bound in SQL. */ export type DescriptionDocumentId = string; diff --git a/packages/sdk/specs/storage.json b/packages/sdk/specs/storage.json index 5521d1e7448..9fdae3135c4 100644 --- a/packages/sdk/specs/storage.json +++ b/packages/sdk/specs/storage.json @@ -20341,7 +20341,7 @@ "DescriptionDocumentId": { "type": "string", "format": "uuid", - "description": "Id of the markdown document that holds an initiative's description.\n\nThe documents side mints it as a UUID; `\"Document\".id` and\n`initiative.description_document_id` store it as TEXT, so it is parsed once at the\nadapter boundary and displayed back when bound in SQL." + "description": "Id of the markdown document that holds an initiative's description." }, "DocumentBasic": { "type": "object", From 2106b074e31fe09306551d5861be82c43e5c307f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 17 Sep 2026 19:04:34 +0000 Subject: [PATCH 4/8] test(documents): pin Exact initial link share skips team default lookup Exact create must not call get_team_default_link_share. Mockall would only catch that if make_mock_repo later gained a default expectation. Co-authored-by: Will Hutchinson --- crates/documents/src/domain/service/tests.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/documents/src/domain/service/tests.rs b/crates/documents/src/domain/service/tests.rs index b65b456c6be..2abb357fa96 100644 --- a/crates/documents/src/domain/service/tests.rs +++ b/crates/documents/src/domain/service/tests.rs @@ -2367,6 +2367,7 @@ async fn exact_initial_link_share_bypasses_md_public_edit_default() { ] { let mut repo = make_mock_repo(); let created_metadata = make_test_metadata(); + repo.expect_get_team_default_link_share().times(0); repo.expect_create_document() .withf(move |args, share_permission| { args.initial_link_share == InitialLinkShare::Exact(state) @@ -2423,6 +2424,7 @@ async fn initiative_description_accepts_exact_link_share() { let mut repo = make_mock_repo(); let created_metadata = make_test_metadata(); + repo.expect_get_team_default_link_share().times(0); repo.expect_create_document() .withf(|args, share_permission| { args.sub_type == Some(document_sub_type::DocumentSubType::InitiativeDescription) From 4d8074ea2ce103e094f99a26f37565ed7e1e4227 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 18 Sep 2026 12:49:19 +0000 Subject: [PATCH 5/8] chore(api): regenerate clients after initiative_description Notification, search, and cognition OpenAPI now include the new document subtype so gen-api --check and SDK generated-code check pass. Co-authored-by: Will Hutchinson --- .../generated/tools/schemas.ts | 2 + .../generated/tools/types.ts | 11 +++- .../generated/schemas/index.ts | 2 + .../schemas/notificationDocumentSubType.ts | 4 +- .../notificationDocumentSubTypeOneOfSeven.ts | 11 ++++ ...tificationDocumentSubTypeOneOfSevenType.ts | 14 ++++ .../service-notification/generated/zod.ts | 66 +++++++++++++++++++ .../service-notification/openapi.json | 10 +++ .../generated/models/documentSubType.ts | 4 ++ .../service-search/openapi.json | 4 +- .../sdk/generated/notification/types.gen.ts | 2 + packages/sdk/generated/search/types.gen.ts | 5 +- packages/sdk/specs/notification.json | 10 +++ packages/sdk/specs/search.json | 4 +- 14 files changed, 141 insertions(+), 8 deletions(-) create mode 100644 apps/web/src/lib/service-clients/service-notification/generated/schemas/notificationDocumentSubTypeOneOfSeven.ts create mode 100644 apps/web/src/lib/service-clients/service-notification/generated/schemas/notificationDocumentSubTypeOneOfSevenType.ts diff --git a/apps/web/src/lib/service-clients/service-cognition/generated/tools/schemas.ts b/apps/web/src/lib/service-clients/service-cognition/generated/tools/schemas.ts index ae07ea355ab..9289098f7f5 100644 --- a/apps/web/src/lib/service-clients/service-cognition/generated/tools/schemas.ts +++ b/apps/web/src/lib/service-clients/service-cognition/generated/tools/schemas.ts @@ -811,6 +811,7 @@ export const SearchToolResponse = z.object({ z.literal('task'), z.literal('snippet'), z.literal('skill'), + z.literal('initiative_description'), ]; const errors = schemas.reduce( (errors, schema) => @@ -4905,6 +4906,7 @@ export const ReadMetadataResponse = z.object({ z.literal('task'), z.literal('snippet'), z.literal('skill'), + z.literal('initiative_description'), ]; const errors = schemas.reduce( (errors, schema) => diff --git a/apps/web/src/lib/service-clients/service-cognition/generated/tools/types.ts b/apps/web/src/lib/service-clients/service-cognition/generated/tools/types.ts index 46b60e9d342..eb70658322b 100644 --- a/apps/web/src/lib/service-clients/service-cognition/generated/tools/types.ts +++ b/apps/web/src/lib/service-clients/service-cognition/generated/tools/types.ts @@ -239,8 +239,15 @@ export type TaggedSearchResult1 = /** * The document sub type enum represents all values of document sub types. * These values should match the `document_sub_type_value` table in macrodb. + * + * Wire, database, and `Display` spellings are all `snake_case` so a + * multi-word variant serializes identically in every system. */ -export type DocumentSubType = 'task' | 'snippet' | 'skill'; +export type DocumentSubType = + | 'task' + | 'snippet' + | 'skill' + | 'initiative_description'; /** * Viewer-relative attendance status for a call record. * Serializes as `ATTENDED`, `MISSED`, or `UNATTENDED`. @@ -584,7 +591,7 @@ export type EntityItem = fileType?: string | null; /** * The document's sub type: "task" for Macro tasks, "snippet" for snippets, - * "skill" for skills. + * "skill" for skills, "initiative_description" for an initiative's description. */ subType?: string | null; /** diff --git a/apps/web/src/lib/service-clients/service-notification/generated/schemas/index.ts b/apps/web/src/lib/service-clients/service-notification/generated/schemas/index.ts index 4e896590f7a..d235d2f668a 100644 --- a/apps/web/src/lib/service-clients/service-notification/generated/schemas/index.ts +++ b/apps/web/src/lib/service-clients/service-notification/generated/schemas/index.ts @@ -187,6 +187,8 @@ export * from './notificationDocumentSubType'; export * from './notificationDocumentSubTypeOneOf'; export * from './notificationDocumentSubTypeOneOfFive'; export * from './notificationDocumentSubTypeOneOfFiveType'; +export * from './notificationDocumentSubTypeOneOfSeven'; +export * from './notificationDocumentSubTypeOneOfSevenType'; export * from './notificationDocumentSubTypeOneOfThree'; export * from './notificationDocumentSubTypeOneOfThreeType'; export * from './notificationDocumentSubTypeOneOfType'; diff --git a/apps/web/src/lib/service-clients/service-notification/generated/schemas/notificationDocumentSubType.ts b/apps/web/src/lib/service-clients/service-notification/generated/schemas/notificationDocumentSubType.ts index ef44e2a299f..80e5e2e0148 100644 --- a/apps/web/src/lib/service-clients/service-notification/generated/schemas/notificationDocumentSubType.ts +++ b/apps/web/src/lib/service-clients/service-notification/generated/schemas/notificationDocumentSubType.ts @@ -6,6 +6,7 @@ */ import type { NotificationDocumentSubTypeOneOf } from './notificationDocumentSubTypeOneOf'; import type { NotificationDocumentSubTypeOneOfFive } from './notificationDocumentSubTypeOneOfFive'; +import type { NotificationDocumentSubTypeOneOfSeven } from './notificationDocumentSubTypeOneOfSeven'; import type { NotificationDocumentSubTypeOneOfThree } from './notificationDocumentSubTypeOneOfThree'; /** @@ -15,4 +16,5 @@ Serializes as `{ "type": "task" }` matching the storage service pattern. export type NotificationDocumentSubType = | NotificationDocumentSubTypeOneOf | NotificationDocumentSubTypeOneOfThree - | NotificationDocumentSubTypeOneOfFive; + | NotificationDocumentSubTypeOneOfFive + | NotificationDocumentSubTypeOneOfSeven; diff --git a/apps/web/src/lib/service-clients/service-notification/generated/schemas/notificationDocumentSubTypeOneOfSeven.ts b/apps/web/src/lib/service-clients/service-notification/generated/schemas/notificationDocumentSubTypeOneOfSeven.ts new file mode 100644 index 00000000000..634c5a3608c --- /dev/null +++ b/apps/web/src/lib/service-clients/service-notification/generated/schemas/notificationDocumentSubTypeOneOfSeven.ts @@ -0,0 +1,11 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * notification_service + * OpenAPI spec version: 0.1.0 + */ +import type { NotificationDocumentSubTypeOneOfSevenType } from './notificationDocumentSubTypeOneOfSevenType'; + +export type NotificationDocumentSubTypeOneOfSeven = { + type: NotificationDocumentSubTypeOneOfSevenType; +}; diff --git a/apps/web/src/lib/service-clients/service-notification/generated/schemas/notificationDocumentSubTypeOneOfSevenType.ts b/apps/web/src/lib/service-clients/service-notification/generated/schemas/notificationDocumentSubTypeOneOfSevenType.ts new file mode 100644 index 00000000000..f05476d8b1f --- /dev/null +++ b/apps/web/src/lib/service-clients/service-notification/generated/schemas/notificationDocumentSubTypeOneOfSevenType.ts @@ -0,0 +1,14 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * notification_service + * OpenAPI spec version: 0.1.0 + */ + +export type NotificationDocumentSubTypeOneOfSevenType = + (typeof NotificationDocumentSubTypeOneOfSevenType)[keyof typeof NotificationDocumentSubTypeOneOfSevenType]; + +// eslint-disable-next-line @typescript-eslint/no-redeclare +export const NotificationDocumentSubTypeOneOfSevenType = { + initiative_description: 'initiative_description', +} as const; diff --git a/apps/web/src/lib/service-clients/service-notification/generated/zod.ts b/apps/web/src/lib/service-clients/service-notification/generated/zod.ts index 4bebd46b0d5..2aabdf8af93 100644 --- a/apps/web/src/lib/service-clients/service-notification/generated/zod.ts +++ b/apps/web/src/lib/service-clients/service-notification/generated/zod.ts @@ -281,6 +281,11 @@ export const listTypedNotificationsResponse = zod zod.object({ type: zod.enum(['skill']), }), + zod.object({ + type: zod.enum([ + 'initiative_description', + ]), + }), ]) .describe( 'The sub type of a document in a notification.\nSerializes as `{ \"type\": \"task\" }` matching the storage service pattern.' @@ -345,6 +350,9 @@ export const listTypedNotificationsResponse = zod zod.object({ type: zod.enum(['skill']), }), + zod.object({ + type: zod.enum(['initiative_description']), + }), ]) .describe( 'The sub type of a document in a notification.\nSerializes as `{ \"type\": \"task\" }` matching the storage service pattern.' @@ -428,6 +436,9 @@ export const listTypedNotificationsResponse = zod zod.object({ type: zod.enum(['skill']), }), + zod.object({ + type: zod.enum(['initiative_description']), + }), ]) .describe( 'The sub type of a document in a notification.\nSerializes as `{ \"type\": \"task\" }` matching the storage service pattern.' @@ -513,6 +524,9 @@ export const listTypedNotificationsResponse = zod zod.object({ type: zod.enum(['skill']), }), + zod.object({ + type: zod.enum(['initiative_description']), + }), ]) .describe( 'The sub type of a document in a notification.\nSerializes as `{ \"type\": \"task\" }` matching the storage service pattern.' @@ -781,6 +795,9 @@ export const listTypedNotificationsResponse = zod zod.object({ type: zod.enum(['skill']), }), + zod.object({ + type: zod.enum(['initiative_description']), + }), ]) .describe( 'The sub type of a document in a notification.\nSerializes as `{ \"type\": \"task\" }` matching the storage service pattern.' @@ -2019,6 +2036,11 @@ export const bulkGetTypedNotificationsByEventItemIdsResponse = zod zod.object({ type: zod.enum(['skill']), }), + zod.object({ + type: zod.enum([ + 'initiative_description', + ]), + }), ]) .describe( 'The sub type of a document in a notification.\nSerializes as `{ \"type\": \"task\" }` matching the storage service pattern.' @@ -2083,6 +2105,9 @@ export const bulkGetTypedNotificationsByEventItemIdsResponse = zod zod.object({ type: zod.enum(['skill']), }), + zod.object({ + type: zod.enum(['initiative_description']), + }), ]) .describe( 'The sub type of a document in a notification.\nSerializes as `{ \"type\": \"task\" }` matching the storage service pattern.' @@ -2166,6 +2191,9 @@ export const bulkGetTypedNotificationsByEventItemIdsResponse = zod zod.object({ type: zod.enum(['skill']), }), + zod.object({ + type: zod.enum(['initiative_description']), + }), ]) .describe( 'The sub type of a document in a notification.\nSerializes as `{ \"type\": \"task\" }` matching the storage service pattern.' @@ -2251,6 +2279,9 @@ export const bulkGetTypedNotificationsByEventItemIdsResponse = zod zod.object({ type: zod.enum(['skill']), }), + zod.object({ + type: zod.enum(['initiative_description']), + }), ]) .describe( 'The sub type of a document in a notification.\nSerializes as `{ \"type\": \"task\" }` matching the storage service pattern.' @@ -2519,6 +2550,9 @@ export const bulkGetTypedNotificationsByEventItemIdsResponse = zod zod.object({ type: zod.enum(['skill']), }), + zod.object({ + type: zod.enum(['initiative_description']), + }), ]) .describe( 'The sub type of a document in a notification.\nSerializes as `{ \"type\": \"task\" }` matching the storage service pattern.' @@ -3751,6 +3785,11 @@ export const getTypedNotificationsByEventItemIdResponse = zod zod.object({ type: zod.enum(['skill']), }), + zod.object({ + type: zod.enum([ + 'initiative_description', + ]), + }), ]) .describe( 'The sub type of a document in a notification.\nSerializes as `{ \"type\": \"task\" }` matching the storage service pattern.' @@ -3815,6 +3854,9 @@ export const getTypedNotificationsByEventItemIdResponse = zod zod.object({ type: zod.enum(['skill']), }), + zod.object({ + type: zod.enum(['initiative_description']), + }), ]) .describe( 'The sub type of a document in a notification.\nSerializes as `{ \"type\": \"task\" }` matching the storage service pattern.' @@ -3898,6 +3940,9 @@ export const getTypedNotificationsByEventItemIdResponse = zod zod.object({ type: zod.enum(['skill']), }), + zod.object({ + type: zod.enum(['initiative_description']), + }), ]) .describe( 'The sub type of a document in a notification.\nSerializes as `{ \"type\": \"task\" }` matching the storage service pattern.' @@ -3983,6 +4028,9 @@ export const getTypedNotificationsByEventItemIdResponse = zod zod.object({ type: zod.enum(['skill']), }), + zod.object({ + type: zod.enum(['initiative_description']), + }), ]) .describe( 'The sub type of a document in a notification.\nSerializes as `{ \"type\": \"task\" }` matching the storage service pattern.' @@ -4251,6 +4299,9 @@ export const getTypedNotificationsByEventItemIdResponse = zod zod.object({ type: zod.enum(['skill']), }), + zod.object({ + type: zod.enum(['initiative_description']), + }), ]) .describe( 'The sub type of a document in a notification.\nSerializes as `{ \"type\": \"task\" }` matching the storage service pattern.' @@ -5482,6 +5533,9 @@ export const getTypedNotificationByIdResponse = zod zod.object({ type: zod.enum(['skill']), }), + zod.object({ + type: zod.enum(['initiative_description']), + }), ]) .describe( 'The sub type of a document in a notification.\nSerializes as `{ \"type\": \"task\" }` matching the storage service pattern.' @@ -5542,6 +5596,9 @@ export const getTypedNotificationByIdResponse = zod zod.object({ type: zod.enum(['skill']), }), + zod.object({ + type: zod.enum(['initiative_description']), + }), ]) .describe( 'The sub type of a document in a notification.\nSerializes as `{ \"type\": \"task\" }` matching the storage service pattern.' @@ -5617,6 +5674,9 @@ export const getTypedNotificationByIdResponse = zod zod.object({ type: zod.enum(['skill']), }), + zod.object({ + type: zod.enum(['initiative_description']), + }), ]) .describe( 'The sub type of a document in a notification.\nSerializes as `{ \"type\": \"task\" }` matching the storage service pattern.' @@ -5694,6 +5754,9 @@ export const getTypedNotificationByIdResponse = zod zod.object({ type: zod.enum(['skill']), }), + zod.object({ + type: zod.enum(['initiative_description']), + }), ]) .describe( 'The sub type of a document in a notification.\nSerializes as `{ \"type\": \"task\" }` matching the storage service pattern.' @@ -5944,6 +6007,9 @@ export const getTypedNotificationByIdResponse = zod zod.object({ type: zod.enum(['skill']), }), + zod.object({ + type: zod.enum(['initiative_description']), + }), ]) .describe( 'The sub type of a document in a notification.\nSerializes as `{ \"type\": \"task\" }` matching the storage service pattern.' diff --git a/apps/web/src/lib/service-clients/service-notification/openapi.json b/apps/web/src/lib/service-clients/service-notification/openapi.json index 62040128af2..f58d433a39e 100644 --- a/apps/web/src/lib/service-clients/service-notification/openapi.json +++ b/apps/web/src/lib/service-clients/service-notification/openapi.json @@ -2614,6 +2614,16 @@ "enum": ["skill"] } } + }, + { + "type": "object", + "required": ["type"], + "properties": { + "type": { + "type": "string", + "enum": ["initiative_description"] + } + } } ], "description": "The sub type of a document in a notification.\nSerializes as `{ \"type\": \"task\" }` matching the storage service pattern." diff --git a/apps/web/src/lib/service-clients/service-search/generated/models/documentSubType.ts b/apps/web/src/lib/service-clients/service-search/generated/models/documentSubType.ts index 833d9fda5e9..962112483e2 100644 --- a/apps/web/src/lib/service-clients/service-search/generated/models/documentSubType.ts +++ b/apps/web/src/lib/service-clients/service-search/generated/models/documentSubType.ts @@ -8,6 +8,9 @@ /** * The document sub type enum represents all values of document sub types. These values should match the `document_sub_type_value` table in macrodb. + +Wire, database, and `Display` spellings are all `snake_case` so a +multi-word variant serializes identically in every system. */ export type DocumentSubType = (typeof DocumentSubType)[keyof typeof DocumentSubType]; @@ -17,4 +20,5 @@ export const DocumentSubType = { task: 'task', snippet: 'snippet', skill: 'skill', + initiative_description: 'initiative_description', } as const; diff --git a/apps/web/src/lib/service-clients/service-search/openapi.json b/apps/web/src/lib/service-clients/service-search/openapi.json index 75e451ca2dc..ba0b767231b 100644 --- a/apps/web/src/lib/service-clients/service-search/openapi.json +++ b/apps/web/src/lib/service-clients/service-search/openapi.json @@ -1759,8 +1759,8 @@ }, "DocumentSubType": { "type": "string", - "description": "The document sub type enum represents all values of document sub types.\nThese values should match the `document_sub_type_value` table in macrodb.", - "enum": ["task", "snippet", "skill"] + "description": "The document sub type enum represents all values of document sub types.\nThese values should match the `document_sub_type_value` table in macrodb.\n\nWire, database, and `Display` spellings are all `snake_case` so a\nmulti-word variant serializes identically in every system.", + "enum": ["task", "snippet", "skill", "initiative_description"] }, "EmailFilters": { "type": "object", diff --git a/packages/sdk/generated/notification/types.gen.ts b/packages/sdk/generated/notification/types.gen.ts index d02b56cfad1..f00fa360801 100644 --- a/packages/sdk/generated/notification/types.gen.ts +++ b/packages/sdk/generated/notification/types.gen.ts @@ -1033,6 +1033,8 @@ export type NotificationDocumentSubType = { type: 'snippet'; } | { type: 'skill'; +} | { + type: 'initiative_description'; }; export type NotificationServiceApiVersion = 'v1'; diff --git a/packages/sdk/generated/search/types.gen.ts b/packages/sdk/generated/search/types.gen.ts index 49d82b1e160..2280ee88f3d 100644 --- a/packages/sdk/generated/search/types.gen.ts +++ b/packages/sdk/generated/search/types.gen.ts @@ -1016,8 +1016,11 @@ export type DocumentSearchResult = { /** * The document sub type enum represents all values of document sub types. * These values should match the `document_sub_type_value` table in macrodb. + * + * Wire, database, and `Display` spellings are all `snake_case` so a + * multi-word variant serializes identically in every system. */ -export type DocumentSubType = 'task' | 'snippet' | 'skill'; +export type DocumentSubType = 'task' | 'snippet' | 'skill' | 'initiative_description'; /** * The email filters used to filter down what emails you search over. diff --git a/packages/sdk/specs/notification.json b/packages/sdk/specs/notification.json index 62040128af2..f58d433a39e 100644 --- a/packages/sdk/specs/notification.json +++ b/packages/sdk/specs/notification.json @@ -2614,6 +2614,16 @@ "enum": ["skill"] } } + }, + { + "type": "object", + "required": ["type"], + "properties": { + "type": { + "type": "string", + "enum": ["initiative_description"] + } + } } ], "description": "The sub type of a document in a notification.\nSerializes as `{ \"type\": \"task\" }` matching the storage service pattern." diff --git a/packages/sdk/specs/search.json b/packages/sdk/specs/search.json index 75e451ca2dc..ba0b767231b 100644 --- a/packages/sdk/specs/search.json +++ b/packages/sdk/specs/search.json @@ -1759,8 +1759,8 @@ }, "DocumentSubType": { "type": "string", - "description": "The document sub type enum represents all values of document sub types.\nThese values should match the `document_sub_type_value` table in macrodb.", - "enum": ["task", "snippet", "skill"] + "description": "The document sub type enum represents all values of document sub types.\nThese values should match the `document_sub_type_value` table in macrodb.\n\nWire, database, and `Display` spellings are all `snake_case` so a\nmulti-word variant serializes identically in every system.", + "enum": ["task", "snippet", "skill", "initiative_description"] }, "EmailFilters": { "type": "object", From 390586cd8a3706f2fea5eadf1986d8cf9bbe02ba Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 18 Sep 2026 13:11:53 +0000 Subject: [PATCH 6/8] fix(initiative): keep description documents required and undeletable ON DELETE SET NULL plus a required id let a standalone document delete null the FK and break get, list, and update with no reattach path. Make the column NOT NULL with ON DELETE RESTRICT, reject InitiativeDescription soft-deletes, and drop the unused description column now that no initiative rows exist. Co-authored-by: Will Hutchinson --- ...6d70a9dd31a02d35fa44ef3a41e8d9872ca1d.json | 2 +- ...a09309a8dfd144886330fcef5e6fd87e2c6f8.json | 2 +- ...ce983b8ac666f70adefadb95f50cf366f727f.json | 2 +- ...a7767d298b720b29e137285c705e4f3ce9484.json | 2 +- ...71b038eda324b78b4c99c98ab0851b3599930.json | 2 +- crates/documents/src/domain/service.rs | 14 ++++- crates/documents/src/domain/service/tests.rs | 22 +++++++ crates/initiative/src/domain/ports.rs | 5 +- crates/initiative/src/domain/service.rs | 8 +-- crates/initiative/src/domain/service/test.rs | 16 +---- .../src/outbound/pg_initiative_repo.rs | 21 ++----- .../src/outbound/pg_initiative_repo/create.rs | 17 ++---- .../src/outbound/pg_initiative_repo/list.rs | 6 +- .../src/outbound/pg_initiative_repo/share.rs | 4 +- .../src/outbound/pg_initiative_repo/test.rs | 59 +++++++++++-------- ...d_initiative_description_document.down.sql | 13 +++- ...add_initiative_description_document.up.sql | 16 +++-- 17 files changed, 120 insertions(+), 91 deletions(-) diff --git a/.sqlx/query-186988a35770a5c4fb4b82ff4506d70a9dd31a02d35fa44ef3a41e8d9872ca1d.json b/.sqlx/query-186988a35770a5c4fb4b82ff4506d70a9dd31a02d35fa44ef3a41e8d9872ca1d.json index 8d9554b7ed7..5a8cebde520 100644 --- a/.sqlx/query-186988a35770a5c4fb4b82ff4506d70a9dd31a02d35fa44ef3a41e8d9872ca1d.json +++ b/.sqlx/query-186988a35770a5c4fb4b82ff4506d70a9dd31a02d35fa44ef3a41e8d9872ca1d.json @@ -101,7 +101,7 @@ "nullable": [ false, false, - true, + false, false, false, false, diff --git a/.sqlx/query-630a797b9b8fe15ef5a79f2368ca09309a8dfd144886330fcef5e6fd87e2c6f8.json b/.sqlx/query-630a797b9b8fe15ef5a79f2368ca09309a8dfd144886330fcef5e6fd87e2c6f8.json index 4688a98fe8e..4cc3885d90f 100644 --- a/.sqlx/query-630a797b9b8fe15ef5a79f2368ca09309a8dfd144886330fcef5e6fd87e2c6f8.json +++ b/.sqlx/query-630a797b9b8fe15ef5a79f2368ca09309a8dfd144886330fcef5e6fd87e2c6f8.json @@ -21,7 +21,7 @@ }, "nullable": [ false, - true + false ] }, "hash": "630a797b9b8fe15ef5a79f2368ca09309a8dfd144886330fcef5e6fd87e2c6f8" diff --git a/.sqlx/query-be20430fb02550589194607b27dce983b8ac666f70adefadb95f50cf366f727f.json b/.sqlx/query-be20430fb02550589194607b27dce983b8ac666f70adefadb95f50cf366f727f.json index 2cdbc8a0bf4..8b986f4a5e4 100644 --- a/.sqlx/query-be20430fb02550589194607b27dce983b8ac666f70adefadb95f50cf366f727f.json +++ b/.sqlx/query-be20430fb02550589194607b27dce983b8ac666f70adefadb95f50cf366f727f.json @@ -23,7 +23,7 @@ }, "nullable": [ false, - true + false ] }, "hash": "be20430fb02550589194607b27dce983b8ac666f70adefadb95f50cf366f727f" diff --git a/.sqlx/query-ca54e370d5330bf3655ff50e0dda7767d298b720b29e137285c705e4f3ce9484.json b/.sqlx/query-ca54e370d5330bf3655ff50e0dda7767d298b720b29e137285c705e4f3ce9484.json index d1b73fba058..842b9a572b7 100644 --- a/.sqlx/query-ca54e370d5330bf3655ff50e0dda7767d298b720b29e137285c705e4f3ce9484.json +++ b/.sqlx/query-ca54e370d5330bf3655ff50e0dda7767d298b720b29e137285c705e4f3ce9484.json @@ -15,7 +15,7 @@ ] }, "nullable": [ - true + false ] }, "hash": "ca54e370d5330bf3655ff50e0dda7767d298b720b29e137285c705e4f3ce9484" diff --git a/.sqlx/query-cc009247db4fe48002dfbbd29c071b038eda324b78b4c99c98ab0851b3599930.json b/.sqlx/query-cc009247db4fe48002dfbbd29c071b038eda324b78b4c99c98ab0851b3599930.json index fa4156bad7b..058dc8bd8b8 100644 --- a/.sqlx/query-cc009247db4fe48002dfbbd29c071b038eda324b78b4c99c98ab0851b3599930.json +++ b/.sqlx/query-cc009247db4fe48002dfbbd29c071b038eda324b78b4c99c98ab0851b3599930.json @@ -32,7 +32,7 @@ "nullable": [ false, false, - true, + false, false ] }, diff --git a/crates/documents/src/domain/service.rs b/crates/documents/src/domain/service.rs index 81198f37f2c..c250fabf59e 100644 --- a/crates/documents/src/domain/service.rs +++ b/crates/documents/src/domain/service.rs @@ -1103,8 +1103,20 @@ impl< entity_access_receipt: EntityAccessReceipt, project_id: Option, ) -> Result<(), DocumentError> { + let document_id = entity_access_receipt.entity().entity_id.clone(); + let metadata = self + .repo + .get_document_metadata(&document_id) + .await + .map_err(|e| DocumentError::Internal(e.into()))?; + if metadata.sub_type == Some(DocumentSubType::InitiativeDescription) { + return Err(DocumentError::BadRequest( + "initiative description documents cannot be deleted".to_string(), + )); + } + self.repo - .soft_delete_document(&entity_access_receipt.entity().entity_id.clone()) + .soft_delete_document(&document_id) .await .map_err(|e| DocumentError::Internal(e.into()))?; diff --git a/crates/documents/src/domain/service/tests.rs b/crates/documents/src/domain/service/tests.rs index 2abb357fa96..d76202078f7 100644 --- a/crates/documents/src/domain/service/tests.rs +++ b/crates/documents/src/domain/service/tests.rs @@ -844,6 +844,8 @@ async fn bot_document_has_no_saved_user_view_location() { #[tokio::test] async fn bot_lifecycle_event_has_no_actor_user_id() { let mut repo = make_mock_repo(); + repo.expect_get_document_metadata() + .returning(|_| Box::pin(std::future::ready(Ok(make_test_metadata())))); repo.expect_soft_delete_document() .withf(|id| id == "doc-1") .return_once(|_| Box::pin(std::future::ready(Ok(())))); @@ -1872,6 +1874,8 @@ async fn content_uploaded_maps_an_immediate_broker_failure_to_internal() { #[tokio::test] async fn test_delete_document_publishes_document_deleted_event() { let mut repo = make_mock_repo(); + repo.expect_get_document_metadata() + .returning(|_| Box::pin(std::future::ready(Ok(make_test_metadata())))); repo.expect_soft_delete_document() .withf(|id| id == "doc-1") .returning(|_| Box::pin(std::future::ready(Ok(())))); @@ -1910,6 +1914,8 @@ async fn test_delete_document_publishes_document_deleted_event() { #[tokio::test] async fn test_delete_document_publishes_no_event_when_repo_fails() { let mut repo = make_mock_repo(); + repo.expect_get_document_metadata() + .returning(|_| Box::pin(std::future::ready(Ok(make_test_metadata())))); repo.expect_soft_delete_document() .withf(|id| id == "doc-1") .returning(|_| Box::pin(std::future::ready(Err(anyhow!("db is down"))))); @@ -1922,6 +1928,22 @@ async fn test_delete_document_publishes_no_event_when_repo_fails() { assert!(event_broker.published().lock().unwrap().is_empty()); } +#[tokio::test] +async fn delete_document_rejects_initiative_description() { + let mut repo = make_mock_repo(); + let mut metadata = make_test_metadata(); + metadata.sub_type = Some(DocumentSubType::InitiativeDescription); + repo.expect_get_document_metadata() + .return_once(move |_| Box::pin(std::future::ready(Ok(metadata)))); + repo.expect_soft_delete_document().times(0); + + let (service, event_broker) = make_test_service_with_event_broker(repo); + let result = service.delete_document(owner_receipt("doc-1"), None).await; + + assert!(matches!(result, Err(DocumentError::BadRequest(_)))); + assert!(event_broker.published().lock().unwrap().is_empty()); +} + #[tokio::test] async fn edit_document_sets_revocation_intent_from_link_share_target() { for (link_share, expected_revocation) in [ diff --git a/crates/initiative/src/domain/ports.rs b/crates/initiative/src/domain/ports.rs index c02905e959f..d5a085f2357 100644 --- a/crates/initiative/src/domain/ports.rs +++ b/crates/initiative/src/domain/ports.rs @@ -101,12 +101,11 @@ pub trait InitiativeRepo: Send + Sync + 'static { ) -> impl Future> + Send; /// Delete the initiative and clean up its own rows in one transaction, returning the - /// description document id for the caller to purge afterwards. `None` only for a row - /// written before the column existed. + /// description document id for the caller to purge afterwards. fn delete( &self, id: InitiativeId, - ) -> impl Future, Self::Err>> + Send; + ) -> impl Future> + Send; } /// Inbound service port: the initiative API used by drivers (HTTP). diff --git a/crates/initiative/src/domain/service.rs b/crates/initiative/src/domain/service.rs index c69b77ace43..bede4866e52 100644 --- a/crates/initiative/src/domain/service.rs +++ b/crates/initiative/src/domain/service.rs @@ -306,17 +306,15 @@ where .map_err(Into::into) } - /// Initiative rows first, then the document. The FK's `ON DELETE SET NULL` means - /// the other order can leave an initiative with no description after a half-failure. + /// Initiative rows first, then the document. The FK's `ON DELETE RESTRICT` + /// would reject a document-first purge while the initiative still names it. #[tracing::instrument(err, skip_all)] async fn delete( &self, receipt: EntityAccessReceipt, ) -> Result<(), InitiativeError> { let id = initiative_id_from_receipt(&receipt)?; - let Some(description_document_id) = self.repo.delete(id).await.map_err(Into::into)? else { - return Ok(()); - }; + let description_document_id = self.repo.delete(id).await.map_err(Into::into)?; self.description_documents .purge(description_document_id) .await diff --git a/crates/initiative/src/domain/service/test.rs b/crates/initiative/src/domain/service/test.rs index 295604646d8..d1619236ec5 100644 --- a/crates/initiative/src/domain/service/test.rs +++ b/crates/initiative/src/domain/service/test.rs @@ -634,7 +634,7 @@ async fn delete_purges_the_document_after_the_initiative_is_gone() { .withf(|id| *id == initiative_id()) .times(1) .in_sequence(&mut sequence) - .return_once(|_| Box::pin(async { Ok(Some(description_document_id())) })); + .return_once(|_| Box::pin(async { Ok(description_document_id()) })); documents .expect_purge() .withf(|id| *id == description_document_id()) @@ -648,24 +648,12 @@ async fn delete_purges_the_document_after_the_initiative_is_gone() { .expect("deleted"); } -#[tokio::test] -async fn delete_skips_the_purge_for_a_row_without_a_document() { - let mut repo = MockInitiativeRepo::new(); - repo.expect_delete() - .return_once(|_| Box::pin(async { Ok(None) })); - - service(repo) - .delete(owner_receipt()) - .await - .expect("deleted"); -} - #[tokio::test] async fn delete_surfaces_a_failed_purge_after_the_initiative_is_gone() { let mut repo = MockInitiativeRepo::new(); let mut documents = MockInitiativeDescriptionDocuments::new(); repo.expect_delete() - .return_once(|_| Box::pin(async { Ok(Some(description_document_id())) })); + .return_once(|_| Box::pin(async { Ok(description_document_id()) })); documents.expect_purge().return_once(|_| { Box::pin(async { Err(InitiativeError::Internal(rootcause::report!("sync down"))) }) }); diff --git a/crates/initiative/src/outbound/pg_initiative_repo.rs b/crates/initiative/src/outbound/pg_initiative_repo.rs index 775b5c50c94..aede1ede29c 100644 --- a/crates/initiative/src/outbound/pg_initiative_repo.rs +++ b/crates/initiative/src/outbound/pg_initiative_repo.rs @@ -125,7 +125,7 @@ impl InitiativeRepo for PgInitiativeRepo { } #[tracing::instrument(err, skip(self))] - async fn delete(&self, id: InitiativeId) -> Result, Self::Err> { + async fn delete(&self, id: InitiativeId) -> Result { create::delete(&self.pool, id).await } } @@ -173,7 +173,7 @@ impl GrantTargets { struct InitiativeRecord { id: uuid::Uuid, name: String, - description_document_id: Option, + description_document_id: String, owner_user_id: String, created_at: chrono::DateTime, updated_at: chrono::DateTime, @@ -201,9 +201,9 @@ impl InitiativeRecord { Ok(InitiativeDetail { id: InitiativeId::from_uuid(self.id), name: self.name, - description_document_id: require_description_document_id( + description_document_id: parse_description_document_id( self.id, - self.description_document_id, + &self.description_document_id, )?, owner_id, member_ids: parse_members(self.member_ids)?, @@ -216,19 +216,6 @@ impl InitiativeRecord { } } -/// A NULL is a deploy-window artifact. Fail here so callers never treat it as optional. -fn require_description_document_id( - initiative: uuid::Uuid, - raw: Option, -) -> Result { - let raw = raw.ok_or_else(|| { - InitiativeError::Internal(report!( - "initiative {initiative} has no description document; backfill required" - )) - })?; - parse_description_document_id(initiative, &raw) -} - fn parse_description_document_id( initiative: uuid::Uuid, raw: &str, diff --git a/crates/initiative/src/outbound/pg_initiative_repo/create.rs b/crates/initiative/src/outbound/pg_initiative_repo/create.rs index bc649784d60..fb11168fa9f 100644 --- a/crates/initiative/src/outbound/pg_initiative_repo/create.rs +++ b/crates/initiative/src/outbound/pg_initiative_repo/create.rs @@ -10,10 +10,7 @@ use models_permissions::share_permission::team_share::{ use share_permission_db_utils::team_share; use sqlx::{PgPool, Postgres, Transaction}; -use super::{ - AdapterError, GrantTargets, map_sqlx, parse_description_document_id, - require_description_document_id, require_detail, -}; +use super::{AdapterError, GrantTargets, map_sqlx, parse_description_document_id, require_detail}; use crate::domain::models::{ CreateInitiativeRepoArgs, DescriptionDocumentId, InitiativeDetail, InitiativeError, InitiativeId, UpdateInitiativeRepoArgs, @@ -193,7 +190,7 @@ pub(super) async fn update( pub(super) async fn delete( pool: &PgPool, id: InitiativeId, -) -> Result, InitiativeError> { +) -> Result { let mut tx = pool .begin() .await @@ -237,11 +234,7 @@ pub(super) async fn delete( .map_err(AdapterError::Sqlx) .map_err(map_sqlx)?; - deleted - .description_document_id - .as_deref() - .map(|raw| parse_description_document_id(uuid, raw)) - .transpose() + parse_description_document_id(uuid, &deleted.description_document_id) } struct PatchedRow { @@ -273,9 +266,9 @@ async fn patch_initiative_row( .ok_or(InitiativeError::NotFound)?; Ok(PatchedRow { share_permission_id: row.share_permission_id, - description_document_id: require_description_document_id( + description_document_id: parse_description_document_id( args.id.as_uuid(), - row.description_document_id, + &row.description_document_id, )?, }) } diff --git a/crates/initiative/src/outbound/pg_initiative_repo/list.rs b/crates/initiative/src/outbound/pg_initiative_repo/list.rs index fa9d2422b7d..3b5e7d58283 100644 --- a/crates/initiative/src/outbound/pg_initiative_repo/list.rs +++ b/crates/initiative/src/outbound/pg_initiative_repo/list.rs @@ -1,7 +1,7 @@ use macro_user_id::user_id::MacroUserIdStr; use sqlx::PgPool; -use super::{AdapterError, map_sqlx, require_description_document_id}; +use super::{AdapterError, map_sqlx, parse_description_document_id}; use crate::domain::models::{InitiativeError, InitiativeId, InitiativeList, InitiativeSummary}; pub(super) async fn list_accessible( @@ -59,9 +59,9 @@ pub(super) async fn list_accessible( Ok(InitiativeSummary { id: InitiativeId::from_uuid(row.id), name: row.name, - description_document_id: require_description_document_id( + description_document_id: parse_description_document_id( row.id, - row.description_document_id, + &row.description_document_id, )?, updated_at: row.updated_at, }) diff --git a/crates/initiative/src/outbound/pg_initiative_repo/share.rs b/crates/initiative/src/outbound/pg_initiative_repo/share.rs index a4fcda6cb88..26024e9e98e 100644 --- a/crates/initiative/src/outbound/pg_initiative_repo/share.rs +++ b/crates/initiative/src/outbound/pg_initiative_repo/share.rs @@ -9,7 +9,7 @@ use share_permission_db_utils::{InsertChannelSharePermissionResult, team_share}; use sqlx::{PgPool, Postgres, Transaction}; use uuid::Uuid; -use super::{AdapterError, GrantTargets, map_sqlx, require_description_document_id}; +use super::{AdapterError, GrantTargets, map_sqlx, parse_description_document_id}; use crate::domain::models::{ DescriptionDocumentId, InitiativeError, InitiativeId, LockstepTeamShareFacts, }; @@ -272,7 +272,7 @@ pub(super) async fn get_lockstep_team_share_facts( .ok_or(InitiativeError::NotFound)?; let targets = GrantTargets::new( id, - require_description_document_id(id.as_uuid(), description_document_id)?, + parse_description_document_id(id.as_uuid(), &description_document_id)?, ); let initiative = team_share::load_facts(&mut tx, &targets.initiative_entity()) .await diff --git a/crates/initiative/src/outbound/pg_initiative_repo/test.rs b/crates/initiative/src/outbound/pg_initiative_repo/test.rs index e1fb95bce51..3a9cf7029d9 100644 --- a/crates/initiative/src/outbound/pg_initiative_repo/test.rs +++ b/crates/initiative/src/outbound/pg_initiative_repo/test.rs @@ -399,14 +399,6 @@ async fn create_links_description_document_and_mirrors_member_and_team_grants_as ); assert_eq!(detail.user_access_level, AccessLevel::View); - let still_empty = sqlx::query_scalar!( - r#"SELECT description FROM initiative WHERE id = $1"#, - id.as_uuid(), - ) - .fetch_one(&pool) - .await?; - assert_eq!(still_empty, ""); - let owner = Some("owner".to_string()); let edit = Some("edit".to_string()); assert_eq!( @@ -864,35 +856,56 @@ async fn update_rejects_lockstep_commands_naming_another_initiative( } #[sqlx::test(migrator = "MACRO_DB_MIGRATIONS")] -async fn reads_fail_loudly_when_description_document_id_is_null( +async fn description_document_cannot_be_nulled_or_deleted_while_the_initiative_exists( pool: PgPool, ) -> anyhow::Result<()> { insert_user(&pool, OWNER).await?; let repo = repo(pool.clone()); let created = repo .create( - create_args(&pool, OWNER, "Pre-expand", &[]).await?, + create_args(&pool, OWNER, "Launch", &[]).await?, share_off(), TeamShareCreation::Unshared, ) .await?; - sqlx::query!( + let document_id = created.description_document_id.to_string(); + + let null_error = sqlx::query!( r#"UPDATE initiative SET description_document_id = NULL WHERE id = $1"#, created.id.as_uuid(), ) .execute(&pool) - .await?; + .await + .expect_err("column is NOT NULL"); + assert_eq!( + null_error.as_database_error().unwrap().code().as_deref(), + Some("23502") + ); - assert!(matches!( - repo.get_detail(created.id).await, - Err(InitiativeError::Internal(_)) - )); - assert!(matches!( - repo.list_accessible(&user(OWNER)).await, - Err(InitiativeError::Internal(_)) - )); - assert!(repo.get_basic(created.id).await?.is_some()); - assert_eq!(repo.delete(created.id).await?, None); + let delete_error = sqlx::query!(r#"DELETE FROM "Document" WHERE id = $1"#, document_id,) + .execute(&pool) + .await + .expect_err("FK is ON DELETE RESTRICT"); + assert!( + delete_error + .as_database_error() + .unwrap() + .is_foreign_key_violation() + ); + + let detail = repo.get_detail(created.id).await?.expect("still readable"); + assert_eq!(detail.description_document_id.to_string(), document_id); + let listed = repo.list_accessible(&user(OWNER)).await?; + assert_eq!( + listed + .initiatives + .iter() + .map(|summary| summary.description_document_id.to_string()) + .collect::>(), + vec![document_id.clone()] + ); + let updated = repo.update(update_args(created.id)).await?; + assert_eq!(updated.description_document_id.to_string(), document_id); Ok(()) } @@ -1024,7 +1037,7 @@ async fn delete_returns_the_document_id_and_leaves_no_initiative_rows( let initiative_id = created.id.as_uuid(); let document_id = created.description_document_id; - assert_eq!(repo.delete(created.id).await?, Some(document_id)); + assert_eq!(repo.delete(created.id).await?, document_id); let leftover_share = sqlx::query_scalar!( r#"SELECT EXISTS(SELECT 1 FROM "SharePermission" WHERE id = $1) AS "exists!""#, diff --git a/crates/macro_db_client/migrations/20260917140621_add_initiative_description_document.down.sql b/crates/macro_db_client/migrations/20260917140621_add_initiative_description_document.down.sql index 0784bb1f653..e83c682346f 100644 --- a/crates/macro_db_client/migrations/20260917140621_add_initiative_description_document.down.sql +++ b/crates/macro_db_client/migrations/20260917140621_add_initiative_description_document.down.sql @@ -1,3 +1,14 @@ --- Postgres cannot drop an enum value. Roll back the column only. +-- Postgres cannot drop an enum value. Restore the legacy description column +-- and remove the document link. ALTER TABLE initiative DROP COLUMN IF EXISTS description_document_id; + +ALTER TABLE initiative + ADD COLUMN IF NOT EXISTS description TEXT NOT NULL DEFAULT ''; + +ALTER TABLE initiative + DROP CONSTRAINT IF EXISTS initiative_description_max_length; + +ALTER TABLE initiative + ADD CONSTRAINT initiative_description_max_length + CHECK (length(description) <= 10000); diff --git a/crates/macro_db_client/migrations/20260917140621_add_initiative_description_document.up.sql b/crates/macro_db_client/migrations/20260917140621_add_initiative_description_document.up.sql index 5dc79a4408d..6fe62138dd6 100644 --- a/crates/macro_db_client/migrations/20260917140621_add_initiative_description_document.up.sql +++ b/crates/macro_db_client/migrations/20260917140621_add_initiative_description_document.up.sql @@ -1,7 +1,6 @@ --- Expand step only. Currently deployed initiative writers still INSERT --- `description` and omit this column, so `description_document_id` is nullable --- and `description` stays. A later PR after deploy drops `description` and --- may SET NOT NULL on the document id. +-- No production initiative rows exist yet, so this migration can install the +-- final shape: every initiative has a description document, and that document +-- cannot disappear while the initiative row still names it. -- -- ADD VALUE is safe in the same transaction as the column add because this -- migration never uses the new enum value. @@ -9,5 +8,12 @@ ALTER TYPE document_sub_type_value ADD VALUE IF NOT EXISTS 'initiative_descripti ALTER TABLE initiative ADD COLUMN IF NOT EXISTS description_document_id TEXT + NOT NULL UNIQUE - REFERENCES "Document" (id) ON DELETE SET NULL; + REFERENCES "Document" (id) ON DELETE RESTRICT; + +ALTER TABLE initiative + DROP CONSTRAINT IF EXISTS initiative_description_max_length; + +ALTER TABLE initiative + DROP COLUMN IF EXISTS description; From ea44d1e4a6f80d97138663cbd110146ff5bb9024 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 18 Sep 2026 13:15:41 +0000 Subject: [PATCH 7/8] test(initiative): assert RESTRICT as Postgres restrict_violation ON DELETE RESTRICT raises SQLSTATE 23001, not the 23503 that sqlx classifies as a foreign-key violation. Co-authored-by: Will Hutchinson --- .../src/outbound/pg_initiative_repo/test.rs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/crates/initiative/src/outbound/pg_initiative_repo/test.rs b/crates/initiative/src/outbound/pg_initiative_repo/test.rs index 3a9cf7029d9..2fdcce5e408 100644 --- a/crates/initiative/src/outbound/pg_initiative_repo/test.rs +++ b/crates/initiative/src/outbound/pg_initiative_repo/test.rs @@ -886,11 +886,12 @@ async fn description_document_cannot_be_nulled_or_deleted_while_the_initiative_e .execute(&pool) .await .expect_err("FK is ON DELETE RESTRICT"); - assert!( - delete_error - .as_database_error() - .unwrap() - .is_foreign_key_violation() + let delete_db = delete_error.as_database_error().expect("database error"); + // Postgres uses 23001 (restrict_violation) for ON DELETE RESTRICT, not 23503. + assert_eq!(delete_db.code().as_deref(), Some("23001")); + assert_eq!( + delete_db.constraint(), + Some("initiative_description_document_id_fkey") ); let detail = repo.get_detail(created.id).await?.expect("still readable"); From 38321561459f7f40f5f3033bd576ebee0e4beca7 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 18 Sep 2026 13:21:25 +0000 Subject: [PATCH 8/8] test: supply description documents in initiative fixtures description_document_id is NOT NULL with a Document FK. The entity-access helper and team-share fixture still inserted initiative rows without that id, so those tests fail the constraint. Co-authored-by: Will Hutchinson --- .../queries/initiative_access/test.rs | 22 +++++++++++++++++-- .../fixtures/team_share.sql | 7 +++--- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/crates/entity_access/src/outbound/pg_access_repo/queries/initiative_access/test.rs b/crates/entity_access/src/outbound/pg_access_repo/queries/initiative_access/test.rs index 642b3ff0d6a..2d08d881012 100644 --- a/crates/entity_access/src/outbound/pg_access_repo/queries/initiative_access/test.rs +++ b/crates/entity_access/src/outbound/pg_access_repo/queries/initiative_access/test.rs @@ -78,14 +78,32 @@ async fn insert_initiative(pool: &PgPool, owner: &str, link: LinkShare) -> anyho .await?; let initiative_id = Uuid::now_v7(); + let description_document_id = Uuid::now_v7().to_string(); sqlx::query!( r#" - INSERT INTO initiative (id, name, owner_user_id, share_permission_id) - VALUES ($1, 'Test initiative', $2, $3) + INSERT INTO "Document" (id, name, owner) + VALUES ($1, 'Test initiative description', $2) + "#, + description_document_id, + owner, + ) + .execute(pool) + .await?; + sqlx::query!( + r#" + INSERT INTO initiative ( + id, + name, + owner_user_id, + share_permission_id, + description_document_id + ) + VALUES ($1, 'Test initiative', $2, $3, $4) "#, initiative_id, owner, share_permission_id, + description_document_id, ) .execute(pool) .await?; diff --git a/crates/share_permission_db_utils/fixtures/team_share.sql b/crates/share_permission_db_utils/fixtures/team_share.sql index dec4eb349e1..f5110cedb21 100644 --- a/crates/share_permission_db_utils/fixtures/team_share.sql +++ b/crates/share_permission_db_utils/fixtures/team_share.sql @@ -11,12 +11,13 @@ INSERT INTO team_user (team_id, user_id, team_role) VALUES INSERT INTO "Project" (id, name, "userId") VALUES ('20000000-0000-0000-0000-000000000001', 'Root', 'macro|owner@example.com'); INSERT INTO "Document" (id, name, owner) VALUES - ('20000000-0000-0000-0000-000000000002', 'Document', 'macro|owner@example.com'); + ('20000000-0000-0000-0000-000000000002', 'Document', 'macro|owner@example.com'), + ('20000000-0000-0000-0000-000000000008', 'Initiative description', 'macro|owner@example.com'); INSERT INTO "Chat" (id, name, "userId") VALUES ('20000000-0000-0000-0000-000000000003', 'Chat', 'macro|owner@example.com'); INSERT INTO "SharePermission" (id) VALUES ('project'), ('document'), ('chat'), ('active-call'), ('archived-call'), ('initiative'); -INSERT INTO initiative (id, name, owner_user_id, share_permission_id) VALUES - ('20000000-0000-0000-0000-000000000007', 'Initiative', 'macro|owner@example.com', 'initiative'); +INSERT INTO initiative (id, name, owner_user_id, share_permission_id, description_document_id) VALUES + ('20000000-0000-0000-0000-000000000007', 'Initiative', 'macro|owner@example.com', 'initiative', '20000000-0000-0000-0000-000000000008'); INSERT INTO "ProjectPermission" ("projectId", "sharePermissionId") VALUES ('20000000-0000-0000-0000-000000000001', 'project'); INSERT INTO "DocumentPermission" ("documentId", "sharePermissionId") VALUES ('20000000-0000-0000-0000-000000000002', 'document'); INSERT INTO "ChatPermission" ("chatId", "sharePermissionId") VALUES ('20000000-0000-0000-0000-000000000003', 'chat');