@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.
- package/CONFIGURATION.md +71 -1
- package/README.md +2 -0
- package/dist/{chunk-ZQS5W45U.js → chunk-5IAKUUZF.js} +1 -1
- package/dist/{chunk-Q5NRCDD4.js → chunk-7BOG3S5H.js} +1560 -1574
- package/dist/{chunk-5QQ7WKAI.js → chunk-BCFKLY4H.js} +76 -1
- package/dist/{chunk-PAIP5KHB.js → chunk-C4U75IUR.js} +790 -515
- package/dist/chunk-GWDOLYFJ.js +624 -0
- package/dist/{chunk-EG77SQHO.js → chunk-JWVZ3T35.js} +58 -3
- package/dist/{client-AAMF22YJ.js → client-NFUHPLLM.js} +1 -1
- package/dist/{grants-service-FZD6QM6V.js → grants-service-O4AO326A.js} +2 -2
- package/dist/index.js +1833 -814
- package/dist/{migrations.postgres-BRXZY2GE.js → migrations.postgres-CYKO5CFV.js} +41 -1
- package/dist/review-service-JUAFYB73.js +47 -0
- package/dist/{stewardship-service-B6L7UACX.js → stewardship-service-FHJ2RBKN.js} +4 -4
- package/dist/{version-service-L52F4WVZ.js → version-service-ECYCBVSE.js} +7 -3
- package/dist/web3/assets/index-BaMJ4tEz.css +1 -0
- package/dist/web3/assets/index-D950G4hR.js +1052 -0
- package/dist/web3/index.html +2 -2
- package/package.json +3 -2
- package/dist/chunk-XQ46F76G.js +0 -16
- package/dist/review-service-Q3NDK5TX.js +0 -29
- package/dist/web3/assets/index-DcmDj50q.css +0 -1
- package/dist/web3/assets/index-EGPZ6MAy.js +0 -1023
|
@@ -2,36 +2,42 @@ import {
|
|
|
2
2
|
grantCoversNode,
|
|
3
3
|
listUserGrants,
|
|
4
4
|
resolveNodeGrant
|
|
5
|
-
} from "./chunk-
|
|
5
|
+
} from "./chunk-5IAKUUZF.js";
|
|
6
6
|
import {
|
|
7
7
|
createVersion,
|
|
8
8
|
getApprovedVersion,
|
|
9
|
+
getCurrentVersion,
|
|
9
10
|
setApprovedVersion
|
|
10
|
-
} from "./chunk-
|
|
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
|
-
|
|
21
|
-
|
|
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-
|
|
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 ? `"${
|
|
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
|
-
`"${
|
|
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/
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
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
|
-
|
|
511
|
-
|
|
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] ?? ""} *[${
|
|
681
|
+
return { text: escapeMrkdwn(`${EVENT_EMOJI[ev.kind] ?? ""} *[${nestName2}]* ${ev.message}`) };
|
|
514
682
|
}
|
|
515
683
|
if (channel === "teams") {
|
|
516
|
-
return {
|
|
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:
|
|
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
|
-
|
|
588
|
-
|
|
589
|
-
|
|
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",
|
|
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
|
|
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, "&").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
|
-
}
|
|
763
|
+
void postWebhook(url, formatBody(r.channel, nestName2, ev), `connector:${r.id}`);
|
|
855
764
|
}
|
|
856
765
|
}
|
|
857
|
-
async function
|
|
858
|
-
const url = config.
|
|
859
|
-
|
|
860
|
-
if (!
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
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
|
|
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
|
-
|
|
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)
|
|
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, {
|
|
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
|
|
954
|
+
async function repointPendingReview(params) {
|
|
1042
955
|
const db = getDb();
|
|
1043
956
|
const pending = await db.get(
|
|
1044
|
-
"SELECT
|
|
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
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
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
|
-
|
|
1057
|
-
|
|
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
|
-
|
|
1061
|
-
|
|
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
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
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
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
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
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
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
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
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
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
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
|
-
|
|
1093
|
+
return await getReviewRequest(pending.id);
|
|
1094
|
+
});
|
|
1143
1095
|
}
|
|
1144
1096
|
async function reject(params) {
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
1189
|
-
|
|
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
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
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
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
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
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
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
|
-
|
|
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
|
};
|