@lightupai/polaris 0.0.76 → 0.0.78

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/src/service/db.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import postgres from "postgres";
2
- import type { PolarisEvent, Project, Session, ParticipantId } from "../types";
2
+ import type { Annotation, PolarisEvent, Project, Session, ParticipantId } from "../types";
3
3
 
4
4
  export type Sql = postgres.Sql;
5
5
 
@@ -26,6 +26,11 @@ export interface User {
26
26
  created_at: string;
27
27
  }
28
28
 
29
+ export interface ProjectMember {
30
+ participant_id: string;
31
+ role: string | null;
32
+ }
33
+
29
34
  // --- Schema ---
30
35
 
31
36
  export async function createDb(connectionString?: string): Promise<Sql> {
@@ -38,11 +43,8 @@ export async function createDb(connectionString?: string): Promise<Sql> {
38
43
  const SCHEMA_LOCK_KEY = 0x504c5253; // "PLRS"
39
44
 
40
45
  export async function ensureSchema(sql: Sql): Promise<void> {
41
- // API and web both call this on startup. Serialize concurrent callers with a
42
- // session-level advisory lock held on a reserved (dedicated) connection, so
43
- // they don't race on `CREATE TABLE IF NOT EXISTS` — which isn't concurrency
44
- // safe: two connections can both pass the existence check and then collide on
45
- // the underlying pg_type creation (issue #117).
46
+ // Serialize concurrent callers (api + web start together) with an advisory lock on a
47
+ // reserved connection — concurrent CREATE TABLE IF NOT EXISTS races otherwise (issue #117).
46
48
  const lock = await sql.reserve();
47
49
  try {
48
50
  await lock`SELECT pg_advisory_lock(${SCHEMA_LOCK_KEY}::bigint)`;
@@ -82,26 +84,12 @@ async function createSchemaTables(sql: Sql): Promise<void> {
82
84
  )
83
85
  `;
84
86
 
85
- // Migrate: if projects table exists without `id` column, drop and recreate
86
- const [{ exists: hasId }] = await sql`
87
- SELECT EXISTS (
88
- SELECT 1 FROM information_schema.columns
89
- WHERE table_name = 'projects' AND column_name = 'id'
90
- ) as exists
87
+ await sql`
88
+ CREATE TABLE IF NOT EXISTS schema_migrations (
89
+ id TEXT PRIMARY KEY,
90
+ applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
91
+ )
91
92
  `;
92
- if (!hasId) {
93
- // Check if the old table exists at all
94
- const [{ exists: hasTable }] = await sql`
95
- SELECT EXISTS (
96
- SELECT 1 FROM information_schema.tables WHERE table_name = 'projects'
97
- ) as exists
98
- `;
99
- if (hasTable) {
100
- await sql`DROP TABLE IF EXISTS events`;
101
- await sql`DROP TABLE IF EXISTS sessions`;
102
- await sql`DROP TABLE IF EXISTS projects`;
103
- }
104
- }
105
93
 
106
94
  await sql`
107
95
  CREATE TABLE IF NOT EXISTS projects (
@@ -147,6 +135,39 @@ async function createSchemaTables(sql: Sql): Promise<void> {
147
135
  CREATE INDEX IF NOT EXISTS idx_events_session ON events(project_id, session, timestamp)
148
136
  `;
149
137
 
138
+ // Curation annotations (stars, tags, decisions). Kept free of foreign keys.
139
+ await sql`
140
+ CREATE TABLE IF NOT EXISTS annotations (
141
+ id UUID PRIMARY KEY,
142
+ org_id TEXT NOT NULL,
143
+ event_id UUID,
144
+ project TEXT NOT NULL,
145
+ session TEXT NOT NULL,
146
+ participant_id TEXT,
147
+ kind TEXT NOT NULL,
148
+ value TEXT,
149
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
150
+ )
151
+ `;
152
+
153
+ await sql`
154
+ CREATE INDEX IF NOT EXISTS idx_annotations_session ON annotations(org_id, project, session)
155
+ `;
156
+
157
+ await sql`
158
+ CREATE INDEX IF NOT EXISTS idx_annotations_event ON annotations(event_id)
159
+ `;
160
+
161
+ // Per-project ACL membership. Kept free of foreign keys (see annotations note above).
162
+ await sql`
163
+ CREATE TABLE IF NOT EXISTS project_members (
164
+ project_id UUID,
165
+ participant_id TEXT,
166
+ role TEXT,
167
+ PRIMARY KEY (project_id, participant_id)
168
+ )
169
+ `;
170
+
150
171
  await sql`
151
172
  CREATE TABLE IF NOT EXISTS plan_changes (
152
173
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
@@ -157,6 +178,27 @@ async function createSchemaTables(sql: Sql): Promise<void> {
157
178
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
158
179
  )
159
180
  `;
181
+
182
+ await runMigrations(sql);
183
+ }
184
+
185
+ // Additive, idempotent migrations. Each statement uses IF NOT EXISTS so it is safe to
186
+ // re-run on every startup; schema_migrations records what has been applied. NEVER drop a table.
187
+ async function runMigrations(sql: Sql): Promise<void> {
188
+ const migrations: Array<{ id: string; run: () => Promise<unknown> }> = [
189
+ {
190
+ id: "001-sessions-label",
191
+ run: () => sql`ALTER TABLE sessions ADD COLUMN IF NOT EXISTS label TEXT`,
192
+ },
193
+ {
194
+ id: "002-projects-visibility",
195
+ run: () => sql`ALTER TABLE projects ADD COLUMN IF NOT EXISTS visibility TEXT NOT NULL DEFAULT 'org'`,
196
+ },
197
+ ];
198
+ for (const migration of migrations) {
199
+ await migration.run();
200
+ await sql`INSERT INTO schema_migrations (id) VALUES (${migration.id}) ON CONFLICT (id) DO NOTHING`;
201
+ }
160
202
  }
161
203
 
162
204
  // --- Orgs ---
@@ -249,20 +291,26 @@ export async function getRecentSignups(sql: Sql, since: Date, limit = 10): Promi
249
291
 
250
292
  // --- Projects (org-scoped) ---
251
293
 
252
- export async function createProject(sql: Sql, orgId: string, name: string): Promise<Project> {
253
- const [row] = await sql`
254
- INSERT INTO projects (org_id, name) VALUES (${orgId}, ${name})
255
- RETURNING id, name, slack_channel_id, slack_channel_name, created_at
256
- `;
294
+ // SELECT * (not an explicit column list) so reads tolerate pre-visibility-migration rows.
295
+ function rowToProject(row: postgres.Row): Project {
257
296
  return {
258
297
  id: row.id,
259
298
  name: row.name,
260
299
  slack_channel_id: row.slack_channel_id ?? null,
261
300
  slack_channel_name: row.slack_channel_name ?? null,
301
+ visibility: row.visibility ?? "org",
262
302
  created_at: row.created_at.toISOString(),
263
303
  };
264
304
  }
265
305
 
306
+ export async function createProject(sql: Sql, orgId: string, name: string): Promise<Project> {
307
+ const [row] = await sql`
308
+ INSERT INTO projects (org_id, name) VALUES (${orgId}, ${name})
309
+ RETURNING *
310
+ `;
311
+ return rowToProject(row);
312
+ }
313
+
266
314
  export async function renameProject(sql: Sql, orgId: string, oldName: string, newName: string): Promise<void> {
267
315
  await sql`
268
316
  UPDATE projects SET name = ${newName}, slack_channel_name = ${newName}
@@ -279,31 +327,74 @@ export async function setProjectSlackChannel(sql: Sql, orgId: string, projectNam
279
327
 
280
328
  export async function listProjects(sql: Sql, orgId: string): Promise<Project[]> {
281
329
  const rows = await sql`
282
- SELECT id, name, slack_channel_id, slack_channel_name, created_at
283
- FROM projects WHERE org_id = ${orgId} ORDER BY created_at ASC
284
- `;
285
- return rows.map((r) => ({
286
- id: r.id,
287
- name: r.name,
288
- slack_channel_id: r.slack_channel_id ?? null,
289
- slack_channel_name: r.slack_channel_name ?? null,
290
- created_at: r.created_at.toISOString(),
291
- }));
330
+ SELECT * FROM projects WHERE org_id = ${orgId} ORDER BY created_at ASC
331
+ `;
332
+ return rows.map(rowToProject);
292
333
  }
293
334
 
294
335
  export async function getProject(sql: Sql, orgId: string, name: string): Promise<Project | null> {
295
336
  const [row] = await sql`
296
- SELECT id, name, slack_channel_id, slack_channel_name, created_at
297
- FROM projects WHERE org_id = ${orgId} AND name = ${name}
337
+ SELECT * FROM projects WHERE org_id = ${orgId} AND name = ${name}
298
338
  `;
299
339
  if (!row) return null;
300
- return {
301
- id: row.id,
302
- name: row.name,
303
- slack_channel_id: row.slack_channel_id ?? null,
304
- slack_channel_name: row.slack_channel_name ?? null,
305
- created_at: row.created_at.toISOString(),
306
- };
340
+ return rowToProject(row);
341
+ }
342
+
343
+ // --- Project visibility & members (ACL) ---
344
+
345
+ export async function setProjectVisibility(sql: Sql, orgId: string, project: string, visibility: string): Promise<void> {
346
+ // Self-heal: resetTestData recreates projects without the visibility column, so make
347
+ // sure it exists (idempotent) before writing.
348
+ await sql`ALTER TABLE projects ADD COLUMN IF NOT EXISTS visibility TEXT NOT NULL DEFAULT 'org'`;
349
+ await sql`
350
+ UPDATE projects SET visibility = ${visibility}
351
+ WHERE org_id = ${orgId} AND name = ${project}
352
+ `;
353
+ }
354
+
355
+ export async function addProjectMember(sql: Sql, orgId: string, project: string, participantId: string, role?: string): Promise<void> {
356
+ await sql`
357
+ INSERT INTO project_members (project_id, participant_id, role)
358
+ SELECT p.id, ${participantId}, ${role ?? null}
359
+ FROM projects p WHERE p.org_id = ${orgId} AND p.name = ${project}
360
+ ON CONFLICT (project_id, participant_id) DO UPDATE SET role = EXCLUDED.role
361
+ `;
362
+ }
363
+
364
+ export async function removeProjectMember(sql: Sql, orgId: string, project: string, participantId: string): Promise<void> {
365
+ await sql`
366
+ DELETE FROM project_members
367
+ WHERE participant_id = ${participantId}
368
+ AND project_id = (SELECT id FROM projects WHERE org_id = ${orgId} AND name = ${project})
369
+ `;
370
+ }
371
+
372
+ export async function listProjectMembers(sql: Sql, orgId: string, project: string): Promise<ProjectMember[]> {
373
+ const rows = await sql`
374
+ SELECT m.participant_id, m.role
375
+ FROM project_members m
376
+ JOIN projects p ON m.project_id = p.id
377
+ WHERE p.org_id = ${orgId} AND p.name = ${project}
378
+ ORDER BY m.participant_id ASC
379
+ `;
380
+ return rows.map((r) => ({ participant_id: r.participant_id, role: r.role ?? null }));
381
+ }
382
+
383
+ export async function userCanAccessProject(sql: Sql, orgId: string, project: string, participantId: string | null): Promise<boolean> {
384
+ // Default-open: anonymous callers, unknown projects, 'org' visibility, and any schema
385
+ // created before the visibility migration / project_members table all mean accessible.
386
+ if (!participantId) return true;
387
+ try {
388
+ const [row] = await sql`SELECT * FROM projects WHERE org_id = ${orgId} AND name = ${project}`;
389
+ if (!row) return true;
390
+ if ((row.visibility ?? "org") !== "members") return true;
391
+ const members = await sql`
392
+ SELECT 1 FROM project_members WHERE project_id = ${row.id} AND participant_id = ${participantId}
393
+ `;
394
+ return members.length > 0;
395
+ } catch {
396
+ return true;
397
+ }
307
398
  }
308
399
 
309
400
  // --- Sessions (org-scoped) ---
@@ -315,24 +406,27 @@ export async function createSession(
315
406
  name: string,
316
407
  driver: ParticipantId | null
317
408
  ): Promise<Session> {
409
+ // RETURNING * so this tolerates pre-label-migration session tables (label -> null).
318
410
  const [row] = await sql`
319
411
  INSERT INTO sessions (name, project_id, org_id, driver)
320
412
  SELECT ${name}, p.id, ${orgId}, ${driver}
321
413
  FROM projects p WHERE p.org_id = ${orgId} AND p.name = ${project}
322
- RETURNING name, driver, created_at
414
+ RETURNING *
323
415
  `;
324
416
  if (!row) throw new Error(`Project not found: ${project}`);
325
417
  return {
326
418
  name: row.name,
327
419
  project,
328
420
  driver: row.driver,
421
+ label: row.label ?? null,
329
422
  created_at: row.created_at.toISOString(),
330
423
  };
331
424
  }
332
425
 
333
426
  export async function getSession(sql: Sql, orgId: string, project: string, name: string): Promise<Session | null> {
427
+ // s.* so this tolerates pre-label-migration session tables (label -> null).
334
428
  const [row] = await sql`
335
- SELECT s.name, p.name as project, s.driver, s.created_at
429
+ SELECT s.*, p.name as project
336
430
  FROM sessions s
337
431
  JOIN projects p ON s.project_id = p.id
338
432
  WHERE s.org_id = ${orgId} AND p.name = ${project} AND s.name = ${name}
@@ -342,6 +436,7 @@ export async function getSession(sql: Sql, orgId: string, project: string, name:
342
436
  name: row.name,
343
437
  project: row.project,
344
438
  driver: row.driver,
439
+ label: row.label ?? null,
345
440
  created_at: row.created_at.toISOString(),
346
441
  };
347
442
  }
@@ -349,14 +444,14 @@ export async function getSession(sql: Sql, orgId: string, project: string, name:
349
444
  export async function listSessions(sql: Sql, orgId: string, project?: string): Promise<Session[]> {
350
445
  const rows = project
351
446
  ? await sql`
352
- SELECT s.name, p.name as project, s.driver, s.created_at
447
+ SELECT s.*, p.name as project
353
448
  FROM sessions s
354
449
  JOIN projects p ON s.project_id = p.id
355
450
  WHERE s.org_id = ${orgId} AND p.name = ${project}
356
451
  ORDER BY s.created_at ASC
357
452
  `
358
453
  : await sql`
359
- SELECT s.name, p.name as project, s.driver, s.created_at
454
+ SELECT s.*, p.name as project
360
455
  FROM sessions s
361
456
  JOIN projects p ON s.project_id = p.id
362
457
  WHERE s.org_id = ${orgId}
@@ -366,6 +461,7 @@ export async function listSessions(sql: Sql, orgId: string, project?: string): P
366
461
  name: row.name,
367
462
  project: row.project,
368
463
  driver: row.driver,
464
+ label: row.label ?? null,
369
465
  created_at: row.created_at.toISOString(),
370
466
  }));
371
467
  }
@@ -413,25 +509,28 @@ export async function clearDriver(sql: Sql, orgId: string, project: string, sess
413
509
  `;
414
510
  }
415
511
 
512
+ export async function setSessionLabel(sql: Sql, orgId: string, project: string, session: string, label: string): Promise<void> {
513
+ await sql`
514
+ UPDATE sessions SET label = ${label}
515
+ WHERE org_id = ${orgId} AND name = ${session}
516
+ AND project_id = (SELECT id FROM projects WHERE org_id = ${orgId} AND name = ${project})
517
+ `;
518
+ }
519
+
416
520
  // --- Events (org-scoped) ---
417
521
 
418
522
  export async function pushEvent(sql: Sql, orgId: string, event: PolarisEvent): Promise<void> {
419
- await sql`
523
+ const result = await sql`
420
524
  INSERT INTO events (id, org_id, project_id, session, timestamp, source, sender, payload)
421
525
  SELECT ${event.id}, ${orgId}, p.id, ${event.session}, ${event.timestamp}, ${event.source}, ${event.sender}, ${sql.json(event.payload)}
422
526
  FROM projects p WHERE p.org_id = ${orgId} AND p.name = ${event.project}
423
527
  `;
528
+ // Realtime backbone: notify listeners (server WS/SSE fan-out, Slack bridge) with the
529
+ // event id only — tiny payload; listeners fetch the full event via getEventById.
530
+ if (result.count > 0) await sql.notify("polaris_event", event.id);
424
531
  }
425
532
 
426
- function rowToEvent(row: {
427
- id: string;
428
- project: string;
429
- session: string;
430
- timestamp: Date;
431
- source: string;
432
- sender: string;
433
- payload: unknown;
434
- }): PolarisEvent {
533
+ function rowToEvent(row: postgres.Row): PolarisEvent {
435
534
  return {
436
535
  id: row.id,
437
536
  project: row.project,
@@ -443,6 +542,21 @@ function rowToEvent(row: {
443
542
  };
444
543
  }
445
544
 
545
+ // Lookup for LISTEN('polaris_event') subscribers. Returns org_id too so multi-org
546
+ // consumers (e.g. the Slack bridge) can route without a second query.
547
+ export async function getEventById(sql: Sql, id: string): Promise<(PolarisEvent & { org_id: string }) | null> {
548
+ // NOTIFY payloads are untyped strings; a non-UUID would fail the uuid cast, so treat it as not-found.
549
+ if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id)) return null;
550
+ const [row] = await sql`
551
+ SELECT e.id, e.org_id, p.name as project, e.session, e.timestamp, e.source, e.sender, e.payload
552
+ FROM events e
553
+ JOIN projects p ON e.project_id = p.id
554
+ WHERE e.id = ${id}
555
+ `;
556
+ if (!row) return null;
557
+ return { ...rowToEvent(row), org_id: row.org_id };
558
+ }
559
+
446
560
  export async function getProjectEvents(sql: Sql, orgId: string, project: string): Promise<PolarisEvent[]> {
447
561
  const rows = await sql`
448
562
  SELECT e.id, p.name as project, e.session, e.timestamp, e.source, e.sender, e.payload
@@ -465,6 +579,93 @@ export async function getSessionEvents(sql: Sql, orgId: string, project: string,
465
579
  return rows.map(rowToEvent);
466
580
  }
467
581
 
582
+ export async function getSessionEventsPage(
583
+ sql: Sql,
584
+ orgId: string,
585
+ project: string,
586
+ session: string,
587
+ opts?: { limit?: number; before?: string }
588
+ ): Promise<{ events: PolarisEvent[]; nextCursor: string | null }> {
589
+ const limit = Math.min(Math.max(1, Math.floor(opts?.limit ?? 100)), 500);
590
+ // Cursor is `${iso_timestamp}|${id}`; keyset pagination on (timestamp, id) DESC.
591
+ let beforeTs: string | null = null;
592
+ let beforeId: string | null = null;
593
+ if (opts?.before) {
594
+ const sep = opts.before.lastIndexOf("|");
595
+ if (sep > 0) {
596
+ beforeTs = opts.before.slice(0, sep);
597
+ beforeId = opts.before.slice(sep + 1);
598
+ }
599
+ }
600
+ const rows = beforeTs && beforeId
601
+ ? await sql`
602
+ SELECT e.id, p.name as project, e.session, e.timestamp, e.source, e.sender, e.payload
603
+ FROM events e
604
+ JOIN projects p ON e.project_id = p.id
605
+ WHERE e.org_id = ${orgId} AND p.name = ${project} AND e.session = ${session}
606
+ AND (e.timestamp, e.id) < (${beforeTs}::timestamptz, ${beforeId}::uuid)
607
+ ORDER BY e.timestamp DESC, e.id DESC
608
+ LIMIT ${limit}
609
+ `
610
+ : await sql`
611
+ SELECT e.id, p.name as project, e.session, e.timestamp, e.source, e.sender, e.payload
612
+ FROM events e
613
+ JOIN projects p ON e.project_id = p.id
614
+ WHERE e.org_id = ${orgId} AND p.name = ${project} AND e.session = ${session}
615
+ ORDER BY e.timestamp DESC, e.id DESC
616
+ LIMIT ${limit}
617
+ `;
618
+ const events = rows.map(rowToEvent);
619
+ const oldest = events[events.length - 1];
620
+ const nextCursor = events.length === limit && oldest ? `${oldest.timestamp}|${oldest.id}` : null;
621
+ return { events, nextCursor };
622
+ }
623
+
624
+ // Query-time full-text search (explicit 'english'::regconfig form, immutable-safe; no index yet).
625
+ // FOLLOW-UP: trigger-maintained indexed search_tsv before scale.
626
+ export async function searchEvents(
627
+ sql: Sql,
628
+ orgId: string,
629
+ opts: { q: string; project?: string; session?: string; sender?: string; source?: string; tag?: string; limit?: number; participantId?: string | null }
630
+ ): Promise<{ results: Array<{ event: PolarisEvent; snippet: string }> }> {
631
+ const limit = Math.min(Math.max(1, Math.floor(opts.limit ?? 50)), 100);
632
+ // Restrict to projects the caller may access — without this an unfiltered search
633
+ // would leak 'members'-restricted projects. (no participant -> default-open)
634
+ const aclFilter = opts.participantId
635
+ ? sql`AND (COALESCE(p.visibility, 'org') <> 'members' OR EXISTS (
636
+ SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.participant_id = ${opts.participantId}
637
+ ))`
638
+ : sql``;
639
+ const rows = await sql`
640
+ SELECT e.id, p.name as project, e.session, e.timestamp, e.source, e.sender, e.payload,
641
+ ts_headline('english'::regconfig, t.text, q.query) as snippet,
642
+ ts_rank(to_tsvector('english'::regconfig, t.text), q.query) as score
643
+ FROM events e
644
+ JOIN projects p ON e.project_id = p.id
645
+ CROSS JOIN LATERAL (
646
+ SELECT coalesce(e.payload->>'prompt', '') || ' ' || coalesce(e.payload->>'stop_response', '') || ' ' ||
647
+ coalesce(e.payload->>'last_assistant_message', '') || ' ' || coalesce(e.payload->>'content', '') as text
648
+ ) t
649
+ CROSS JOIN LATERAL (SELECT websearch_to_tsquery('english', ${opts.q}) as query) q
650
+ WHERE e.org_id = ${orgId}
651
+ AND to_tsvector('english'::regconfig, t.text) @@ q.query
652
+ ${opts.project ? sql`AND p.name = ${opts.project}` : sql``}
653
+ ${opts.session ? sql`AND e.session = ${opts.session}` : sql``}
654
+ ${opts.sender ? sql`AND e.sender = ${opts.sender}` : sql``}
655
+ ${opts.source ? sql`AND e.source = ${opts.source}` : sql``}
656
+ ${opts.tag ? sql`AND EXISTS (
657
+ SELECT 1 FROM annotations a
658
+ WHERE a.event_id = e.id AND a.org_id = ${orgId} AND a.kind = 'tag' AND a.value = ${opts.tag}
659
+ )` : sql``}
660
+ ${aclFilter}
661
+ ORDER BY score DESC, e.timestamp DESC
662
+ LIMIT ${limit}
663
+ `;
664
+ return {
665
+ results: rows.map((row) => ({ event: rowToEvent(row), snippet: row.snippet as string })),
666
+ };
667
+ }
668
+
468
669
  export async function getOrgEventsSince(sql: Sql, orgId: string, since: string): Promise<PolarisEvent[]> {
469
670
  const rows = await sql`
470
671
  SELECT e.id, p.name as project, e.session, e.timestamp, e.source, e.sender, e.payload
@@ -486,3 +687,77 @@ export async function getEventsSince(sql: Sql, orgId: string, project: string, s
486
687
  `;
487
688
  return rows.map(rowToEvent);
488
689
  }
690
+
691
+ // --- Annotations (org-scoped curation: stars, tags, decisions) ---
692
+
693
+ function rowToAnnotation(row: postgres.Row): Annotation {
694
+ return {
695
+ id: row.id,
696
+ event_id: row.event_id ?? null,
697
+ project: row.project,
698
+ session: row.session,
699
+ participant_id: row.participant_id ?? null,
700
+ kind: row.kind as Annotation["kind"],
701
+ value: row.value ?? null,
702
+ created_at: row.created_at.toISOString(),
703
+ };
704
+ }
705
+
706
+ export async function addAnnotation(
707
+ sql: Sql,
708
+ orgId: string,
709
+ annotation: {
710
+ event_id?: string | null;
711
+ project: string;
712
+ session: string;
713
+ participant_id?: string | null;
714
+ kind: Annotation["kind"];
715
+ value?: string | null;
716
+ }
717
+ ): Promise<{ id: string }> {
718
+ const id = crypto.randomUUID();
719
+ await sql`
720
+ INSERT INTO annotations (id, org_id, event_id, project, session, participant_id, kind, value)
721
+ VALUES (${id}, ${orgId}, ${annotation.event_id ?? null}, ${annotation.project}, ${annotation.session},
722
+ ${annotation.participant_id ?? null}, ${annotation.kind}, ${annotation.value ?? null})
723
+ `;
724
+ return { id };
725
+ }
726
+
727
+ export async function listSessionAnnotations(sql: Sql, orgId: string, project: string, session: string): Promise<Annotation[]> {
728
+ const rows = await sql`
729
+ SELECT * FROM annotations
730
+ WHERE org_id = ${orgId} AND project = ${project} AND session = ${session}
731
+ ORDER BY created_at ASC
732
+ `;
733
+ return rows.map(rowToAnnotation);
734
+ }
735
+
736
+ export async function listDecisions(sql: Sql, orgId: string, opts?: { project?: string; limit?: number; participantId?: string | null }): Promise<Annotation[]> {
737
+ const limit = Math.min(Math.max(1, Math.floor(opts?.limit ?? 100)), 500);
738
+ // Exclude decisions from 'members'-restricted projects the caller isn't in
739
+ // (default-open: missing project row / 'org' visibility / member -> kept).
740
+ const aclFilter = opts?.participantId
741
+ ? sql`AND NOT EXISTS (
742
+ SELECT 1 FROM projects p
743
+ WHERE p.org_id = annotations.org_id AND p.name = annotations.project
744
+ AND COALESCE(p.visibility, 'org') = 'members'
745
+ AND NOT EXISTS (
746
+ SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.participant_id = ${opts.participantId}
747
+ )
748
+ )`
749
+ : sql``;
750
+ const rows = await sql`
751
+ SELECT * FROM annotations
752
+ WHERE org_id = ${orgId} AND kind = 'decision'
753
+ ${opts?.project ? sql`AND project = ${opts.project}` : sql``}
754
+ ${aclFilter}
755
+ ORDER BY created_at DESC
756
+ LIMIT ${limit}
757
+ `;
758
+ return rows.map(rowToAnnotation);
759
+ }
760
+
761
+ export async function deleteAnnotation(sql: Sql, orgId: string, id: string): Promise<void> {
762
+ await sql`DELETE FROM annotations WHERE org_id = ${orgId} AND id = ${id}`;
763
+ }