@realtimex/folio 0.1.16 → 0.1.17

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 (47) hide show
  1. package/api/src/middleware/auth.ts +77 -0
  2. package/api/src/routes/chat.ts +7 -1
  3. package/api/src/routes/index.ts +2 -0
  4. package/api/src/routes/ingestions.ts +45 -5
  5. package/api/src/routes/policies.ts +50 -7
  6. package/api/src/routes/stats.ts +9 -5
  7. package/api/src/routes/workspaces.ts +290 -0
  8. package/api/src/services/ChatService.ts +8 -2
  9. package/api/src/services/IngestionService.ts +38 -26
  10. package/api/src/services/PolicyEngine.ts +4 -1
  11. package/api/src/services/PolicyLearningService.ts +31 -6
  12. package/api/src/services/PolicyLoader.ts +44 -25
  13. package/api/src/services/RAGService.ts +52 -12
  14. package/dist/api/src/middleware/auth.js +59 -0
  15. package/dist/api/src/routes/chat.js +1 -1
  16. package/dist/api/src/routes/index.js +2 -0
  17. package/dist/api/src/routes/ingestions.js +45 -8
  18. package/dist/api/src/routes/policies.js +49 -7
  19. package/dist/api/src/routes/stats.js +9 -5
  20. package/dist/api/src/routes/workspaces.js +220 -0
  21. package/dist/api/src/services/ChatService.js +7 -2
  22. package/dist/api/src/services/IngestionService.js +35 -30
  23. package/dist/api/src/services/PolicyEngine.js +2 -1
  24. package/dist/api/src/services/PolicyLearningService.js +28 -6
  25. package/dist/api/src/services/PolicyLoader.js +29 -25
  26. package/dist/api/src/services/RAGService.js +43 -11
  27. package/dist/assets/index-CTn5FcC4.js +113 -0
  28. package/dist/assets/index-Dq9sxoZK.css +1 -0
  29. package/dist/index.html +2 -2
  30. package/package.json +1 -1
  31. package/supabase/functions/workspace-invite/index.ts +110 -0
  32. package/supabase/migrations/20260223000000_initial_foundation.sql +5 -0
  33. package/supabase/migrations/20260224000004_add_avatars_storage.sql +4 -0
  34. package/supabase/migrations/20260224000006_add_policies_table.sql +5 -0
  35. package/supabase/migrations/20260224000008_add_ingestions_table.sql +2 -0
  36. package/supabase/migrations/20260225000000_setup_compatible_mode.sql +17 -4
  37. package/supabase/migrations/20260225000003_add_baseline_configs.sql +4 -3
  38. package/supabase/migrations/20260226000000_add_processing_events.sql +1 -0
  39. package/supabase/migrations/20260226000002_add_dynamic_rag.sql +1 -0
  40. package/supabase/migrations/20260226000005_add_chat_tables.sql +3 -0
  41. package/supabase/migrations/20260228000001_add_policy_match_feedback.sql +4 -0
  42. package/supabase/migrations/20260302064608_add_ingestion_llm_settings_compat.sql +15 -0
  43. package/supabase/migrations/20260303000000_add_workspaces_phase1.sql +459 -0
  44. package/supabase/migrations/20260303010000_add_workspace_management_rpc.sql +310 -0
  45. package/supabase/migrations/20260303020000_workspace_scope_document_chunks.sql +139 -0
  46. package/dist/assets/index-DzN8-j-e.css +0 -1
  47. package/dist/assets/index-dnBz6SWG.js +0 -113
@@ -10,14 +10,24 @@ const router = Router();
10
10
  router.use(optionalAuth);
11
11
  // GET /api/policies — list all loaded policies
12
12
  router.get("/", asyncHandler(async (req, res) => {
13
- const policies = await PolicyLoader.load(false, req.supabase);
14
- if (!req.supabase || !req.user || policies.length === 0) {
13
+ if (!req.supabase || !req.user) {
14
+ const policies = await PolicyLoader.load(false, req.supabase, req.workspaceId);
15
+ res.json({ success: true, policies });
16
+ return;
17
+ }
18
+ if (!req.workspaceId) {
19
+ res.status(403).json({ success: false, error: "Workspace membership required" });
20
+ return;
21
+ }
22
+ const policies = await PolicyLoader.load(false, req.supabase, req.workspaceId);
23
+ if (policies.length === 0) {
15
24
  res.json({ success: true, policies });
16
25
  return;
17
26
  }
18
27
  const stats = await PolicyLearningService.getPolicyLearningStats({
19
28
  supabase: req.supabase,
20
29
  userId: req.user.id,
30
+ workspaceId: req.workspaceId,
21
31
  policyIds: policies.map((policy) => policy.metadata.id),
22
32
  });
23
33
  const enrichedPolicies = policies.map((policy) => {
@@ -35,17 +45,33 @@ router.get("/", asyncHandler(async (req, res) => {
35
45
  }));
36
46
  // POST /api/policies — save a new policy
37
47
  router.post("/", asyncHandler(async (req, res) => {
48
+ if (!req.supabase || !req.user) {
49
+ res.status(401).json({ success: false, error: "Authentication required" });
50
+ return;
51
+ }
52
+ if (!req.workspaceId) {
53
+ res.status(403).json({ success: false, error: "Workspace membership required" });
54
+ return;
55
+ }
38
56
  const policy = req.body;
39
57
  if (!PolicyLoader.validate(policy)) {
40
58
  res.status(400).json({ success: false, error: "Invalid policy schema" });
41
59
  return;
42
60
  }
43
- const filePath = await PolicyLoader.save(policy, req.supabase, req.user?.id);
61
+ const filePath = await PolicyLoader.save(policy, req.supabase, req.user.id, req.workspaceId);
44
62
  res.status(201).json({ success: true, filePath });
45
63
  }));
46
64
  // DELETE /api/policies/:id — delete a policy by ID
47
65
  router.delete("/:id", asyncHandler(async (req, res) => {
48
- const deleted = await PolicyLoader.delete(req.params["id"], req.supabase, req.user?.id);
66
+ if (!req.supabase || !req.user) {
67
+ res.status(401).json({ success: false, error: "Authentication required" });
68
+ return;
69
+ }
70
+ if (!req.workspaceId) {
71
+ res.status(403).json({ success: false, error: "Workspace membership required" });
72
+ return;
73
+ }
74
+ const deleted = await PolicyLoader.delete(req.params["id"], req.supabase, req.user.id, req.workspaceId);
49
75
  if (!deleted) {
50
76
  res.status(404).json({ success: false, error: "Policy not found" });
51
77
  return;
@@ -54,14 +80,30 @@ router.delete("/:id", asyncHandler(async (req, res) => {
54
80
  }));
55
81
  // PATCH /api/policies/:id — partial update (enabled toggle, metadata fields)
56
82
  router.patch("/:id", asyncHandler(async (req, res) => {
83
+ if (!req.supabase || !req.user) {
84
+ res.status(401).json({ success: false, error: "Authentication required" });
85
+ return;
86
+ }
87
+ if (!req.workspaceId) {
88
+ res.status(403).json({ success: false, error: "Workspace membership required" });
89
+ return;
90
+ }
57
91
  const { enabled, name, description, tags, priority } = req.body;
58
- await PolicyLoader.patch(req.params["id"], { enabled, name, description, tags, priority }, req.supabase, req.user?.id);
92
+ await PolicyLoader.patch(req.params["id"], { enabled, name, description, tags, priority }, req.supabase, req.user.id, req.workspaceId);
59
93
  res.json({ success: true });
60
94
  }));
61
95
  // POST /api/policies/reload — force cache invalidation
62
96
  router.post("/reload", asyncHandler(async (req, res) => {
63
- PolicyLoader.invalidateCache();
64
- const policies = await PolicyLoader.load(true, req.supabase);
97
+ if (!req.supabase || !req.user) {
98
+ res.status(401).json({ success: false, error: "Authentication required" });
99
+ return;
100
+ }
101
+ if (!req.workspaceId) {
102
+ res.status(403).json({ success: false, error: "Workspace membership required" });
103
+ return;
104
+ }
105
+ PolicyLoader.invalidateCache(req.workspaceId);
106
+ const policies = await PolicyLoader.load(true, req.supabase, req.workspaceId);
65
107
  res.json({ success: true, count: policies.length });
66
108
  }));
67
109
  // POST /api/policies/synthesize — NL → Policy via LLM
@@ -9,29 +9,33 @@ router.get("/", async (req, res) => {
9
9
  return;
10
10
  }
11
11
  try {
12
- const userId = req.user.id;
12
+ const workspaceId = req.workspaceId;
13
+ if (!workspaceId) {
14
+ res.status(403).json({ success: false, error: "Workspace membership required" });
15
+ return;
16
+ }
13
17
  const s = req.supabase; // the scoped service client
14
18
  // 1. Total Documents Ingested
15
19
  const { count: totalDocumentsCount, error: err1 } = await s
16
20
  .from("ingestions")
17
21
  .select("*", { count: 'exact', head: true })
18
- .eq("user_id", userId);
22
+ .eq("workspace_id", workspaceId);
19
23
  // 2. Active Policies
20
24
  const { count: activePoliciesCount, error: err2 } = await s
21
25
  .from("policies")
22
26
  .select("*", { count: 'exact', head: true })
23
- .eq("user_id", userId)
27
+ .eq("workspace_id", workspaceId)
24
28
  .eq("enabled", true);
25
29
  // 3. RAG Knowledge Base (Chunks)
26
30
  const { count: ragChunksCount, error: err3 } = await s
27
31
  .from("document_chunks")
28
32
  .select("*", { count: 'exact', head: true })
29
- .eq("user_id", userId);
33
+ .eq("workspace_id", workspaceId);
30
34
  // 4. Automation Runs (Sum of actions taken across ingestions)
31
35
  const { data: ingestionsWithActions, error: err4 } = await s
32
36
  .from("ingestions")
33
37
  .select("actions_taken")
34
- .eq("user_id", userId)
38
+ .eq("workspace_id", workspaceId)
35
39
  .not("actions_taken", "is", null);
36
40
  let automationRunsCount = 0;
37
41
  if (ingestionsWithActions) {
@@ -0,0 +1,220 @@
1
+ import { Router } from "express";
2
+ import { config } from "../config/index.js";
3
+ import { optionalAuth } from "../middleware/auth.js";
4
+ import { asyncHandler } from "../middleware/errorHandler.js";
5
+ import { getSupabaseConfigFromHeaders } from "../services/supabase.js";
6
+ function mapRpcStatus(errorCode) {
7
+ if (errorCode === "42501")
8
+ return 403;
9
+ if (errorCode === "22023")
10
+ return 400;
11
+ return 500;
12
+ }
13
+ const router = Router();
14
+ router.use(optionalAuth);
15
+ router.get("/", asyncHandler(async (req, res) => {
16
+ if (!req.user || !req.supabase) {
17
+ res.status(401).json({ success: false, error: "Authentication required" });
18
+ return;
19
+ }
20
+ const { data: membershipsData, error: membershipsError } = await req.supabase
21
+ .from("workspace_members")
22
+ .select("workspace_id,role,status,created_at,updated_at")
23
+ .eq("user_id", req.user.id)
24
+ .eq("status", "active")
25
+ .order("created_at", { ascending: true });
26
+ if (membershipsError) {
27
+ const code = membershipsError.code;
28
+ // Backward compatibility for projects before workspace migration.
29
+ if (code === "42P01") {
30
+ res.json({
31
+ success: true,
32
+ workspaces: [],
33
+ activeWorkspaceId: null,
34
+ activeWorkspaceRole: null,
35
+ });
36
+ return;
37
+ }
38
+ res.status(500).json({ success: false, error: membershipsError.message });
39
+ return;
40
+ }
41
+ const memberships = (membershipsData ?? []);
42
+ if (memberships.length === 0) {
43
+ res.json({
44
+ success: true,
45
+ workspaces: [],
46
+ activeWorkspaceId: null,
47
+ activeWorkspaceRole: null,
48
+ });
49
+ return;
50
+ }
51
+ const workspaceIds = memberships.map((membership) => membership.workspace_id);
52
+ const { data: workspaceData, error: workspaceError } = await req.supabase
53
+ .from("workspaces")
54
+ .select("id,name,owner_user_id,created_at,updated_at")
55
+ .in("id", workspaceIds);
56
+ if (workspaceError) {
57
+ res.status(500).json({ success: false, error: workspaceError.message });
58
+ return;
59
+ }
60
+ const workspaceMap = new Map();
61
+ for (const workspace of (workspaceData ?? [])) {
62
+ workspaceMap.set(workspace.id, workspace);
63
+ }
64
+ const workspaces = memberships
65
+ .map((membership) => {
66
+ const workspace = workspaceMap.get(membership.workspace_id);
67
+ if (!workspace)
68
+ return null;
69
+ return {
70
+ id: workspace.id,
71
+ name: workspace.name,
72
+ owner_user_id: workspace.owner_user_id,
73
+ created_at: workspace.created_at,
74
+ updated_at: workspace.updated_at,
75
+ role: membership.role,
76
+ membership_status: membership.status,
77
+ membership_created_at: membership.created_at,
78
+ };
79
+ })
80
+ .filter((workspace) => Boolean(workspace));
81
+ const activeWorkspaceId = req.workspaceId ?? workspaces[0]?.id ?? null;
82
+ const activeWorkspace = workspaces.find((workspace) => workspace.id === activeWorkspaceId) ?? null;
83
+ res.json({
84
+ success: true,
85
+ workspaces,
86
+ activeWorkspaceId,
87
+ activeWorkspaceRole: activeWorkspace?.role ?? null,
88
+ });
89
+ }));
90
+ router.get("/:workspaceId/members", asyncHandler(async (req, res) => {
91
+ if (!req.user || !req.supabase) {
92
+ res.status(401).json({ success: false, error: "Authentication required" });
93
+ return;
94
+ }
95
+ const workspaceId = req.params["workspaceId"];
96
+ if (!workspaceId) {
97
+ res.status(400).json({ success: false, error: "workspaceId is required" });
98
+ return;
99
+ }
100
+ const { data, error } = await req.supabase.rpc("workspace_list_members", {
101
+ p_workspace_id: workspaceId,
102
+ });
103
+ if (error) {
104
+ const status = mapRpcStatus(error.code);
105
+ res.status(status).json({ success: false, error: error.message });
106
+ return;
107
+ }
108
+ res.json({
109
+ success: true,
110
+ members: (data ?? []),
111
+ });
112
+ }));
113
+ router.post("/:workspaceId/members/invite", asyncHandler(async (req, res) => {
114
+ if (!req.user || !req.supabase) {
115
+ res.status(401).json({ success: false, error: "Authentication required" });
116
+ return;
117
+ }
118
+ const workspaceId = req.params["workspaceId"];
119
+ const email = typeof req.body?.email === "string" ? req.body.email.trim() : "";
120
+ const role = req.body?.role === "admin" ? "admin" : "member";
121
+ if (!workspaceId) {
122
+ res.status(400).json({ success: false, error: "workspaceId is required" });
123
+ return;
124
+ }
125
+ if (!email) {
126
+ res.status(400).json({ success: false, error: "email is required" });
127
+ return;
128
+ }
129
+ const headerConfig = getSupabaseConfigFromHeaders(req.headers);
130
+ const envUrl = config.supabase.url;
131
+ const envKey = config.supabase.anonKey;
132
+ const envIsValid = envUrl.startsWith("http://") || envUrl.startsWith("https://");
133
+ const supabaseUrl = envIsValid && envKey ? envUrl : headerConfig?.url;
134
+ const anonKey = envIsValid && envKey ? envKey : headerConfig?.anonKey;
135
+ if (!supabaseUrl || !anonKey) {
136
+ res.status(500).json({ success: false, error: "Supabase config unavailable for invite workflow." });
137
+ return;
138
+ }
139
+ const authHeader = req.headers.authorization;
140
+ if (!authHeader?.startsWith("Bearer ")) {
141
+ res.status(401).json({ success: false, error: "Authentication required" });
142
+ return;
143
+ }
144
+ const response = await fetch(`${supabaseUrl}/functions/v1/workspace-invite`, {
145
+ method: "POST",
146
+ headers: {
147
+ "Content-Type": "application/json",
148
+ Authorization: authHeader,
149
+ apikey: anonKey,
150
+ },
151
+ body: JSON.stringify({
152
+ workspace_id: workspaceId,
153
+ email,
154
+ role,
155
+ }),
156
+ });
157
+ const payload = await response.json().catch(() => ({}));
158
+ if (!response.ok) {
159
+ res.status(response.status).json({
160
+ success: false,
161
+ error: payload?.error?.message || payload?.error || `Invite workflow failed (${response.status})`,
162
+ });
163
+ return;
164
+ }
165
+ res.json(payload);
166
+ }));
167
+ router.patch("/:workspaceId/members/:userId", asyncHandler(async (req, res) => {
168
+ if (!req.user || !req.supabase) {
169
+ res.status(401).json({ success: false, error: "Authentication required" });
170
+ return;
171
+ }
172
+ const workspaceId = req.params["workspaceId"];
173
+ const userId = req.params["userId"];
174
+ const role = req.body?.role === "admin" ? "admin" : req.body?.role === "member" ? "member" : "";
175
+ if (!workspaceId || !userId) {
176
+ res.status(400).json({ success: false, error: "workspaceId and userId are required" });
177
+ return;
178
+ }
179
+ if (!role) {
180
+ res.status(400).json({ success: false, error: "role must be admin or member" });
181
+ return;
182
+ }
183
+ const { data, error } = await req.supabase.rpc("workspace_update_member_role", {
184
+ p_workspace_id: workspaceId,
185
+ p_target_user_id: userId,
186
+ p_role: role,
187
+ });
188
+ if (error) {
189
+ const status = mapRpcStatus(error.code);
190
+ res.status(status).json({ success: false, error: error.message });
191
+ return;
192
+ }
193
+ res.json({
194
+ success: true,
195
+ member: Array.isArray(data) ? data[0] ?? null : null,
196
+ });
197
+ }));
198
+ router.delete("/:workspaceId/members/:userId", asyncHandler(async (req, res) => {
199
+ if (!req.user || !req.supabase) {
200
+ res.status(401).json({ success: false, error: "Authentication required" });
201
+ return;
202
+ }
203
+ const workspaceId = req.params["workspaceId"];
204
+ const userId = req.params["userId"];
205
+ if (!workspaceId || !userId) {
206
+ res.status(400).json({ success: false, error: "workspaceId and userId are required" });
207
+ return;
208
+ }
209
+ const { data, error } = await req.supabase.rpc("workspace_remove_member", {
210
+ p_workspace_id: workspaceId,
211
+ p_target_user_id: userId,
212
+ });
213
+ if (error) {
214
+ const status = mapRpcStatus(error.code);
215
+ res.status(status).json({ success: false, error: error.message });
216
+ return;
217
+ }
218
+ res.json({ success: true, removed: data === true });
219
+ }));
220
+ export default router;
@@ -9,7 +9,7 @@ export class ChatService {
9
9
  * Send a message to an existing session, augment with RAG context if needed,
10
10
  * and stream the AI response back into the database.
11
11
  */
12
- static async handleMessage(sessionId, userId, content, supabase) {
12
+ static async handleMessage(sessionId, userId, content, supabase, workspaceId) {
13
13
  // 1. Get User/Session Settings (Models to use)
14
14
  const { data: settings } = await supabase
15
15
  .from("user_settings")
@@ -49,7 +49,12 @@ export class ChatService {
49
49
  embedding_model: embedSettings.embedding_model ?? "auto",
50
50
  query_preview: content.slice(0, 180),
51
51
  }, supabase);
52
- contextSources = await RAGService.searchDocuments(content, userId, supabase, { topK: 5, similarityThreshold: 0.65, settings: embedSettings });
52
+ contextSources = await RAGService.searchDocuments(content, userId, supabase, {
53
+ topK: 5,
54
+ similarityThreshold: 0.65,
55
+ settings: embedSettings,
56
+ workspaceId
57
+ });
53
58
  Actuator.logEvent(null, userId, "analysis", "RAG Retrieval", {
54
59
  action: "RAG query response",
55
60
  session_id: sessionId,
@@ -161,7 +161,7 @@ export class IngestionService {
161
161
  action: "Queued synthetic VLM embedding",
162
162
  ...details,
163
163
  }, opts.supabase);
164
- RAGService.chunkAndEmbed(opts.ingestionId, opts.userId, syntheticText, opts.supabase, opts.embedSettings).then(() => {
164
+ RAGService.chunkAndEmbed(opts.ingestionId, opts.userId, syntheticText, opts.supabase, opts.embedSettings, opts.workspaceId).then(() => {
165
165
  Actuator.logEvent(opts.ingestionId, opts.userId, "analysis", "RAG Embedding", {
166
166
  action: "Completed synthetic VLM embedding",
167
167
  ...details,
@@ -257,13 +257,13 @@ export class IngestionService {
257
257
  * Ingest a document using Hybrid Routing Architecture.
258
258
  */
259
259
  static async ingest(opts) {
260
- const { supabase, userId, filename, mimeType, fileSize, source = "upload", filePath, content, fileHash } = opts;
260
+ const { supabase, userId, workspaceId, filename, mimeType, fileSize, source = "upload", filePath, content, fileHash } = opts;
261
261
  // Duplicate detection — check if this exact file content was already ingested
262
262
  if (fileHash) {
263
263
  const { data: existing } = await supabase
264
264
  .from("ingestions")
265
265
  .select("id, filename, created_at")
266
- .eq("user_id", userId)
266
+ .eq("workspace_id", workspaceId)
267
267
  .eq("file_hash", fileHash)
268
268
  .eq("status", "matched")
269
269
  .order("created_at", { ascending: true })
@@ -274,6 +274,7 @@ export class IngestionService {
274
274
  const { data: dupIngestion } = await supabase
275
275
  .from("ingestions")
276
276
  .insert({
277
+ workspace_id: workspaceId,
277
278
  user_id: userId,
278
279
  source,
279
280
  filename,
@@ -293,6 +294,7 @@ export class IngestionService {
293
294
  const { data: ingestion, error: insertErr } = await supabase
294
295
  .from("ingestions")
295
296
  .insert({
297
+ workspace_id: workspaceId,
296
298
  user_id: userId,
297
299
  source,
298
300
  filename,
@@ -397,7 +399,7 @@ export class IngestionService {
397
399
  try {
398
400
  // 3. Fast Path — fetch all dependencies in parallel
399
401
  const [userPolicies, processingSettingsRow, baselineConfig] = await Promise.all([
400
- PolicyLoader.load(false, supabase),
402
+ PolicyLoader.load(false, supabase, workspaceId),
401
403
  supabase.from("user_settings").select("llm_provider, llm_model, ingestion_llm_provider, ingestion_llm_model, embedding_provider, embedding_model").eq("user_id", userId).maybeSingle(),
402
404
  BaselineConfigService.getActive(supabase, userId),
403
405
  ]);
@@ -409,11 +411,11 @@ export class IngestionService {
409
411
  const resolvedProvider = llmSettings.llm_provider ?? llmProvider;
410
412
  const resolvedModel = llmSettings.llm_model ?? llmModel;
411
413
  const runFastPathAttempt = async (attemptContent, attemptType) => {
412
- const doc = { filePath: filePath, text: attemptContent, ingestionId: ingestion.id, userId, supabase };
414
+ const doc = { filePath: filePath, text: attemptContent, ingestionId: ingestion.id, userId, workspaceId, supabase };
413
415
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
414
416
  const baselineTrace = [];
415
417
  // Fire and forget Semantic Embedding Storage
416
- RAGService.chunkAndEmbed(ingestion.id, userId, doc.text, supabase, embedSettings).catch(err => {
418
+ RAGService.chunkAndEmbed(ingestion.id, userId, doc.text, supabase, embedSettings, workspaceId).catch(err => {
417
419
  logger.error(`RAG embedding failed for ${ingestion.id}`, err);
418
420
  });
419
421
  // 4. Stage 1: Baseline extraction (always runs, LLM call 1 of max 2)
@@ -482,6 +484,7 @@ export class IngestionService {
482
484
  const embeddingMeta = this.queueVlmSemanticEmbedding({
483
485
  ingestionId: ingestion.id,
484
486
  userId,
487
+ workspaceId,
485
488
  filename,
486
489
  finalStatus,
487
490
  policyName,
@@ -646,12 +649,12 @@ export class IngestionService {
646
649
  /**
647
650
  * Re-run an existing ingestion
648
651
  */
649
- static async rerun(ingestionId, supabase, userId, opts = {}) {
652
+ static async rerun(ingestionId, supabase, userId, workspaceId, opts = {}) {
650
653
  const { data: ingestion, error } = await supabase
651
654
  .from("ingestions")
652
655
  .select("*")
653
656
  .eq("id", ingestionId)
654
- .eq("user_id", userId)
657
+ .eq("workspace_id", workspaceId)
655
658
  .single();
656
659
  if (error || !ingestion)
657
660
  throw new Error("Ingestion not found");
@@ -747,7 +750,7 @@ export class IngestionService {
747
750
  }
748
751
  if (isFastPath) {
749
752
  const [userPolicies, processingSettingsRow, baselineConfig] = await Promise.all([
750
- PolicyLoader.load(false, supabase),
753
+ PolicyLoader.load(false, supabase, workspaceId),
751
754
  supabase.from("user_settings").select("llm_provider, llm_model, ingestion_llm_provider, ingestion_llm_model, embedding_provider, embedding_model").eq("user_id", userId).maybeSingle(),
752
755
  BaselineConfigService.getActive(supabase, userId),
753
756
  ]);
@@ -759,11 +762,11 @@ export class IngestionService {
759
762
  const resolvedProvider = llmSettings.llm_provider ?? llmProvider;
760
763
  const resolvedModel = llmSettings.llm_model ?? llmModel;
761
764
  const runFastPathAttempt = async (attemptContent, attemptType) => {
762
- const doc = { filePath, text: attemptContent, ingestionId, userId, supabase };
765
+ const doc = { filePath, text: attemptContent, ingestionId, userId, workspaceId, supabase };
763
766
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
764
767
  const baselineTrace = [];
765
768
  // Fire and forget Semantic Embedding Storage for re-runs
766
- RAGService.chunkAndEmbed(ingestionId, userId, doc.text, supabase, embedSettings).catch(err => {
769
+ RAGService.chunkAndEmbed(ingestionId, userId, doc.text, supabase, embedSettings, workspaceId).catch(err => {
767
770
  logger.error(`RAG embedding failed during rerun for ${ingestionId}`, err);
768
771
  });
769
772
  baselineTrace.push({
@@ -842,6 +845,7 @@ export class IngestionService {
842
845
  const embeddingMeta = this.queueVlmSemanticEmbedding({
843
846
  ingestionId,
844
847
  userId,
848
+ workspaceId,
845
849
  filename,
846
850
  finalStatus,
847
851
  policyName,
@@ -982,7 +986,7 @@ export class IngestionService {
982
986
  * Manually assign an ingestion to a policy and optionally persist it as
983
987
  * learning feedback for future automatic matching.
984
988
  */
985
- static async matchToPolicy(ingestionId, policyId, supabase, userId, opts = {}) {
989
+ static async matchToPolicy(ingestionId, policyId, supabase, userId, workspaceId, opts = {}) {
986
990
  const learn = opts.learn !== false;
987
991
  const rerun = opts.rerun !== false;
988
992
  const allowSideEffects = opts.allowSideEffects === true;
@@ -994,7 +998,7 @@ export class IngestionService {
994
998
  .from("ingestions")
995
999
  .select("*")
996
1000
  .eq("id", ingestionId)
997
- .eq("user_id", userId)
1001
+ .eq("workspace_id", workspaceId)
998
1002
  .single();
999
1003
  if (ingestionError || !ingestion) {
1000
1004
  throw new Error("Ingestion not found");
@@ -1002,7 +1006,7 @@ export class IngestionService {
1002
1006
  if (ingestion.status === "processing" || ingestion.status === "pending") {
1003
1007
  throw new Error("Cannot manually match while ingestion is still processing");
1004
1008
  }
1005
- const policies = await PolicyLoader.load(false, supabase);
1009
+ const policies = await PolicyLoader.load(false, supabase, workspaceId);
1006
1010
  const policy = policies.find((item) => item.metadata.id === normalizedPolicyId);
1007
1011
  if (!policy) {
1008
1012
  throw new Error(`Policy "${normalizedPolicyId}" was not found or is disabled.`);
@@ -1021,8 +1025,8 @@ export class IngestionService {
1021
1025
  learn,
1022
1026
  risky_actions: riskyActions,
1023
1027
  }, supabase);
1024
- await this.rerun(ingestionId, supabase, userId, { forcedPolicyId: policy.metadata.id });
1025
- const refreshed = await this.get(ingestionId, supabase, userId);
1028
+ await this.rerun(ingestionId, supabase, userId, workspaceId, { forcedPolicyId: policy.metadata.id });
1029
+ const refreshed = await this.get(ingestionId, supabase, workspaceId);
1026
1030
  if (!refreshed) {
1027
1031
  throw new Error("Ingestion not found after rerun.");
1028
1032
  }
@@ -1052,7 +1056,7 @@ export class IngestionService {
1052
1056
  trace: nextTrace,
1053
1057
  })
1054
1058
  .eq("id", ingestionId)
1055
- .eq("user_id", userId)
1059
+ .eq("workspace_id", workspaceId)
1056
1060
  .select("*")
1057
1061
  .single();
1058
1062
  if (updateError || !updatedIngestion) {
@@ -1071,6 +1075,7 @@ export class IngestionService {
1071
1075
  await PolicyLearningService.recordManualMatch({
1072
1076
  supabase,
1073
1077
  userId,
1078
+ workspaceId,
1074
1079
  ingestion: effectiveIngestion,
1075
1080
  policyId: policy.metadata.id,
1076
1081
  policyName: policy.metadata.name,
@@ -1082,7 +1087,7 @@ export class IngestionService {
1082
1087
  * Generate a user-reviewable refinement draft for an existing policy
1083
1088
  * using evidence from a specific ingestion.
1084
1089
  */
1085
- static async suggestPolicyRefinement(ingestionId, policyId, supabase, userId, opts = {}) {
1090
+ static async suggestPolicyRefinement(ingestionId, policyId, supabase, userId, workspaceId, opts = {}) {
1086
1091
  const normalizedPolicyId = policyId.trim();
1087
1092
  if (!normalizedPolicyId) {
1088
1093
  throw new Error("policy_id is required");
@@ -1091,12 +1096,12 @@ export class IngestionService {
1091
1096
  .from("ingestions")
1092
1097
  .select("id,filename,mime_type,status,tags,summary,extracted,trace")
1093
1098
  .eq("id", ingestionId)
1094
- .eq("user_id", userId)
1099
+ .eq("workspace_id", workspaceId)
1095
1100
  .single();
1096
1101
  if (ingestionError || !ingestion) {
1097
1102
  throw new Error("Ingestion not found");
1098
1103
  }
1099
- const policies = await PolicyLoader.load(false, supabase);
1104
+ const policies = await PolicyLoader.load(false, supabase, workspaceId);
1100
1105
  const targetPolicy = policies.find((policy) => policy.metadata.id === normalizedPolicyId);
1101
1106
  if (!targetPolicy) {
1102
1107
  throw new Error(`Policy "${normalizedPolicyId}" was not found or is disabled.`);
@@ -1125,19 +1130,19 @@ export class IngestionService {
1125
1130
  };
1126
1131
  }
1127
1132
  /**
1128
- * List ingestions for a user, newest first.
1133
+ * List ingestions for an active workspace, newest first.
1129
1134
  * Supports server-side pagination and ILIKE search across native text columns
1130
1135
  * (filename, policy_name, summary). Tags are handled client-side via the
1131
1136
  * filter bar; extracted JSONB search requires a tsvector migration (deferred).
1132
1137
  */
1133
- static async list(supabase, userId, opts = {}) {
1138
+ static async list(supabase, workspaceId, opts = {}) {
1134
1139
  const { page = 1, pageSize = 20, query } = opts;
1135
1140
  const from = (page - 1) * pageSize;
1136
1141
  const to = from + pageSize - 1;
1137
1142
  let q = supabase
1138
1143
  .from("ingestions")
1139
1144
  .select("*", { count: "exact" })
1140
- .eq("user_id", userId)
1145
+ .eq("workspace_id", workspaceId)
1141
1146
  .order("created_at", { ascending: false });
1142
1147
  if (query?.trim()) {
1143
1148
  const term = `%${query.trim()}%`;
@@ -1156,24 +1161,24 @@ export class IngestionService {
1156
1161
  /**
1157
1162
  * Get a single ingestion by ID.
1158
1163
  */
1159
- static async get(id, supabase, userId) {
1164
+ static async get(id, supabase, workspaceId) {
1160
1165
  const { data } = await supabase
1161
1166
  .from("ingestions")
1162
1167
  .select("*")
1163
1168
  .eq("id", id)
1164
- .eq("user_id", userId)
1169
+ .eq("workspace_id", workspaceId)
1165
1170
  .single();
1166
1171
  return data;
1167
1172
  }
1168
1173
  /**
1169
1174
  * Delete an ingestion record.
1170
1175
  */
1171
- static async delete(id, supabase, userId) {
1176
+ static async delete(id, supabase, workspaceId) {
1172
1177
  const { count, error } = await supabase
1173
1178
  .from("ingestions")
1174
1179
  .delete({ count: "exact" })
1175
1180
  .eq("id", id)
1176
- .eq("user_id", userId);
1181
+ .eq("workspace_id", workspaceId);
1177
1182
  if (error)
1178
1183
  throw new Error(`Failed to delete ingestion: ${error.message}`);
1179
1184
  return (count ?? 0) > 0;
@@ -1183,12 +1188,12 @@ export class IngestionService {
1183
1188
  * Builds the prompt from already-extracted entities — no file I/O needed.
1184
1189
  * The result is saved back to ingestion.summary so subsequent calls are instant.
1185
1190
  */
1186
- static async summarize(id, supabase, userId, llmSettings = {}) {
1191
+ static async summarize(id, supabase, userId, workspaceId, llmSettings = {}) {
1187
1192
  const { data: ing } = await supabase
1188
1193
  .from("ingestions")
1189
1194
  .select("id, filename, extracted, summary, status")
1190
1195
  .eq("id", id)
1191
- .eq("user_id", userId)
1196
+ .eq("workspace_id", workspaceId)
1192
1197
  .single();
1193
1198
  if (!ing)
1194
1199
  throw new Error("Ingestion not found");
@@ -1244,7 +1249,7 @@ export class IngestionService {
1244
1249
  .from("ingestions")
1245
1250
  .update({ summary })
1246
1251
  .eq("id", id)
1247
- .eq("user_id", userId);
1252
+ .eq("workspace_id", workspaceId);
1248
1253
  logger.info(`Summary generated and cached for ingestion ${id}`);
1249
1254
  return summary;
1250
1255
  }
@@ -767,7 +767,7 @@ export class PolicyEngine {
767
767
  */
768
768
  static async process(doc, settings = {}, baselineEntities = {}) {
769
769
  logger.info(`Processing document: ${doc.filePath}`);
770
- const policies = await PolicyLoader.load();
770
+ const policies = await PolicyLoader.load(false, doc.supabase, doc.workspaceId);
771
771
  const globalTrace = [{ timestamp: new Date().toISOString(), step: "Loaded policies", details: { count: policies.length } }];
772
772
  Actuator.logEvent(doc.ingestionId, doc.userId, "info", "Triage", { action: "Loaded policies", count: policies.length }, doc.supabase);
773
773
  for (const policy of policies) {
@@ -841,6 +841,7 @@ export class PolicyEngine {
841
841
  const learned = await PolicyLearningService.resolveLearnedCandidate({
842
842
  supabase: doc.supabase,
843
843
  userId: doc.userId,
844
+ workspaceId: doc.workspaceId,
844
845
  policyIds: policies.map((policy) => policy.metadata.id),
845
846
  filePath: doc.filePath,
846
847
  baselineEntities,