@promptowl/contextnest-community 1.13.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.
@@ -2,36 +2,42 @@ import {
2
2
  grantCoversNode,
3
3
  listUserGrants,
4
4
  resolveNodeGrant
5
- } from "./chunk-ZQS5W45U.js";
5
+ } from "./chunk-5IAKUUZF.js";
6
6
  import {
7
7
  createVersion,
8
8
  getApprovedVersion,
9
+ getCurrentVersion,
9
10
  setApprovedVersion
10
- } from "./chunk-EG77SQHO.js";
11
+ } from "./chunk-JWVZ3T35.js";
11
12
  import {
12
- buildDocContext,
13
- buildTitleMap,
14
13
  canUserAccess,
15
14
  canUserApprove,
15
+ resolveStewardsForNode
16
+ } from "./chunk-GWDOLYFJ.js";
17
+ import {
18
+ buildDocContext,
19
+ buildTitleMap,
16
20
  describeNode,
17
21
  engineCache,
22
+ insertOrReplace,
18
23
  isPublicReader,
19
24
  isStewardshipEnabled,
20
- resolveStewardsForNode
21
- } from "./chunk-Q5NRCDD4.js";
25
+ nestName,
26
+ notifyEmailForNest,
27
+ nowExpr,
28
+ prettyFolderPath,
29
+ sendEmailToRecipient,
30
+ titleForNode
31
+ } from "./chunk-7BOG3S5H.js";
22
32
  import {
23
33
  ConflictError,
24
34
  NotFoundError,
25
35
  ValidationError
26
36
  } from "./chunk-GUNJTORH.js";
27
- import {
28
- insertOrReplace,
29
- nowExpr
30
- } from "./chunk-XQ46F76G.js";
31
37
  import {
32
38
  config,
33
39
  getDb
34
- } from "./chunk-5QQ7WKAI.js";
40
+ } from "./chunk-BCFKLY4H.js";
35
41
 
36
42
  // src/governance/review-service.ts
37
43
  import { v4 as uuid3 } from "uuid";
@@ -80,6 +86,7 @@ async function notifyReviewRequested(params) {
80
86
  [nestId, nodeId, nestId, nodeId]
81
87
  );
82
88
  const now = (/* @__PURE__ */ new Date()).toISOString();
89
+ const label = await describeNode(nestId, nodeId);
83
90
  let created = 0;
84
91
  for (const r of rows) {
85
92
  if (r.user_email.toLowerCase() === requestedBy.toLowerCase()) continue;
@@ -91,7 +98,7 @@ async function notifyReviewRequested(params) {
91
98
  nestId,
92
99
  r.user_email.toLowerCase(),
93
100
  reviewId,
94
- 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`,
95
102
  now
96
103
  ]
97
104
  );
@@ -123,6 +130,7 @@ async function notifyReviewResolved(params) {
123
130
  recipients.add(requestedBy.toLowerCase());
124
131
  recipients.delete(resolvedBy.toLowerCase());
125
132
  const now = (/* @__PURE__ */ new Date()).toISOString();
133
+ const label = await describeNode(nestId, nodeId);
126
134
  let created = 0;
127
135
  for (const email of recipients) {
128
136
  await db.run(
@@ -134,7 +142,7 @@ async function notifyReviewResolved(params) {
134
142
  email,
135
143
  `review_${status}`,
136
144
  reviewId,
137
- `"${nodeId}" was ${status} by ${resolvedBy}`,
145
+ `"${label}" was ${status} by ${resolvedBy}`,
138
146
  now
139
147
  ]
140
148
  );
@@ -146,6 +154,25 @@ async function notifyReviewResolved(params) {
146
154
  return 0;
147
155
  }
148
156
  }
157
+ async function notifyNestInvite(params) {
158
+ const { nestId, inviteeEmail, permission, nestName: nestName2 } = params;
159
+ try {
160
+ await getDb().run(
161
+ `INSERT INTO notifications (id, nest_id, user_email, kind, subject_id, message, created_at)
162
+ VALUES (?, ?, ?, 'nest_invite', ?, ?, ?)`,
163
+ [
164
+ uuid(),
165
+ nestId,
166
+ inviteeEmail.toLowerCase(),
167
+ nestId,
168
+ `You were added to "${nestName2}" as ${permission}`,
169
+ (/* @__PURE__ */ new Date()).toISOString()
170
+ ]
171
+ );
172
+ } catch (err) {
173
+ console.error("[notify] invite inbox insert failed", nestId, inviteeEmail, err);
174
+ }
175
+ }
149
176
  async function listNotifications(userEmail, opts = {}) {
150
177
  const limit = Math.min(opts.limit ?? 50, 200);
151
178
  return await getDb().all(
@@ -497,28 +524,169 @@ async function envValues(nestId) {
497
524
  return { ...base, ...Object.fromEntries(rows.map((r) => [r.key, r.value])) };
498
525
  }
499
526
 
500
- // src/notify/dispatch.ts
527
+ // src/notify/webhook.ts
528
+ async function resolveNestName(nestId) {
529
+ try {
530
+ const row = await getDb().get("SELECT name FROM nests WHERE id = ?", [
531
+ nestId
532
+ ]);
533
+ if (row?.name) return row.name;
534
+ } catch {
535
+ }
536
+ return nestId;
537
+ }
538
+ async function rawPost(url, body, timeoutMs) {
539
+ const ctrl = new AbortController();
540
+ const timer = setTimeout(() => ctrl.abort(), timeoutMs);
541
+ try {
542
+ return await fetch(url, {
543
+ method: "POST",
544
+ headers: { "Content-Type": "application/json" },
545
+ body: JSON.stringify(body),
546
+ signal: ctrl.signal
547
+ });
548
+ } finally {
549
+ clearTimeout(timer);
550
+ }
551
+ }
552
+ var warned = /* @__PURE__ */ new Set();
553
+ async function postWebhook(url, body, who) {
554
+ try {
555
+ await rawPost(url, body, 3e3);
556
+ } catch (err) {
557
+ if (!warned.has(who)) {
558
+ warned.add(who);
559
+ console.warn(
560
+ `[notify] delivery failed for ${who} (muting further warnings):`,
561
+ err instanceof Error ? err.message : err
562
+ );
563
+ }
564
+ }
565
+ }
566
+ async function testWebhook(url, body) {
567
+ try {
568
+ const res = await rawPost(url, body, 5e3);
569
+ return res.ok ? { ok: true } : { ok: false, error: `Webhook responded ${res.status} ${res.statusText}.` };
570
+ } catch (err) {
571
+ return {
572
+ ok: false,
573
+ error: err instanceof Error ? err.message : "Request failed."
574
+ };
575
+ }
576
+ }
577
+
578
+ // src/notify/msteams.ts
579
+ var EMOJI = {
580
+ ":memo:": "\u{1F4DD}",
581
+ ":white_check_mark:": "\u2705",
582
+ ":x:": "\u274C",
583
+ ":rotating_light:": "\u{1F6A8}",
584
+ ":key:": "\u{1F511}"
585
+ // collaborator added (sharing-service)
586
+ };
587
+ var BOLD_SPAN = /(^|\s)\*([^*\n]+)\*(?=\s|[.,;:!?)]|$)/g;
588
+ function slackEmojiToUnicode(s) {
589
+ return s.replace(/:[a-z0-9_+-]+:/g, (m) => EMOJI[m] ?? m);
590
+ }
591
+ function escapeAdaptiveText(s) {
592
+ return s.replace(/\\/g, "\\\\").replace(/\[/g, "\\[").replace(/\]/g, "\\]");
593
+ }
594
+ function toAdaptiveText(s) {
595
+ return slackEmojiToUnicode(escapeAdaptiveText(s)).replace(BOLD_SPAN, "$1**$2**");
596
+ }
597
+ function toPlainText(s) {
598
+ return slackEmojiToUnicode(s).replace(BOLD_SPAN, "$1$2");
599
+ }
600
+ function msTeamsPayload(title, message) {
601
+ return {
602
+ type: "message",
603
+ attachments: [
604
+ {
605
+ contentType: "application/vnd.microsoft.card.adaptive",
606
+ contentUrl: null,
607
+ content: {
608
+ $schema: "http://adaptivecards.io/schemas/adaptive-card.json",
609
+ type: "AdaptiveCard",
610
+ version: "1.4",
611
+ msteams: { width: "Full" },
612
+ fallbackText: `[${toPlainText(title)}] ${toPlainText(message)}`,
613
+ body: [
614
+ {
615
+ // Same pipeline as the message: the nest name is user-
616
+ // controlled text too and must not carry link syntax.
617
+ type: "TextBlock",
618
+ text: toAdaptiveText(title),
619
+ weight: "Bolder",
620
+ size: "Medium",
621
+ wrap: true
622
+ },
623
+ {
624
+ type: "TextBlock",
625
+ text: toAdaptiveText(message),
626
+ wrap: true
627
+ }
628
+ ]
629
+ }
630
+ }
631
+ ]
632
+ };
633
+ }
634
+ async function notifyMsTeamsForNest(nestId, message) {
635
+ if (!config.MSTEAMS_WEBHOOK_URL) return;
636
+ const nestName2 = await resolveNestName(nestId);
637
+ return notifyMsTeams(nestName2, message);
638
+ }
639
+ async function notifyMsTeams(title, message) {
640
+ const url = config.MSTEAMS_WEBHOOK_URL;
641
+ if (!url) return;
642
+ await postWebhook(url, msTeamsPayload(title, message), "msteams");
643
+ }
644
+
645
+ // src/notify/slack.ts
646
+ async function notifySlackForNest(nestId, message) {
647
+ if (!config.SLACK_WEBHOOK_URL) return;
648
+ const nestName2 = await resolveNestName(nestId);
649
+ return notifySlack(`*[${nestName2}]* ${message}`);
650
+ }
501
651
  function escapeMrkdwn(s) {
502
652
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
503
653
  }
654
+ async function notifySlack(text) {
655
+ const url = config.SLACK_WEBHOOK_URL;
656
+ if (!url) return;
657
+ await postWebhook(url, { text: escapeMrkdwn(text) }, "slack");
658
+ }
659
+
660
+ // src/notify/dispatch.ts
504
661
  var EVENT_EMOJI = {
505
662
  review_requested: ":memo:",
506
663
  review_approved: ":white_check_mark:",
507
664
  review_rejected: ":x:",
508
- run_failed: ":rotating_light:"
665
+ run_failed: ":rotating_light:",
666
+ deletion_requested: ":wastebasket:",
667
+ deletion_deleted: ":wastebasket:",
668
+ deletion_declined: ":no_entry_sign:"
509
669
  };
510
- function formatBody(channel, nestName, ev) {
511
- const line = `[${nestName}] ${ev.message}`;
670
+ var DIGEST_DELIVERED_KINDS = /* @__PURE__ */ new Set([
671
+ "review_requested",
672
+ "review_approved",
673
+ "review_rejected",
674
+ "deletion_requested",
675
+ "deletion_deleted",
676
+ "deletion_declined"
677
+ ]);
678
+ function formatBody(channel, nestName2, ev) {
679
+ const line = `[${nestName2}] ${ev.message}`;
512
680
  if (channel === "slack") {
513
- return { text: escapeMrkdwn(`${EVENT_EMOJI[ev.kind] ?? ""} *[${nestName}]* ${ev.message}`) };
681
+ return { text: escapeMrkdwn(`${EVENT_EMOJI[ev.kind] ?? ""} *[${nestName2}]* ${ev.message}`) };
514
682
  }
515
683
  if (channel === "teams") {
516
- return { text: line };
684
+ return msTeamsPayload(nestName2, `${EVENT_EMOJI[ev.kind] ?? ""} ${ev.message}`.trim());
517
685
  }
518
686
  return {
519
687
  kind: ev.kind,
520
688
  nest_id: ev.nestId,
521
- nest_name: nestName,
689
+ nest_name: nestName2,
522
690
  message: ev.message,
523
691
  subject_id: ev.subjectId ?? null,
524
692
  actor: ev.actor ?? null,
@@ -557,46 +725,23 @@ async function resolveUrl(nestId, raw) {
557
725
  }
558
726
  return isSafeConnectorUrl(raw) ? raw : null;
559
727
  }
560
- var warned = /* @__PURE__ */ new Set();
561
- async function post(url, body, who) {
562
- try {
563
- const ctrl = new AbortController();
564
- const timer = setTimeout(() => ctrl.abort(), 3e3);
565
- try {
566
- await fetch(url, {
567
- method: "POST",
568
- headers: { "Content-Type": "application/json" },
569
- body: JSON.stringify(body),
570
- signal: ctrl.signal
571
- });
572
- } finally {
573
- clearTimeout(timer);
574
- }
575
- } catch (err) {
576
- if (!warned.has(who)) {
577
- warned.add(who);
578
- console.warn(
579
- `[connectors] delivery failed for ${who} (muting further warnings):`,
580
- err instanceof Error ? err.message : err
581
- );
582
- }
583
- }
584
- }
585
728
  async function dispatchEvent(ev) {
586
729
  const db = getDb();
587
- let nestName = ev.nestId;
588
- try {
589
- const row = await db.get("SELECT name FROM nests WHERE id = ?", [ev.nestId]);
590
- if (row?.name) nestName = row.name;
591
- } catch {
592
- }
593
- if (config.SLACK_WEBHOOK_URL) {
594
- void post(
730
+ const nestName2 = await resolveNestName(ev.nestId);
731
+ if (config.SLACK_WEBHOOK_URL && !DIGEST_DELIVERED_KINDS.has(ev.kind)) {
732
+ void postWebhook(
595
733
  config.SLACK_WEBHOOK_URL,
596
- formatBody("slack", nestName, ev),
734
+ formatBody("slack", nestName2, ev),
597
735
  "server-slack"
598
736
  );
599
737
  }
738
+ if (config.MSTEAMS_WEBHOOK_URL && !DIGEST_DELIVERED_KINDS.has(ev.kind)) {
739
+ void postWebhook(
740
+ config.MSTEAMS_WEBHOOK_URL,
741
+ formatBody("teams", nestName2, ev),
742
+ "server-msteams"
743
+ );
744
+ }
600
745
  let rows = [];
601
746
  try {
602
747
  rows = await db.all(
@@ -615,308 +760,20 @@ async function dispatchEvent(ev) {
615
760
  if (!kinds.includes("*") && !kinds.includes(ev.kind)) continue;
616
761
  const url = await resolveUrl(ev.nestId, r.url);
617
762
  if (!url) continue;
618
- void post(url, formatBody(r.channel, nestName, ev), `connector:${r.id}`);
619
- }
620
- }
621
-
622
- // src/notify/slack.ts
623
- var warnedOnce = false;
624
- async function notifySlackForNest(nestId, message) {
625
- if (!config.SLACK_WEBHOOK_URL) return;
626
- let nestName = nestId;
627
- try {
628
- const row = await getDb().get("SELECT name FROM nests WHERE id = ?", [
629
- nestId
630
- ]);
631
- if (row?.name) nestName = row.name;
632
- } catch {
633
- }
634
- return notifySlack(`*[${nestName}]* ${message}`);
635
- }
636
- function escapeMrkdwn2(s) {
637
- return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
638
- }
639
- async function notifySlack(text) {
640
- const url = config.SLACK_WEBHOOK_URL;
641
- if (!url) return;
642
- try {
643
- const ctrl = new AbortController();
644
- const timer = setTimeout(() => ctrl.abort(), 3e3);
645
- try {
646
- await fetch(url, {
647
- method: "POST",
648
- headers: { "Content-Type": "application/json" },
649
- body: JSON.stringify({ text: escapeMrkdwn2(text) }),
650
- signal: ctrl.signal
651
- });
652
- } finally {
653
- clearTimeout(timer);
654
- }
655
- } catch (err) {
656
- if (!warnedOnce) {
657
- warnedOnce = true;
658
- console.warn(
659
- "[slack] notification failed (suppressing further warnings):",
660
- err instanceof Error ? err.message : err
661
- );
662
- }
663
- }
664
- }
665
-
666
- // src/notify/email-render.ts
667
- function escapeHtml(s) {
668
- return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
669
- }
670
- var EVENT = {
671
- ":white_check_mark:": { icon: "\u2705", color: "#16a34a" },
672
- ":x:": { icon: "\u274C", color: "#dc2626" },
673
- ":memo:": { icon: "\u{1F4DD}", color: "#d97706" },
674
- ":key:": { icon: "\u{1F511}", color: "#2563eb" }
675
- };
676
- var ALL_EMOJI = Object.fromEntries(
677
- Object.entries(EVENT).map(([code, m]) => [code, m.icon])
678
- );
679
- function renderText(message) {
680
- return message.replace(/:[a-z_]+:/g, (m) => ALL_EMOJI[m] ?? "").replace(/\*/g, "").replace(/[ \t]{2,}/g, " ").trim();
681
- }
682
- var STATUS_META = {
683
- approved: { subjectWord: "approved", label: "Approved", color: "#16a34a" },
684
- rejected: { subjectWord: "rejected", label: "Rejected", color: "#dc2626" },
685
- pending_review: {
686
- subjectWord: "awaiting review",
687
- label: "Pending review",
688
- color: "#d97706"
689
- },
690
- shared: { subjectWord: "shared with you", label: "Shared", color: "#2563eb" }
691
- };
692
- function renderEmail(d) {
693
- const meta = STATUS_META[d.status];
694
- const title = d.docTitle.replace(/[\r\n]+/g, " ").trim();
695
- const heading = d.status === "shared" ? `New collaborator on ${title}` : `${title} is ${meta.subjectWord}`;
696
- const subject = heading.replace(/[\r\n]+/g, " ").trim();
697
- const vLabel = d.version != null ? `v${d.version}` : "";
698
- const detailsHeader = d.status === "shared" ? "Details" : "Document details";
699
- const linkLabel = d.status === "shared" ? "Open nest" : "Open document";
700
- const byT = d.actor ? ` by ${d.actor}` : "";
701
- const noteT = d.note ? ` with the note: "${d.note}"` : "";
702
- const vT = vLabel ? ` ${vLabel}` : "";
703
- let lineT;
704
- switch (d.status) {
705
- case "approved":
706
- lineT = `${title}${vT} has been approved${byT}${noteT}.`;
707
- break;
708
- case "rejected":
709
- lineT = `${title}${vT} has been rejected${byT}${noteT}.`;
710
- break;
711
- case "pending_review":
712
- lineT = `A review has been requested for ${title}${vT}${byT}${noteT}.`;
713
- break;
714
- case "shared":
715
- lineT = `${d.actor || "A user"} was added to ${title} as ${d.permission || "a collaborator"}${d.by ? ` by ${d.by}` : ""}.`;
716
- break;
717
- }
718
- const text = [
719
- "Hi,",
720
- "",
721
- lineT,
722
- "",
723
- "Document details:",
724
- ` Path: ${d.path}`,
725
- ` Status: ${meta.label}`,
726
- ...d.version != null ? [` Version: v${d.version}`] : [],
727
- ...d.link ? [` Open: ${d.link}`] : [],
728
- "",
729
- "Regards,",
730
- "ContextNest"
731
- ].join("\n");
732
- const doc = `<strong>${escapeHtml(title)}</strong>`;
733
- const byH = d.actor ? ` by ${escapeHtml(d.actor)}` : "";
734
- const noteH = d.note ? ` with the note: &ldquo;${escapeHtml(d.note)}&rdquo;` : "";
735
- const vH = vLabel ? ` ${vLabel}` : "";
736
- let lineH;
737
- switch (d.status) {
738
- case "approved":
739
- lineH = `${doc}${vH} has been approved${byH}${noteH}.`;
740
- break;
741
- case "rejected":
742
- lineH = `${doc}${vH} has been rejected${byH}${noteH}.`;
743
- break;
744
- case "pending_review":
745
- lineH = `A review has been requested for ${doc}${vH}${byH}${noteH}.`;
746
- break;
747
- case "shared":
748
- lineH = `${escapeHtml(d.actor || "A user")} was added to ${doc} as <strong>${escapeHtml(d.permission || "a collaborator")}</strong>${d.by ? ` by ${escapeHtml(d.by)}` : ""}.`;
749
- break;
750
- }
751
- const detailRow = (label, valueHtml) => `<tr>
752
- <td style="padding:5px 0;font-size:13px;color:#6b7280;width:90px;vertical-align:top;">${label}</td>
753
- <td style="padding:5px 0;font-size:13px;color:#111827;vertical-align:top;">${valueHtml}</td>
754
- </tr>`;
755
- const detailRows = detailRow("Path", escapeHtml(d.path)) + detailRow(
756
- "Status",
757
- `<span style="display:inline-block;padding:2px 9px;border-radius:9999px;background:#f3f4f6;color:${meta.color};font-size:12px;font-weight:600;">${meta.label}</span>`
758
- ) + (d.version != null ? detailRow("Version", `v${d.version}`) : "");
759
- const buttonRow = d.link ? `<tr><td style="padding:20px 24px 0;">
760
- <table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr>
761
- <td bgcolor="${meta.color}" style="border-radius:6px;">
762
- <a href="${escapeHtml(d.link)}" target="_blank" style="display:inline-block;padding:10px 20px;font-size:14px;font-weight:600;color:#ffffff;text-decoration:none;">${linkLabel}</a>
763
- </td>
764
- </tr></table>
765
- </td></tr>` : "";
766
- const html = `<div style="margin:0;padding:24px;background:#f6f8fa;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;color:#111827;">
767
- <table role="presentation" width="480" cellpadding="0" cellspacing="0" border="0" align="center" style="width:100%;max-width:480px;margin:0 auto;background:#ffffff;border:1px solid #e5e7eb;border-radius:12px;">
768
- <tr><td style="padding:20px 24px 0;">
769
- <div style="font-size:12px;font-weight:700;color:#6b7280;letter-spacing:0.06em;text-transform:uppercase;">ContextNest</div>
770
- </td></tr>
771
- <tr><td style="padding:10px 24px 0;">
772
- <h1 style="margin:0;font-size:19px;line-height:1.35;font-weight:700;color:#111827;">${escapeHtml(heading)}</h1>
773
- </td></tr>
774
- <tr><td style="padding:16px 24px 0;font-size:14px;line-height:1.6;color:#374151;">
775
- Hi,<br><br>${lineH}
776
- </td></tr>
777
- <tr><td style="padding:20px 24px 0;">
778
- <table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="border:1px solid #eef0f2;border-radius:8px;">
779
- <tr><td style="padding:12px 16px;">
780
- <div style="font-size:11px;font-weight:600;color:#9ca3af;letter-spacing:0.04em;text-transform:uppercase;padding-bottom:4px;">${detailsHeader}</div>
781
- <table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">${detailRows}</table>
782
- </td></tr>
783
- </table>
784
- </td></tr>
785
- ${buttonRow}
786
- <tr><td style="padding:22px 24px 24px;font-size:14px;line-height:1.6;color:#374151;">
787
- Regards,<br>ContextNest
788
- </td></tr>
789
- <tr><td style="padding:14px 24px;border-top:1px solid #f0f0f0;font-size:11px;line-height:1.5;color:#9ca3af;">
790
- Automated governance notification from ContextNest. You received this because email notifications are enabled on this server.
791
- </td></tr>
792
- </table>
793
- </div>`;
794
- return { subject, text, html };
795
- }
796
- function renderHtml(message, nestName) {
797
- const lead = message.match(/^(:[a-z_]+:)\s*/);
798
- const meta = lead && EVENT[lead[1]] || { icon: "\u{1F514}", color: "#475569" };
799
- const rest = lead ? message.slice(lead[0].length) : message;
800
- const body = escapeHtml(rest).replace(/\*([^*]+)\*/g, "<strong>$1</strong>").replace(/\n/g, "<br>");
801
- const safeNest = escapeHtml(nestName);
802
- return `<div style="background:#f6f8fa;padding:24px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;">
803
- <table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width:480px;margin:0 auto;background:#ffffff;border:1px solid #e5e7eb;border-radius:12px;overflow:hidden;">
804
- <tr><td style="padding:20px 24px 16px;border-bottom:1px solid #f0f0f0;">
805
- <div style="font-size:15px;font-weight:700;color:#111827;letter-spacing:-0.01em;">ContextNest</div>
806
- <div style="font-size:13px;color:#6b7280;margin-top:2px;">${safeNest}</div>
807
- </td></tr>
808
- <tr><td style="padding:24px;">
809
- <table role="presentation" cellpadding="0" cellspacing="0"><tr>
810
- <td valign="top" style="font-size:22px;line-height:1;padding-right:14px;">${meta.icon}</td>
811
- <td valign="top" style="font-size:15px;line-height:1.55;color:#111827;border-left:3px solid ${meta.color};padding-left:14px;">${body}</td>
812
- </tr></table>
813
- </td></tr>
814
- <tr><td style="padding:14px 24px;border-top:1px solid #f0f0f0;font-size:11px;line-height:1.5;color:#9ca3af;">
815
- Automated governance notification from ContextNest. You received this because email notifications are enabled on this server.
816
- </td></tr>
817
- </table>
818
- </div>`;
819
- }
820
-
821
- // src/notify/email.ts
822
- var transporterPromise = null;
823
- var configuredUrl = null;
824
- var warnedOnce2 = false;
825
- var SMTP_TIMEOUT_DEFAULTS = [
826
- ["connectionTimeout", 5e3],
827
- ["greetingTimeout", 5e3],
828
- ["socketTimeout", 1e4]
829
- ];
830
- function withTimeouts(url) {
831
- const query = url.includes("?") ? url.slice(url.indexOf("?") + 1) : "";
832
- const missing = SMTP_TIMEOUT_DEFAULTS.filter(
833
- ([name]) => !new RegExp(`(^|&)${name}=`, "i").test(query)
834
- ).map(([name, ms]) => `${name}=${ms}`);
835
- if (missing.length === 0) return url;
836
- return url + (url.includes("?") ? "&" : "?") + missing.join("&");
837
- }
838
- async function verifySmtp(url) {
839
- let transporter = null;
840
- try {
841
- const nodemailer = await import("nodemailer");
842
- transporter = nodemailer.default.createTransport(withTimeouts(url), {
843
- disableFileAccess: true,
844
- disableUrlAccess: true
845
- });
846
- await transporter.verify();
847
- return { ok: true };
848
- } catch (err) {
849
- return { ok: false, error: err instanceof Error ? err.message : String(err) };
850
- } finally {
851
- try {
852
- transporter?.close?.();
853
- } catch {
854
- }
763
+ void postWebhook(url, formatBody(r.channel, nestName2, ev), `connector:${r.id}`);
855
764
  }
856
765
  }
857
- async function getTransporter() {
858
- const url = config.SMTP_URL;
859
- if (!url) return null;
860
- if (!transporterPromise || configuredUrl !== url) {
861
- configuredUrl = url;
862
- transporterPromise = import("nodemailer").then(
863
- (nodemailer) => nodemailer.default.createTransport(withTimeouts(url), {
864
- // Per-message defaults (belt-and-braces; also set on each sendMail).
865
- disableFileAccess: true,
866
- disableUrlAccess: true
867
- })
868
- );
869
- transporterPromise.catch(() => {
870
- transporterPromise = null;
871
- configuredUrl = null;
872
- });
873
- }
874
- return transporterPromise;
875
- }
876
- async function notifyEmailForNest(nestId, message, details) {
877
- const from = config.NOTIFY_EMAIL_FROM;
878
- const to = config.NOTIFY_EMAIL_TO;
879
- if (!config.SMTP_URL || !from || !to) return;
880
- try {
881
- const transporter = await getTransporter();
882
- if (!transporter) return;
883
- let subject;
884
- let text;
885
- let html;
886
- if (details) {
887
- ({ subject, text, html } = renderEmail(details));
888
- } else {
889
- let name = nestId;
890
- try {
891
- const row = await getDb().get("SELECT name FROM nests WHERE id = ?", [
892
- nestId
893
- ]);
894
- if (row?.name) name = row.name;
895
- } catch {
896
- }
897
- const safeName = name.replace(/[\r\n]+/g, " ").trim();
898
- subject = `ContextNest \xB7 ${safeName}`;
899
- text = renderText(message);
900
- html = renderHtml(message, safeName);
901
- }
902
- await transporter.sendMail({
903
- from,
904
- to,
905
- subject,
906
- text,
907
- html,
908
- disableFileAccess: true,
909
- disableUrlAccess: true
910
- });
911
- } catch (err) {
912
- if (!warnedOnce2) {
913
- warnedOnce2 = true;
914
- console.warn(
915
- "[email] notification failed (suppressing further warnings):",
916
- err instanceof Error ? err.message : err
917
- );
918
- }
919
- }
766
+ async function sendTestNotification(channel) {
767
+ const url = channel === "slack" ? config.SLACK_WEBHOOK_URL : config.MSTEAMS_WEBHOOK_URL;
768
+ const label = channel === "slack" ? "Slack" : "Microsoft Teams";
769
+ if (!url)
770
+ return { ok: false, error: `Save a ${label} webhook URL first, then test.` };
771
+ const ev = {
772
+ kind: "test",
773
+ nestId: "",
774
+ message: "Test notification from ContextNest \u2014 your connector is working."
775
+ };
776
+ return testWebhook(url, formatBody(channel, "ContextNest", ev));
920
777
  }
921
778
 
922
779
  // src/notify/notify.ts
@@ -935,22 +792,46 @@ async function flush(nestId) {
935
792
  if (!buf) return;
936
793
  buffers.delete(nestId);
937
794
  const text = buildText(buf.events.map((e) => e.line));
938
- const details = buf.events.length === 1 ? buf.events[0].details : void 0;
795
+ const emailEvents = buf.events.filter((e) => !e.skipEmail);
796
+ const emailText = buildText(emailEvents.map((e) => e.line));
797
+ const emailDetails = emailEvents.length === 1 ? emailEvents[0].details : void 0;
939
798
  await Promise.allSettled([
940
799
  notifySlackForNest(nestId, text),
941
- notifyEmailForNest(nestId, text, details)
800
+ notifyMsTeamsForNest(nestId, text),
801
+ emailEvents.length > 0 ? notifyEmailForNest(nestId, emailText, emailDetails) : Promise.resolve()
942
802
  ]);
943
803
  }
944
- async function notifyNestEvent(nestId, message, details) {
945
- if (!config.SLACK_WEBHOOK_URL && !config.SMTP_URL) return;
804
+ async function notifyNestEvent(nestId, message, details, opts) {
805
+ if (!config.SLACK_WEBHOOK_URL && !config.MSTEAMS_WEBHOOK_URL && !config.SMTP_URL)
806
+ return;
946
807
  const buf = buffers.get(nestId);
947
808
  if (buf) {
948
- buf.events.push({ line: message, details });
809
+ buf.events.push({ line: message, details, skipEmail: opts?.skipEmail });
949
810
  return;
950
811
  }
951
812
  const timer = setTimeout(() => void flush(nestId), config.NOTIFY_DEBOUNCE_MS);
952
813
  timer.unref?.();
953
- buffers.set(nestId, { events: [{ line: message, details }], timer });
814
+ buffers.set(nestId, {
815
+ events: [{ line: message, details, skipEmail: opts?.skipEmail }],
816
+ timer
817
+ });
818
+ }
819
+
820
+ // src/shared/node-write-lock.ts
821
+ var nodeWriteChains = /* @__PURE__ */ new Map();
822
+ function nodeWriteKey(nestId, nodeId) {
823
+ return `${nestId}::${nodeId}`;
824
+ }
825
+ function withNodeWriteLock(key, fn) {
826
+ const prev = (nodeWriteChains.get(key) ?? Promise.resolve()).catch(() => {
827
+ });
828
+ const run = prev.then(fn);
829
+ nodeWriteChains.set(key, run);
830
+ void run.catch(() => {
831
+ }).finally(() => {
832
+ if (nodeWriteChains.get(key) === run) nodeWriteChains.delete(key);
833
+ });
834
+ return run;
954
835
  }
955
836
 
956
837
  // src/governance/safe-publish.ts
@@ -965,6 +846,12 @@ async function safePublishDocument(storage, docId, options) {
965
846
  await storage.writeDocument(docId, serializeDocument(cleanedNode));
966
847
  return publishDocument(storage, docId, options);
967
848
  }
849
+ function serializeNodeSafe(node) {
850
+ return serializeDocument({
851
+ ...node,
852
+ frontmatter: stripUndefinedDeep(node.frontmatter)
853
+ });
854
+ }
968
855
  function stripUndefinedDeep(value) {
969
856
  if (Array.isArray(value)) {
970
857
  return value.filter((v) => v !== void 0).map((v) => stripUndefinedDeep(v));
@@ -1036,187 +923,270 @@ async function submitForReview(params) {
1036
923
  requestedBy: params.requestedBy,
1037
924
  reviewId: id
1038
925
  });
926
+ try {
927
+ const stewards = await resolveStewardsForNode(params.nestId, params.nodeId);
928
+ const reviewers = [
929
+ ...new Set(
930
+ stewards.filter((s) => s.steward.role === "reviewer").map((s) => s.steward.userEmail.toLowerCase())
931
+ )
932
+ ].filter((email) => email !== params.requestedBy.toLowerCase());
933
+ if (reviewers.length > 0) {
934
+ void sendEmailToRecipient(reviewers, {
935
+ status: "pending_review",
936
+ docTitle: reviewCtx.docTitle,
937
+ path: reviewCtx.path,
938
+ link: reviewCtx.link,
939
+ actor: params.requestedBy,
940
+ version: params.version,
941
+ note: params.note || null
942
+ });
943
+ }
944
+ } catch (err) {
945
+ console.error(
946
+ "[notify] reviewer email fan-out failed",
947
+ params.nestId,
948
+ params.nodeId,
949
+ err
950
+ );
951
+ }
1039
952
  return await getReviewRequest(id);
1040
953
  }
1041
- async function approve(params) {
954
+ async function repointPendingReview(params) {
1042
955
  const db = getDb();
1043
956
  const pending = await db.get(
1044
- "SELECT * FROM review_requests WHERE nest_id = ? AND node_id = ? AND status = 'pending' ORDER BY requested_at DESC LIMIT 1",
957
+ "SELECT id, version FROM review_requests WHERE nest_id = ? AND node_id = ? AND status = 'pending' ORDER BY requested_at DESC LIMIT 1",
1045
958
  [params.nestId, params.nodeId]
1046
959
  );
1047
- if (!pending) {
1048
- throw new Error("No pending review for this node");
1049
- }
1050
- if (!params.override) {
1051
- const check = await canUserApprove(
1052
- params.nestId,
1053
- params.nodeId,
1054
- params.approvedBy
960
+ if (!pending || pending.version === params.newVersion) return;
961
+ await db.transaction(async (tx) => {
962
+ await tx.run("UPDATE review_requests SET version = ? WHERE id = ?", [
963
+ params.newVersion,
964
+ pending.id
965
+ ]);
966
+ await tx.run(
967
+ "UPDATE node_versions SET status = 'draft' WHERE nest_id = ? AND node_id = ? AND version = ?",
968
+ [params.nestId, params.nodeId, pending.version]
1055
969
  );
1056
- if (!check.allowed) {
1057
- throw new Error(check.reason);
970
+ await tx.run(
971
+ "UPDATE node_versions SET status = 'pending_review' WHERE nest_id = ? AND node_id = ? AND version = ?",
972
+ [params.nestId, params.nodeId, params.newVersion]
973
+ );
974
+ });
975
+ }
976
+ async function approve(params) {
977
+ return withNodeWriteLock(nodeWriteKey(params.nestId, params.nodeId), async () => {
978
+ const db = getDb();
979
+ const version = await getCurrentVersion(params.nestId, params.nodeId);
980
+ const pending = await db.get(
981
+ "SELECT * FROM review_requests WHERE nest_id = ? AND node_id = ? AND status = 'pending' ORDER BY requested_at DESC LIMIT 1",
982
+ [params.nestId, params.nodeId]
983
+ );
984
+ if (!pending) {
985
+ throw new Error("No pending review for this node");
1058
986
  }
1059
- }
1060
- await db.run(
1061
- `UPDATE review_requests
987
+ if (!params.override) {
988
+ const check = await canUserApprove(
989
+ params.nestId,
990
+ params.nodeId,
991
+ params.approvedBy
992
+ );
993
+ if (!check.allowed) {
994
+ throw new Error(check.reason);
995
+ }
996
+ }
997
+ await db.run(
998
+ `UPDATE review_requests
1062
999
  SET status = 'approved', resolved_by = ?, resolved_at = ${nowExpr(db)},
1063
1000
  resolution_note = ?, is_override = ?
1064
1001
  WHERE id = ?`,
1065
- [params.approvedBy, params.note || null, params.override ? 1 : 0, pending.id]
1066
- );
1067
- void dispatchEvent({
1068
- kind: "review_approved",
1069
- nestId: params.nestId,
1070
- subjectId: params.nodeId,
1071
- actor: params.approvedBy,
1072
- message: `*${await describeNode(params.nestId, params.nodeId)}* v${params.version} approved by ${params.approvedBy}`
1073
- });
1074
- await notifyReviewResolved({
1075
- nestId: params.nestId,
1076
- nodeId: params.nodeId,
1077
- status: "approved",
1078
- resolvedBy: params.approvedBy,
1079
- requestedBy: pending.requested_by,
1080
- reviewId: pending.id
1081
- });
1082
- await db.run(
1083
- "UPDATE node_versions SET status = 'published' WHERE nest_id = ? AND node_id = ? AND version = ?",
1084
- [params.nestId, params.nodeId, params.version]
1085
- );
1086
- await db.run(
1087
- insertOrReplace(
1088
- db,
1089
- `INSERT INTO approved_versions (nest_id, node_id, approved_version, approved_by)
1090
- VALUES (?, ?, ?, ?)`,
1091
- ["nest_id", "node_id"],
1092
- `approved_version = excluded.approved_version, approved_by = excluded.approved_by, approved_at = ${nowExpr(db)}`
1093
- ),
1094
- [params.nestId, params.nodeId, params.version, params.approvedBy]
1095
- );
1096
- try {
1097
- const { storage } = await engineCache.get(params.nestId);
1098
- const result = await safePublishDocument(storage, params.nodeId, {
1099
- editedBy: params.approvedBy,
1100
- note: params.note || `Approved review request ${pending.id}`
1002
+ [params.approvedBy, params.note || null, params.override ? 1 : 0, pending.id]
1003
+ );
1004
+ void dispatchEvent({
1005
+ kind: "review_approved",
1006
+ nestId: params.nestId,
1007
+ subjectId: params.nodeId,
1008
+ actor: params.approvedBy,
1009
+ message: `*${await describeNode(params.nestId, params.nodeId)}* v${version} approved by ${params.approvedBy}`
1010
+ });
1011
+ await notifyReviewResolved({
1012
+ nestId: params.nestId,
1013
+ nodeId: params.nodeId,
1014
+ status: "approved",
1015
+ resolvedBy: params.approvedBy,
1016
+ requestedBy: pending.requested_by,
1017
+ reviewId: pending.id
1101
1018
  });
1102
- const engineVersion = result.versionEntry.version;
1103
- if (engineVersion !== params.version) {
1104
- const node = result.node;
1105
- const tags = node.frontmatter.tags || [];
1106
- await createVersion({
1107
- nestId: params.nestId,
1108
- nodeId: params.nodeId,
1109
- version: engineVersion,
1110
- content: node.body || "",
1111
- author: params.approvedBy,
1112
- status: "published",
1113
- tags,
1114
- changeNote: params.note || `Approved review request ${pending.id}`
1019
+ await db.run(
1020
+ "UPDATE node_versions SET status = 'published' WHERE nest_id = ? AND node_id = ? AND version = ?",
1021
+ [params.nestId, params.nodeId, version]
1022
+ );
1023
+ await db.run(
1024
+ insertOrReplace(
1025
+ db,
1026
+ `INSERT INTO approved_versions (nest_id, node_id, approved_version, approved_by)
1027
+ VALUES (?, ?, ?, ?)`,
1028
+ ["nest_id", "node_id"],
1029
+ `approved_version = excluded.approved_version, approved_by = excluded.approved_by, approved_at = ${nowExpr(db)}`
1030
+ ),
1031
+ [params.nestId, params.nodeId, version, params.approvedBy]
1032
+ );
1033
+ try {
1034
+ const { storage } = await engineCache.get(params.nestId);
1035
+ const result = await safePublishDocument(storage, params.nodeId, {
1036
+ editedBy: params.approvedBy,
1037
+ note: params.note || `Approved review request ${pending.id}`
1115
1038
  });
1116
- await setApprovedVersion(
1117
- params.nestId,
1118
- params.nodeId,
1119
- engineVersion,
1120
- params.approvedBy
1039
+ const engineVersion = result.versionEntry.version;
1040
+ if (engineVersion !== version) {
1041
+ const node = result.node;
1042
+ const tags = node.frontmatter.tags || [];
1043
+ await createVersion({
1044
+ nestId: params.nestId,
1045
+ nodeId: params.nodeId,
1046
+ version: engineVersion,
1047
+ content: node.body || "",
1048
+ author: params.approvedBy,
1049
+ status: "published",
1050
+ tags,
1051
+ changeNote: params.note || `Approved review request ${pending.id}`
1052
+ });
1053
+ await setApprovedVersion(
1054
+ params.nestId,
1055
+ params.nodeId,
1056
+ engineVersion,
1057
+ params.approvedBy
1058
+ );
1059
+ }
1060
+ } catch (err) {
1061
+ console.error(
1062
+ `publishDocument failed for ${params.nestId}/${params.nodeId} on approve:`,
1063
+ err
1121
1064
  );
1122
1065
  }
1123
- } catch (err) {
1124
- console.error(
1125
- `publishDocument failed for ${params.nestId}/${params.nodeId} on approve:`,
1126
- err
1066
+ const approveCtx = await buildDocContext(params.nestId, params.nodeId, params.baseUrl);
1067
+ void notifyNestEvent(
1068
+ params.nestId,
1069
+ `:white_check_mark: *${approveCtx.label}* v${version} approved by ${params.approvedBy}`,
1070
+ {
1071
+ status: "approved",
1072
+ docTitle: approveCtx.docTitle,
1073
+ path: approveCtx.path,
1074
+ link: approveCtx.link,
1075
+ actor: params.approvedBy,
1076
+ version
1077
+ },
1078
+ // The verdict emails the submitter directly (below), not the shared
1079
+ // NOTIFY_EMAIL_TO team inbox — skip the team-channel email; Slack/Teams fire.
1080
+ { skipEmail: true }
1127
1081
  );
1128
- }
1129
- const approveCtx = await buildDocContext(params.nestId, params.nodeId, params.baseUrl);
1130
- void notifyNestEvent(
1131
- params.nestId,
1132
- `:white_check_mark: *${approveCtx.label}* v${params.version} approved by ${params.approvedBy}`,
1133
- {
1134
- status: "approved",
1135
- docTitle: approveCtx.docTitle,
1136
- path: approveCtx.path,
1137
- link: approveCtx.link,
1138
- actor: params.approvedBy,
1139
- version: params.version
1082
+ if (pending.requested_by?.toLowerCase() !== params.approvedBy.toLowerCase()) {
1083
+ void sendEmailToRecipient(pending.requested_by, {
1084
+ status: "approved",
1085
+ docTitle: approveCtx.docTitle,
1086
+ path: approveCtx.path,
1087
+ link: approveCtx.link,
1088
+ actor: params.approvedBy,
1089
+ version,
1090
+ note: params.note || null
1091
+ });
1140
1092
  }
1141
- );
1142
- return await getReviewRequest(pending.id);
1093
+ return await getReviewRequest(pending.id);
1094
+ });
1143
1095
  }
1144
1096
  async function reject(params) {
1145
- const db = getDb();
1146
- const pending = await db.get(
1147
- "SELECT * FROM review_requests WHERE nest_id = ? AND node_id = ? AND status = 'pending' ORDER BY requested_at DESC LIMIT 1",
1148
- [params.nestId, params.nodeId]
1149
- );
1150
- if (!pending) {
1151
- throw new Error("No pending review for this node");
1152
- }
1153
- const check = await canUserApprove(
1154
- params.nestId,
1155
- params.nodeId,
1156
- params.rejectedBy
1157
- );
1158
- if (!check.allowed) {
1159
- throw new Error(check.reason);
1160
- }
1161
- await db.run(
1162
- `UPDATE review_requests
1097
+ return withNodeWriteLock(nodeWriteKey(params.nestId, params.nodeId), async () => {
1098
+ const db = getDb();
1099
+ const version = await getCurrentVersion(params.nestId, params.nodeId);
1100
+ const pending = await db.get(
1101
+ "SELECT * FROM review_requests WHERE nest_id = ? AND node_id = ? AND status = 'pending' ORDER BY requested_at DESC LIMIT 1",
1102
+ [params.nestId, params.nodeId]
1103
+ );
1104
+ if (!pending) {
1105
+ throw new Error("No pending review for this node");
1106
+ }
1107
+ const check = await canUserApprove(
1108
+ params.nestId,
1109
+ params.nodeId,
1110
+ params.rejectedBy
1111
+ );
1112
+ if (!check.allowed) {
1113
+ throw new Error(check.reason);
1114
+ }
1115
+ await db.run(
1116
+ `UPDATE review_requests
1163
1117
  SET status = 'rejected', resolved_by = ?, resolved_at = ${nowExpr(db)}, resolution_note = ?
1164
1118
  WHERE id = ?`,
1165
- [params.rejectedBy, params.note, pending.id]
1166
- );
1167
- await db.run(
1168
- "UPDATE node_versions SET status = 'rejected' WHERE nest_id = ? AND node_id = ? AND version = ?",
1169
- [params.nestId, params.nodeId, params.version]
1170
- );
1171
- const rejectCtx = await buildDocContext(params.nestId, params.nodeId, params.baseUrl);
1172
- void dispatchEvent({
1173
- kind: "review_rejected",
1174
- nestId: params.nestId,
1175
- subjectId: params.nodeId,
1176
- actor: params.rejectedBy,
1177
- message: `*${rejectCtx.label}* v${params.version} rejected by ${params.rejectedBy}${params.note ? ` \u2014 "${params.note}"` : ""}`
1178
- });
1179
- void notifyNestEvent(
1180
- params.nestId,
1181
- `:x: *${rejectCtx.label}* v${params.version} rejected by ${params.rejectedBy} \u2014 "${params.note}"`,
1182
- {
1183
- status: "rejected",
1184
- docTitle: rejectCtx.docTitle,
1185
- path: rejectCtx.path,
1186
- link: rejectCtx.link,
1119
+ [params.rejectedBy, params.note, pending.id]
1120
+ );
1121
+ await db.run(
1122
+ "UPDATE node_versions SET status = 'rejected' WHERE nest_id = ? AND node_id = ? AND version = ?",
1123
+ [params.nestId, params.nodeId, version]
1124
+ );
1125
+ const rejectCtx = await buildDocContext(params.nestId, params.nodeId, params.baseUrl);
1126
+ void dispatchEvent({
1127
+ kind: "review_rejected",
1128
+ nestId: params.nestId,
1129
+ subjectId: params.nodeId,
1187
1130
  actor: params.rejectedBy,
1188
- version: params.version,
1189
- note: params.note || null
1131
+ message: `*${rejectCtx.label}* v${version} rejected by ${params.rejectedBy}${params.note ? ` \u2014 "${params.note}"` : ""}`
1132
+ });
1133
+ void notifyNestEvent(
1134
+ params.nestId,
1135
+ `:x: *${rejectCtx.label}* v${version} rejected by ${params.rejectedBy} \u2014 "${params.note}"`,
1136
+ {
1137
+ status: "rejected",
1138
+ docTitle: rejectCtx.docTitle,
1139
+ path: rejectCtx.path,
1140
+ link: rejectCtx.link,
1141
+ actor: params.rejectedBy,
1142
+ version,
1143
+ note: params.note || null
1144
+ },
1145
+ // Verdict emails the submitter directly (below), not the team inbox.
1146
+ { skipEmail: true }
1147
+ );
1148
+ if (pending.requested_by?.toLowerCase() !== params.rejectedBy.toLowerCase()) {
1149
+ void sendEmailToRecipient(pending.requested_by, {
1150
+ status: "rejected",
1151
+ docTitle: rejectCtx.docTitle,
1152
+ path: rejectCtx.path,
1153
+ link: rejectCtx.link,
1154
+ actor: params.rejectedBy,
1155
+ version,
1156
+ note: params.note || null
1157
+ });
1190
1158
  }
1191
- );
1192
- await notifyReviewResolved({
1193
- nestId: params.nestId,
1194
- nodeId: params.nodeId,
1195
- status: "rejected",
1196
- resolvedBy: params.rejectedBy,
1197
- requestedBy: pending.requested_by,
1198
- reviewId: pending.id
1159
+ await notifyReviewResolved({
1160
+ nestId: params.nestId,
1161
+ nodeId: params.nodeId,
1162
+ status: "rejected",
1163
+ resolvedBy: params.rejectedBy,
1164
+ requestedBy: pending.requested_by,
1165
+ reviewId: pending.id
1166
+ });
1167
+ return await getReviewRequest(pending.id);
1199
1168
  });
1200
- return await getReviewRequest(pending.id);
1201
1169
  }
1202
1170
  async function cancelReview(params) {
1203
- const db = getDb();
1204
- const pending = await db.get(
1205
- "SELECT * FROM review_requests WHERE nest_id = ? AND node_id = ? AND status = 'pending' ORDER BY requested_at DESC LIMIT 1",
1206
- [params.nestId, params.nodeId]
1207
- );
1208
- if (!pending) return null;
1209
- await db.run(
1210
- `UPDATE review_requests
1171
+ return withNodeWriteLock(nodeWriteKey(params.nestId, params.nodeId), async () => {
1172
+ const db = getDb();
1173
+ const pending = await db.get(
1174
+ "SELECT * FROM review_requests WHERE nest_id = ? AND node_id = ? AND status = 'pending' ORDER BY requested_at DESC LIMIT 1",
1175
+ [params.nestId, params.nodeId]
1176
+ );
1177
+ if (!pending) return null;
1178
+ await db.run(
1179
+ `UPDATE review_requests
1211
1180
  SET status = 'cancelled', resolved_by = ?, resolved_at = ${nowExpr(db)}
1212
1181
  WHERE id = ?`,
1213
- [params.cancelledBy, pending.id]
1214
- );
1215
- await db.run(
1216
- "UPDATE node_versions SET status = 'draft' WHERE nest_id = ? AND node_id = ? AND version = ?",
1217
- [params.nestId, params.nodeId, pending.version]
1218
- );
1219
- return await getReviewRequest(pending.id);
1182
+ [params.cancelledBy, pending.id]
1183
+ );
1184
+ await db.run(
1185
+ "UPDATE node_versions SET status = 'draft' WHERE nest_id = ? AND node_id = ? AND version = ?",
1186
+ [params.nestId, params.nodeId, pending.version]
1187
+ );
1188
+ return await getReviewRequest(pending.id);
1189
+ });
1220
1190
  }
1221
1191
  async function getReviewQueue(params) {
1222
1192
  const db = getDb();
@@ -1295,6 +1265,297 @@ async function getPendingReview(nestId, nodeId) {
1295
1265
  );
1296
1266
  return row ? rowToReviewRequest(row) : null;
1297
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
+ }
1298
1559
  async function getReviewRequest(id) {
1299
1560
  const db = getDb();
1300
1561
  const row = await db.get(
@@ -1322,9 +1583,12 @@ function rowToReviewRequest(row) {
1322
1583
 
1323
1584
  export {
1324
1585
  safePublishDocument,
1586
+ serializeNodeSafe,
1587
+ stripUndefinedDeep,
1325
1588
  listWatchers,
1326
1589
  addWatcher,
1327
1590
  removeWatcher,
1591
+ notifyNestInvite,
1328
1592
  listNotifications,
1329
1593
  markNotificationsRead,
1330
1594
  resolveCallerEmail,
@@ -1340,13 +1604,24 @@ export {
1340
1604
  envValues,
1341
1605
  isSafeConnectorUrl,
1342
1606
  dispatchEvent,
1343
- verifySmtp,
1607
+ sendTestNotification,
1344
1608
  notifyNestEvent,
1609
+ nodeWriteKey,
1610
+ withNodeWriteLock,
1345
1611
  submitForReview,
1612
+ repointPendingReview,
1346
1613
  approve,
1347
1614
  reject,
1348
1615
  cancelReview,
1349
1616
  getReviewQueue,
1350
1617
  getReviewHistory,
1351
- getPendingReview
1618
+ getPendingReview,
1619
+ deletionTargetTitle,
1620
+ notifyDeletionResolved,
1621
+ requestDeletion,
1622
+ getLatestDeletionRequest,
1623
+ getDeletionRequest,
1624
+ listDeletionRequests,
1625
+ dropDeletionRequest,
1626
+ declineDeletion
1352
1627
  };