@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.
- package/api/src/middleware/auth.ts +77 -0
- package/api/src/routes/chat.ts +7 -1
- package/api/src/routes/index.ts +2 -0
- package/api/src/routes/ingestions.ts +45 -5
- package/api/src/routes/policies.ts +50 -7
- package/api/src/routes/stats.ts +9 -5
- package/api/src/routes/workspaces.ts +290 -0
- package/api/src/services/ChatService.ts +8 -2
- package/api/src/services/IngestionService.ts +38 -26
- package/api/src/services/PolicyEngine.ts +4 -1
- package/api/src/services/PolicyLearningService.ts +31 -6
- package/api/src/services/PolicyLoader.ts +44 -25
- package/api/src/services/RAGService.ts +52 -12
- package/dist/api/src/middleware/auth.js +59 -0
- package/dist/api/src/routes/chat.js +1 -1
- package/dist/api/src/routes/index.js +2 -0
- package/dist/api/src/routes/ingestions.js +45 -8
- package/dist/api/src/routes/policies.js +49 -7
- package/dist/api/src/routes/stats.js +9 -5
- package/dist/api/src/routes/workspaces.js +220 -0
- package/dist/api/src/services/ChatService.js +7 -2
- package/dist/api/src/services/IngestionService.js +35 -30
- package/dist/api/src/services/PolicyEngine.js +2 -1
- package/dist/api/src/services/PolicyLearningService.js +28 -6
- package/dist/api/src/services/PolicyLoader.js +29 -25
- package/dist/api/src/services/RAGService.js +43 -11
- package/dist/assets/index-CTn5FcC4.js +113 -0
- package/dist/assets/index-Dq9sxoZK.css +1 -0
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/supabase/functions/workspace-invite/index.ts +110 -0
- package/supabase/migrations/20260223000000_initial_foundation.sql +5 -0
- package/supabase/migrations/20260224000004_add_avatars_storage.sql +4 -0
- package/supabase/migrations/20260224000006_add_policies_table.sql +5 -0
- package/supabase/migrations/20260224000008_add_ingestions_table.sql +2 -0
- package/supabase/migrations/20260225000000_setup_compatible_mode.sql +17 -4
- package/supabase/migrations/20260225000003_add_baseline_configs.sql +4 -3
- package/supabase/migrations/20260226000000_add_processing_events.sql +1 -0
- package/supabase/migrations/20260226000002_add_dynamic_rag.sql +1 -0
- package/supabase/migrations/20260226000005_add_chat_tables.sql +3 -0
- package/supabase/migrations/20260228000001_add_policy_match_feedback.sql +4 -0
- package/supabase/migrations/20260302064608_add_ingestion_llm_settings_compat.sql +15 -0
- package/supabase/migrations/20260303000000_add_workspaces_phase1.sql +459 -0
- package/supabase/migrations/20260303010000_add_workspace_management_rpc.sql +310 -0
- package/supabase/migrations/20260303020000_workspace_scope_document_chunks.sql +139 -0
- package/dist/assets/index-DzN8-j-e.css +0 -1
- 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
|
-
{
|
|
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("
|
|
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("
|
|
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("
|
|
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,
|
|
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("
|
|
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("
|
|
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
|
|
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
|
-
|
|
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("
|
|
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,
|
|
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("
|
|
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,
|
|
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("
|
|
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("
|
|
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("
|
|
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,
|