@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
@@ -0,0 +1,290 @@
1
+ import { Router } from "express";
2
+
3
+ import { config } from "../config/index.js";
4
+ import { optionalAuth } from "../middleware/auth.js";
5
+ import { asyncHandler } from "../middleware/errorHandler.js";
6
+ import { getSupabaseConfigFromHeaders } from "../services/supabase.js";
7
+
8
+ type WorkspaceRole = "owner" | "admin" | "member";
9
+ type WorkspaceStatus = "active" | "invited" | "disabled";
10
+
11
+ type WorkspaceMembershipRow = {
12
+ workspace_id: string;
13
+ role: WorkspaceRole;
14
+ status: WorkspaceStatus;
15
+ created_at: string;
16
+ updated_at: string;
17
+ };
18
+
19
+ type WorkspaceRow = {
20
+ id: string;
21
+ name: string;
22
+ owner_user_id: string;
23
+ created_at: string;
24
+ updated_at: string;
25
+ };
26
+
27
+ type WorkspaceMemberRow = {
28
+ user_id: string;
29
+ role: WorkspaceRole;
30
+ status: WorkspaceStatus;
31
+ joined_at: string;
32
+ first_name: string | null;
33
+ last_name: string | null;
34
+ email: string | null;
35
+ avatar_url: string | null;
36
+ is_current_user: boolean;
37
+ };
38
+
39
+ function mapRpcStatus(errorCode?: string): number {
40
+ if (errorCode === "42501") return 403;
41
+ if (errorCode === "22023") return 400;
42
+ return 500;
43
+ }
44
+
45
+ const router = Router();
46
+ router.use(optionalAuth);
47
+
48
+ router.get("/", asyncHandler(async (req, res) => {
49
+ if (!req.user || !req.supabase) {
50
+ res.status(401).json({ success: false, error: "Authentication required" });
51
+ return;
52
+ }
53
+
54
+ const { data: membershipsData, error: membershipsError } = await req.supabase
55
+ .from("workspace_members")
56
+ .select("workspace_id,role,status,created_at,updated_at")
57
+ .eq("user_id", req.user.id)
58
+ .eq("status", "active")
59
+ .order("created_at", { ascending: true });
60
+
61
+ if (membershipsError) {
62
+ const code = (membershipsError as { code?: string }).code;
63
+ // Backward compatibility for projects before workspace migration.
64
+ if (code === "42P01") {
65
+ res.json({
66
+ success: true,
67
+ workspaces: [],
68
+ activeWorkspaceId: null,
69
+ activeWorkspaceRole: null,
70
+ });
71
+ return;
72
+ }
73
+ res.status(500).json({ success: false, error: membershipsError.message });
74
+ return;
75
+ }
76
+
77
+ const memberships = (membershipsData ?? []) as WorkspaceMembershipRow[];
78
+ if (memberships.length === 0) {
79
+ res.json({
80
+ success: true,
81
+ workspaces: [],
82
+ activeWorkspaceId: null,
83
+ activeWorkspaceRole: null,
84
+ });
85
+ return;
86
+ }
87
+
88
+ const workspaceIds = memberships.map((membership) => membership.workspace_id);
89
+ const { data: workspaceData, error: workspaceError } = await req.supabase
90
+ .from("workspaces")
91
+ .select("id,name,owner_user_id,created_at,updated_at")
92
+ .in("id", workspaceIds);
93
+
94
+ if (workspaceError) {
95
+ res.status(500).json({ success: false, error: workspaceError.message });
96
+ return;
97
+ }
98
+
99
+ const workspaceMap = new Map<string, WorkspaceRow>();
100
+ for (const workspace of (workspaceData ?? []) as WorkspaceRow[]) {
101
+ workspaceMap.set(workspace.id, workspace);
102
+ }
103
+
104
+ const workspaces = memberships
105
+ .map((membership) => {
106
+ const workspace = workspaceMap.get(membership.workspace_id);
107
+ if (!workspace) return null;
108
+ return {
109
+ id: workspace.id,
110
+ name: workspace.name,
111
+ owner_user_id: workspace.owner_user_id,
112
+ created_at: workspace.created_at,
113
+ updated_at: workspace.updated_at,
114
+ role: membership.role,
115
+ membership_status: membership.status,
116
+ membership_created_at: membership.created_at,
117
+ };
118
+ })
119
+ .filter((workspace): workspace is NonNullable<typeof workspace> => Boolean(workspace));
120
+
121
+ const activeWorkspaceId = req.workspaceId ?? workspaces[0]?.id ?? null;
122
+ const activeWorkspace = workspaces.find((workspace) => workspace.id === activeWorkspaceId) ?? null;
123
+
124
+ res.json({
125
+ success: true,
126
+ workspaces,
127
+ activeWorkspaceId,
128
+ activeWorkspaceRole: activeWorkspace?.role ?? null,
129
+ });
130
+ }));
131
+
132
+ router.get("/:workspaceId/members", asyncHandler(async (req, res) => {
133
+ if (!req.user || !req.supabase) {
134
+ res.status(401).json({ success: false, error: "Authentication required" });
135
+ return;
136
+ }
137
+
138
+ const workspaceId = req.params["workspaceId"] as string;
139
+ if (!workspaceId) {
140
+ res.status(400).json({ success: false, error: "workspaceId is required" });
141
+ return;
142
+ }
143
+
144
+ const { data, error } = await req.supabase.rpc("workspace_list_members", {
145
+ p_workspace_id: workspaceId,
146
+ });
147
+
148
+ if (error) {
149
+ const status = mapRpcStatus((error as { code?: string }).code);
150
+ res.status(status).json({ success: false, error: error.message });
151
+ return;
152
+ }
153
+
154
+ res.json({
155
+ success: true,
156
+ members: (data ?? []) as WorkspaceMemberRow[],
157
+ });
158
+ }));
159
+
160
+ router.post("/:workspaceId/members/invite", asyncHandler(async (req, res) => {
161
+ if (!req.user || !req.supabase) {
162
+ res.status(401).json({ success: false, error: "Authentication required" });
163
+ return;
164
+ }
165
+
166
+ const workspaceId = req.params["workspaceId"] as string;
167
+ const email = typeof req.body?.email === "string" ? req.body.email.trim() : "";
168
+ const role = req.body?.role === "admin" ? "admin" : "member";
169
+
170
+ if (!workspaceId) {
171
+ res.status(400).json({ success: false, error: "workspaceId is required" });
172
+ return;
173
+ }
174
+
175
+ if (!email) {
176
+ res.status(400).json({ success: false, error: "email is required" });
177
+ return;
178
+ }
179
+
180
+ const headerConfig = getSupabaseConfigFromHeaders(req.headers as Record<string, unknown>);
181
+ const envUrl = config.supabase.url;
182
+ const envKey = config.supabase.anonKey;
183
+ const envIsValid = envUrl.startsWith("http://") || envUrl.startsWith("https://");
184
+ const supabaseUrl = envIsValid && envKey ? envUrl : headerConfig?.url;
185
+ const anonKey = envIsValid && envKey ? envKey : headerConfig?.anonKey;
186
+
187
+ if (!supabaseUrl || !anonKey) {
188
+ res.status(500).json({ success: false, error: "Supabase config unavailable for invite workflow." });
189
+ return;
190
+ }
191
+
192
+ const authHeader = req.headers.authorization;
193
+ if (!authHeader?.startsWith("Bearer ")) {
194
+ res.status(401).json({ success: false, error: "Authentication required" });
195
+ return;
196
+ }
197
+
198
+ const response = await fetch(`${supabaseUrl}/functions/v1/workspace-invite`, {
199
+ method: "POST",
200
+ headers: {
201
+ "Content-Type": "application/json",
202
+ Authorization: authHeader,
203
+ apikey: anonKey,
204
+ },
205
+ body: JSON.stringify({
206
+ workspace_id: workspaceId,
207
+ email,
208
+ role,
209
+ }),
210
+ });
211
+
212
+ const payload = await response.json().catch(() => ({}));
213
+ if (!response.ok) {
214
+ res.status(response.status).json({
215
+ success: false,
216
+ error: payload?.error?.message || payload?.error || `Invite workflow failed (${response.status})`,
217
+ });
218
+ return;
219
+ }
220
+
221
+ res.json(payload);
222
+ }));
223
+
224
+ router.patch("/:workspaceId/members/:userId", asyncHandler(async (req, res) => {
225
+ if (!req.user || !req.supabase) {
226
+ res.status(401).json({ success: false, error: "Authentication required" });
227
+ return;
228
+ }
229
+
230
+ const workspaceId = req.params["workspaceId"] as string;
231
+ const userId = req.params["userId"] as string;
232
+ const role = req.body?.role === "admin" ? "admin" : req.body?.role === "member" ? "member" : "";
233
+
234
+ if (!workspaceId || !userId) {
235
+ res.status(400).json({ success: false, error: "workspaceId and userId are required" });
236
+ return;
237
+ }
238
+
239
+ if (!role) {
240
+ res.status(400).json({ success: false, error: "role must be admin or member" });
241
+ return;
242
+ }
243
+
244
+ const { data, error } = await req.supabase.rpc("workspace_update_member_role", {
245
+ p_workspace_id: workspaceId,
246
+ p_target_user_id: userId,
247
+ p_role: role,
248
+ });
249
+
250
+ if (error) {
251
+ const status = mapRpcStatus((error as { code?: string }).code);
252
+ res.status(status).json({ success: false, error: error.message });
253
+ return;
254
+ }
255
+
256
+ res.json({
257
+ success: true,
258
+ member: Array.isArray(data) ? data[0] ?? null : null,
259
+ });
260
+ }));
261
+
262
+ router.delete("/:workspaceId/members/:userId", asyncHandler(async (req, res) => {
263
+ if (!req.user || !req.supabase) {
264
+ res.status(401).json({ success: false, error: "Authentication required" });
265
+ return;
266
+ }
267
+
268
+ const workspaceId = req.params["workspaceId"] as string;
269
+ const userId = req.params["userId"] as string;
270
+
271
+ if (!workspaceId || !userId) {
272
+ res.status(400).json({ success: false, error: "workspaceId and userId are required" });
273
+ return;
274
+ }
275
+
276
+ const { data, error } = await req.supabase.rpc("workspace_remove_member", {
277
+ p_workspace_id: workspaceId,
278
+ p_target_user_id: userId,
279
+ });
280
+
281
+ if (error) {
282
+ const status = mapRpcStatus((error as { code?: string }).code);
283
+ res.status(status).json({ success: false, error: error.message });
284
+ return;
285
+ }
286
+
287
+ res.json({ success: true, removed: data === true });
288
+ }));
289
+
290
+ export default router;
@@ -24,7 +24,8 @@ export class ChatService {
24
24
  sessionId: string,
25
25
  userId: string,
26
26
  content: string,
27
- supabase: SupabaseClient
27
+ supabase: SupabaseClient,
28
+ workspaceId?: string
28
29
  ): Promise<Message> {
29
30
  // 1. Get User/Session Settings (Models to use)
30
31
  const { data: settings } = await supabase
@@ -75,7 +76,12 @@ export class ChatService {
75
76
  content,
76
77
  userId,
77
78
  supabase,
78
- { topK: 5, similarityThreshold: 0.65, settings: embedSettings }
79
+ {
80
+ topK: 5,
81
+ similarityThreshold: 0.65,
82
+ settings: embedSettings,
83
+ workspaceId
84
+ }
79
85
  );
80
86
  Actuator.logEvent(null, userId, "analysis", "RAG Retrieval", {
81
87
  action: "RAG query response",
@@ -199,6 +199,7 @@ export class IngestionService {
199
199
  private static queueVlmSemanticEmbedding(opts: {
200
200
  ingestionId: string;
201
201
  userId: string;
202
+ workspaceId: string;
202
203
  filename: string;
203
204
  finalStatus: string;
204
205
  policyName?: string;
@@ -232,7 +233,8 @@ export class IngestionService {
232
233
  opts.userId,
233
234
  syntheticText,
234
235
  opts.supabase,
235
- opts.embedSettings
236
+ opts.embedSettings,
237
+ opts.workspaceId
236
238
  ).then(() => {
237
239
  Actuator.logEvent(opts.ingestionId, opts.userId, "analysis", "RAG Embedding", {
238
240
  action: "Completed synthetic VLM embedding",
@@ -364,6 +366,7 @@ export class IngestionService {
364
366
  static async ingest(opts: {
365
367
  supabase: SupabaseClient;
366
368
  userId: string;
369
+ workspaceId: string;
367
370
  filename: string;
368
371
  mimeType?: string;
369
372
  fileSize?: number;
@@ -372,14 +375,14 @@ export class IngestionService {
372
375
  content: string;
373
376
  fileHash?: string;
374
377
  }): Promise<Ingestion> {
375
- const { supabase, userId, filename, mimeType, fileSize, source = "upload", filePath, content, fileHash } = opts;
378
+ const { supabase, userId, workspaceId, filename, mimeType, fileSize, source = "upload", filePath, content, fileHash } = opts;
376
379
 
377
380
  // Duplicate detection — check if this exact file content was already ingested
378
381
  if (fileHash) {
379
382
  const { data: existing } = await supabase
380
383
  .from("ingestions")
381
384
  .select("id, filename, created_at")
382
- .eq("user_id", userId)
385
+ .eq("workspace_id", workspaceId)
383
386
  .eq("file_hash", fileHash)
384
387
  .eq("status", "matched")
385
388
  .order("created_at", { ascending: true })
@@ -391,6 +394,7 @@ export class IngestionService {
391
394
  const { data: dupIngestion } = await supabase
392
395
  .from("ingestions")
393
396
  .insert({
397
+ workspace_id: workspaceId,
394
398
  user_id: userId,
395
399
  source,
396
400
  filename,
@@ -411,6 +415,7 @@ export class IngestionService {
411
415
  const { data: ingestion, error: insertErr } = await supabase
412
416
  .from("ingestions")
413
417
  .insert({
418
+ workspace_id: workspaceId,
414
419
  user_id: userId,
415
420
  source,
416
421
  filename,
@@ -513,7 +518,7 @@ export class IngestionService {
513
518
  try {
514
519
  // 3. Fast Path — fetch all dependencies in parallel
515
520
  const [userPolicies, processingSettingsRow, baselineConfig] = await Promise.all([
516
- PolicyLoader.load(false, supabase),
521
+ PolicyLoader.load(false, supabase, workspaceId),
517
522
  supabase.from("user_settings").select("llm_provider, llm_model, ingestion_llm_provider, ingestion_llm_model, embedding_provider, embedding_model").eq("user_id", userId).maybeSingle(),
518
523
  BaselineConfigService.getActive(supabase, userId),
519
524
  ]);
@@ -529,12 +534,12 @@ export class IngestionService {
529
534
  attemptContent: string,
530
535
  attemptType: "primary" | "reencoded_image_retry"
531
536
  ): Promise<Ingestion> => {
532
- const doc = { filePath: filePath, text: attemptContent, ingestionId: ingestion.id, userId, supabase };
537
+ const doc = { filePath: filePath, text: attemptContent, ingestionId: ingestion.id, userId, workspaceId, supabase };
533
538
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
534
539
  const baselineTrace: Array<{ timestamp: string; step: string; details?: any }> = [];
535
540
 
536
541
  // Fire and forget Semantic Embedding Storage
537
- RAGService.chunkAndEmbed(ingestion.id, userId, doc.text, supabase, embedSettings).catch(err => {
542
+ RAGService.chunkAndEmbed(ingestion.id, userId, doc.text, supabase, embedSettings, workspaceId).catch(err => {
538
543
  logger.error(`RAG embedding failed for ${ingestion.id}`, err);
539
544
  });
540
545
 
@@ -614,6 +619,7 @@ export class IngestionService {
614
619
  const embeddingMeta = this.queueVlmSemanticEmbedding({
615
620
  ingestionId: ingestion.id,
616
621
  userId,
622
+ workspaceId,
617
623
  filename,
618
624
  finalStatus,
619
625
  policyName,
@@ -788,13 +794,14 @@ export class IngestionService {
788
794
  ingestionId: string,
789
795
  supabase: SupabaseClient,
790
796
  userId: string,
797
+ workspaceId: string,
791
798
  opts: { forcedPolicyId?: string } = {}
792
799
  ): Promise<boolean> {
793
800
  const { data: ingestion, error } = await supabase
794
801
  .from("ingestions")
795
802
  .select("*")
796
803
  .eq("id", ingestionId)
797
- .eq("user_id", userId)
804
+ .eq("workspace_id", workspaceId)
798
805
  .single();
799
806
 
800
807
  if (error || !ingestion) throw new Error("Ingestion not found");
@@ -889,7 +896,7 @@ export class IngestionService {
889
896
 
890
897
  if (isFastPath) {
891
898
  const [userPolicies, processingSettingsRow, baselineConfig] = await Promise.all([
892
- PolicyLoader.load(false, supabase),
899
+ PolicyLoader.load(false, supabase, workspaceId),
893
900
  supabase.from("user_settings").select("llm_provider, llm_model, ingestion_llm_provider, ingestion_llm_model, embedding_provider, embedding_model").eq("user_id", userId).maybeSingle(),
894
901
  BaselineConfigService.getActive(supabase, userId),
895
902
  ]);
@@ -905,12 +912,12 @@ export class IngestionService {
905
912
  attemptContent: string,
906
913
  attemptType: "primary" | "reencoded_image_retry"
907
914
  ): Promise<boolean> => {
908
- const doc = { filePath, text: attemptContent, ingestionId, userId, supabase };
915
+ const doc = { filePath, text: attemptContent, ingestionId, userId, workspaceId, supabase };
909
916
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
910
917
  const baselineTrace: Array<{ timestamp: string; step: string; details?: any }> = [];
911
918
 
912
919
  // Fire and forget Semantic Embedding Storage for re-runs
913
- RAGService.chunkAndEmbed(ingestionId, userId, doc.text, supabase, embedSettings).catch(err => {
920
+ RAGService.chunkAndEmbed(ingestionId, userId, doc.text, supabase, embedSettings, workspaceId).catch(err => {
914
921
  logger.error(`RAG embedding failed during rerun for ${ingestionId}`, err);
915
922
  });
916
923
 
@@ -1008,6 +1015,7 @@ export class IngestionService {
1008
1015
  const embeddingMeta = this.queueVlmSemanticEmbedding({
1009
1016
  ingestionId,
1010
1017
  userId,
1018
+ workspaceId,
1011
1019
  filename,
1012
1020
  finalStatus,
1013
1021
  policyName,
@@ -1159,6 +1167,7 @@ export class IngestionService {
1159
1167
  policyId: string,
1160
1168
  supabase: SupabaseClient,
1161
1169
  userId: string,
1170
+ workspaceId: string,
1162
1171
  opts: { learn?: boolean; rerun?: boolean; allowSideEffects?: boolean } = {}
1163
1172
  ): Promise<Ingestion> {
1164
1173
  const learn = opts.learn !== false;
@@ -1173,7 +1182,7 @@ export class IngestionService {
1173
1182
  .from("ingestions")
1174
1183
  .select("*")
1175
1184
  .eq("id", ingestionId)
1176
- .eq("user_id", userId)
1185
+ .eq("workspace_id", workspaceId)
1177
1186
  .single();
1178
1187
  if (ingestionError || !ingestion) {
1179
1188
  throw new Error("Ingestion not found");
@@ -1183,7 +1192,7 @@ export class IngestionService {
1183
1192
  throw new Error("Cannot manually match while ingestion is still processing");
1184
1193
  }
1185
1194
 
1186
- const policies = await PolicyLoader.load(false, supabase);
1195
+ const policies = await PolicyLoader.load(false, supabase, workspaceId);
1187
1196
  const policy = policies.find((item) => item.metadata.id === normalizedPolicyId);
1188
1197
  if (!policy) {
1189
1198
  throw new Error(`Policy "${normalizedPolicyId}" was not found or is disabled.`);
@@ -1207,8 +1216,8 @@ export class IngestionService {
1207
1216
  risky_actions: riskyActions,
1208
1217
  }, supabase);
1209
1218
 
1210
- await this.rerun(ingestionId, supabase, userId, { forcedPolicyId: policy.metadata.id });
1211
- const refreshed = await this.get(ingestionId, supabase, userId);
1219
+ await this.rerun(ingestionId, supabase, userId, workspaceId, { forcedPolicyId: policy.metadata.id });
1220
+ const refreshed = await this.get(ingestionId, supabase, workspaceId);
1212
1221
  if (!refreshed) {
1213
1222
  throw new Error("Ingestion not found after rerun.");
1214
1223
  }
@@ -1238,7 +1247,7 @@ export class IngestionService {
1238
1247
  trace: nextTrace,
1239
1248
  })
1240
1249
  .eq("id", ingestionId)
1241
- .eq("user_id", userId)
1250
+ .eq("workspace_id", workspaceId)
1242
1251
  .select("*")
1243
1252
  .single();
1244
1253
 
@@ -1260,6 +1269,7 @@ export class IngestionService {
1260
1269
  await PolicyLearningService.recordManualMatch({
1261
1270
  supabase,
1262
1271
  userId,
1272
+ workspaceId,
1263
1273
  ingestion: effectiveIngestion,
1264
1274
  policyId: policy.metadata.id,
1265
1275
  policyName: policy.metadata.name,
@@ -1278,6 +1288,7 @@ export class IngestionService {
1278
1288
  policyId: string,
1279
1289
  supabase: SupabaseClient,
1280
1290
  userId: string,
1291
+ workspaceId: string,
1281
1292
  opts: { provider?: string; model?: string } = {}
1282
1293
  ): Promise<{ policy: FolioPolicy; rationale: string[] }> {
1283
1294
  const normalizedPolicyId = policyId.trim();
@@ -1289,14 +1300,14 @@ export class IngestionService {
1289
1300
  .from("ingestions")
1290
1301
  .select("id,filename,mime_type,status,tags,summary,extracted,trace")
1291
1302
  .eq("id", ingestionId)
1292
- .eq("user_id", userId)
1303
+ .eq("workspace_id", workspaceId)
1293
1304
  .single();
1294
1305
 
1295
1306
  if (ingestionError || !ingestion) {
1296
1307
  throw new Error("Ingestion not found");
1297
1308
  }
1298
1309
 
1299
- const policies = await PolicyLoader.load(false, supabase);
1310
+ const policies = await PolicyLoader.load(false, supabase, workspaceId);
1300
1311
  const targetPolicy = policies.find((policy) => policy.metadata.id === normalizedPolicyId);
1301
1312
  if (!targetPolicy) {
1302
1313
  throw new Error(`Policy "${normalizedPolicyId}" was not found or is disabled.`);
@@ -1333,14 +1344,14 @@ export class IngestionService {
1333
1344
  }
1334
1345
 
1335
1346
  /**
1336
- * List ingestions for a user, newest first.
1347
+ * List ingestions for an active workspace, newest first.
1337
1348
  * Supports server-side pagination and ILIKE search across native text columns
1338
1349
  * (filename, policy_name, summary). Tags are handled client-side via the
1339
1350
  * filter bar; extracted JSONB search requires a tsvector migration (deferred).
1340
1351
  */
1341
1352
  static async list(
1342
1353
  supabase: SupabaseClient,
1343
- userId: string,
1354
+ workspaceId: string,
1344
1355
  opts: { page?: number; pageSize?: number; query?: string } = {}
1345
1356
  ): Promise<{ ingestions: Ingestion[]; total: number }> {
1346
1357
  const { page = 1, pageSize = 20, query } = opts;
@@ -1350,7 +1361,7 @@ export class IngestionService {
1350
1361
  let q = supabase
1351
1362
  .from("ingestions")
1352
1363
  .select("*", { count: "exact" })
1353
- .eq("user_id", userId)
1364
+ .eq("workspace_id", workspaceId)
1354
1365
  .order("created_at", { ascending: false });
1355
1366
 
1356
1367
  if (query?.trim()) {
@@ -1374,12 +1385,12 @@ export class IngestionService {
1374
1385
  /**
1375
1386
  * Get a single ingestion by ID.
1376
1387
  */
1377
- static async get(id: string, supabase: SupabaseClient, userId: string): Promise<Ingestion | null> {
1388
+ static async get(id: string, supabase: SupabaseClient, workspaceId: string): Promise<Ingestion | null> {
1378
1389
  const { data } = await supabase
1379
1390
  .from("ingestions")
1380
1391
  .select("*")
1381
1392
  .eq("id", id)
1382
- .eq("user_id", userId)
1393
+ .eq("workspace_id", workspaceId)
1383
1394
  .single();
1384
1395
  return data as Ingestion | null;
1385
1396
  }
@@ -1387,12 +1398,12 @@ export class IngestionService {
1387
1398
  /**
1388
1399
  * Delete an ingestion record.
1389
1400
  */
1390
- static async delete(id: string, supabase: SupabaseClient, userId: string): Promise<boolean> {
1401
+ static async delete(id: string, supabase: SupabaseClient, workspaceId: string): Promise<boolean> {
1391
1402
  const { count, error } = await supabase
1392
1403
  .from("ingestions")
1393
1404
  .delete({ count: "exact" })
1394
1405
  .eq("id", id)
1395
- .eq("user_id", userId);
1406
+ .eq("workspace_id", workspaceId);
1396
1407
 
1397
1408
  if (error) throw new Error(`Failed to delete ingestion: ${error.message}`);
1398
1409
  return (count ?? 0) > 0;
@@ -1407,13 +1418,14 @@ export class IngestionService {
1407
1418
  id: string,
1408
1419
  supabase: SupabaseClient,
1409
1420
  userId: string,
1421
+ workspaceId: string,
1410
1422
  llmSettings: { llm_provider?: string; llm_model?: string } = {}
1411
1423
  ): Promise<string | null> {
1412
1424
  const { data: ing } = await supabase
1413
1425
  .from("ingestions")
1414
1426
  .select("id, filename, extracted, summary, status")
1415
1427
  .eq("id", id)
1416
- .eq("user_id", userId)
1428
+ .eq("workspace_id", workspaceId)
1417
1429
  .single();
1418
1430
 
1419
1431
  if (!ing) throw new Error("Ingestion not found");
@@ -1481,7 +1493,7 @@ export class IngestionService {
1481
1493
  .from("ingestions")
1482
1494
  .update({ summary })
1483
1495
  .eq("id", id)
1484
- .eq("user_id", userId);
1496
+ .eq("workspace_id", workspaceId);
1485
1497
 
1486
1498
  logger.info(`Summary generated and cached for ingestion ${id}`);
1487
1499
  return summary;
@@ -23,6 +23,8 @@ export interface DocumentObject {
23
23
  ingestionId: string;
24
24
  /** ID of the user */
25
25
  userId: string;
26
+ /** Active workspace scope for this ingestion */
27
+ workspaceId?: string;
26
28
  /** Authenticated Supabase client used for RLS-safe event writes */
27
29
  supabase?: SupabaseClient;
28
30
  }
@@ -942,7 +944,7 @@ export class PolicyEngine {
942
944
  */
943
945
  static async process(doc: DocumentObject, settings: { llm_provider?: string; llm_model?: string } = {}, baselineEntities: Record<string, unknown> = {}): Promise<ProcessingResult> {
944
946
  logger.info(`Processing document: ${doc.filePath}`);
945
- const policies = await PolicyLoader.load();
947
+ const policies = await PolicyLoader.load(false, doc.supabase, doc.workspaceId);
946
948
  const globalTrace: TraceLog[] = [{ timestamp: new Date().toISOString(), step: "Loaded policies", details: { count: policies.length } }];
947
949
  Actuator.logEvent(doc.ingestionId, doc.userId, "info", "Triage", { action: "Loaded policies", count: policies.length }, doc.supabase);
948
950
 
@@ -1034,6 +1036,7 @@ export class PolicyEngine {
1034
1036
  const learned = await PolicyLearningService.resolveLearnedCandidate({
1035
1037
  supabase: doc.supabase,
1036
1038
  userId: doc.userId,
1039
+ workspaceId: doc.workspaceId,
1037
1040
  policyIds: policies.map((policy) => policy.metadata.id),
1038
1041
  filePath: doc.filePath,
1039
1042
  baselineEntities,