@promptowl/contextnest-community 1.14.0 → 1.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -711,6 +711,12 @@ function runMigrations(db) {
711
711
  if (!nestCols.includes("allow_self_approve")) {
712
712
  db.exec("ALTER TABLE nests ADD COLUMN allow_self_approve INTEGER NOT NULL DEFAULT 0");
713
713
  }
714
+ if (!nestCols.includes("prime_only_review")) {
715
+ db.exec("ALTER TABLE nests ADD COLUMN prime_only_review INTEGER NOT NULL DEFAULT 0");
716
+ }
717
+ if (!nestCols.includes("prime_tags")) {
718
+ db.exec("ALTER TABLE nests ADD COLUMN prime_tags TEXT NOT NULL DEFAULT 'prime-document'");
719
+ }
714
720
  const userCols = db.prepare("PRAGMA table_info(users)").all().map((c) => c.name);
715
721
  if (!userCols.includes("is_admin")) {
716
722
  db.exec("ALTER TABLE users ADD COLUMN is_admin INTEGER NOT NULL DEFAULT 0");
@@ -1454,6 +1460,53 @@ function runMigrations(db) {
1454
1460
  })();
1455
1461
  recordMigration("031_team_source");
1456
1462
  }
1463
+ if (!hasMigration("032_deletion_requests")) {
1464
+ db.transaction(() => {
1465
+ db.exec(`
1466
+ CREATE TABLE IF NOT EXISTS deletion_requests (
1467
+ id TEXT PRIMARY KEY,
1468
+ nest_id TEXT NOT NULL REFERENCES nests(id) ON DELETE CASCADE,
1469
+ node_id TEXT NOT NULL,
1470
+ requested_by TEXT NOT NULL, -- email
1471
+ reason TEXT NOT NULL,
1472
+ status TEXT NOT NULL DEFAULT 'pending'
1473
+ CHECK(status IN ('pending', 'declined')),
1474
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
1475
+ resolved_by TEXT, -- email
1476
+ resolved_at TEXT,
1477
+ resolution_note TEXT
1478
+ );
1479
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_deletion_requests_pending
1480
+ ON deletion_requests(nest_id, node_id) WHERE status = 'pending';
1481
+ CREATE INDEX IF NOT EXISTS idx_deletion_requests_nest
1482
+ ON deletion_requests(nest_id, status);
1483
+ `);
1484
+ })();
1485
+ recordMigration("032_deletion_requests");
1486
+ }
1487
+ if (!hasMigration("033_deletion_request_targets")) {
1488
+ db.transaction(() => {
1489
+ const cols = db.prepare("PRAGMA table_info(deletion_requests)").all().map((c) => c.name);
1490
+ if (!cols.includes("target_type")) {
1491
+ db.exec(
1492
+ "ALTER TABLE deletion_requests ADD COLUMN target_type TEXT NOT NULL DEFAULT 'document'"
1493
+ );
1494
+ }
1495
+ db.exec(`
1496
+ DROP INDEX IF EXISTS idx_deletion_requests_pending;
1497
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_deletion_requests_pending
1498
+ ON deletion_requests(nest_id, target_type, node_id) WHERE status = 'pending';
1499
+ `);
1500
+ })();
1501
+ recordMigration("033_deletion_request_targets");
1502
+ }
1503
+ if (!hasMigration("034_versions_author_status_index")) {
1504
+ db.exec(`
1505
+ CREATE INDEX IF NOT EXISTS idx_versions_author_status
1506
+ ON node_versions(author, status);
1507
+ `);
1508
+ recordMigration("034_versions_author_status_index");
1509
+ }
1457
1510
  }
1458
1511
  function mergeCaseCollidingUsers(db) {
1459
1512
  const groups = db.prepare(
@@ -1671,7 +1724,7 @@ async function initDb() {
1671
1724
  if (config.DB_DRIVER === "postgres") {
1672
1725
  const { Pool } = await import("pg");
1673
1726
  const { PostgresAdapter } = await import("./adapter.postgres-YOODX2BI.js");
1674
- const { runPostgresMigrations } = await import("./migrations.postgres-BRXZY2GE.js");
1727
+ const { runPostgresMigrations } = await import("./migrations.postgres-CYKO5CFV.js");
1675
1728
  const pool = new Pool(buildPgConfig());
1676
1729
  adapter = new PostgresAdapter(pool);
1677
1730
  await runPostgresMigrations(adapter);
@@ -2,39 +2,42 @@ import {
2
2
  grantCoversNode,
3
3
  listUserGrants,
4
4
  resolveNodeGrant
5
- } from "./chunk-IHGPZ6NY.js";
5
+ } from "./chunk-5IAKUUZF.js";
6
6
  import {
7
7
  createVersion,
8
8
  getApprovedVersion,
9
9
  getCurrentVersion,
10
10
  setApprovedVersion
11
- } from "./chunk-JP67WXLA.js";
11
+ } from "./chunk-JWVZ3T35.js";
12
12
  import {
13
- buildDocContext,
14
- buildTitleMap,
15
13
  canUserAccess,
16
14
  canUserApprove,
15
+ resolveStewardsForNode
16
+ } from "./chunk-GWDOLYFJ.js";
17
+ import {
18
+ buildDocContext,
19
+ buildTitleMap,
17
20
  describeNode,
18
21
  engineCache,
22
+ insertOrReplace,
19
23
  isPublicReader,
20
24
  isStewardshipEnabled,
25
+ nestName,
21
26
  notifyEmailForNest,
22
- resolveStewardsForNode,
23
- sendEmailToRecipient
24
- } from "./chunk-KPK656BH.js";
27
+ nowExpr,
28
+ prettyFolderPath,
29
+ sendEmailToRecipient,
30
+ titleForNode
31
+ } from "./chunk-7BOG3S5H.js";
25
32
  import {
26
33
  ConflictError,
27
34
  NotFoundError,
28
35
  ValidationError
29
36
  } from "./chunk-GUNJTORH.js";
30
- import {
31
- insertOrReplace,
32
- nowExpr
33
- } from "./chunk-XQ46F76G.js";
34
37
  import {
35
38
  config,
36
39
  getDb
37
- } from "./chunk-I5KYGMIT.js";
40
+ } from "./chunk-BCFKLY4H.js";
38
41
 
39
42
  // src/governance/review-service.ts
40
43
  import { v4 as uuid3 } from "uuid";
@@ -83,6 +86,7 @@ async function notifyReviewRequested(params) {
83
86
  [nestId, nodeId, nestId, nodeId]
84
87
  );
85
88
  const now = (/* @__PURE__ */ new Date()).toISOString();
89
+ const label = await describeNode(nestId, nodeId);
86
90
  let created = 0;
87
91
  for (const r of rows) {
88
92
  if (r.user_email.toLowerCase() === requestedBy.toLowerCase()) continue;
@@ -94,7 +98,7 @@ async function notifyReviewRequested(params) {
94
98
  nestId,
95
99
  r.user_email.toLowerCase(),
96
100
  reviewId,
97
- r.via === nodeId ? `"${nodeId}" has a new version awaiting review (submitted by ${requestedBy})` : `Agent "${r.via}" produced output on "${nodeId}" \u2014 awaiting review`,
101
+ r.via === nodeId ? `"${label}" has a new version awaiting review (submitted by ${requestedBy})` : `Agent "${await describeNode(nestId, r.via)}" produced output on "${label}" \u2014 awaiting review`,
98
102
  now
99
103
  ]
100
104
  );
@@ -126,6 +130,7 @@ async function notifyReviewResolved(params) {
126
130
  recipients.add(requestedBy.toLowerCase());
127
131
  recipients.delete(resolvedBy.toLowerCase());
128
132
  const now = (/* @__PURE__ */ new Date()).toISOString();
133
+ const label = await describeNode(nestId, nodeId);
129
134
  let created = 0;
130
135
  for (const email of recipients) {
131
136
  await db.run(
@@ -137,7 +142,7 @@ async function notifyReviewResolved(params) {
137
142
  email,
138
143
  `review_${status}`,
139
144
  reviewId,
140
- `"${nodeId}" was ${status} by ${resolvedBy}`,
145
+ `"${label}" was ${status} by ${resolvedBy}`,
141
146
  now
142
147
  ]
143
148
  );
@@ -150,7 +155,7 @@ async function notifyReviewResolved(params) {
150
155
  }
151
156
  }
152
157
  async function notifyNestInvite(params) {
153
- const { nestId, inviteeEmail, permission, nestName } = params;
158
+ const { nestId, inviteeEmail, permission, nestName: nestName2 } = params;
154
159
  try {
155
160
  await getDb().run(
156
161
  `INSERT INTO notifications (id, nest_id, user_email, kind, subject_id, message, created_at)
@@ -160,7 +165,7 @@ async function notifyNestInvite(params) {
160
165
  nestId,
161
166
  inviteeEmail.toLowerCase(),
162
167
  nestId,
163
- `You were added to "${nestName}" as ${permission}`,
168
+ `You were added to "${nestName2}" as ${permission}`,
164
169
  (/* @__PURE__ */ new Date()).toISOString()
165
170
  ]
166
171
  );
@@ -628,8 +633,8 @@ function msTeamsPayload(title, message) {
628
633
  }
629
634
  async function notifyMsTeamsForNest(nestId, message) {
630
635
  if (!config.MSTEAMS_WEBHOOK_URL) return;
631
- const nestName = await resolveNestName(nestId);
632
- return notifyMsTeams(nestName, message);
636
+ const nestName2 = await resolveNestName(nestId);
637
+ return notifyMsTeams(nestName2, message);
633
638
  }
634
639
  async function notifyMsTeams(title, message) {
635
640
  const url = config.MSTEAMS_WEBHOOK_URL;
@@ -640,8 +645,8 @@ async function notifyMsTeams(title, message) {
640
645
  // src/notify/slack.ts
641
646
  async function notifySlackForNest(nestId, message) {
642
647
  if (!config.SLACK_WEBHOOK_URL) return;
643
- const nestName = await resolveNestName(nestId);
644
- return notifySlack(`*[${nestName}]* ${message}`);
648
+ const nestName2 = await resolveNestName(nestId);
649
+ return notifySlack(`*[${nestName2}]* ${message}`);
645
650
  }
646
651
  function escapeMrkdwn(s) {
647
652
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
@@ -657,25 +662,31 @@ var EVENT_EMOJI = {
657
662
  review_requested: ":memo:",
658
663
  review_approved: ":white_check_mark:",
659
664
  review_rejected: ":x:",
660
- run_failed: ":rotating_light:"
665
+ run_failed: ":rotating_light:",
666
+ deletion_requested: ":wastebasket:",
667
+ deletion_deleted: ":wastebasket:",
668
+ deletion_declined: ":no_entry_sign:"
661
669
  };
662
670
  var DIGEST_DELIVERED_KINDS = /* @__PURE__ */ new Set([
663
671
  "review_requested",
664
672
  "review_approved",
665
- "review_rejected"
673
+ "review_rejected",
674
+ "deletion_requested",
675
+ "deletion_deleted",
676
+ "deletion_declined"
666
677
  ]);
667
- function formatBody(channel, nestName, ev) {
668
- const line = `[${nestName}] ${ev.message}`;
678
+ function formatBody(channel, nestName2, ev) {
679
+ const line = `[${nestName2}] ${ev.message}`;
669
680
  if (channel === "slack") {
670
- return { text: escapeMrkdwn(`${EVENT_EMOJI[ev.kind] ?? ""} *[${nestName}]* ${ev.message}`) };
681
+ return { text: escapeMrkdwn(`${EVENT_EMOJI[ev.kind] ?? ""} *[${nestName2}]* ${ev.message}`) };
671
682
  }
672
683
  if (channel === "teams") {
673
- return msTeamsPayload(nestName, `${EVENT_EMOJI[ev.kind] ?? ""} ${ev.message}`.trim());
684
+ return msTeamsPayload(nestName2, `${EVENT_EMOJI[ev.kind] ?? ""} ${ev.message}`.trim());
674
685
  }
675
686
  return {
676
687
  kind: ev.kind,
677
688
  nest_id: ev.nestId,
678
- nest_name: nestName,
689
+ nest_name: nestName2,
679
690
  message: ev.message,
680
691
  subject_id: ev.subjectId ?? null,
681
692
  actor: ev.actor ?? null,
@@ -716,18 +727,18 @@ async function resolveUrl(nestId, raw) {
716
727
  }
717
728
  async function dispatchEvent(ev) {
718
729
  const db = getDb();
719
- const nestName = await resolveNestName(ev.nestId);
730
+ const nestName2 = await resolveNestName(ev.nestId);
720
731
  if (config.SLACK_WEBHOOK_URL && !DIGEST_DELIVERED_KINDS.has(ev.kind)) {
721
732
  void postWebhook(
722
733
  config.SLACK_WEBHOOK_URL,
723
- formatBody("slack", nestName, ev),
734
+ formatBody("slack", nestName2, ev),
724
735
  "server-slack"
725
736
  );
726
737
  }
727
738
  if (config.MSTEAMS_WEBHOOK_URL && !DIGEST_DELIVERED_KINDS.has(ev.kind)) {
728
739
  void postWebhook(
729
740
  config.MSTEAMS_WEBHOOK_URL,
730
- formatBody("teams", nestName, ev),
741
+ formatBody("teams", nestName2, ev),
731
742
  "server-msteams"
732
743
  );
733
744
  }
@@ -749,7 +760,7 @@ async function dispatchEvent(ev) {
749
760
  if (!kinds.includes("*") && !kinds.includes(ev.kind)) continue;
750
761
  const url = await resolveUrl(ev.nestId, r.url);
751
762
  if (!url) continue;
752
- void postWebhook(url, formatBody(r.channel, nestName, ev), `connector:${r.id}`);
763
+ void postWebhook(url, formatBody(r.channel, nestName2, ev), `connector:${r.id}`);
753
764
  }
754
765
  }
755
766
  async function sendTestNotification(channel) {
@@ -835,6 +846,12 @@ async function safePublishDocument(storage, docId, options) {
835
846
  await storage.writeDocument(docId, serializeDocument(cleanedNode));
836
847
  return publishDocument(storage, docId, options);
837
848
  }
849
+ function serializeNodeSafe(node) {
850
+ return serializeDocument({
851
+ ...node,
852
+ frontmatter: stripUndefinedDeep(node.frontmatter)
853
+ });
854
+ }
838
855
  function stripUndefinedDeep(value) {
839
856
  if (Array.isArray(value)) {
840
857
  return value.filter((v) => v !== void 0).map((v) => stripUndefinedDeep(v));
@@ -1248,6 +1265,297 @@ async function getPendingReview(nestId, nodeId) {
1248
1265
  );
1249
1266
  return row ? rowToReviewRequest(row) : null;
1250
1267
  }
1268
+ async function deletionTargetTitle(request, titles) {
1269
+ if (request.targetType === "folder") return prettyFolderPath(request.nodeId);
1270
+ if (request.targetType === "nest") return await nestName(request.nestId);
1271
+ return titles?.get(request.nodeId) ?? await titleForNode(request.nestId, request.nodeId);
1272
+ }
1273
+ async function nestAdminEmails(nestId) {
1274
+ const db = getDb();
1275
+ const rows = await db.all(
1276
+ `SELECT u.email FROM nests n JOIN users u ON u.id = n.user_id WHERE n.id = ?
1277
+ UNION
1278
+ SELECT u.email FROM nest_collaborators nc JOIN users u ON u.id = nc.user_id
1279
+ WHERE nc.nest_id = ? AND nc.permission IN ('owner', 'admin')`,
1280
+ [nestId, nestId]
1281
+ );
1282
+ return [...new Set(rows.map((r) => r.email.toLowerCase()))];
1283
+ }
1284
+ async function inboxInsert(nestId, recipients, kind, subjectId, message) {
1285
+ const db = getDb();
1286
+ const now = (/* @__PURE__ */ new Date()).toISOString();
1287
+ for (const email of recipients) {
1288
+ try {
1289
+ await db.run(
1290
+ `INSERT INTO notifications (id, nest_id, user_email, kind, subject_id, message, created_at)
1291
+ VALUES (?, ?, ?, ?, ?, ?, ?)`,
1292
+ [uuid3(), nestId, email.toLowerCase(), kind, subjectId, message, now]
1293
+ );
1294
+ } catch (err) {
1295
+ console.error("[notify] deletion inbox insert failed", nestId, email, err);
1296
+ }
1297
+ }
1298
+ }
1299
+ async function deletionTargetContext(request, baseUrl) {
1300
+ if (request.targetType === "document") {
1301
+ const ctx = await buildDocContext(request.nestId, request.nodeId, baseUrl);
1302
+ return { noun: "document", ...ctx };
1303
+ }
1304
+ const name = await nestName(request.nestId);
1305
+ const label = request.targetType === "folder" ? prettyFolderPath(request.nodeId) : name;
1306
+ return {
1307
+ noun: request.targetType,
1308
+ label,
1309
+ docTitle: label,
1310
+ path: name,
1311
+ link: baseUrl ? `${baseUrl}/?nest=${encodeURIComponent(request.nestId)}` : ""
1312
+ };
1313
+ }
1314
+ async function notifyDeletionRequested(request, baseUrl) {
1315
+ try {
1316
+ const ctx = await deletionTargetContext(request, baseUrl);
1317
+ const line = `Deletion requested for ${ctx.noun} *${ctx.label}* by ${request.requestedBy} \u2014 "${request.reason}"`;
1318
+ const admins = (await nestAdminEmails(request.nestId)).filter(
1319
+ (e) => e !== request.requestedBy.toLowerCase()
1320
+ );
1321
+ await inboxInsert(
1322
+ request.nestId,
1323
+ admins,
1324
+ "deletion_requested",
1325
+ request.id,
1326
+ `${ctx.noun === "document" ? "" : `${ctx.noun} `}"${ctx.docTitle}" was flagged for deletion by ${request.requestedBy} \u2014 "${request.reason}"`
1327
+ );
1328
+ void dispatchEvent({
1329
+ kind: "deletion_requested",
1330
+ nestId: request.nestId,
1331
+ subjectId: request.nodeId,
1332
+ actor: request.requestedBy,
1333
+ message: line
1334
+ });
1335
+ void notifyNestEvent(
1336
+ request.nestId,
1337
+ `:wastebasket: ${line}`,
1338
+ {
1339
+ status: "deletion_requested",
1340
+ docTitle: ctx.docTitle,
1341
+ path: ctx.path,
1342
+ link: ctx.link,
1343
+ actor: request.requestedBy,
1344
+ note: request.reason
1345
+ },
1346
+ // The admins are emailed directly below; don't also hit the shared
1347
+ // NOTIFY_EMAIL_TO team inbox with the same event.
1348
+ { skipEmail: true }
1349
+ );
1350
+ if (admins.length > 0) {
1351
+ void sendEmailToRecipient(admins, {
1352
+ status: "deletion_requested",
1353
+ docTitle: ctx.docTitle,
1354
+ path: ctx.path,
1355
+ link: ctx.link,
1356
+ actor: request.requestedBy,
1357
+ note: request.reason
1358
+ });
1359
+ }
1360
+ } catch (err) {
1361
+ console.error(
1362
+ "[notify] deletion request fan-out failed",
1363
+ request.nestId,
1364
+ request.nodeId,
1365
+ err
1366
+ );
1367
+ }
1368
+ }
1369
+ async function notifyDeletionResolved(params) {
1370
+ try {
1371
+ const { outcome, resolvedBy, note } = params;
1372
+ const verb = outcome === "deleted" ? "deleted" : "declined the deletion of";
1373
+ const line = `${resolvedBy} ${verb} *${params.docTitle}*${note ? ` \u2014 "${note}"` : ""}`;
1374
+ const recipient = params.requestedBy.toLowerCase();
1375
+ const canInbox = !(params.targetType === "nest" && outcome === "deleted");
1376
+ if (canInbox && recipient !== resolvedBy.toLowerCase()) {
1377
+ await inboxInsert(
1378
+ params.nestId,
1379
+ [recipient],
1380
+ `deletion_${outcome}`,
1381
+ params.requestId,
1382
+ outcome === "deleted" ? `"${params.docTitle}" was deleted by ${resolvedBy} \u2014 your request was accepted` : `Your deletion request for "${params.docTitle}" was declined by ${resolvedBy}${note ? ` \u2014 "${note}"` : ""}`
1383
+ );
1384
+ }
1385
+ void dispatchEvent({
1386
+ kind: `deletion_${outcome}`,
1387
+ nestId: params.nestId,
1388
+ subjectId: params.nodeId,
1389
+ actor: resolvedBy,
1390
+ message: line
1391
+ });
1392
+ void notifyNestEvent(
1393
+ params.nestId,
1394
+ `${outcome === "deleted" ? ":wastebasket:" : ":no_entry_sign:"} ${line}`,
1395
+ void 0,
1396
+ // Verdict emails the requester directly (below), not the team inbox.
1397
+ { skipEmail: true }
1398
+ );
1399
+ if (recipient !== resolvedBy.toLowerCase()) {
1400
+ const link = params.baseUrl ? `${params.baseUrl}/?nest=${encodeURIComponent(params.nestId)}` : "";
1401
+ void sendEmailToRecipient(recipient, {
1402
+ status: outcome === "deleted" ? "deletion_deleted" : "deletion_declined",
1403
+ docTitle: params.docTitle,
1404
+ path: params.nestId,
1405
+ link: outcome === "deleted" ? link : void 0,
1406
+ actor: resolvedBy,
1407
+ note: note || null
1408
+ });
1409
+ }
1410
+ } catch (err) {
1411
+ console.error(
1412
+ "[notify] deletion verdict fan-out failed",
1413
+ params.nestId,
1414
+ params.nodeId,
1415
+ err
1416
+ );
1417
+ }
1418
+ }
1419
+ async function requestDeletion(params) {
1420
+ const db = getDb();
1421
+ const targetType = params.targetType || "document";
1422
+ const noun = targetType === "document" ? "node" : targetType === "folder" ? "folder" : "nest";
1423
+ const existing = await db.get(
1424
+ "SELECT id FROM deletion_requests WHERE nest_id = ? AND node_id = ? AND target_type = ? AND status = 'pending'",
1425
+ [params.nestId, params.nodeId, targetType]
1426
+ );
1427
+ if (existing) {
1428
+ throw new ConflictError(`A deletion request is already pending for this ${noun}`);
1429
+ }
1430
+ const id = uuid3();
1431
+ try {
1432
+ await db.run(
1433
+ `INSERT INTO deletion_requests (id, nest_id, node_id, target_type, requested_by, reason)
1434
+ VALUES (?, ?, ?, ?, ?, ?)`,
1435
+ [
1436
+ id,
1437
+ params.nestId,
1438
+ params.nodeId,
1439
+ targetType,
1440
+ params.requestedBy,
1441
+ params.reason
1442
+ ]
1443
+ );
1444
+ } catch (err) {
1445
+ console.error(
1446
+ "[deletion] request insert failed",
1447
+ params.nestId,
1448
+ params.nodeId,
1449
+ err
1450
+ );
1451
+ throw new ConflictError(`A deletion request is already pending for this ${noun}`);
1452
+ }
1453
+ const request = await getDeletionRequest(id);
1454
+ await notifyDeletionRequested(request, params.baseUrl);
1455
+ return request;
1456
+ }
1457
+ async function getLatestDeletionRequest(nestId, nodeId) {
1458
+ const db = getDb();
1459
+ const row = await db.get(
1460
+ `SELECT * FROM deletion_requests WHERE nest_id = ? AND node_id = ?
1461
+ ORDER BY created_at DESC LIMIT 1`,
1462
+ [nestId, nodeId]
1463
+ );
1464
+ return row ? rowToDeletionRequest(row) : null;
1465
+ }
1466
+ async function getDeletionRequest(id) {
1467
+ const db = getDb();
1468
+ const row = await db.get(
1469
+ "SELECT * FROM deletion_requests WHERE id = ?",
1470
+ [id]
1471
+ );
1472
+ return row ? rowToDeletionRequest(row) : null;
1473
+ }
1474
+ async function listDeletionRequests(params) {
1475
+ const db = getDb();
1476
+ const status = params.status || "pending";
1477
+ const where = [];
1478
+ const args = [];
1479
+ if (params.nestId) {
1480
+ where.push("nest_id = ?");
1481
+ args.push(params.nestId);
1482
+ }
1483
+ if (params.nestIds) {
1484
+ if (params.nestIds.length === 0) return [];
1485
+ where.push(`nest_id IN (${params.nestIds.map(() => "?").join(",")})`);
1486
+ args.push(...params.nestIds);
1487
+ }
1488
+ if (status !== "all") {
1489
+ where.push("status = ?");
1490
+ args.push(status);
1491
+ }
1492
+ const rows = await db.all(
1493
+ `SELECT * FROM deletion_requests${where.length ? ` WHERE ${where.join(" AND ")}` : ""} ORDER BY created_at DESC`,
1494
+ args
1495
+ );
1496
+ if (rows.length === 0) return [];
1497
+ const titlesByNest = /* @__PURE__ */ new Map();
1498
+ for (const nid of new Set(rows.map((r) => r.nest_id))) {
1499
+ titlesByNest.set(nid, await buildTitleMap(nid));
1500
+ }
1501
+ const out = [];
1502
+ for (const r of rows) {
1503
+ const request = rowToDeletionRequest(r);
1504
+ out.push({
1505
+ ...request,
1506
+ title: await deletionTargetTitle(request, titlesByNest.get(r.nest_id))
1507
+ });
1508
+ }
1509
+ return out;
1510
+ }
1511
+ async function dropDeletionRequest(id) {
1512
+ await getDb().run("DELETE FROM deletion_requests WHERE id = ?", [id]);
1513
+ }
1514
+ async function declineDeletion(params) {
1515
+ const db = getDb();
1516
+ const pending = await getDeletionRequest(params.id);
1517
+ if (!pending || pending.nestId !== params.nestId || pending.status !== "pending") {
1518
+ return null;
1519
+ }
1520
+ const { changes } = await db.run(
1521
+ `UPDATE deletion_requests
1522
+ SET status = 'declined', resolved_by = ?, resolved_at = ${nowExpr(db)},
1523
+ resolution_note = ?
1524
+ WHERE id = ? AND status = 'pending'`,
1525
+ [params.declinedBy, params.note, params.id]
1526
+ );
1527
+ if (changes === 0) return null;
1528
+ const declined = await getDeletionRequest(params.id);
1529
+ if (declined) {
1530
+ await notifyDeletionResolved({
1531
+ nestId: params.nestId,
1532
+ nodeId: declined.nodeId,
1533
+ docTitle: await deletionTargetTitle(declined),
1534
+ outcome: "declined",
1535
+ requestedBy: declined.requestedBy,
1536
+ resolvedBy: params.declinedBy,
1537
+ requestId: declined.id,
1538
+ note: params.note,
1539
+ baseUrl: params.baseUrl
1540
+ });
1541
+ }
1542
+ return declined;
1543
+ }
1544
+ function rowToDeletionRequest(row) {
1545
+ return {
1546
+ id: row.id,
1547
+ nestId: row.nest_id,
1548
+ nodeId: row.node_id,
1549
+ targetType: row.target_type || "document",
1550
+ requestedBy: row.requested_by,
1551
+ reason: row.reason,
1552
+ status: row.status,
1553
+ createdAt: row.created_at,
1554
+ resolvedBy: row.resolved_by || void 0,
1555
+ resolvedAt: row.resolved_at || void 0,
1556
+ resolutionNote: row.resolution_note || void 0
1557
+ };
1558
+ }
1251
1559
  async function getReviewRequest(id) {
1252
1560
  const db = getDb();
1253
1561
  const row = await db.get(
@@ -1275,6 +1583,7 @@ function rowToReviewRequest(row) {
1275
1583
 
1276
1584
  export {
1277
1585
  safePublishDocument,
1586
+ serializeNodeSafe,
1278
1587
  stripUndefinedDeep,
1279
1588
  listWatchers,
1280
1589
  addWatcher,
@@ -1306,5 +1615,13 @@ export {
1306
1615
  cancelReview,
1307
1616
  getReviewQueue,
1308
1617
  getReviewHistory,
1309
- getPendingReview
1618
+ getPendingReview,
1619
+ deletionTargetTitle,
1620
+ notifyDeletionResolved,
1621
+ requestDeletion,
1622
+ getLatestDeletionRequest,
1623
+ getDeletionRequest,
1624
+ listDeletionRequests,
1625
+ dropDeletionRequest,
1626
+ declineDeletion
1310
1627
  };