@opengeni/db 1.0.1 → 3.2.0-canary.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (271) hide show
  1. package/dist/attached-browser-devices.d.ts +7 -0
  2. package/dist/automations.d.ts +154 -0
  3. package/dist/canonical-human-identities.js +3 -3
  4. package/dist/capability-integrations.d.ts +13 -0
  5. package/dist/child-lifecycle-notices.d.ts +68 -0
  6. package/dist/{chunk-4SR4HKKA.js → chunk-2EH4WRHL.js} +19 -11
  7. package/dist/chunk-2EH4WRHL.js.map +1 -0
  8. package/dist/{chunk-CUPM3FGB.js → chunk-2PRS4J5Q.js} +3 -3
  9. package/dist/chunk-6HMM2R5T.js +390 -0
  10. package/dist/chunk-6HMM2R5T.js.map +1 -0
  11. package/dist/{chunk-ZWREYEWA.js → chunk-AZHW7R5V.js} +56 -11
  12. package/dist/chunk-AZHW7R5V.js.map +1 -0
  13. package/dist/{chunk-LTG3S6ZY.js → chunk-E6CLMKEE.js} +2 -2
  14. package/dist/{chunk-6ZYKCRFO.js → chunk-KRLPEMLH.js} +7591 -4417
  15. package/dist/chunk-KRLPEMLH.js.map +1 -0
  16. package/dist/chunk-NAB6FKFN.js +516 -0
  17. package/dist/chunk-NAB6FKFN.js.map +1 -0
  18. package/dist/{chunk-SK7YYHBI.js → chunk-SIBYBA2D.js} +46 -1
  19. package/dist/chunk-SIBYBA2D.js.map +1 -0
  20. package/dist/chunk-UZNXG44K.js +4508 -0
  21. package/dist/chunk-UZNXG44K.js.map +1 -0
  22. package/dist/codemode-operations.d.ts +1 -6
  23. package/dist/company-brain-context-selection-schema.d.ts +705 -0
  24. package/dist/company-brain-context-selection.d.ts +94 -0
  25. package/dist/company-brain-governed-writes.d.ts +24 -0
  26. package/dist/company-brain.d.ts +55 -0
  27. package/dist/company-profile-agent-admin.d.ts +15 -0
  28. package/dist/company-profile-schema.d.ts +619 -2
  29. package/dist/company-profile.d.ts +9 -4
  30. package/dist/connection-authority.d.ts +95 -0
  31. package/dist/connection-scopes.d.ts +8 -0
  32. package/dist/connection-tenancy-backfill.d.ts +85 -0
  33. package/dist/connection-token-resolver.d.ts +30 -1
  34. package/dist/database.d.ts +23 -4
  35. package/dist/editable-artifact-durable-export.js +2 -2
  36. package/dist/editable-artifacts.d.ts +2 -2
  37. package/dist/editable-artifacts.js +3 -3
  38. package/dist/governed-learning-activation-schema.d.ts +984 -0
  39. package/dist/governed-learning-activation.d.ts +98 -0
  40. package/dist/governed-learning-evaluator-schema.d.ts +633 -0
  41. package/dist/governed-learning-evaluator.d.ts +28 -0
  42. package/dist/index.d.ts +1606 -81
  43. package/dist/index.js +21898 -8147
  44. package/dist/index.js.map +1 -1
  45. package/dist/insights.d.ts +18 -0
  46. package/dist/integration-bindings.d.ts +1 -0
  47. package/dist/integration-facets.d.ts +30 -0
  48. package/dist/interaction-placement-loss.d.ts +22 -0
  49. package/dist/managed-human-provisioning.d.ts +25 -0
  50. package/dist/memory-domain.d.ts +3 -3
  51. package/dist/migrate.d.ts +11 -1
  52. package/dist/migrate.js +1 -1
  53. package/dist/new-session-drafts.d.ts +43 -5
  54. package/dist/organization-membership-backfill.d.ts +174 -0
  55. package/dist/organization-membership-lifecycle.d.ts +162 -0
  56. package/dist/pr-review.d.ts +107 -0
  57. package/dist/preference-registry-schema.d.ts +2 -0
  58. package/dist/preference-registry.d.ts +2 -0
  59. package/dist/provision-roles.js +1 -1
  60. package/dist/runtime-posture.d.ts +29 -8
  61. package/dist/schema.d.ts +24914 -13703
  62. package/dist/schema.js +99 -1
  63. package/dist/scoped-knowledge-schema.d.ts +52 -1
  64. package/dist/session-background-commands.d.ts +125 -0
  65. package/dist/session-background-commands.js +34 -0
  66. package/dist/session-background-commands.js.map +1 -0
  67. package/dist/session-control.d.ts +156 -3
  68. package/dist/session-goal-pacing.d.ts +68 -0
  69. package/dist/session-queue-commands.d.ts +43 -4
  70. package/dist/session-realtime-ledger.d.ts +2 -0
  71. package/dist/session-tenancy.d.ts +71 -1
  72. package/dist/session-tenancy.js +25 -3
  73. package/dist/session-tool-call-settlement.d.ts +9 -4
  74. package/dist/slack-routing-personal-workspace.d.ts +23 -0
  75. package/dist/slack-routing.d.ts +268 -0
  76. package/dist/slack-task-policy-schema.d.ts +34 -0
  77. package/dist/slack-task-policy.d.ts +8 -0
  78. package/dist/slack-user-link-access.d.ts +28 -0
  79. package/dist/task-notes-schema.d.ts +594 -0
  80. package/dist/task-notes.d.ts +39 -0
  81. package/dist/turn-initiator.d.ts +2 -0
  82. package/dist/user-resource-authority.d.ts +109 -0
  83. package/dist/video-generation.js +3 -3
  84. package/dist/workspace-authority.d.ts +81 -0
  85. package/dist/workspace-instruction-policies-schema.d.ts +279 -0
  86. package/dist/workspace-instruction-policies.d.ts +62 -0
  87. package/dist/workspace-learning-policy.d.ts +1 -0
  88. package/dist/workspace-membership-permissions.d.ts +2 -0
  89. package/drizzle/0240_enrollment_connection_authority.sql +39 -0
  90. package/drizzle/0241_atomic_personal_resource_delegation.sql +1000 -0
  91. package/drizzle/0241_enrollment_agent_runtime.sql +70 -0
  92. package/drizzle/0244_slack_app_home_refresh_queue.sql +120 -0
  93. package/drizzle/0245_model_context_contribution_facts.sql +88 -0
  94. package/drizzle/0246_integration_personal_instance_authority.sql +70 -0
  95. package/drizzle/0247_terraform_stacks_provenance_repair.sql +428 -0
  96. package/drizzle/0248_terraform_stacks_component_resolution_fence.sql +83 -0
  97. package/drizzle/0249_personal_resource_delegation_authority_correction.sql +662 -0
  98. package/drizzle/0250_direct_retained_process_owner_liveness.sql +199 -0
  99. package/drizzle/0251_connected_machine_operation_policy.sql +45 -0
  100. package/drizzle/0252_scheduled_personal_resource_delegation.sql +1867 -0
  101. package/drizzle/0253_common_user_resource_authority_lifecycle.sql +1555 -0
  102. package/drizzle/0254_scoped_variable_set_authority.sql +1255 -0
  103. package/drizzle/0255_company_brain_governed_write_proposals.sql +524 -0
  104. package/drizzle/0256_connection_authority_delegation.sql +703 -0
  105. package/drizzle/0257_goal_revision_decisions_and_root_constraints.sql +436 -0
  106. package/drizzle/0258_three_scope_document_knowledge_authority.sql +1273 -0
  107. package/drizzle/0259_company_brain_context_selection_receipts.sql +869 -0
  108. package/drizzle/0260_task_note_knowledge_promotion.sql +820 -0
  109. package/drizzle/0261_preference_knowledge_proposal_actor_binding.sql +471 -0
  110. package/drizzle/0262_scoped_connected_machines_and_rigs.sql +1700 -0
  111. package/drizzle/0263_organization_membership_lifecycle.sql +3284 -0
  112. package/drizzle/0264_connection_authority_runtime_activation.sql +1084 -0
  113. package/drizzle/0266_company_brain_context_receipt_inspection.sql +156 -0
  114. package/drizzle/0268_governed_learning_decision_receipts.sql +825 -0
  115. package/drizzle/0269_governed_learning_activation_controller.sql +1874 -0
  116. package/drizzle/0270_governed_learning_history_inspection.sql +170 -0
  117. package/drizzle/0271_company_brain_retrieval_only_default.sql +524 -0
  118. package/drizzle/0272_human_confirmed_learning_activation.sql +591 -0
  119. package/drizzle/0273_scheduled_variable_set_materialization.sql +268 -0
  120. package/drizzle/0274_human_confirmed_knowledge_review.sql +310 -0
  121. package/drizzle/0275_scheduled_connection_authority.sql +5527 -0
  122. package/drizzle/0276_onboarding_proposal_initiating_human_guc.sql +198 -0
  123. package/drizzle/0277_workspace_writer_authority_attribution.sql +393 -0
  124. package/drizzle/0278_workspace_membership_removal_fencing.sql +1174 -0
  125. package/drizzle/0279_workspace_connection_use_lane.sql +343 -0
  126. package/drizzle/0280_connection_and_variable_set_audit_attribution.sql +773 -0
  127. package/drizzle/0281_viewer_holder_authority_claims.sql +52 -0
  128. package/drizzle/0282_variable_set_session_attach_attribution.sql +178 -0
  129. package/drizzle/0283_editable_spreadsheet_authored_state.sql +82 -0
  130. package/drizzle/0284_truthful_human_confirmed_review_reason.sql +930 -0
  131. package/drizzle/0285_organization_tenancy_inventory.sql +329 -0
  132. package/drizzle/0286_widen_task_note_expiry_ceiling.sql +391 -0
  133. package/drizzle/0287_open_suffix_pending_tool_calls.sql +18 -0
  134. package/drizzle/0288_attached_browser_reenrollment.sql +64 -0
  135. package/drizzle/0289_session_composer_policy_authority.sql +597 -0
  136. package/drizzle/0290_organization_membership_backfill.sql +268 -0
  137. package/drizzle/0291_resource_authority_classification_assertion.sql +371 -0
  138. package/drizzle/0292_truthful_tenancy_inventory_counters.sql +291 -0
  139. package/drizzle/0293_confirm_time_rule_rebaseline.sql +615 -0
  140. package/drizzle/0294_preference_activation_authority.sql +237 -0
  141. package/drizzle/0295_retire_legacy_standing_memory_mode.sql +476 -0
  142. package/drizzle/0296_force_rls_backfill_noop_repair.sql +215 -0
  143. package/drizzle/0297_session_ownership_classification_and_backfill.sql +752 -0
  144. package/drizzle/0298_organization_tenancy_parity.sql +609 -0
  145. package/drizzle/0299_organization_membership_lock_order.sql +291 -0
  146. package/drizzle/0300_tenancy_backfill_ledger.sql +459 -0
  147. package/drizzle/0301_session_snapshot_and_pin_visibility.sql +181 -0
  148. package/drizzle/0302_personal_workspace_session_ownership.sql +303 -0
  149. package/drizzle/0303_session_tenancy_product_activation.sql +919 -0
  150. package/drizzle/0304_personal_workspace_private_session_reads.sql +97 -0
  151. package/drizzle/0305_personal_resource_grant_management.sql +584 -0
  152. package/drizzle/0306_atomic_personal_resource_attachments.sql +1374 -0
  153. package/drizzle/0307_session_attention_state.sql +51 -0
  154. package/drizzle/0308_session_archives.sql +35 -0
  155. package/drizzle/0309_channel_project_pins.sql +6 -0
  156. package/drizzle/0310_channel_project_order.sql +27 -0
  157. package/drizzle/0311_company_scope_and_private_session_create.sql +656 -0
  158. package/drizzle/0312_quiescent_session_tree_deletion.sql +39 -0
  159. package/drizzle/0313_private_child_session_authority.sql +365 -0
  160. package/drizzle/0314_unregistered_organization_invitations.sql +606 -0
  161. package/drizzle/0315_personal_github_repository_selection.sql +624 -0
  162. package/drizzle/0316_human_confirmed_activation_resumed_generation.sql +869 -0
  163. package/drizzle/0317_session_goal_continuation_hold.sql +64 -0
  164. package/drizzle/0318_session_turn_startup_milestones.sql +115 -0
  165. package/drizzle/0319_event_triggered_automations.sql +254 -0
  166. package/drizzle/0320_pr_review_pack.sql +113 -0
  167. package/drizzle/0321_slack_bot_environment_display_name.sql +15 -0
  168. package/drizzle/0322_session_turns_unclaimed_prompt_trigger_index.sql +5 -0
  169. package/drizzle/0323_organization_private_session_enablement.sql +489 -0
  170. package/drizzle/0324_human_confirmed_company_profile_agent_admin.sql +856 -0
  171. package/drizzle/0325_child_lifecycle_notices.sql +44 -0
  172. package/drizzle/0326_interaction_operation_error_codes.sql +62 -0
  173. package/drizzle/0327_slack_first_task_hint.sql +29 -0
  174. package/drizzle/0328_session_background_commands.sql +590 -0
  175. package/drizzle/0329_slack_orchestration_delivery_events.sql +91 -0
  176. package/drizzle/0330_api_key_descriptions.sql +20 -0
  177. package/drizzle/0331_managed_organization_creation.sql +163 -0
  178. package/drizzle/0332_organization_shared_workspace_control_plane.sql +539 -0
  179. package/drizzle/0333_session_turn_prompt_routing.sql +45 -0
  180. package/drizzle/0334_connected_machine_workspace_root.sql +99 -0
  181. package/drizzle/0335_slack_workspace_routing.sql +460 -0
  182. package/drizzle/0336_atomic_session_fork_visibility.sql +667 -0
  183. package/drizzle/0337_slack_routed_action_handles.sql +189 -0
  184. package/drizzle/0338_atomic_connected_machine_attachments.sql +410 -0
  185. package/drizzle/0339_document_authority_reclassification.sql +1153 -0
  186. package/drizzle/0340_tenancy_backfill_activation_evidence.sql +1347 -0
  187. package/drizzle/0341_slack_routing_probe_organization_fence.sql +109 -0
  188. package/drizzle/0342_slack_route_prompt_single_pending.sql +32 -0
  189. package/drizzle/0343_personal_document_force_rls_lock_repair.sql +146 -0
  190. package/drizzle/0344_private_session_visibility_transition_gate.sql +85 -0
  191. package/drizzle/0345_tenant_scoped_session_tenancy_fence.sql +2839 -0
  192. package/drizzle/0346_document_migration_audit_surface.sql +454 -0
  193. package/drizzle/0347_connection_authority_convergence_evidence.sql +396 -0
  194. package/package.json +10 -6
  195. package/src/attached-browser-devices.ts +233 -5
  196. package/src/automations.ts +911 -0
  197. package/src/browser-sessions.ts +10 -0
  198. package/src/capability-integrations.ts +175 -1
  199. package/src/child-lifecycle-notices.ts +241 -0
  200. package/src/codemode-operations.ts +1 -35
  201. package/src/company-brain-context-selection-schema.ts +100 -0
  202. package/src/company-brain-context-selection.ts +359 -0
  203. package/src/company-brain-governed-writes.ts +1170 -0
  204. package/src/company-brain.ts +309 -0
  205. package/src/company-profile-agent-admin.ts +225 -0
  206. package/src/company-profile-schema.ts +121 -1
  207. package/src/company-profile.ts +21 -0
  208. package/src/computer-sessions.ts +10 -1
  209. package/src/connection-authority.ts +290 -0
  210. package/src/connection-scopes.ts +20 -0
  211. package/src/connection-tenancy-backfill.ts +167 -0
  212. package/src/connection-token-resolver.ts +263 -31
  213. package/src/database.ts +87 -2
  214. package/src/editable-artifacts-schema.ts +10 -5
  215. package/src/editable-artifacts.ts +21 -11
  216. package/src/governed-learning-activation-schema.ts +121 -0
  217. package/src/governed-learning-activation.ts +588 -0
  218. package/src/governed-learning-evaluator-schema.ts +72 -0
  219. package/src/governed-learning-evaluator.ts +217 -0
  220. package/src/index.ts +11676 -1550
  221. package/src/insights.ts +107 -0
  222. package/src/integration-bindings.ts +88 -6
  223. package/src/integration-facets.ts +287 -84
  224. package/src/interaction-placement-loss.ts +234 -0
  225. package/src/managed-human-provisioning.ts +116 -0
  226. package/src/memory-domain.ts +27 -32
  227. package/src/migrate.ts +77 -1
  228. package/src/new-session-drafts.ts +229 -35
  229. package/src/organization-membership-backfill.ts +735 -0
  230. package/src/organization-membership-lifecycle.ts +995 -0
  231. package/src/pr-review.ts +890 -0
  232. package/src/preference-registry.ts +31 -1
  233. package/src/provision-roles.ts +905 -6
  234. package/src/runtime-posture-cli.ts +2 -0
  235. package/src/runtime-posture.ts +1317 -72
  236. package/src/schema.ts +3316 -428
  237. package/src/scoped-knowledge-schema.ts +16 -8
  238. package/src/scoped-knowledge.ts +1 -1
  239. package/src/session-background-commands.ts +759 -0
  240. package/src/session-control.ts +640 -74
  241. package/src/session-goal-pacing.ts +126 -0
  242. package/src/session-queue-commands.ts +441 -85
  243. package/src/session-realtime-context.ts +4 -6
  244. package/src/session-realtime-ledger.ts +28 -14
  245. package/src/session-realtime-mirror.ts +1 -1
  246. package/src/session-tenancy.ts +410 -49
  247. package/src/session-tool-call-settlement.ts +38 -29
  248. package/src/slack-routing-personal-workspace.ts +67 -0
  249. package/src/slack-routing.ts +867 -0
  250. package/src/slack-task-policy-schema.ts +5 -0
  251. package/src/slack-task-policy.ts +18 -0
  252. package/src/slack-user-link-access.ts +138 -11
  253. package/src/task-notes-schema.ts +74 -0
  254. package/src/task-notes.ts +124 -2
  255. package/src/turn-initiator.ts +8 -0
  256. package/src/user-resource-authority.ts +377 -0
  257. package/src/workspace-authority.ts +160 -0
  258. package/src/workspace-instruction-policies-schema.ts +53 -1
  259. package/src/workspace-instruction-policies.ts +528 -77
  260. package/src/workspace-learning-policy.ts +21 -5
  261. package/src/workspace-membership-permissions.ts +9 -0
  262. package/dist/chunk-4SR4HKKA.js.map +0 -1
  263. package/dist/chunk-6ZYKCRFO.js.map +0 -1
  264. package/dist/chunk-NXAFLCQX.js +0 -2468
  265. package/dist/chunk-NXAFLCQX.js.map +0 -1
  266. package/dist/chunk-SK7YYHBI.js.map +0 -1
  267. package/dist/chunk-WD5ROSLL.js +0 -117
  268. package/dist/chunk-WD5ROSLL.js.map +0 -1
  269. package/dist/chunk-ZWREYEWA.js.map +0 -1
  270. /package/dist/{chunk-CUPM3FGB.js.map → chunk-2PRS4J5Q.js.map} +0 -0
  271. /package/dist/{chunk-LTG3S6ZY.js.map → chunk-E6CLMKEE.js.map} +0 -0
@@ -0,0 +1,867 @@
1
+ /**
2
+ * Per-channel and per-DM Slack workspace routing reads and writes.
3
+ *
4
+ * Two tenancy scopes, not one relocated workspace:
5
+ *
6
+ * - **HOME** is the installation binding's `(accountId, workspaceId)`. It owns
7
+ * the `connections` row and bot credential, the bot post/update/delete
8
+ * ledgers, `slack_interaction_inbox`, `slack_bot_user_links`, App Home
9
+ * refreshes, reaction-summon settings, Slack task policy, and every table in
10
+ * this module. The bot client is ALWAYS built from HOME.
11
+ * - **TARGET** is the routed `(accountId, workspaceId)`. It owns the
12
+ * `slack_interactions` row, its action handles, its progress deliveries, the
13
+ * grant, the session, and every session event.
14
+ *
15
+ * Both sides always sit in one organization: `target_account_id = account_id`
16
+ * is a table CHECK.
17
+ *
18
+ * `resolveSlackTargetAuthority` - the only place a Slack request may reach a
19
+ * managed human's personal workspace - deliberately lives in `./index`
20
+ * alongside `getWorkspaceGrant`, `managedPersonalWorkspacePermissions`, and
21
+ * `namedSubjectHasLiveWorkspaceAuthority`, because no sibling module in this
22
+ * package imports `./index` and this one must not be the first.
23
+ */
24
+ import { and, eq, lte, sql } from "drizzle-orm";
25
+
26
+ import { rawRows, withRlsContext, type Database } from "./database";
27
+ import { namedSubjectPersonalWorkspaceId } from "./slack-routing-personal-workspace";
28
+ import * as schema from "./schema";
29
+ import { workspaceMembershipPermissionsAllowSlackLink } from "./slack-user-link-access";
30
+
31
+ /** The installation binding's own tenancy: credential, identity, and inbox. */
32
+ export type SlackRouteHome = { accountId: string; workspaceId: string };
33
+
34
+ /** How a stored route was decided. `picker` is a human answering the Slack card. */
35
+ export type SlackRouteSource = "picker" | "admin";
36
+
37
+ export type SlackChannelRoute = {
38
+ id: string;
39
+ accountId: string;
40
+ workspaceId: string;
41
+ connectionId: string;
42
+ slackTeamId: string;
43
+ slackChannelId: string;
44
+ targetAccountId: string;
45
+ targetWorkspaceId: string;
46
+ decidedBySubjectId: string;
47
+ decidedBySlackUserId: string;
48
+ source: SlackRouteSource;
49
+ version: number;
50
+ createdAt: Date;
51
+ updatedAt: Date;
52
+ };
53
+
54
+ export type SlackUserDmRoute = Omit<SlackChannelRoute, "slackChannelId"> & {
55
+ slackUserId: string;
56
+ };
57
+
58
+ export type UpsertSlackChannelRouteInput = {
59
+ connectionId: string;
60
+ slackTeamId: string;
61
+ slackChannelId: string;
62
+ targetAccountId: string;
63
+ targetWorkspaceId: string;
64
+ decidedBySubjectId: string;
65
+ decidedBySlackUserId: string;
66
+ source: SlackRouteSource;
67
+ };
68
+
69
+ export type UpsertSlackUserDmRouteInput = Omit<UpsertSlackChannelRouteInput, "slackChannelId"> & {
70
+ slackUserId: string;
71
+ };
72
+
73
+ /** Ids only. Never content, never a subject. */
74
+ export type SlackInteractionTenancy = {
75
+ accountId: string;
76
+ workspaceId: string;
77
+ interactionId: string;
78
+ };
79
+
80
+ /** One workspace a Slack-linked subject may start work in. */
81
+ export type SlackRoutableWorkspace = {
82
+ accountId: string;
83
+ workspaceId: string;
84
+ label: string;
85
+ /** True for the subject's own personal workspace, which has no membership row. */
86
+ personal: boolean;
87
+ };
88
+
89
+ function mapChannelRoute(row: typeof schema.slackChannelRoutes.$inferSelect): SlackChannelRoute {
90
+ return {
91
+ id: row.id,
92
+ accountId: row.accountId,
93
+ workspaceId: row.workspaceId,
94
+ connectionId: row.connectionId,
95
+ slackTeamId: row.slackTeamId,
96
+ slackChannelId: row.slackChannelId,
97
+ targetAccountId: row.targetAccountId,
98
+ targetWorkspaceId: row.targetWorkspaceId,
99
+ decidedBySubjectId: row.decidedBySubjectId,
100
+ decidedBySlackUserId: row.decidedBySlackUserId,
101
+ source: row.source,
102
+ version: row.version,
103
+ createdAt: row.createdAt,
104
+ updatedAt: row.updatedAt,
105
+ };
106
+ }
107
+
108
+ function mapUserDmRoute(row: typeof schema.slackUserDmRoutes.$inferSelect): SlackUserDmRoute {
109
+ return {
110
+ id: row.id,
111
+ accountId: row.accountId,
112
+ workspaceId: row.workspaceId,
113
+ connectionId: row.connectionId,
114
+ slackTeamId: row.slackTeamId,
115
+ slackUserId: row.slackUserId,
116
+ targetAccountId: row.targetAccountId,
117
+ targetWorkspaceId: row.targetWorkspaceId,
118
+ decidedBySubjectId: row.decidedBySubjectId,
119
+ decidedBySlackUserId: row.decidedBySlackUserId,
120
+ source: row.source,
121
+ version: row.version,
122
+ createdAt: row.createdAt,
123
+ updatedAt: row.updatedAt,
124
+ };
125
+ }
126
+
127
+ /**
128
+ * Resolve which tenancy owns the `slack_interactions` row for one exact
129
+ * connection-scoped route key, through the content-free SECURITY DEFINER probe.
130
+ *
131
+ * Thread continuation must cross workspaces: `slack_interactions_route_uq` is
132
+ * `(connection_id, route_key)` and therefore connection-global, while every
133
+ * ordinary interaction read is workspace-fenced and would miss a thread whose
134
+ * interaction lives in a routed workspace.
135
+ *
136
+ * This returns IDS ONLY. It cannot widen visibility, because the caller must
137
+ * re-read the full row under the returned tenancy's own RLS - which is what
138
+ * `getSlackInteractionById` already does.
139
+ */
140
+ export async function probeSlackInteractionTenancy(
141
+ db: Database,
142
+ input: { accountId: string; connectionId: string; routeKey: string },
143
+ ): Promise<SlackInteractionTenancy | null> {
144
+ const rows = await rawRows<{
145
+ account_id: string;
146
+ workspace_id: string;
147
+ interaction_id: string;
148
+ }>(
149
+ db,
150
+ sql`select account_id, workspace_id, interaction_id
151
+ from opengeni_private.resolve_slack_interaction_tenancy(
152
+ ${input.accountId}::uuid,
153
+ ${input.connectionId}::uuid,
154
+ ${input.routeKey}
155
+ )`,
156
+ );
157
+ const row = rows[0];
158
+ return row
159
+ ? {
160
+ accountId: row.account_id,
161
+ workspaceId: row.workspace_id,
162
+ interactionId: row.interaction_id,
163
+ }
164
+ : null;
165
+ }
166
+
167
+ /**
168
+ * Which workspace owns one Slack action handle.
169
+ *
170
+ * A button click arrives on an inbox row that carries the installation's
171
+ * tenancy and names the handle only by id, while the handle lives in the
172
+ * workspace that owns its session. Content-free, ids only: the caller must
173
+ * re-read the full handle under the returned tenancy's own RLS, where every
174
+ * existing authorization check already lives.
175
+ */
176
+ export async function probeSlackActionHandleTenancy(
177
+ db: Database,
178
+ input: { accountId: string; connectionId: string; handleId: string },
179
+ ): Promise<SlackRouteHome | null> {
180
+ const rows = await rawRows<{ account_id: string; workspace_id: string }>(
181
+ db,
182
+ sql`select account_id, workspace_id
183
+ from opengeni_private.resolve_slack_action_handle_tenancy(
184
+ ${input.accountId}::uuid,
185
+ ${input.connectionId}::uuid,
186
+ ${input.handleId}::uuid
187
+ )`,
188
+ );
189
+ const row = rows[0];
190
+ return row ? { accountId: row.account_id, workspaceId: row.workspace_id } : null;
191
+ }
192
+
193
+ /** Every channel route this installation has, for the admin surface. */
194
+ export async function listSlackChannelRoutes(
195
+ db: Database,
196
+ home: SlackRouteHome,
197
+ input: { connectionId: string },
198
+ ): Promise<SlackChannelRoute[]> {
199
+ return await withRlsContext(db, home, async (scopedDb) => {
200
+ const rows = await scopedDb
201
+ .select()
202
+ .from(schema.slackChannelRoutes)
203
+ .where(
204
+ and(
205
+ eq(schema.slackChannelRoutes.accountId, home.accountId),
206
+ eq(schema.slackChannelRoutes.workspaceId, home.workspaceId),
207
+ eq(schema.slackChannelRoutes.connectionId, input.connectionId),
208
+ ),
209
+ )
210
+ .orderBy(schema.slackChannelRoutes.slackChannelId);
211
+ return rows.map(mapChannelRoute);
212
+ });
213
+ }
214
+
215
+ export async function getSlackChannelRoute(
216
+ db: Database,
217
+ home: SlackRouteHome,
218
+ input: { connectionId: string; slackChannelId: string },
219
+ ): Promise<SlackChannelRoute | null> {
220
+ return await withRlsContext(db, home, async (scopedDb) => {
221
+ const [row] = await scopedDb
222
+ .select()
223
+ .from(schema.slackChannelRoutes)
224
+ .where(
225
+ and(
226
+ eq(schema.slackChannelRoutes.accountId, home.accountId),
227
+ eq(schema.slackChannelRoutes.workspaceId, home.workspaceId),
228
+ eq(schema.slackChannelRoutes.connectionId, input.connectionId),
229
+ eq(schema.slackChannelRoutes.slackChannelId, input.slackChannelId),
230
+ ),
231
+ )
232
+ .limit(1);
233
+ return row ? mapChannelRoute(row) : null;
234
+ });
235
+ }
236
+
237
+ export async function getSlackUserDmRoute(
238
+ db: Database,
239
+ home: SlackRouteHome,
240
+ input: { connectionId: string; slackUserId: string },
241
+ ): Promise<SlackUserDmRoute | null> {
242
+ return await withRlsContext(db, home, async (scopedDb) => {
243
+ const [row] = await scopedDb
244
+ .select()
245
+ .from(schema.slackUserDmRoutes)
246
+ .where(
247
+ and(
248
+ eq(schema.slackUserDmRoutes.accountId, home.accountId),
249
+ eq(schema.slackUserDmRoutes.workspaceId, home.workspaceId),
250
+ eq(schema.slackUserDmRoutes.connectionId, input.connectionId),
251
+ eq(schema.slackUserDmRoutes.slackUserId, input.slackUserId),
252
+ ),
253
+ )
254
+ .limit(1);
255
+ return row ? mapUserDmRoute(row) : null;
256
+ });
257
+ }
258
+
259
+ /**
260
+ * The ask-once memory for a channel. A repeat write for the same
261
+ * `(connectionId, slackChannelId)` re-points the channel and bumps `version`;
262
+ * it never creates a second row, so a channel always has exactly one answer.
263
+ *
264
+ * The caller is responsible for having authorized `target*` for the deciding
265
+ * subject through `resolveSlackTargetAuthority` first - a stored route is
266
+ * memory, never authority, and every later use re-authorizes it live.
267
+ */
268
+ export async function upsertSlackChannelRoute(
269
+ db: Database,
270
+ home: SlackRouteHome,
271
+ input: UpsertSlackChannelRouteInput,
272
+ ): Promise<SlackChannelRoute> {
273
+ return await withRlsContext(db, home, async (scopedDb) => {
274
+ const [row] = await scopedDb
275
+ .insert(schema.slackChannelRoutes)
276
+ .values({
277
+ accountId: home.accountId,
278
+ workspaceId: home.workspaceId,
279
+ connectionId: input.connectionId,
280
+ slackTeamId: input.slackTeamId,
281
+ slackChannelId: input.slackChannelId,
282
+ targetAccountId: input.targetAccountId,
283
+ targetWorkspaceId: input.targetWorkspaceId,
284
+ decidedBySubjectId: input.decidedBySubjectId,
285
+ decidedBySlackUserId: input.decidedBySlackUserId,
286
+ source: input.source,
287
+ })
288
+ .onConflictDoUpdate({
289
+ target: [schema.slackChannelRoutes.connectionId, schema.slackChannelRoutes.slackChannelId],
290
+ set: {
291
+ slackTeamId: input.slackTeamId,
292
+ targetAccountId: input.targetAccountId,
293
+ targetWorkspaceId: input.targetWorkspaceId,
294
+ decidedBySubjectId: input.decidedBySubjectId,
295
+ decidedBySlackUserId: input.decidedBySlackUserId,
296
+ source: input.source,
297
+ version: sql`${schema.slackChannelRoutes.version} + 1`,
298
+ updatedAt: sql`now()`,
299
+ },
300
+ })
301
+ .returning();
302
+ if (!row) throw new Error("Slack channel route write returned no row");
303
+ return mapChannelRoute(row);
304
+ });
305
+ }
306
+
307
+ /** The ask-once memory for one Slack human's direct messages. */
308
+ export async function upsertSlackUserDmRoute(
309
+ db: Database,
310
+ home: SlackRouteHome,
311
+ input: UpsertSlackUserDmRouteInput,
312
+ ): Promise<SlackUserDmRoute> {
313
+ return await withRlsContext(db, home, async (scopedDb) => {
314
+ const [row] = await scopedDb
315
+ .insert(schema.slackUserDmRoutes)
316
+ .values({
317
+ accountId: home.accountId,
318
+ workspaceId: home.workspaceId,
319
+ connectionId: input.connectionId,
320
+ slackTeamId: input.slackTeamId,
321
+ slackUserId: input.slackUserId,
322
+ targetAccountId: input.targetAccountId,
323
+ targetWorkspaceId: input.targetWorkspaceId,
324
+ decidedBySubjectId: input.decidedBySubjectId,
325
+ decidedBySlackUserId: input.decidedBySlackUserId,
326
+ source: input.source,
327
+ })
328
+ .onConflictDoUpdate({
329
+ target: [schema.slackUserDmRoutes.connectionId, schema.slackUserDmRoutes.slackUserId],
330
+ set: {
331
+ slackTeamId: input.slackTeamId,
332
+ targetAccountId: input.targetAccountId,
333
+ targetWorkspaceId: input.targetWorkspaceId,
334
+ decidedBySubjectId: input.decidedBySubjectId,
335
+ decidedBySlackUserId: input.decidedBySlackUserId,
336
+ source: input.source,
337
+ version: sql`${schema.slackUserDmRoutes.version} + 1`,
338
+ updatedAt: sql`now()`,
339
+ },
340
+ })
341
+ .returning();
342
+ if (!row) throw new Error("Slack DM route write returned no row");
343
+ return mapUserDmRoute(row);
344
+ });
345
+ }
346
+
347
+ /** Clear a channel's stored answer; the next message asks again. */
348
+ export async function deleteSlackChannelRoute(
349
+ db: Database,
350
+ home: SlackRouteHome,
351
+ input: { connectionId: string; slackChannelId: string },
352
+ ): Promise<boolean> {
353
+ return await withRlsContext(db, home, async (scopedDb) => {
354
+ const removed = await scopedDb
355
+ .delete(schema.slackChannelRoutes)
356
+ .where(
357
+ and(
358
+ eq(schema.slackChannelRoutes.accountId, home.accountId),
359
+ eq(schema.slackChannelRoutes.workspaceId, home.workspaceId),
360
+ eq(schema.slackChannelRoutes.connectionId, input.connectionId),
361
+ eq(schema.slackChannelRoutes.slackChannelId, input.slackChannelId),
362
+ ),
363
+ )
364
+ .returning({ id: schema.slackChannelRoutes.id });
365
+ return removed.length > 0;
366
+ });
367
+ }
368
+
369
+ /** Clear one Slack human's stored DM answer; DMs fall back to the derived personal workspace. */
370
+ export async function deleteSlackUserDmRoute(
371
+ db: Database,
372
+ home: SlackRouteHome,
373
+ input: { connectionId: string; slackUserId: string },
374
+ ): Promise<boolean> {
375
+ return await withRlsContext(db, home, async (scopedDb) => {
376
+ const removed = await scopedDb
377
+ .delete(schema.slackUserDmRoutes)
378
+ .where(
379
+ and(
380
+ eq(schema.slackUserDmRoutes.accountId, home.accountId),
381
+ eq(schema.slackUserDmRoutes.workspaceId, home.workspaceId),
382
+ eq(schema.slackUserDmRoutes.connectionId, input.connectionId),
383
+ eq(schema.slackUserDmRoutes.slackUserId, input.slackUserId),
384
+ ),
385
+ )
386
+ .returning({ id: schema.slackUserDmRoutes.id });
387
+ return removed.length > 0;
388
+ });
389
+ }
390
+
391
+ /**
392
+ * THE workspaces one subject may start Slack work in, within one organization.
393
+ *
394
+ * This is deliberately one function so the "workspaces this subject may create
395
+ * sessions in" question has a single implementation that a future shared
396
+ * candidate-set contract can replace wholesale, rather than several call sites
397
+ * each re-deriving it slightly differently.
398
+ *
399
+ * Two independent sources, unioned:
400
+ *
401
+ * 1. `workspace_memberships` joined to `workspaces` in this account, filtered
402
+ * to memberships that actually permit starting work (`sessions:create`, or
403
+ * `workspace:admin` which implies it).
404
+ * 2. The subject's OWN personal workspace, from the active organization
405
+ * membership's `personalWorkspaceId` pointer.
406
+ *
407
+ * `listWorkspacesForSubject` is NOT usable here: it is a bare membership join
408
+ * with no permission filter, and it cannot see personal workspaces at all,
409
+ * because a managed human's personal workspace deliberately has no
410
+ * `workspace_memberships` row.
411
+ *
412
+ * The personal-workspace id is DERIVED from the membership pointer. It is never
413
+ * accepted from a Slack payload, a route row, or a caller argument.
414
+ *
415
+ * Ordered stably by label then workspace id, with plain code-unit comparison so
416
+ * two hosts with different ICU data cannot render a picker differently.
417
+ *
418
+ * ORACLE, NOT AN AUTHORIZATION. This answers "what could this named subject use"
419
+ * for whatever subject it is handed; it does not establish that the caller IS
420
+ * that subject. Pass only a subject authenticated out of band - for Slack, one
421
+ * named by a durable `slack_bot_user_links` row. Never pass a subject taken
422
+ * from a request payload.
423
+ */
424
+ export async function listNamedSubjectSlackRoutableWorkspaces(
425
+ db: Database,
426
+ input: { accountId: string; subjectId: string },
427
+ ): Promise<SlackRoutableWorkspace[]> {
428
+ const membershipRows = await db
429
+ .select({
430
+ workspaceId: schema.workspaces.id,
431
+ accountId: schema.workspaces.accountId,
432
+ name: schema.workspaces.name,
433
+ permissions: schema.workspaceMemberships.permissions,
434
+ })
435
+ .from(schema.workspaceMemberships)
436
+ .innerJoin(schema.workspaces, eq(schema.workspaceMemberships.workspaceId, schema.workspaces.id))
437
+ .where(
438
+ and(
439
+ eq(schema.workspaceMemberships.subjectId, input.subjectId),
440
+ eq(schema.workspaces.accountId, input.accountId),
441
+ ),
442
+ );
443
+
444
+ const candidates = new Map<string, SlackRoutableWorkspace>();
445
+ for (const row of membershipRows) {
446
+ if (!workspaceMembershipPermissionsAllowSlackLink(row.permissions)) continue;
447
+ candidates.set(row.workspaceId, {
448
+ accountId: row.accountId,
449
+ workspaceId: row.workspaceId,
450
+ label: row.name,
451
+ personal: false,
452
+ });
453
+ }
454
+
455
+ const personalWorkspaceId = await namedSubjectPersonalWorkspaceId(db, input);
456
+ if (personalWorkspaceId) {
457
+ const [row] = await db
458
+ .select({
459
+ id: schema.workspaces.id,
460
+ accountId: schema.workspaces.accountId,
461
+ name: schema.workspaces.name,
462
+ })
463
+ .from(schema.workspaces)
464
+ .where(
465
+ and(
466
+ eq(schema.workspaces.id, personalWorkspaceId),
467
+ eq(schema.workspaces.accountId, input.accountId),
468
+ ),
469
+ )
470
+ .limit(1);
471
+ if (row) {
472
+ candidates.set(row.id, {
473
+ accountId: row.accountId,
474
+ workspaceId: row.id,
475
+ label: row.name,
476
+ personal: true,
477
+ });
478
+ }
479
+ }
480
+
481
+ return [...candidates.values()].sort(
482
+ (left, right) =>
483
+ compareCodeUnits(left.label, right.label) ||
484
+ compareCodeUnits(left.workspaceId, right.workspaceId),
485
+ );
486
+ }
487
+
488
+ function compareCodeUnits(left: string, right: string): number {
489
+ if (left === right) return 0;
490
+ return left < right ? -1 : 1;
491
+ }
492
+
493
+ /** The trigger kinds a picker can be opened from, mirroring the table's CHECK. */
494
+ export type SlackRoutePromptTriggerKind =
495
+ (typeof schema.slackRoutePrompts.$inferSelect)["triggerKind"];
496
+
497
+ /** A pending first-use picker plus the workspaces it offered. */
498
+ export type SlackRoutePrompt = {
499
+ id: string;
500
+ accountId: string;
501
+ workspaceId: string;
502
+ connectionId: string;
503
+ inboxId: string;
504
+ slackTeamId: string;
505
+ slackUserId: string;
506
+ slackChannelId: string;
507
+ slackMessageTs: string;
508
+ providerEventId: string;
509
+ triggerKind: SlackRoutePromptTriggerKind;
510
+ requestText: string;
511
+ hasFiles: boolean;
512
+ slackThreadTs: string | null;
513
+ messageOperationId: string;
514
+ status: "pending" | "answered" | "expired" | "cancelled";
515
+ expiresAt: Date;
516
+ options: readonly SlackRoutePromptOption[];
517
+ };
518
+
519
+ export type SlackRoutePromptOption = {
520
+ id: string;
521
+ candidateAccountId: string;
522
+ candidateWorkspaceId: string;
523
+ candidateLabel: string;
524
+ position: number;
525
+ };
526
+
527
+ function mapPrompt(
528
+ row: typeof schema.slackRoutePrompts.$inferSelect,
529
+ options: readonly SlackRoutePromptOption[],
530
+ ): SlackRoutePrompt {
531
+ return {
532
+ id: row.id,
533
+ accountId: row.accountId,
534
+ workspaceId: row.workspaceId,
535
+ connectionId: row.connectionId,
536
+ inboxId: row.inboxId,
537
+ slackTeamId: row.slackTeamId,
538
+ slackUserId: row.slackUserId,
539
+ slackChannelId: row.slackChannelId,
540
+ slackMessageTs: row.slackMessageTs,
541
+ providerEventId: row.providerEventId,
542
+ triggerKind: row.triggerKind,
543
+ requestText: row.requestText,
544
+ hasFiles: row.hasFiles,
545
+ slackThreadTs: row.slackThreadTs,
546
+ messageOperationId: row.messageOperationId,
547
+ status: row.status,
548
+ expiresAt: row.expiresAt,
549
+ options,
550
+ };
551
+ }
552
+
553
+ async function promptOptions(
554
+ scopedDb: Database,
555
+ promptId: string,
556
+ ): Promise<SlackRoutePromptOption[]> {
557
+ const rows = await scopedDb
558
+ .select()
559
+ .from(schema.slackRoutePromptOptions)
560
+ .where(eq(schema.slackRoutePromptOptions.promptId, promptId))
561
+ .orderBy(schema.slackRoutePromptOptions.position);
562
+ return rows.map((row) => ({
563
+ id: row.id,
564
+ candidateAccountId: row.candidateAccountId,
565
+ candidateWorkspaceId: row.candidateWorkspaceId,
566
+ candidateLabel: row.candidateLabel,
567
+ position: row.position,
568
+ }));
569
+ }
570
+
571
+ /**
572
+ * Open one first-use picker for a conversation, or return the one already open.
573
+ *
574
+ * Three separate uniques make this idempotent from three directions: Slack's
575
+ * retries of one event collide on `(connection_id, provider_event_id)`, a
576
+ * replayed inbox row collides on `(connection_id, inbox_id)`, and a genuinely
577
+ * different message in the same conversation collides on the partial pending
578
+ * index. The last one is why a second message never posts a second card: the
579
+ * person is already being asked the same question.
580
+ */
581
+ export async function openSlackRoutePrompt(
582
+ db: Database,
583
+ home: SlackRouteHome,
584
+ input: {
585
+ connectionId: string;
586
+ inboxId: string;
587
+ slackTeamId: string;
588
+ slackUserId: string;
589
+ slackChannelId: string;
590
+ slackMessageTs: string;
591
+ providerEventId: string;
592
+ triggerKind: SlackRoutePromptTriggerKind;
593
+ requestText: string;
594
+ hasFiles: boolean;
595
+ slackThreadTs: string | null;
596
+ messageOperationId: string;
597
+ expiresAt: Date;
598
+ candidates: readonly SlackRoutableWorkspace[];
599
+ now?: Date;
600
+ },
601
+ ): Promise<{ opened: boolean; prompt: SlackRoutePrompt } | null> {
602
+ const now = input.now ?? new Date();
603
+ return await withRlsContext(db, home, async (scopedDb) => {
604
+ return await scopedDb.transaction(async (tx) => {
605
+ const scoped = tx as unknown as Database;
606
+ // A card nobody ever clicked must not hold this person's slot forever.
607
+ // `now()` is not immutable, so the partial unique index cannot exclude an
608
+ // aged-out row; the writer settles it here instead, immediately before
609
+ // trying to open the next one. This is what gives `expired` a writer.
610
+ await scoped
611
+ .update(schema.slackRoutePrompts)
612
+ .set({ status: "expired", updatedAt: sql`now()` })
613
+ .where(
614
+ and(
615
+ eq(schema.slackRoutePrompts.connectionId, input.connectionId),
616
+ eq(schema.slackRoutePrompts.slackChannelId, input.slackChannelId),
617
+ eq(schema.slackRoutePrompts.slackUserId, input.slackUserId),
618
+ eq(schema.slackRoutePrompts.status, "pending"),
619
+ lte(schema.slackRoutePrompts.expiresAt, now),
620
+ ),
621
+ );
622
+ const [created] = await scoped
623
+ .insert(schema.slackRoutePrompts)
624
+ .values([
625
+ {
626
+ accountId: home.accountId,
627
+ workspaceId: home.workspaceId,
628
+ connectionId: input.connectionId,
629
+ inboxId: input.inboxId,
630
+ slackTeamId: input.slackTeamId,
631
+ slackUserId: input.slackUserId,
632
+ slackChannelId: input.slackChannelId,
633
+ slackMessageTs: input.slackMessageTs,
634
+ providerEventId: input.providerEventId,
635
+ triggerKind: input.triggerKind,
636
+ requestText: input.requestText,
637
+ hasFiles: input.hasFiles,
638
+ slackThreadTs: input.slackThreadTs,
639
+ messageOperationId: input.messageOperationId,
640
+ status: "pending",
641
+ expiresAt: input.expiresAt,
642
+ },
643
+ ])
644
+ .onConflictDoNothing()
645
+ .returning();
646
+ if (!created) {
647
+ const [existing] = await scoped
648
+ .select()
649
+ .from(schema.slackRoutePrompts)
650
+ .where(
651
+ and(
652
+ eq(schema.slackRoutePrompts.connectionId, input.connectionId),
653
+ eq(schema.slackRoutePrompts.slackChannelId, input.slackChannelId),
654
+ eq(schema.slackRoutePrompts.slackUserId, input.slackUserId),
655
+ eq(schema.slackRoutePrompts.status, "pending"),
656
+ ),
657
+ )
658
+ .limit(1);
659
+ if (!existing) return null;
660
+ return {
661
+ opened: false,
662
+ prompt: mapPrompt(existing, await promptOptions(scoped, existing.id)),
663
+ };
664
+ }
665
+ const offered = input.candidates.map((candidate, index) => ({
666
+ accountId: home.accountId,
667
+ workspaceId: home.workspaceId,
668
+ promptId: created.id,
669
+ candidateAccountId: candidate.accountId,
670
+ candidateWorkspaceId: candidate.workspaceId,
671
+ candidateLabel: candidate.label,
672
+ position: index,
673
+ }));
674
+ if (offered.length > 0) {
675
+ await scoped.insert(schema.slackRoutePromptOptions).values(offered);
676
+ }
677
+ return { opened: true, prompt: mapPrompt(created, await promptOptions(scoped, created.id)) };
678
+ });
679
+ });
680
+ }
681
+
682
+ /**
683
+ * The prompt one offered button belongs to.
684
+ *
685
+ * The button's `value` is the option row id, which is a UUID precisely because
686
+ * the block-action normalizer already requires that shape. The option rows are a
687
+ * prompt-time SNAPSHOT and are never authority: the caller must re-authorize the
688
+ * chosen workspace live.
689
+ */
690
+ export async function getSlackRoutePromptByOption(
691
+ db: Database,
692
+ home: SlackRouteHome,
693
+ optionId: string,
694
+ ): Promise<{ prompt: SlackRoutePrompt; chosen: SlackRoutePromptOption } | null> {
695
+ return await withRlsContext(db, home, async (scopedDb) => {
696
+ const [option] = await scopedDb
697
+ .select()
698
+ .from(schema.slackRoutePromptOptions)
699
+ .where(eq(schema.slackRoutePromptOptions.id, optionId))
700
+ .limit(1);
701
+ if (!option) return null;
702
+ const [row] = await scopedDb
703
+ .select()
704
+ .from(schema.slackRoutePrompts)
705
+ .where(eq(schema.slackRoutePrompts.id, option.promptId))
706
+ .limit(1);
707
+ if (!row) return null;
708
+ return {
709
+ prompt: mapPrompt(row, await promptOptions(scopedDb, row.id)),
710
+ chosen: {
711
+ id: option.id,
712
+ candidateAccountId: option.candidateAccountId,
713
+ candidateWorkspaceId: option.candidateWorkspaceId,
714
+ candidateLabel: option.candidateLabel,
715
+ position: option.position,
716
+ },
717
+ };
718
+ });
719
+ }
720
+
721
+ /**
722
+ * Settle a prompt without answering it: the person pressed the cancel action, or
723
+ * it aged out. Only a still-pending row moves, so this cannot overwrite an
724
+ * answer that landed first.
725
+ */
726
+ export async function settleSlackRoutePrompt(
727
+ db: Database,
728
+ home: SlackRouteHome,
729
+ input: { promptId: string; status: "expired" | "cancelled" },
730
+ ): Promise<boolean> {
731
+ return await withRlsContext(db, home, async (scopedDb) => {
732
+ const settled = await scopedDb
733
+ .update(schema.slackRoutePrompts)
734
+ .set({ status: input.status, updatedAt: sql`now()` })
735
+ .where(
736
+ and(
737
+ eq(schema.slackRoutePrompts.id, input.promptId),
738
+ eq(schema.slackRoutePrompts.status, "pending"),
739
+ ),
740
+ )
741
+ .returning({ id: schema.slackRoutePrompts.id });
742
+ return settled.length > 0;
743
+ });
744
+ }
745
+
746
+ /**
747
+ * Answer a picker: freeze the choice, remember it, and re-queue the request.
748
+ *
749
+ * All three commit together. The route row is the ask-once memory, so a later
750
+ * message in that conversation never asks again, and the re-queued inbox row is
751
+ * what actually starts the work the person asked for before they were
752
+ * interrupted by the question.
753
+ *
754
+ * The re-queued row carries a DERIVED provider event id, not the original one.
755
+ * The original inbox row still exists and is settled, so re-using its id would
756
+ * collide with the inbox's own `(connection_id, provider_event_id)` dedupe and
757
+ * silently queue nothing at all. Deriving it from the prompt keeps the insert
758
+ * idempotent for exactly this answer, so a double-click cannot create two
759
+ * sessions.
760
+ *
761
+ * Only a still-pending prompt answers. A second click, an expiry sweep, or a
762
+ * cancel that landed first all leave `answered: false` and change nothing.
763
+ */
764
+ export async function answerSlackRoutePrompt(
765
+ db: Database,
766
+ home: SlackRouteHome,
767
+ input: {
768
+ promptId: string;
769
+ targetAccountId: string;
770
+ targetWorkspaceId: string;
771
+ answeredBySubjectId: string;
772
+ decidedBySlackUserId: string;
773
+ now?: Date;
774
+ },
775
+ ): Promise<{ answered: boolean; queuedProviderEventId: string | null }> {
776
+ const now = input.now ?? new Date();
777
+ return await withRlsContext(db, home, async (scopedDb) => {
778
+ return await scopedDb.transaction(async (tx) => {
779
+ const scoped = tx as unknown as Database;
780
+ const [prompt] = await scoped
781
+ .select()
782
+ .from(schema.slackRoutePrompts)
783
+ .where(eq(schema.slackRoutePrompts.id, input.promptId))
784
+ .limit(1)
785
+ .for("update");
786
+ if (!prompt || prompt.status !== "pending") {
787
+ return { answered: false, queuedProviderEventId: null };
788
+ }
789
+ if (prompt.expiresAt.getTime() <= now.getTime()) {
790
+ // Settle it here rather than leaving a dead row holding this person's
791
+ // slot: `expired` needs a writer on every path that observes expiry.
792
+ await scoped
793
+ .update(schema.slackRoutePrompts)
794
+ .set({ status: "expired", updatedAt: sql`now()` })
795
+ .where(
796
+ and(
797
+ eq(schema.slackRoutePrompts.id, prompt.id),
798
+ eq(schema.slackRoutePrompts.status, "pending"),
799
+ ),
800
+ );
801
+ return { answered: false, queuedProviderEventId: null };
802
+ }
803
+ await scoped
804
+ .update(schema.slackRoutePrompts)
805
+ .set({
806
+ status: "answered",
807
+ answeredTargetAccountId: input.targetAccountId,
808
+ answeredTargetWorkspaceId: input.targetWorkspaceId,
809
+ answeredBySubjectId: input.answeredBySubjectId,
810
+ answeredAt: now,
811
+ updatedAt: sql`now()`,
812
+ })
813
+ .where(eq(schema.slackRoutePrompts.id, prompt.id));
814
+
815
+ const directMessage = prompt.slackChannelId.startsWith("D") || prompt.triggerKind === "dm";
816
+ if (directMessage) {
817
+ await upsertSlackUserDmRoute(scoped, home, {
818
+ connectionId: prompt.connectionId,
819
+ slackTeamId: prompt.slackTeamId,
820
+ slackUserId: prompt.slackUserId,
821
+ targetAccountId: input.targetAccountId,
822
+ targetWorkspaceId: input.targetWorkspaceId,
823
+ decidedBySubjectId: input.answeredBySubjectId,
824
+ decidedBySlackUserId: input.decidedBySlackUserId,
825
+ source: "picker",
826
+ });
827
+ } else {
828
+ await upsertSlackChannelRoute(scoped, home, {
829
+ connectionId: prompt.connectionId,
830
+ slackTeamId: prompt.slackTeamId,
831
+ slackChannelId: prompt.slackChannelId,
832
+ targetAccountId: input.targetAccountId,
833
+ targetWorkspaceId: input.targetWorkspaceId,
834
+ decidedBySubjectId: input.answeredBySubjectId,
835
+ decidedBySlackUserId: input.decidedBySlackUserId,
836
+ source: "picker",
837
+ });
838
+ }
839
+
840
+ const queuedProviderEventId = `${prompt.providerEventId}:route:${prompt.id}`;
841
+ await scoped
842
+ .insert(schema.slackInteractionInbox)
843
+ .values([
844
+ {
845
+ accountId: home.accountId,
846
+ workspaceId: home.workspaceId,
847
+ connectionId: prompt.connectionId,
848
+ providerEventId: queuedProviderEventId,
849
+ providerMessageId: queuedProviderEventId,
850
+ slackTeamId: prompt.slackTeamId,
851
+ slackUserId: prompt.slackUserId,
852
+ slackChannelId: prompt.slackChannelId,
853
+ slackMessageTs: prompt.slackMessageTs,
854
+ slackThreadTs: prompt.slackThreadTs,
855
+ triggerKind: prompt.triggerKind,
856
+ text: prompt.requestText,
857
+ hasFiles: prompt.hasFiles,
858
+ routeState: "resolved",
859
+ targetAccountId: input.targetAccountId,
860
+ targetWorkspaceId: input.targetWorkspaceId,
861
+ },
862
+ ])
863
+ .onConflictDoNothing();
864
+ return { answered: true, queuedProviderEventId };
865
+ });
866
+ });
867
+ }