@promptowl/contextnest-community 1.13.0 → 1.14.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.
- package/CONFIGURATION.md +57 -1
- package/README.md +2 -0
- package/dist/{chunk-5QQ7WKAI.js → chunk-I5KYGMIT.js} +22 -0
- package/dist/{chunk-ZQS5W45U.js → chunk-IHGPZ6NY.js} +1 -1
- package/dist/{chunk-EG77SQHO.js → chunk-JP67WXLA.js} +1 -1
- package/dist/{chunk-Q5NRCDD4.js → chunk-KPK656BH.js} +456 -27
- package/dist/{chunk-PAIP5KHB.js → chunk-MZ4IR5KH.js} +458 -500
- package/dist/{client-AAMF22YJ.js → client-3MGIP57D.js} +1 -1
- package/dist/{grants-service-FZD6QM6V.js → grants-service-5ZFISCBH.js} +2 -2
- package/dist/index.js +189 -60
- package/dist/{review-service-Q3NDK5TX.js → review-service-6G7MVTY6.js} +7 -5
- package/dist/{stewardship-service-B6L7UACX.js → stewardship-service-G7IF7ZNP.js} +2 -2
- package/dist/{version-service-L52F4WVZ.js → version-service-FGL47APL.js} +2 -2
- package/dist/web3/assets/index-BYmOkm4p.js +1052 -0
- package/dist/web3/assets/index-p6uQyhml.css +1 -0
- package/dist/web3/index.html +2 -2
- package/package.json +2 -1
- package/dist/web3/assets/index-DcmDj50q.css +0 -1
- package/dist/web3/assets/index-EGPZ6MAy.js +0 -1023
|
@@ -2,12 +2,13 @@ import {
|
|
|
2
2
|
grantCoversNode,
|
|
3
3
|
listUserGrants,
|
|
4
4
|
resolveNodeGrant
|
|
5
|
-
} from "./chunk-
|
|
5
|
+
} from "./chunk-IHGPZ6NY.js";
|
|
6
6
|
import {
|
|
7
7
|
createVersion,
|
|
8
8
|
getApprovedVersion,
|
|
9
|
+
getCurrentVersion,
|
|
9
10
|
setApprovedVersion
|
|
10
|
-
} from "./chunk-
|
|
11
|
+
} from "./chunk-JP67WXLA.js";
|
|
11
12
|
import {
|
|
12
13
|
buildDocContext,
|
|
13
14
|
buildTitleMap,
|
|
@@ -17,8 +18,10 @@ import {
|
|
|
17
18
|
engineCache,
|
|
18
19
|
isPublicReader,
|
|
19
20
|
isStewardshipEnabled,
|
|
20
|
-
|
|
21
|
-
|
|
21
|
+
notifyEmailForNest,
|
|
22
|
+
resolveStewardsForNode,
|
|
23
|
+
sendEmailToRecipient
|
|
24
|
+
} from "./chunk-KPK656BH.js";
|
|
22
25
|
import {
|
|
23
26
|
ConflictError,
|
|
24
27
|
NotFoundError,
|
|
@@ -31,7 +34,7 @@ import {
|
|
|
31
34
|
import {
|
|
32
35
|
config,
|
|
33
36
|
getDb
|
|
34
|
-
} from "./chunk-
|
|
37
|
+
} from "./chunk-I5KYGMIT.js";
|
|
35
38
|
|
|
36
39
|
// src/governance/review-service.ts
|
|
37
40
|
import { v4 as uuid3 } from "uuid";
|
|
@@ -146,6 +149,25 @@ async function notifyReviewResolved(params) {
|
|
|
146
149
|
return 0;
|
|
147
150
|
}
|
|
148
151
|
}
|
|
152
|
+
async function notifyNestInvite(params) {
|
|
153
|
+
const { nestId, inviteeEmail, permission, nestName } = params;
|
|
154
|
+
try {
|
|
155
|
+
await getDb().run(
|
|
156
|
+
`INSERT INTO notifications (id, nest_id, user_email, kind, subject_id, message, created_at)
|
|
157
|
+
VALUES (?, ?, ?, 'nest_invite', ?, ?, ?)`,
|
|
158
|
+
[
|
|
159
|
+
uuid(),
|
|
160
|
+
nestId,
|
|
161
|
+
inviteeEmail.toLowerCase(),
|
|
162
|
+
nestId,
|
|
163
|
+
`You were added to "${nestName}" as ${permission}`,
|
|
164
|
+
(/* @__PURE__ */ new Date()).toISOString()
|
|
165
|
+
]
|
|
166
|
+
);
|
|
167
|
+
} catch (err) {
|
|
168
|
+
console.error("[notify] invite inbox insert failed", nestId, inviteeEmail, err);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
149
171
|
async function listNotifications(userEmail, opts = {}) {
|
|
150
172
|
const limit = Math.min(opts.limit ?? 50, 200);
|
|
151
173
|
return await getDb().all(
|
|
@@ -497,23 +519,158 @@ async function envValues(nestId) {
|
|
|
497
519
|
return { ...base, ...Object.fromEntries(rows.map((r) => [r.key, r.value])) };
|
|
498
520
|
}
|
|
499
521
|
|
|
500
|
-
// src/notify/
|
|
522
|
+
// src/notify/webhook.ts
|
|
523
|
+
async function resolveNestName(nestId) {
|
|
524
|
+
try {
|
|
525
|
+
const row = await getDb().get("SELECT name FROM nests WHERE id = ?", [
|
|
526
|
+
nestId
|
|
527
|
+
]);
|
|
528
|
+
if (row?.name) return row.name;
|
|
529
|
+
} catch {
|
|
530
|
+
}
|
|
531
|
+
return nestId;
|
|
532
|
+
}
|
|
533
|
+
async function rawPost(url, body, timeoutMs) {
|
|
534
|
+
const ctrl = new AbortController();
|
|
535
|
+
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
536
|
+
try {
|
|
537
|
+
return await fetch(url, {
|
|
538
|
+
method: "POST",
|
|
539
|
+
headers: { "Content-Type": "application/json" },
|
|
540
|
+
body: JSON.stringify(body),
|
|
541
|
+
signal: ctrl.signal
|
|
542
|
+
});
|
|
543
|
+
} finally {
|
|
544
|
+
clearTimeout(timer);
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
var warned = /* @__PURE__ */ new Set();
|
|
548
|
+
async function postWebhook(url, body, who) {
|
|
549
|
+
try {
|
|
550
|
+
await rawPost(url, body, 3e3);
|
|
551
|
+
} catch (err) {
|
|
552
|
+
if (!warned.has(who)) {
|
|
553
|
+
warned.add(who);
|
|
554
|
+
console.warn(
|
|
555
|
+
`[notify] delivery failed for ${who} (muting further warnings):`,
|
|
556
|
+
err instanceof Error ? err.message : err
|
|
557
|
+
);
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
async function testWebhook(url, body) {
|
|
562
|
+
try {
|
|
563
|
+
const res = await rawPost(url, body, 5e3);
|
|
564
|
+
return res.ok ? { ok: true } : { ok: false, error: `Webhook responded ${res.status} ${res.statusText}.` };
|
|
565
|
+
} catch (err) {
|
|
566
|
+
return {
|
|
567
|
+
ok: false,
|
|
568
|
+
error: err instanceof Error ? err.message : "Request failed."
|
|
569
|
+
};
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// src/notify/msteams.ts
|
|
574
|
+
var EMOJI = {
|
|
575
|
+
":memo:": "\u{1F4DD}",
|
|
576
|
+
":white_check_mark:": "\u2705",
|
|
577
|
+
":x:": "\u274C",
|
|
578
|
+
":rotating_light:": "\u{1F6A8}",
|
|
579
|
+
":key:": "\u{1F511}"
|
|
580
|
+
// collaborator added (sharing-service)
|
|
581
|
+
};
|
|
582
|
+
var BOLD_SPAN = /(^|\s)\*([^*\n]+)\*(?=\s|[.,;:!?)]|$)/g;
|
|
583
|
+
function slackEmojiToUnicode(s) {
|
|
584
|
+
return s.replace(/:[a-z0-9_+-]+:/g, (m) => EMOJI[m] ?? m);
|
|
585
|
+
}
|
|
586
|
+
function escapeAdaptiveText(s) {
|
|
587
|
+
return s.replace(/\\/g, "\\\\").replace(/\[/g, "\\[").replace(/\]/g, "\\]");
|
|
588
|
+
}
|
|
589
|
+
function toAdaptiveText(s) {
|
|
590
|
+
return slackEmojiToUnicode(escapeAdaptiveText(s)).replace(BOLD_SPAN, "$1**$2**");
|
|
591
|
+
}
|
|
592
|
+
function toPlainText(s) {
|
|
593
|
+
return slackEmojiToUnicode(s).replace(BOLD_SPAN, "$1$2");
|
|
594
|
+
}
|
|
595
|
+
function msTeamsPayload(title, message) {
|
|
596
|
+
return {
|
|
597
|
+
type: "message",
|
|
598
|
+
attachments: [
|
|
599
|
+
{
|
|
600
|
+
contentType: "application/vnd.microsoft.card.adaptive",
|
|
601
|
+
contentUrl: null,
|
|
602
|
+
content: {
|
|
603
|
+
$schema: "http://adaptivecards.io/schemas/adaptive-card.json",
|
|
604
|
+
type: "AdaptiveCard",
|
|
605
|
+
version: "1.4",
|
|
606
|
+
msteams: { width: "Full" },
|
|
607
|
+
fallbackText: `[${toPlainText(title)}] ${toPlainText(message)}`,
|
|
608
|
+
body: [
|
|
609
|
+
{
|
|
610
|
+
// Same pipeline as the message: the nest name is user-
|
|
611
|
+
// controlled text too and must not carry link syntax.
|
|
612
|
+
type: "TextBlock",
|
|
613
|
+
text: toAdaptiveText(title),
|
|
614
|
+
weight: "Bolder",
|
|
615
|
+
size: "Medium",
|
|
616
|
+
wrap: true
|
|
617
|
+
},
|
|
618
|
+
{
|
|
619
|
+
type: "TextBlock",
|
|
620
|
+
text: toAdaptiveText(message),
|
|
621
|
+
wrap: true
|
|
622
|
+
}
|
|
623
|
+
]
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
]
|
|
627
|
+
};
|
|
628
|
+
}
|
|
629
|
+
async function notifyMsTeamsForNest(nestId, message) {
|
|
630
|
+
if (!config.MSTEAMS_WEBHOOK_URL) return;
|
|
631
|
+
const nestName = await resolveNestName(nestId);
|
|
632
|
+
return notifyMsTeams(nestName, message);
|
|
633
|
+
}
|
|
634
|
+
async function notifyMsTeams(title, message) {
|
|
635
|
+
const url = config.MSTEAMS_WEBHOOK_URL;
|
|
636
|
+
if (!url) return;
|
|
637
|
+
await postWebhook(url, msTeamsPayload(title, message), "msteams");
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
// src/notify/slack.ts
|
|
641
|
+
async function notifySlackForNest(nestId, message) {
|
|
642
|
+
if (!config.SLACK_WEBHOOK_URL) return;
|
|
643
|
+
const nestName = await resolveNestName(nestId);
|
|
644
|
+
return notifySlack(`*[${nestName}]* ${message}`);
|
|
645
|
+
}
|
|
501
646
|
function escapeMrkdwn(s) {
|
|
502
647
|
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
503
648
|
}
|
|
649
|
+
async function notifySlack(text) {
|
|
650
|
+
const url = config.SLACK_WEBHOOK_URL;
|
|
651
|
+
if (!url) return;
|
|
652
|
+
await postWebhook(url, { text: escapeMrkdwn(text) }, "slack");
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// src/notify/dispatch.ts
|
|
504
656
|
var EVENT_EMOJI = {
|
|
505
657
|
review_requested: ":memo:",
|
|
506
658
|
review_approved: ":white_check_mark:",
|
|
507
659
|
review_rejected: ":x:",
|
|
508
660
|
run_failed: ":rotating_light:"
|
|
509
661
|
};
|
|
662
|
+
var DIGEST_DELIVERED_KINDS = /* @__PURE__ */ new Set([
|
|
663
|
+
"review_requested",
|
|
664
|
+
"review_approved",
|
|
665
|
+
"review_rejected"
|
|
666
|
+
]);
|
|
510
667
|
function formatBody(channel, nestName, ev) {
|
|
511
668
|
const line = `[${nestName}] ${ev.message}`;
|
|
512
669
|
if (channel === "slack") {
|
|
513
670
|
return { text: escapeMrkdwn(`${EVENT_EMOJI[ev.kind] ?? ""} *[${nestName}]* ${ev.message}`) };
|
|
514
671
|
}
|
|
515
672
|
if (channel === "teams") {
|
|
516
|
-
return {
|
|
673
|
+
return msTeamsPayload(nestName, `${EVENT_EMOJI[ev.kind] ?? ""} ${ev.message}`.trim());
|
|
517
674
|
}
|
|
518
675
|
return {
|
|
519
676
|
kind: ev.kind,
|
|
@@ -557,46 +714,23 @@ async function resolveUrl(nestId, raw) {
|
|
|
557
714
|
}
|
|
558
715
|
return isSafeConnectorUrl(raw) ? raw : null;
|
|
559
716
|
}
|
|
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
717
|
async function dispatchEvent(ev) {
|
|
586
718
|
const db = getDb();
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
if (row?.name) nestName = row.name;
|
|
591
|
-
} catch {
|
|
592
|
-
}
|
|
593
|
-
if (config.SLACK_WEBHOOK_URL) {
|
|
594
|
-
void post(
|
|
719
|
+
const nestName = await resolveNestName(ev.nestId);
|
|
720
|
+
if (config.SLACK_WEBHOOK_URL && !DIGEST_DELIVERED_KINDS.has(ev.kind)) {
|
|
721
|
+
void postWebhook(
|
|
595
722
|
config.SLACK_WEBHOOK_URL,
|
|
596
723
|
formatBody("slack", nestName, ev),
|
|
597
724
|
"server-slack"
|
|
598
725
|
);
|
|
599
726
|
}
|
|
727
|
+
if (config.MSTEAMS_WEBHOOK_URL && !DIGEST_DELIVERED_KINDS.has(ev.kind)) {
|
|
728
|
+
void postWebhook(
|
|
729
|
+
config.MSTEAMS_WEBHOOK_URL,
|
|
730
|
+
formatBody("teams", nestName, ev),
|
|
731
|
+
"server-msteams"
|
|
732
|
+
);
|
|
733
|
+
}
|
|
600
734
|
let rows = [];
|
|
601
735
|
try {
|
|
602
736
|
rows = await db.all(
|
|
@@ -615,308 +749,20 @@ async function dispatchEvent(ev) {
|
|
|
615
749
|
if (!kinds.includes("*") && !kinds.includes(ev.kind)) continue;
|
|
616
750
|
const url = await resolveUrl(ev.nestId, r.url);
|
|
617
751
|
if (!url) continue;
|
|
618
|
-
void
|
|
752
|
+
void postWebhook(url, formatBody(r.channel, nestName, ev), `connector:${r.id}`);
|
|
619
753
|
}
|
|
620
754
|
}
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
} catch {
|
|
633
|
-
}
|
|
634
|
-
return notifySlack(`*[${nestName}]* ${message}`);
|
|
635
|
-
}
|
|
636
|
-
function escapeMrkdwn2(s) {
|
|
637
|
-
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
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: “${escapeHtml(d.note)}”` : "";
|
|
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
|
-
}
|
|
855
|
-
}
|
|
856
|
-
}
|
|
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
|
-
}
|
|
755
|
+
async function sendTestNotification(channel) {
|
|
756
|
+
const url = channel === "slack" ? config.SLACK_WEBHOOK_URL : config.MSTEAMS_WEBHOOK_URL;
|
|
757
|
+
const label = channel === "slack" ? "Slack" : "Microsoft Teams";
|
|
758
|
+
if (!url)
|
|
759
|
+
return { ok: false, error: `Save a ${label} webhook URL first, then test.` };
|
|
760
|
+
const ev = {
|
|
761
|
+
kind: "test",
|
|
762
|
+
nestId: "",
|
|
763
|
+
message: "Test notification from ContextNest \u2014 your connector is working."
|
|
764
|
+
};
|
|
765
|
+
return testWebhook(url, formatBody(channel, "ContextNest", ev));
|
|
920
766
|
}
|
|
921
767
|
|
|
922
768
|
// src/notify/notify.ts
|
|
@@ -935,22 +781,46 @@ async function flush(nestId) {
|
|
|
935
781
|
if (!buf) return;
|
|
936
782
|
buffers.delete(nestId);
|
|
937
783
|
const text = buildText(buf.events.map((e) => e.line));
|
|
938
|
-
const
|
|
784
|
+
const emailEvents = buf.events.filter((e) => !e.skipEmail);
|
|
785
|
+
const emailText = buildText(emailEvents.map((e) => e.line));
|
|
786
|
+
const emailDetails = emailEvents.length === 1 ? emailEvents[0].details : void 0;
|
|
939
787
|
await Promise.allSettled([
|
|
940
788
|
notifySlackForNest(nestId, text),
|
|
941
|
-
|
|
789
|
+
notifyMsTeamsForNest(nestId, text),
|
|
790
|
+
emailEvents.length > 0 ? notifyEmailForNest(nestId, emailText, emailDetails) : Promise.resolve()
|
|
942
791
|
]);
|
|
943
792
|
}
|
|
944
|
-
async function notifyNestEvent(nestId, message, details) {
|
|
945
|
-
if (!config.SLACK_WEBHOOK_URL && !config.SMTP_URL)
|
|
793
|
+
async function notifyNestEvent(nestId, message, details, opts) {
|
|
794
|
+
if (!config.SLACK_WEBHOOK_URL && !config.MSTEAMS_WEBHOOK_URL && !config.SMTP_URL)
|
|
795
|
+
return;
|
|
946
796
|
const buf = buffers.get(nestId);
|
|
947
797
|
if (buf) {
|
|
948
|
-
buf.events.push({ line: message, details });
|
|
798
|
+
buf.events.push({ line: message, details, skipEmail: opts?.skipEmail });
|
|
949
799
|
return;
|
|
950
800
|
}
|
|
951
801
|
const timer = setTimeout(() => void flush(nestId), config.NOTIFY_DEBOUNCE_MS);
|
|
952
802
|
timer.unref?.();
|
|
953
|
-
buffers.set(nestId, {
|
|
803
|
+
buffers.set(nestId, {
|
|
804
|
+
events: [{ line: message, details, skipEmail: opts?.skipEmail }],
|
|
805
|
+
timer
|
|
806
|
+
});
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
// src/shared/node-write-lock.ts
|
|
810
|
+
var nodeWriteChains = /* @__PURE__ */ new Map();
|
|
811
|
+
function nodeWriteKey(nestId, nodeId) {
|
|
812
|
+
return `${nestId}::${nodeId}`;
|
|
813
|
+
}
|
|
814
|
+
function withNodeWriteLock(key, fn) {
|
|
815
|
+
const prev = (nodeWriteChains.get(key) ?? Promise.resolve()).catch(() => {
|
|
816
|
+
});
|
|
817
|
+
const run = prev.then(fn);
|
|
818
|
+
nodeWriteChains.set(key, run);
|
|
819
|
+
void run.catch(() => {
|
|
820
|
+
}).finally(() => {
|
|
821
|
+
if (nodeWriteChains.get(key) === run) nodeWriteChains.delete(key);
|
|
822
|
+
});
|
|
823
|
+
return run;
|
|
954
824
|
}
|
|
955
825
|
|
|
956
826
|
// src/governance/safe-publish.ts
|
|
@@ -1036,187 +906,270 @@ async function submitForReview(params) {
|
|
|
1036
906
|
requestedBy: params.requestedBy,
|
|
1037
907
|
reviewId: id
|
|
1038
908
|
});
|
|
909
|
+
try {
|
|
910
|
+
const stewards = await resolveStewardsForNode(params.nestId, params.nodeId);
|
|
911
|
+
const reviewers = [
|
|
912
|
+
...new Set(
|
|
913
|
+
stewards.filter((s) => s.steward.role === "reviewer").map((s) => s.steward.userEmail.toLowerCase())
|
|
914
|
+
)
|
|
915
|
+
].filter((email) => email !== params.requestedBy.toLowerCase());
|
|
916
|
+
if (reviewers.length > 0) {
|
|
917
|
+
void sendEmailToRecipient(reviewers, {
|
|
918
|
+
status: "pending_review",
|
|
919
|
+
docTitle: reviewCtx.docTitle,
|
|
920
|
+
path: reviewCtx.path,
|
|
921
|
+
link: reviewCtx.link,
|
|
922
|
+
actor: params.requestedBy,
|
|
923
|
+
version: params.version,
|
|
924
|
+
note: params.note || null
|
|
925
|
+
});
|
|
926
|
+
}
|
|
927
|
+
} catch (err) {
|
|
928
|
+
console.error(
|
|
929
|
+
"[notify] reviewer email fan-out failed",
|
|
930
|
+
params.nestId,
|
|
931
|
+
params.nodeId,
|
|
932
|
+
err
|
|
933
|
+
);
|
|
934
|
+
}
|
|
1039
935
|
return await getReviewRequest(id);
|
|
1040
936
|
}
|
|
1041
|
-
async function
|
|
937
|
+
async function repointPendingReview(params) {
|
|
1042
938
|
const db = getDb();
|
|
1043
939
|
const pending = await db.get(
|
|
1044
|
-
"SELECT
|
|
940
|
+
"SELECT id, version FROM review_requests WHERE nest_id = ? AND node_id = ? AND status = 'pending' ORDER BY requested_at DESC LIMIT 1",
|
|
1045
941
|
[params.nestId, params.nodeId]
|
|
1046
942
|
);
|
|
1047
|
-
if (!pending)
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
943
|
+
if (!pending || pending.version === params.newVersion) return;
|
|
944
|
+
await db.transaction(async (tx) => {
|
|
945
|
+
await tx.run("UPDATE review_requests SET version = ? WHERE id = ?", [
|
|
946
|
+
params.newVersion,
|
|
947
|
+
pending.id
|
|
948
|
+
]);
|
|
949
|
+
await tx.run(
|
|
950
|
+
"UPDATE node_versions SET status = 'draft' WHERE nest_id = ? AND node_id = ? AND version = ?",
|
|
951
|
+
[params.nestId, params.nodeId, pending.version]
|
|
1055
952
|
);
|
|
1056
|
-
|
|
1057
|
-
|
|
953
|
+
await tx.run(
|
|
954
|
+
"UPDATE node_versions SET status = 'pending_review' WHERE nest_id = ? AND node_id = ? AND version = ?",
|
|
955
|
+
[params.nestId, params.nodeId, params.newVersion]
|
|
956
|
+
);
|
|
957
|
+
});
|
|
958
|
+
}
|
|
959
|
+
async function approve(params) {
|
|
960
|
+
return withNodeWriteLock(nodeWriteKey(params.nestId, params.nodeId), async () => {
|
|
961
|
+
const db = getDb();
|
|
962
|
+
const version = await getCurrentVersion(params.nestId, params.nodeId);
|
|
963
|
+
const pending = await db.get(
|
|
964
|
+
"SELECT * FROM review_requests WHERE nest_id = ? AND node_id = ? AND status = 'pending' ORDER BY requested_at DESC LIMIT 1",
|
|
965
|
+
[params.nestId, params.nodeId]
|
|
966
|
+
);
|
|
967
|
+
if (!pending) {
|
|
968
|
+
throw new Error("No pending review for this node");
|
|
1058
969
|
}
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
970
|
+
if (!params.override) {
|
|
971
|
+
const check = await canUserApprove(
|
|
972
|
+
params.nestId,
|
|
973
|
+
params.nodeId,
|
|
974
|
+
params.approvedBy
|
|
975
|
+
);
|
|
976
|
+
if (!check.allowed) {
|
|
977
|
+
throw new Error(check.reason);
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
await db.run(
|
|
981
|
+
`UPDATE review_requests
|
|
1062
982
|
SET status = 'approved', resolved_by = ?, resolved_at = ${nowExpr(db)},
|
|
1063
983
|
resolution_note = ?, is_override = ?
|
|
1064
984
|
WHERE id = ?`,
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
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}`
|
|
985
|
+
[params.approvedBy, params.note || null, params.override ? 1 : 0, pending.id]
|
|
986
|
+
);
|
|
987
|
+
void dispatchEvent({
|
|
988
|
+
kind: "review_approved",
|
|
989
|
+
nestId: params.nestId,
|
|
990
|
+
subjectId: params.nodeId,
|
|
991
|
+
actor: params.approvedBy,
|
|
992
|
+
message: `*${await describeNode(params.nestId, params.nodeId)}* v${version} approved by ${params.approvedBy}`
|
|
1101
993
|
});
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
994
|
+
await notifyReviewResolved({
|
|
995
|
+
nestId: params.nestId,
|
|
996
|
+
nodeId: params.nodeId,
|
|
997
|
+
status: "approved",
|
|
998
|
+
resolvedBy: params.approvedBy,
|
|
999
|
+
requestedBy: pending.requested_by,
|
|
1000
|
+
reviewId: pending.id
|
|
1001
|
+
});
|
|
1002
|
+
await db.run(
|
|
1003
|
+
"UPDATE node_versions SET status = 'published' WHERE nest_id = ? AND node_id = ? AND version = ?",
|
|
1004
|
+
[params.nestId, params.nodeId, version]
|
|
1005
|
+
);
|
|
1006
|
+
await db.run(
|
|
1007
|
+
insertOrReplace(
|
|
1008
|
+
db,
|
|
1009
|
+
`INSERT INTO approved_versions (nest_id, node_id, approved_version, approved_by)
|
|
1010
|
+
VALUES (?, ?, ?, ?)`,
|
|
1011
|
+
["nest_id", "node_id"],
|
|
1012
|
+
`approved_version = excluded.approved_version, approved_by = excluded.approved_by, approved_at = ${nowExpr(db)}`
|
|
1013
|
+
),
|
|
1014
|
+
[params.nestId, params.nodeId, version, params.approvedBy]
|
|
1015
|
+
);
|
|
1016
|
+
try {
|
|
1017
|
+
const { storage } = await engineCache.get(params.nestId);
|
|
1018
|
+
const result = await safePublishDocument(storage, params.nodeId, {
|
|
1019
|
+
editedBy: params.approvedBy,
|
|
1020
|
+
note: params.note || `Approved review request ${pending.id}`
|
|
1115
1021
|
});
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1022
|
+
const engineVersion = result.versionEntry.version;
|
|
1023
|
+
if (engineVersion !== version) {
|
|
1024
|
+
const node = result.node;
|
|
1025
|
+
const tags = node.frontmatter.tags || [];
|
|
1026
|
+
await createVersion({
|
|
1027
|
+
nestId: params.nestId,
|
|
1028
|
+
nodeId: params.nodeId,
|
|
1029
|
+
version: engineVersion,
|
|
1030
|
+
content: node.body || "",
|
|
1031
|
+
author: params.approvedBy,
|
|
1032
|
+
status: "published",
|
|
1033
|
+
tags,
|
|
1034
|
+
changeNote: params.note || `Approved review request ${pending.id}`
|
|
1035
|
+
});
|
|
1036
|
+
await setApprovedVersion(
|
|
1037
|
+
params.nestId,
|
|
1038
|
+
params.nodeId,
|
|
1039
|
+
engineVersion,
|
|
1040
|
+
params.approvedBy
|
|
1041
|
+
);
|
|
1042
|
+
}
|
|
1043
|
+
} catch (err) {
|
|
1044
|
+
console.error(
|
|
1045
|
+
`publishDocument failed for ${params.nestId}/${params.nodeId} on approve:`,
|
|
1046
|
+
err
|
|
1121
1047
|
);
|
|
1122
1048
|
}
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1049
|
+
const approveCtx = await buildDocContext(params.nestId, params.nodeId, params.baseUrl);
|
|
1050
|
+
void notifyNestEvent(
|
|
1051
|
+
params.nestId,
|
|
1052
|
+
`:white_check_mark: *${approveCtx.label}* v${version} approved by ${params.approvedBy}`,
|
|
1053
|
+
{
|
|
1054
|
+
status: "approved",
|
|
1055
|
+
docTitle: approveCtx.docTitle,
|
|
1056
|
+
path: approveCtx.path,
|
|
1057
|
+
link: approveCtx.link,
|
|
1058
|
+
actor: params.approvedBy,
|
|
1059
|
+
version
|
|
1060
|
+
},
|
|
1061
|
+
// The verdict emails the submitter directly (below), not the shared
|
|
1062
|
+
// NOTIFY_EMAIL_TO team inbox — skip the team-channel email; Slack/Teams fire.
|
|
1063
|
+
{ skipEmail: true }
|
|
1127
1064
|
);
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
actor: params.approvedBy,
|
|
1139
|
-
version: params.version
|
|
1065
|
+
if (pending.requested_by?.toLowerCase() !== params.approvedBy.toLowerCase()) {
|
|
1066
|
+
void sendEmailToRecipient(pending.requested_by, {
|
|
1067
|
+
status: "approved",
|
|
1068
|
+
docTitle: approveCtx.docTitle,
|
|
1069
|
+
path: approveCtx.path,
|
|
1070
|
+
link: approveCtx.link,
|
|
1071
|
+
actor: params.approvedBy,
|
|
1072
|
+
version,
|
|
1073
|
+
note: params.note || null
|
|
1074
|
+
});
|
|
1140
1075
|
}
|
|
1141
|
-
|
|
1142
|
-
|
|
1076
|
+
return await getReviewRequest(pending.id);
|
|
1077
|
+
});
|
|
1143
1078
|
}
|
|
1144
1079
|
async function reject(params) {
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1080
|
+
return withNodeWriteLock(nodeWriteKey(params.nestId, params.nodeId), async () => {
|
|
1081
|
+
const db = getDb();
|
|
1082
|
+
const version = await getCurrentVersion(params.nestId, params.nodeId);
|
|
1083
|
+
const pending = await db.get(
|
|
1084
|
+
"SELECT * FROM review_requests WHERE nest_id = ? AND node_id = ? AND status = 'pending' ORDER BY requested_at DESC LIMIT 1",
|
|
1085
|
+
[params.nestId, params.nodeId]
|
|
1086
|
+
);
|
|
1087
|
+
if (!pending) {
|
|
1088
|
+
throw new Error("No pending review for this node");
|
|
1089
|
+
}
|
|
1090
|
+
const check = await canUserApprove(
|
|
1091
|
+
params.nestId,
|
|
1092
|
+
params.nodeId,
|
|
1093
|
+
params.rejectedBy
|
|
1094
|
+
);
|
|
1095
|
+
if (!check.allowed) {
|
|
1096
|
+
throw new Error(check.reason);
|
|
1097
|
+
}
|
|
1098
|
+
await db.run(
|
|
1099
|
+
`UPDATE review_requests
|
|
1163
1100
|
SET status = 'rejected', resolved_by = ?, resolved_at = ${nowExpr(db)}, resolution_note = ?
|
|
1164
1101
|
WHERE id = ?`,
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
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,
|
|
1102
|
+
[params.rejectedBy, params.note, pending.id]
|
|
1103
|
+
);
|
|
1104
|
+
await db.run(
|
|
1105
|
+
"UPDATE node_versions SET status = 'rejected' WHERE nest_id = ? AND node_id = ? AND version = ?",
|
|
1106
|
+
[params.nestId, params.nodeId, version]
|
|
1107
|
+
);
|
|
1108
|
+
const rejectCtx = await buildDocContext(params.nestId, params.nodeId, params.baseUrl);
|
|
1109
|
+
void dispatchEvent({
|
|
1110
|
+
kind: "review_rejected",
|
|
1111
|
+
nestId: params.nestId,
|
|
1112
|
+
subjectId: params.nodeId,
|
|
1187
1113
|
actor: params.rejectedBy,
|
|
1188
|
-
|
|
1189
|
-
|
|
1114
|
+
message: `*${rejectCtx.label}* v${version} rejected by ${params.rejectedBy}${params.note ? ` \u2014 "${params.note}"` : ""}`
|
|
1115
|
+
});
|
|
1116
|
+
void notifyNestEvent(
|
|
1117
|
+
params.nestId,
|
|
1118
|
+
`:x: *${rejectCtx.label}* v${version} rejected by ${params.rejectedBy} \u2014 "${params.note}"`,
|
|
1119
|
+
{
|
|
1120
|
+
status: "rejected",
|
|
1121
|
+
docTitle: rejectCtx.docTitle,
|
|
1122
|
+
path: rejectCtx.path,
|
|
1123
|
+
link: rejectCtx.link,
|
|
1124
|
+
actor: params.rejectedBy,
|
|
1125
|
+
version,
|
|
1126
|
+
note: params.note || null
|
|
1127
|
+
},
|
|
1128
|
+
// Verdict emails the submitter directly (below), not the team inbox.
|
|
1129
|
+
{ skipEmail: true }
|
|
1130
|
+
);
|
|
1131
|
+
if (pending.requested_by?.toLowerCase() !== params.rejectedBy.toLowerCase()) {
|
|
1132
|
+
void sendEmailToRecipient(pending.requested_by, {
|
|
1133
|
+
status: "rejected",
|
|
1134
|
+
docTitle: rejectCtx.docTitle,
|
|
1135
|
+
path: rejectCtx.path,
|
|
1136
|
+
link: rejectCtx.link,
|
|
1137
|
+
actor: params.rejectedBy,
|
|
1138
|
+
version,
|
|
1139
|
+
note: params.note || null
|
|
1140
|
+
});
|
|
1190
1141
|
}
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1142
|
+
await notifyReviewResolved({
|
|
1143
|
+
nestId: params.nestId,
|
|
1144
|
+
nodeId: params.nodeId,
|
|
1145
|
+
status: "rejected",
|
|
1146
|
+
resolvedBy: params.rejectedBy,
|
|
1147
|
+
requestedBy: pending.requested_by,
|
|
1148
|
+
reviewId: pending.id
|
|
1149
|
+
});
|
|
1150
|
+
return await getReviewRequest(pending.id);
|
|
1199
1151
|
});
|
|
1200
|
-
return await getReviewRequest(pending.id);
|
|
1201
1152
|
}
|
|
1202
1153
|
async function cancelReview(params) {
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1154
|
+
return withNodeWriteLock(nodeWriteKey(params.nestId, params.nodeId), async () => {
|
|
1155
|
+
const db = getDb();
|
|
1156
|
+
const pending = await db.get(
|
|
1157
|
+
"SELECT * FROM review_requests WHERE nest_id = ? AND node_id = ? AND status = 'pending' ORDER BY requested_at DESC LIMIT 1",
|
|
1158
|
+
[params.nestId, params.nodeId]
|
|
1159
|
+
);
|
|
1160
|
+
if (!pending) return null;
|
|
1161
|
+
await db.run(
|
|
1162
|
+
`UPDATE review_requests
|
|
1211
1163
|
SET status = 'cancelled', resolved_by = ?, resolved_at = ${nowExpr(db)}
|
|
1212
1164
|
WHERE id = ?`,
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1165
|
+
[params.cancelledBy, pending.id]
|
|
1166
|
+
);
|
|
1167
|
+
await db.run(
|
|
1168
|
+
"UPDATE node_versions SET status = 'draft' WHERE nest_id = ? AND node_id = ? AND version = ?",
|
|
1169
|
+
[params.nestId, params.nodeId, pending.version]
|
|
1170
|
+
);
|
|
1171
|
+
return await getReviewRequest(pending.id);
|
|
1172
|
+
});
|
|
1220
1173
|
}
|
|
1221
1174
|
async function getReviewQueue(params) {
|
|
1222
1175
|
const db = getDb();
|
|
@@ -1322,9 +1275,11 @@ function rowToReviewRequest(row) {
|
|
|
1322
1275
|
|
|
1323
1276
|
export {
|
|
1324
1277
|
safePublishDocument,
|
|
1278
|
+
stripUndefinedDeep,
|
|
1325
1279
|
listWatchers,
|
|
1326
1280
|
addWatcher,
|
|
1327
1281
|
removeWatcher,
|
|
1282
|
+
notifyNestInvite,
|
|
1328
1283
|
listNotifications,
|
|
1329
1284
|
markNotificationsRead,
|
|
1330
1285
|
resolveCallerEmail,
|
|
@@ -1340,9 +1295,12 @@ export {
|
|
|
1340
1295
|
envValues,
|
|
1341
1296
|
isSafeConnectorUrl,
|
|
1342
1297
|
dispatchEvent,
|
|
1343
|
-
|
|
1298
|
+
sendTestNotification,
|
|
1344
1299
|
notifyNestEvent,
|
|
1300
|
+
nodeWriteKey,
|
|
1301
|
+
withNodeWriteLock,
|
|
1345
1302
|
submitForReview,
|
|
1303
|
+
repointPendingReview,
|
|
1346
1304
|
approve,
|
|
1347
1305
|
reject,
|
|
1348
1306
|
cancelReview,
|