@anthusai/papyrus 2.0.0-next.97 → 2.0.0-next.98
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/amplify/data/resource.js +6 -0
- package/amplify/data/resource.ts +7 -0
- package/amplify/data/schema.js +6 -0
- package/amplify/data/schema.ts +7 -0
- package/amplify/functions/content-actions/handler.py +10 -1
- package/amplify/functions/monitoring-view/schema.js +6 -0
- package/amplify/functions/monitoring-view/schema.ts +6 -0
- package/amplify/functions/monitoring-view/wire.js +25 -0
- package/amplify/functions/monitoring-view/wire.ts +32 -0
- package/amplify/site-backend.js +2 -1
- package/amplify/site-backend.ts +2 -1
- package/components/monitoring-overview-view.tsx +137 -7
- package/components/monitoring-panel.tsx +15 -3
- package/components/notification-center.tsx +12 -8
- package/lib/monitoring-client.ts +52 -3
- package/lib/monitoring-overview.ts +35 -1
- package/lib/review-client.ts +6 -3
- package/lib/review-requests.ts +8 -6
- package/package.json +1 -1
- package/routes.manifest.json +1 -1
package/amplify/data/resource.js
CHANGED
|
@@ -597,6 +597,12 @@ const schema = a.schema({
|
|
|
597
597
|
.returns(a.json())
|
|
598
598
|
.authorization((allow) => [allow.groups(contentWriteGroups)])
|
|
599
599
|
.handler(functionHandler("contentActions", contentActions)),
|
|
600
|
+
intakeEmailSubmissionReferences: a
|
|
601
|
+
.mutation()
|
|
602
|
+
.arguments({ input: a.json().required() })
|
|
603
|
+
.returns(a.json())
|
|
604
|
+
.authorization((allow) => [allow.groups(contentWriteGroups)])
|
|
605
|
+
.handler(functionHandler("contentActions", contentActions)),
|
|
600
606
|
saveItemDraft: a
|
|
601
607
|
.mutation()
|
|
602
608
|
.arguments({ input: a.json().required() })
|
package/amplify/data/resource.ts
CHANGED
|
@@ -650,6 +650,13 @@ const schema = a.schema({
|
|
|
650
650
|
.authorization((allow) => [allow.groups(contentWriteGroups)])
|
|
651
651
|
.handler(functionHandler("contentActions", contentActions)),
|
|
652
652
|
|
|
653
|
+
intakeEmailSubmissionReferences: a
|
|
654
|
+
.mutation()
|
|
655
|
+
.arguments({ input: a.json().required() })
|
|
656
|
+
.returns(a.json())
|
|
657
|
+
.authorization((allow) => [allow.groups(contentWriteGroups)])
|
|
658
|
+
.handler(functionHandler("contentActions", contentActions)),
|
|
659
|
+
|
|
653
660
|
saveItemDraft: a
|
|
654
661
|
.mutation()
|
|
655
662
|
.arguments({ input: a.json().required() })
|
package/amplify/data/schema.js
CHANGED
|
@@ -523,6 +523,12 @@ function buildBaseDefinitions(includeEditions) {
|
|
|
523
523
|
.returns(a.json())
|
|
524
524
|
.authorization((allow) => [allow.groups(contentWriteGroups)])
|
|
525
525
|
.handler(functionHandler("contentActions", contentActions)),
|
|
526
|
+
intakeEmailSubmissionReferences: a
|
|
527
|
+
.mutation()
|
|
528
|
+
.arguments({ input: a.json().required() })
|
|
529
|
+
.returns(a.json())
|
|
530
|
+
.authorization((allow) => [allow.groups(contentWriteGroups)])
|
|
531
|
+
.handler(functionHandler("contentActions", contentActions)),
|
|
526
532
|
saveItemDraft: a
|
|
527
533
|
.mutation()
|
|
528
534
|
.arguments({ input: a.json().required() })
|
package/amplify/data/schema.ts
CHANGED
|
@@ -574,6 +574,13 @@ const definitions = {
|
|
|
574
574
|
.authorization((allow) => [allow.groups(contentWriteGroups)])
|
|
575
575
|
.handler(functionHandler("contentActions", contentActions)),
|
|
576
576
|
|
|
577
|
+
intakeEmailSubmissionReferences: a
|
|
578
|
+
.mutation()
|
|
579
|
+
.arguments({ input: a.json().required() })
|
|
580
|
+
.returns(a.json())
|
|
581
|
+
.authorization((allow) => [allow.groups(contentWriteGroups)])
|
|
582
|
+
.handler(functionHandler("contentActions", contentActions)),
|
|
583
|
+
|
|
577
584
|
saveItemDraft: a
|
|
578
585
|
.mutation()
|
|
579
586
|
.arguments({ input: a.json().required() })
|
|
@@ -27,7 +27,7 @@ from papyrus_content.rebuild_trigger import (
|
|
|
27
27
|
from papyrus_content.story_reporting import save_story_version
|
|
28
28
|
from papyrus_content.publication import ensure_publication_from_environment
|
|
29
29
|
from papyrus_content.editorial_checks import GraphQLEditorialStore, ensure_default_policies
|
|
30
|
-
from papyrus_content.reference_intake import IntakeError, intake_references
|
|
30
|
+
from papyrus_content.reference_intake import IntakeError, intake_email_submission_references, intake_references
|
|
31
31
|
from papyrus_content.publish_alerts import PUBLISHED_TASK_KEY, announce_published
|
|
32
32
|
from papyrus_content.publishing import (
|
|
33
33
|
ItemFields,
|
|
@@ -356,6 +356,15 @@ def handler(event, context):
|
|
|
356
356
|
return intake_references(client, arguments, event.get("identity") or {})
|
|
357
357
|
except IntakeError as error:
|
|
358
358
|
return {"ok": False, "errors": [{"code": "intake-rejected", "message": str(error)}]}
|
|
359
|
+
if field == "intakeEmailSubmissionReferences":
|
|
360
|
+
client, _claims = create_authoring_client()
|
|
361
|
+
try:
|
|
362
|
+
from papyrus_content.monitoring_view import default_store_factory
|
|
363
|
+
receipt_store = default_store_factory(os.environ)()
|
|
364
|
+
return intake_email_submission_references(
|
|
365
|
+
client, arguments, event.get("identity") or {}, receipt_store=receipt_store)
|
|
366
|
+
except IntakeError as error:
|
|
367
|
+
return {"ok": False, "errors": [{"code": "intake-rejected", "message": str(error)}]}
|
|
359
368
|
if field == "deriveMarkus":
|
|
360
369
|
return derive_markus_action(arguments)
|
|
361
370
|
if field == "saveItemDraft":
|
|
@@ -20,4 +20,10 @@ export const monitoringViewSchemaDefinitions = {
|
|
|
20
20
|
.returns(a.json())
|
|
21
21
|
.authorization((allow) => [allow.groups(editors)])
|
|
22
22
|
.handler(functionHandler("monitoringView", monitoringView)),
|
|
23
|
+
monitoringResolveEmailArticleLink: a
|
|
24
|
+
.mutation()
|
|
25
|
+
.arguments({ receiptId: a.string().required(), candidateIndex: a.integer().required() })
|
|
26
|
+
.returns(a.json())
|
|
27
|
+
.authorization((allow) => [allow.groups(editors)])
|
|
28
|
+
.handler(functionHandler("monitoringView", monitoringView)),
|
|
23
29
|
};
|
|
@@ -22,4 +22,10 @@ export const monitoringViewSchemaDefinitions = {
|
|
|
22
22
|
.returns(a.json())
|
|
23
23
|
.authorization((allow) => [allow.groups(editors)])
|
|
24
24
|
.handler(functionHandler("monitoringView", monitoringView)),
|
|
25
|
+
monitoringResolveEmailArticleLink: a
|
|
26
|
+
.mutation()
|
|
27
|
+
.arguments({ receiptId: a.string().required(), candidateIndex: a.integer().required() })
|
|
28
|
+
.returns(a.json())
|
|
29
|
+
.authorization((allow) => [allow.groups(editors)])
|
|
30
|
+
.handler(functionHandler("monitoringView", monitoringView)),
|
|
25
31
|
};
|
|
@@ -34,4 +34,29 @@ export function wireMonitoringView(backend) {
|
|
|
34
34
|
actions: MONITORING_VIEW_TABLE_ACTIONS,
|
|
35
35
|
resources: monitoringViewTableArns(stack.region, stack.account),
|
|
36
36
|
}));
|
|
37
|
+
// Resolving a selected newsletter article records its destination in the
|
|
38
|
+
// same receipt partition. The editor-facing resolver validates the receipt
|
|
39
|
+
// and candidate before this single-key write.
|
|
40
|
+
lambda.addToRolePolicy(new PolicyStatement({
|
|
41
|
+
effect: Effect.ALLOW,
|
|
42
|
+
actions: ["dynamodb:PutItem"],
|
|
43
|
+
resources: monitoringViewTableArns(stack.region, stack.account).slice(0, 1),
|
|
44
|
+
conditions: { "ForAllValues:StringEquals": { "dynamodb:LeadingKeys": ["MONITOR#hn"] } },
|
|
45
|
+
}));
|
|
46
|
+
}
|
|
47
|
+
/** Lets the authenticated Reference-intake action verify a selected email receipt. */
|
|
48
|
+
export function wireEmailReferenceIntake(lambda) {
|
|
49
|
+
const stack = Stack.of(lambda);
|
|
50
|
+
lambda.addEnvironment("PAPYRUS_MONITORING_TABLE_PARAMETER", MONITORING_TABLE_PARAMETER);
|
|
51
|
+
lambda.addToRolePolicy(new PolicyStatement({
|
|
52
|
+
effect: Effect.ALLOW,
|
|
53
|
+
actions: ["ssm:GetParameter"],
|
|
54
|
+
resources: [monitoringViewParameterArn(stack.region, stack.account)],
|
|
55
|
+
}));
|
|
56
|
+
lambda.addToRolePolicy(new PolicyStatement({
|
|
57
|
+
effect: Effect.ALLOW,
|
|
58
|
+
actions: MONITORING_VIEW_TABLE_ACTIONS,
|
|
59
|
+
resources: monitoringViewTableArns(stack.region, stack.account).slice(0, 1),
|
|
60
|
+
conditions: { "ForAllValues:StringEquals": { "dynamodb:LeadingKeys": ["MONITOR#hn"] } },
|
|
61
|
+
}));
|
|
37
62
|
}
|
|
@@ -44,4 +44,36 @@ export function wireMonitoringView(backend: any): void {
|
|
|
44
44
|
resources: monitoringViewTableArns(stack.region, stack.account),
|
|
45
45
|
}),
|
|
46
46
|
);
|
|
47
|
+
// Resolving a selected newsletter article records its destination in the
|
|
48
|
+
// same receipt partition. The editor-facing resolver validates the receipt
|
|
49
|
+
// and candidate before this single-key write.
|
|
50
|
+
lambda.addToRolePolicy(
|
|
51
|
+
new PolicyStatement({
|
|
52
|
+
effect: Effect.ALLOW,
|
|
53
|
+
actions: ["dynamodb:PutItem"],
|
|
54
|
+
resources: monitoringViewTableArns(stack.region, stack.account).slice(0, 1),
|
|
55
|
+
conditions: { "ForAllValues:StringEquals": { "dynamodb:LeadingKeys": ["MONITOR#hn"] } },
|
|
56
|
+
}),
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Lets the authenticated Reference-intake action verify a selected email receipt. */
|
|
61
|
+
export function wireEmailReferenceIntake(lambda: LambdaFunction): void {
|
|
62
|
+
const stack = Stack.of(lambda);
|
|
63
|
+
lambda.addEnvironment("PAPYRUS_MONITORING_TABLE_PARAMETER", MONITORING_TABLE_PARAMETER);
|
|
64
|
+
lambda.addToRolePolicy(
|
|
65
|
+
new PolicyStatement({
|
|
66
|
+
effect: Effect.ALLOW,
|
|
67
|
+
actions: ["ssm:GetParameter"],
|
|
68
|
+
resources: [monitoringViewParameterArn(stack.region, stack.account)],
|
|
69
|
+
}),
|
|
70
|
+
);
|
|
71
|
+
lambda.addToRolePolicy(
|
|
72
|
+
new PolicyStatement({
|
|
73
|
+
effect: Effect.ALLOW,
|
|
74
|
+
actions: MONITORING_VIEW_TABLE_ACTIONS,
|
|
75
|
+
resources: monitoringViewTableArns(stack.region, stack.account).slice(0, 1),
|
|
76
|
+
conditions: { "ForAllValues:StringEquals": { "dynamodb:LeadingKeys": ["MONITOR#hn"] } },
|
|
77
|
+
}),
|
|
78
|
+
);
|
|
47
79
|
}
|
package/amplify/site-backend.js
CHANGED
|
@@ -25,7 +25,7 @@ import { editorialChecks } from "./functions/editorial-checks/resource.js";
|
|
|
25
25
|
import { automationView } from "./functions/automation-view/resource.js";
|
|
26
26
|
import { wireAutomationView } from "./functions/automation-view/wire.js";
|
|
27
27
|
import { monitoringView } from "./functions/monitoring-view/resource.js";
|
|
28
|
-
import { wireMonitoringView } from "./functions/monitoring-view/wire.js";
|
|
28
|
+
import { wireEmailReferenceIntake, wireMonitoringView } from "./functions/monitoring-view/wire.js";
|
|
29
29
|
import { wireEditorialChecks } from "./functions/editorial-checks/wire.js";
|
|
30
30
|
import { resolvePlugins } from "./plugins/registry.js";
|
|
31
31
|
import { wirePlugins } from "./plugins/wire.js";
|
|
@@ -336,6 +336,7 @@ export function defineSiteBackend(papyrusSite) {
|
|
|
336
336
|
// and publication to this deployment's pool and existing API endpoint.
|
|
337
337
|
contentActionsLambda.addEnvironment("PAPYRUS_USER_POOL_ISSUER", `https://cognito-idp.${Stack.of(contentActionsLambda).region}.amazonaws.com/${backend.auth.resources.userPool.userPoolId}`);
|
|
338
338
|
contentActionsLambda.addEnvironment("PAPYRUS_GRAPHQL_ENDPOINT", backend.data.resources.cfnResources.cfnGraphqlApi.attrGraphQlUrl);
|
|
339
|
+
wireEmailReferenceIntake(contentActionsLambda);
|
|
339
340
|
contentActionsLambda.addToRolePolicy(new PolicyStatement({
|
|
340
341
|
actions: ["appsync:GraphQL"],
|
|
341
342
|
resources: ["*"],
|
package/amplify/site-backend.ts
CHANGED
|
@@ -32,7 +32,7 @@ import { editorialChecks } from "./functions/editorial-checks/resource";
|
|
|
32
32
|
import { automationView } from "./functions/automation-view/resource";
|
|
33
33
|
import { wireAutomationView } from "./functions/automation-view/wire";
|
|
34
34
|
import { monitoringView } from "./functions/monitoring-view/resource";
|
|
35
|
-
import { wireMonitoringView } from "./functions/monitoring-view/wire";
|
|
35
|
+
import { wireEmailReferenceIntake, wireMonitoringView } from "./functions/monitoring-view/wire";
|
|
36
36
|
import { wireEditorialChecks } from "./functions/editorial-checks/wire";
|
|
37
37
|
import { resolvePlugins } from "./plugins/registry";
|
|
38
38
|
import { wirePlugins } from "./plugins/wire";
|
|
@@ -429,6 +429,7 @@ export function defineSiteBackend(papyrusSite: PapyrusSite) {
|
|
|
429
429
|
"PAPYRUS_GRAPHQL_ENDPOINT",
|
|
430
430
|
backend.data.resources.cfnResources.cfnGraphqlApi.attrGraphQlUrl,
|
|
431
431
|
);
|
|
432
|
+
wireEmailReferenceIntake(contentActionsLambda);
|
|
432
433
|
contentActionsLambda.addToRolePolicy(
|
|
433
434
|
new PolicyStatement({
|
|
434
435
|
actions: ["appsync:GraphQL"],
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from "react";
|
|
1
2
|
import Link from "next/link";
|
|
3
|
+
import { getNewsroomNavHref } from "../lib/newsroom-nav";
|
|
2
4
|
import {
|
|
3
5
|
confidenceText,
|
|
4
6
|
describeDelivery,
|
|
@@ -7,8 +9,14 @@ import {
|
|
|
7
9
|
formatUtc,
|
|
8
10
|
referenceHref,
|
|
9
11
|
routingLabel,
|
|
12
|
+
beginEmailSubmissionAction,
|
|
13
|
+
endEmailSubmissionAction,
|
|
14
|
+
isCurrentEmailSubmissionAction,
|
|
10
15
|
type MonitoringItem,
|
|
11
16
|
type MonitoringEmailDetails,
|
|
17
|
+
type MonitoringEmailArticleResolution,
|
|
18
|
+
type EmailReferenceImportItem,
|
|
19
|
+
type EmailReferenceImportOutcome,
|
|
12
20
|
type MonitoringEmailSummary,
|
|
13
21
|
type MonitoringOverview,
|
|
14
22
|
type MonitoringRelevance,
|
|
@@ -89,16 +97,110 @@ function QueueItem({ item, ownId, demo }: { item: MonitoringItem; ownId: string
|
|
|
89
97
|
);
|
|
90
98
|
}
|
|
91
99
|
|
|
92
|
-
function EmailSubmissions({ items, detail, onOpen }: {
|
|
100
|
+
function EmailSubmissions({ items, detail, onOpen, onResolveArticleLink, onImportCandidates }: {
|
|
93
101
|
items: MonitoringEmailSummary[]; detail: MonitoringEmailDetails | null; onOpen: (receiptId: string) => void;
|
|
102
|
+
onResolveArticleLink?: (receiptId: string, candidateIndex: number) => Promise<MonitoringEmailArticleResolution>;
|
|
103
|
+
onImportCandidates?: (receiptId: string, items: EmailReferenceImportItem[]) => Promise<EmailReferenceImportOutcome>;
|
|
94
104
|
}) {
|
|
105
|
+
const [selectedByReceipt, setSelectedByReceipt] = useState<Record<string, Record<number, boolean>>>({});
|
|
106
|
+
const [resolvedByReceipt, setResolvedByReceipt] = useState<Record<string, Record<number, { url: string; title: string }>>>({});
|
|
107
|
+
const [busyIndexByReceipt, setBusyIndexByReceipt] = useState<Record<string, number | null>>({});
|
|
108
|
+
const [busyImportByReceipt, setBusyImportByReceipt] = useState<Record<string, boolean>>({});
|
|
109
|
+
const [noticeByReceipt, setNoticeByReceipt] = useState<Record<string, string>>({});
|
|
110
|
+
const [importedByReceipt, setImportedByReceipt] = useState<Record<string, EmailReferenceImportOutcome["references"]>>({});
|
|
111
|
+
const receiptId = detail && "submission" in detail ? detail.submission.receiptId : null;
|
|
112
|
+
const receiptKey = receiptId ?? "";
|
|
113
|
+
const selected = selectedByReceipt[receiptKey] ?? {};
|
|
114
|
+
const resolved = resolvedByReceipt[receiptKey] ?? {};
|
|
115
|
+
const busyIndex = busyIndexByReceipt[receiptKey] ?? null;
|
|
116
|
+
const busyImport = busyImportByReceipt[receiptKey] ?? false;
|
|
117
|
+
const notice = noticeByReceipt[receiptKey] ?? "";
|
|
118
|
+
const importedReferences = importedByReceipt[receiptKey] ?? [];
|
|
119
|
+
const activeReceiptId = useRef<string | null>(receiptId);
|
|
120
|
+
activeReceiptId.current = receiptId;
|
|
121
|
+
const inFlightActions = useRef(new Set<string>());
|
|
122
|
+
useEffect(() => {
|
|
123
|
+
if (!detail || !("submission" in detail)) return;
|
|
124
|
+
setSelectedByReceipt((current) => current[receiptKey] ? current : {
|
|
125
|
+
...current,
|
|
126
|
+
[receiptKey]: Object.fromEntries(detail.candidateLinks.map((link, index) => [index, link.resolutionStatus !== "unresolved"])),
|
|
127
|
+
});
|
|
128
|
+
}, [receiptKey, detail]);
|
|
129
|
+
|
|
130
|
+
const resolveLink = async (index: number) => {
|
|
131
|
+
if (!receiptId || !onResolveArticleLink) return;
|
|
132
|
+
const requestReceiptId = receiptId;
|
|
133
|
+
const actionKey = `resolve:${requestReceiptId}:${index}`;
|
|
134
|
+
if (!beginEmailSubmissionAction(inFlightActions.current, actionKey)) return;
|
|
135
|
+
setBusyIndexByReceipt((current) => ({ ...current, [requestReceiptId]: index }));
|
|
136
|
+
setNoticeByReceipt((current) => ({ ...current, [requestReceiptId]: "" }));
|
|
137
|
+
try {
|
|
138
|
+
const result = await onResolveArticleLink(requestReceiptId, index);
|
|
139
|
+
if (!isCurrentEmailSubmissionAction(activeReceiptId.current, requestReceiptId)) return;
|
|
140
|
+
if (!("url" in result)) {
|
|
141
|
+
setNoticeByReceipt((current) => ({ ...current, [requestReceiptId]: "The article destination could not be resolved safely. The original link remains private and unchanged." }));
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
const candidate = detail && "submission" in detail ? detail.candidateLinks[index] : null;
|
|
145
|
+
setResolvedByReceipt((current) => ({ ...current, [requestReceiptId]: { ...current[requestReceiptId], [index]: { url: result.url, title: result.title || candidate?.title || "Article" } } }));
|
|
146
|
+
setSelectedByReceipt((current) => ({ ...current, [requestReceiptId]: { ...current[requestReceiptId], [index]: true } }));
|
|
147
|
+
setNoticeByReceipt((current) => ({ ...current, [requestReceiptId]: "Resolved. This request may be recorded as a click by the newsletter sender." }));
|
|
148
|
+
} catch {
|
|
149
|
+
if (isCurrentEmailSubmissionAction(activeReceiptId.current, requestReceiptId)) {
|
|
150
|
+
setNoticeByReceipt((current) => ({ ...current, [requestReceiptId]: "The article destination could not be resolved. Try again later." }));
|
|
151
|
+
}
|
|
152
|
+
} finally {
|
|
153
|
+
endEmailSubmissionAction(inFlightActions.current, actionKey);
|
|
154
|
+
setBusyIndexByReceipt((current) => ({ ...current, [requestReceiptId]: null }));
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const importSelected = async () => {
|
|
159
|
+
if (!receiptId || !detail || !("submission" in detail) || !onImportCandidates) return;
|
|
160
|
+
const requestReceiptId = receiptId;
|
|
161
|
+
const actionKey = `import:${requestReceiptId}`;
|
|
162
|
+
if (inFlightActions.current.has(actionKey)) return;
|
|
163
|
+
const candidates = detail.candidateLinks.flatMap((link, index) => {
|
|
164
|
+
if (!selected[index]) return [];
|
|
165
|
+
const resolvedLink = resolved[index];
|
|
166
|
+
if (!resolvedLink && link.resolutionStatus === "unresolved") return [];
|
|
167
|
+
const url = resolvedLink?.url ?? link.url;
|
|
168
|
+
return [{ candidateIndex: index, source_uri: url }];
|
|
169
|
+
});
|
|
170
|
+
if (!candidates.length) {
|
|
171
|
+
setNoticeByReceipt((current) => ({ ...current, [requestReceiptId]: "Select at least one available candidate link. Resolve tracking links before importing them." }));
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
if (!beginEmailSubmissionAction(inFlightActions.current, actionKey)) return;
|
|
175
|
+
setBusyImportByReceipt((current) => ({ ...current, [requestReceiptId]: true }));
|
|
176
|
+
setNoticeByReceipt((current) => ({ ...current, [requestReceiptId]: "" }));
|
|
177
|
+
try {
|
|
178
|
+
const result = await onImportCandidates(requestReceiptId, candidates);
|
|
179
|
+
if (!isCurrentEmailSubmissionAction(activeReceiptId.current, requestReceiptId)) return;
|
|
180
|
+
setImportedByReceipt((current) => ({ ...current, [requestReceiptId]: result.references }));
|
|
181
|
+
if (result.error) {
|
|
182
|
+
setNoticeByReceipt((current) => ({ ...current, [requestReceiptId]: `${result.imported} of ${candidates.length} selected candidates were registered or matched. ${result.error}` }));
|
|
183
|
+
} else {
|
|
184
|
+
setNoticeByReceipt((current) => ({ ...current, [requestReceiptId]: `${result.imported} selected candidates are available as References. Import does not approve or publish them.` }));
|
|
185
|
+
}
|
|
186
|
+
} catch {
|
|
187
|
+
if (isCurrentEmailSubmissionAction(activeReceiptId.current, requestReceiptId)) {
|
|
188
|
+
setNoticeByReceipt((current) => ({ ...current, [requestReceiptId]: "The import stopped. Any completed references are safe to retry; no review decision was made." }));
|
|
189
|
+
}
|
|
190
|
+
} finally {
|
|
191
|
+
endEmailSubmissionAction(inFlightActions.current, actionKey);
|
|
192
|
+
setBusyImportByReceipt((current) => ({ ...current, [requestReceiptId]: false }));
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
|
|
95
196
|
return (
|
|
96
197
|
<article className="news-desk-doctrine-card" data-monitoring-section="email-submissions">
|
|
97
198
|
<div className="news-desk-doctrine-card__header"><div><h3>Email submissions</h3><span>Untrusted source material held privately for editor review</span></div></div>
|
|
98
199
|
{items.length ? <ul className="news-desk-monitoring__list">
|
|
99
200
|
{items.map((item) => <li key={item.receiptId} data-email-submission={item.receiptId}>
|
|
100
201
|
<strong>{item.subject || "(no subject)"}</strong>
|
|
101
|
-
<span>{formatUtc(item.observedAt)} · {item.linkCount} candidate links · {item.
|
|
202
|
+
<span>{formatUtc(item.observedAt)} · {item.linkCount} candidate links · {item.reviewRequired
|
|
203
|
+
? `${item.linkCount} await editor import` : `${item.processedCount} queued`}</span>
|
|
102
204
|
{item.reviewRequired ? <span>Authentication warnings: private review required; nothing was routed automatically.</span> : null}
|
|
103
205
|
{item.unresolvedLinkCount ? <span>{item.unresolvedLinkCount} tracking links remain unresolved and were not opened.</span> : null}
|
|
104
206
|
<span>Envelope sender: {item.envelopeSender || "unknown"} · MIME From (unverified): {item.headerFrom || "unknown"}</span>
|
|
@@ -115,9 +217,28 @@ function EmailSubmissions({ items, detail, onOpen }: {
|
|
|
115
217
|
<pre style={{ whiteSpace: "pre-wrap", overflowWrap: "anywhere" }}>{detail.safeBodyText}</pre>
|
|
116
218
|
<h5>Candidate references ({detail.candidateLinks.length})</h5>
|
|
117
219
|
<ul>{detail.candidateLinks.map((link, index) => <li key={`${link.url}-${index}`}>
|
|
118
|
-
{link.resolutionStatus === "unresolved"
|
|
119
|
-
|
|
220
|
+
{link.resolutionStatus === "unresolved" && !resolved[index]
|
|
221
|
+
? <span>{link.title || "Newsletter article link"} (tracking redirect not opened)</span>
|
|
222
|
+
: <a href={(resolved[index]?.url ?? link.url)} target="_blank" rel="noreferrer noopener">{resolved[index]?.title ?? (link.title || "Source link")}</a>}
|
|
223
|
+
<label>
|
|
224
|
+
<input type="checkbox" checked={selected[index] ?? false}
|
|
225
|
+
disabled={link.resolutionStatus === "unresolved" && !resolved[index]}
|
|
226
|
+
onChange={(event) => setSelectedByReceipt((current) => ({ ...current, [receiptKey]: { ...current[receiptKey], [index]: event.target.checked } }))} />
|
|
227
|
+
Add as pending Reference
|
|
228
|
+
</label>
|
|
229
|
+
{link.resolutionStatus === "unresolved" && !resolved[index] ? (
|
|
230
|
+
<button type="button" disabled={busyIndex !== null || !onResolveArticleLink} onClick={() => void resolveLink(index)}>
|
|
231
|
+
{busyIndex === index ? "Resolving…" : "Resolve article redirect (may register a click)"}
|
|
232
|
+
</button>
|
|
233
|
+
) : null}
|
|
120
234
|
</li>)}</ul>
|
|
235
|
+
<p>Adding links creates or reuses pending References. It does not approve, label, classify, or publish them.</p>
|
|
236
|
+
<button type="button" disabled={busyImport || !onImportCandidates || !detail.submission.corpusId}
|
|
237
|
+
onClick={() => void importSelected()}>
|
|
238
|
+
{busyImport ? "Adding References…" : "Add selected links to References"}
|
|
239
|
+
</button>
|
|
240
|
+
{notice ? <p role="status" data-email-import-result>{notice}</p> : null}
|
|
241
|
+
{importedReferences.length ? <p><Link href={getNewsroomNavHref("/newsroom/references", false)}>Review these References</Link></p> : null}
|
|
121
242
|
</section>
|
|
122
243
|
) : detail && "error" in detail ? <p role="alert">You cannot read this submission.</p>
|
|
123
244
|
: detail && "state" in detail ? <p role="alert">Submission details are unavailable.</p> : null}
|
|
@@ -125,8 +246,10 @@ function EmailSubmissions({ items, detail, onOpen }: {
|
|
|
125
246
|
);
|
|
126
247
|
}
|
|
127
248
|
|
|
128
|
-
function Ready({ data, demo, emailDetail, onEmailOpen }: {
|
|
249
|
+
function Ready({ data, demo, emailDetail, onEmailOpen, onResolveArticleLink, onImportCandidates }: {
|
|
129
250
|
data: MonitoringOverview; demo: boolean; emailDetail: MonitoringEmailDetails | null; onEmailOpen: (id: string) => void;
|
|
251
|
+
onResolveArticleLink?: (receiptId: string, candidateIndex: number) => Promise<MonitoringEmailArticleResolution>;
|
|
252
|
+
onImportCandidates?: (receiptId: string, items: EmailReferenceImportItem[]) => Promise<EmailReferenceImportOutcome>;
|
|
130
253
|
}) {
|
|
131
254
|
const router = data.router;
|
|
132
255
|
return (
|
|
@@ -135,7 +258,9 @@ function Ready({ data, demo, emailDetail, onEmailOpen }: {
|
|
|
135
258
|
<p className="news-desk-delegation-note" data-monitoring-notice>This publication is not one of the router's outputs, so no relevance is shown for it here.</p>
|
|
136
259
|
) : null}
|
|
137
260
|
|
|
138
|
-
<EmailSubmissions items={data.emailSubmissions ?? []} detail={emailDetail} onOpen={onEmailOpen}
|
|
261
|
+
<EmailSubmissions items={data.emailSubmissions ?? []} detail={emailDetail} onOpen={onEmailOpen}
|
|
262
|
+
onResolveArticleLink={demo ? undefined : onResolveArticleLink}
|
|
263
|
+
onImportCandidates={demo ? undefined : onImportCandidates} />
|
|
139
264
|
|
|
140
265
|
<article className="news-desk-doctrine-card" data-monitoring-section="router">
|
|
141
266
|
<div className="news-desk-doctrine-card__header"><div><h3>Router</h3><span>Sends collected items to the shared Cyclotron and delivers relevant ones to References</span></div></div>
|
|
@@ -233,6 +358,8 @@ export function MonitoringOverviewView({
|
|
|
233
358
|
demo = false,
|
|
234
359
|
emailDetail = null,
|
|
235
360
|
onEmailOpen = () => {},
|
|
361
|
+
onResolveArticleLink,
|
|
362
|
+
onImportCandidates,
|
|
236
363
|
}: {
|
|
237
364
|
result: MonitoringResult | null;
|
|
238
365
|
canViewAll: boolean;
|
|
@@ -241,6 +368,8 @@ export function MonitoringOverviewView({
|
|
|
241
368
|
demo?: boolean;
|
|
242
369
|
emailDetail?: MonitoringEmailDetails | null;
|
|
243
370
|
onEmailOpen?: (receiptId: string) => void;
|
|
371
|
+
onResolveArticleLink?: (receiptId: string, candidateIndex: number) => Promise<MonitoringEmailArticleResolution>;
|
|
372
|
+
onImportCandidates?: (receiptId: string, items: EmailReferenceImportItem[]) => Promise<EmailReferenceImportOutcome>;
|
|
244
373
|
}) {
|
|
245
374
|
let body: React.ReactNode;
|
|
246
375
|
if (result === null) {
|
|
@@ -256,7 +385,8 @@ export function MonitoringOverviewView({
|
|
|
256
385
|
} else if (result.state === "empty") {
|
|
257
386
|
body = <Message kind="empty" title="Nothing has been collected yet">No feed is configured and no run has been recorded. Items appear here after the collector's first run.</Message>;
|
|
258
387
|
} else {
|
|
259
|
-
body = <Ready data={result} demo={demo} emailDetail={emailDetail} onEmailOpen={onEmailOpen}
|
|
388
|
+
body = <Ready data={result} demo={demo} emailDetail={emailDetail} onEmailOpen={onEmailOpen}
|
|
389
|
+
onResolveArticleLink={onResolveArticleLink} onImportCandidates={onImportCandidates} />;
|
|
260
390
|
}
|
|
261
391
|
const scopeLine = result && "scope" in result
|
|
262
392
|
? (result.scope.mode === "all" ? "Showing the whole shared pipeline, every publication." : `Showing what is relevant to ${result.scope.publicationId ?? "this publication"}.`)
|
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import { useEffect, useState } from "react";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
importMonitoringEmailCandidates,
|
|
6
|
+
loadMonitoringEmailSubmission,
|
|
7
|
+
loadMonitoringOverview,
|
|
8
|
+
resolveMonitoringEmailArticleLink,
|
|
9
|
+
} from "../lib/monitoring-client";
|
|
5
10
|
import { FIXTURE_ALL_PIPELINE_OVERVIEW, FIXTURE_MONITORING_OVERVIEW } from "../lib/monitoring-overview-fixture";
|
|
6
|
-
import type { MonitoringEmailDetails, MonitoringResult } from "../lib/monitoring-overview";
|
|
11
|
+
import type { EmailReferenceImportItem, MonitoringEmailDetails, MonitoringResult } from "../lib/monitoring-overview";
|
|
7
12
|
import { MonitoringOverviewView, type MonitoringMode } from "./monitoring-overview-view";
|
|
8
13
|
|
|
9
14
|
const REFRESH_MILLISECONDS = 60_000;
|
|
@@ -43,6 +48,13 @@ export function MonitoringPanel({ isAdmin, isDemo }: { isAdmin: boolean; isDemo:
|
|
|
43
48
|
setEmailDetail(next);
|
|
44
49
|
};
|
|
45
50
|
|
|
51
|
+
const resolveEmailArticleLink = (receiptId: string, candidateIndex: number) =>
|
|
52
|
+
resolveMonitoringEmailArticleLink(receiptId, candidateIndex);
|
|
53
|
+
|
|
54
|
+
const importEmailCandidates = (receiptId: string, candidates: EmailReferenceImportItem[]) =>
|
|
55
|
+
importMonitoringEmailCandidates(receiptId, candidates);
|
|
56
|
+
|
|
46
57
|
return <MonitoringOverviewView result={result} canViewAll={isAdmin} mode={mode} onModeChange={setMode} demo={isDemo}
|
|
47
|
-
emailDetail={emailDetail} onEmailOpen={openEmail}
|
|
58
|
+
emailDetail={emailDetail} onEmailOpen={isDemo ? () => {} : openEmail} onResolveArticleLink={isDemo ? undefined : resolveEmailArticleLink}
|
|
59
|
+
onImportCandidates={importEmailCandidates} />;
|
|
48
60
|
}
|
|
@@ -23,6 +23,7 @@ type ViewProps = {
|
|
|
23
23
|
held: CyclotronEntry[];
|
|
24
24
|
autoDecided: CyclotronEntry[];
|
|
25
25
|
storyTitles?: Readonly<Record<string, string>>;
|
|
26
|
+
deletedStories?: ReadonlySet<string>;
|
|
26
27
|
demo?: boolean;
|
|
27
28
|
busyId?: string | null;
|
|
28
29
|
error?: string | null;
|
|
@@ -31,8 +32,8 @@ type ViewProps = {
|
|
|
31
32
|
onMarkAllRead?: () => void;
|
|
32
33
|
};
|
|
33
34
|
|
|
34
|
-
function Entry({ request, unread, demo, busy, titles, onAnswer, onMarkRead }: {
|
|
35
|
-
request: ReviewRequest; unread: boolean; demo: boolean; busy: boolean; titles: Readonly<Record<string, string>>;
|
|
35
|
+
function Entry({ request, unread, demo, busy, titles, deleted, onAnswer, onMarkRead }: {
|
|
36
|
+
request: ReviewRequest; unread: boolean; demo: boolean; busy: boolean; titles: Readonly<Record<string, string>>; deleted: ReadonlySet<string>;
|
|
36
37
|
onAnswer?: ViewProps["onAnswer"]; onMarkRead?: ViewProps["onMarkRead"];
|
|
37
38
|
}) {
|
|
38
39
|
const [note, setNote] = useState("");
|
|
@@ -49,7 +50,7 @@ function Entry({ request, unread, demo, busy, titles, onAnswer, onMarkRead }: {
|
|
|
49
50
|
</div>
|
|
50
51
|
<p className="text-xs text-muted-foreground">
|
|
51
52
|
Raised by {request.raisedBy} on {formatWhen(request.raisedAt)}
|
|
52
|
-
{" · "}About: {href ? <Link href={href} onClick={() => onMarkRead?.(request.id)}>{subjectLabel(request.subject, titles)}</Link> : <span>{subjectLabel(request.subject, titles)}</span>}
|
|
53
|
+
{" · "}About: {href ? <Link href={href} onClick={() => onMarkRead?.(request.id)}>{subjectLabel(request.subject, titles, deleted)}</Link> : <span>{subjectLabel(request.subject, titles, deleted)}</span>}
|
|
53
54
|
</p>
|
|
54
55
|
{request.ask ? <p className="text-sm">{request.ask}</p> : null}
|
|
55
56
|
{request.links.length ? (
|
|
@@ -78,7 +79,7 @@ function Entry({ request, unread, demo, busy, titles, onAnswer, onMarkRead }: {
|
|
|
78
79
|
</label>
|
|
79
80
|
<div className="flex flex-wrap gap-2">
|
|
80
81
|
{request.mode === "flag" ? (
|
|
81
|
-
<Button disabled={busy} onClick={() => act({ decision: "
|
|
82
|
+
<Button disabled={busy} onClick={() => act({ decision: "acknowledge" })} size="sm" variant="outline">Acknowledge</Button>
|
|
82
83
|
) : (
|
|
83
84
|
<>
|
|
84
85
|
<Button disabled={busy} onClick={() => act({ decision: "approve" })} size="sm" variant="outline">Approve</Button>
|
|
@@ -125,12 +126,12 @@ function Section({ id, title, count, children, empty }: { id: string; title: str
|
|
|
125
126
|
}
|
|
126
127
|
|
|
127
128
|
/** The CMS console home: what needs a person, what will proceed on its own, and what the system decided. Ignore-safe by design. */
|
|
128
|
-
export function NotificationCenterView({ loadState, requests, readIds, held, autoDecided, demo = false, storyTitles = {}, busyId = null, error = null, onAnswer, onMarkRead, onMarkAllRead }: ViewProps) {
|
|
129
|
+
export function NotificationCenterView({ loadState, requests, readIds, held, autoDecided, demo = false, storyTitles = {}, deletedStories = new Set<string>(), busyId = null, error = null, onAnswer, onMarkRead, onMarkAllRead }: ViewProps) {
|
|
129
130
|
const sections = useMemo(() => sectionReviews(requests), [requests]);
|
|
130
131
|
const unreadCount = requests.filter((request) => isUnread(request, readIds)).length;
|
|
131
132
|
const summary = summarize(sections, held, autoDecided, unreadCount);
|
|
132
133
|
const entry = (request: ReviewRequest) => (
|
|
133
|
-
<Entry busy={busyId === request.id} demo={demo} key={request.id} titles={storyTitles} onAnswer={onAnswer} onMarkRead={onMarkRead} request={request} unread={isUnread(request, readIds)} />
|
|
134
|
+
<Entry busy={busyId === request.id} demo={demo} key={request.id} titles={storyTitles} deleted={deletedStories} onAnswer={onAnswer} onMarkRead={onMarkRead} request={request} unread={isUnread(request, readIds)} />
|
|
134
135
|
);
|
|
135
136
|
if (loadState === "loading") return (
|
|
136
137
|
<section aria-label="Loading Notification Center" className="space-y-4" data-notification-state="loading" role="status">
|
|
@@ -206,6 +207,7 @@ export function NotificationCenter({ references, decisions, humanCuratedReferenc
|
|
|
206
207
|
const [busyId, setBusyId] = useState<string | null>(null);
|
|
207
208
|
const [error, setError] = useState<string | null>(null);
|
|
208
209
|
const [storyTitles, setStoryTitles] = useState<Record<string, string>>({});
|
|
210
|
+
const [deletedStories, setDeletedStories] = useState<ReadonlySet<string>>(new Set());
|
|
209
211
|
|
|
210
212
|
const refresh = useCallback(async () => {
|
|
211
213
|
if (demo) {
|
|
@@ -226,7 +228,9 @@ export function NotificationCenter({ references, decisions, humanCuratedReferenc
|
|
|
226
228
|
setUserKey(loaded.userKey);
|
|
227
229
|
setLoadState("ready");
|
|
228
230
|
const { loadStoryTitles } = await import("../lib/review-client");
|
|
229
|
-
|
|
231
|
+
const stories = await loadStoryTitles(loaded.requests.filter((request) => request.subject.type === "story").map((request) => request.subject.id)).catch(() => ({ titles: {}, deleted: [] as string[] }));
|
|
232
|
+
setStoryTitles(stories.titles);
|
|
233
|
+
setDeletedStories(new Set(stories.deleted));
|
|
230
234
|
} catch {
|
|
231
235
|
setLoadState("unavailable");
|
|
232
236
|
}
|
|
@@ -270,7 +274,7 @@ export function NotificationCenter({ references, decisions, humanCuratedReferenc
|
|
|
270
274
|
|
|
271
275
|
return (
|
|
272
276
|
<NotificationCenterView
|
|
273
|
-
autoDecided={autoDecided} busyId={busyId} storyTitles={storyTitles} demo={demo} error={error} held={held} loadState={loadState}
|
|
277
|
+
autoDecided={autoDecided} busyId={busyId} storyTitles={storyTitles} deletedStories={deletedStories} demo={demo} error={error} held={held} loadState={loadState}
|
|
274
278
|
onAnswer={answer} onMarkAllRead={markAllRead} onMarkRead={markRead} readIds={readIds} requests={requests}
|
|
275
279
|
/>
|
|
276
280
|
);
|
package/lib/monitoring-client.ts
CHANGED
|
@@ -1,12 +1,19 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import { generateClient } from "aws-amplify/data";
|
|
4
|
-
import
|
|
4
|
+
import { emailReferenceBatches } from "./monitoring-overview";
|
|
5
|
+
import type {
|
|
6
|
+
EmailReferenceImportItem,
|
|
7
|
+
EmailReferenceImportOutcome,
|
|
8
|
+
MonitoringEmailArticleResolution,
|
|
9
|
+
MonitoringEmailDetails,
|
|
10
|
+
MonitoringResult,
|
|
11
|
+
} from "./monitoring-overview";
|
|
5
12
|
|
|
6
|
-
type QueryField = "monitoringOverview" | "monitoringEmailSubmission";
|
|
13
|
+
type QueryField = "monitoringOverview" | "monitoringEmailSubmission" | "monitoringResolveEmailArticleLink" | "intakeEmailSubmissionReferences";
|
|
7
14
|
type GraphQLResult = { data?: Partial<Record<QueryField, unknown>>; errors?: { message?: string; errorType?: string }[] };
|
|
8
15
|
|
|
9
|
-
async function query<T>(field: QueryField, queryText: string, variables: Record<string, string>): Promise<T | { error: "forbidden" } | { state: "unavailable" }> {
|
|
16
|
+
async function query<T>(field: QueryField, queryText: string, variables: Record<string, string | number>): Promise<T | { error: "forbidden" } | { state: "unavailable" }> {
|
|
10
17
|
const client = generateClient({ authMode: "userPool" });
|
|
11
18
|
let result: GraphQLResult;
|
|
12
19
|
try {
|
|
@@ -32,3 +39,45 @@ export async function loadMonitoringEmailSubmission(receiptId: string): Promise<
|
|
|
32
39
|
return query<MonitoringEmailDetails>("monitoringEmailSubmission",
|
|
33
40
|
`query MonitoringEmailSubmission($receiptId: String!) { monitoringEmailSubmission(receiptId: $receiptId) }`, { receiptId });
|
|
34
41
|
}
|
|
42
|
+
|
|
43
|
+
/** Resolve one explicitly selected Beehiiv article redirect; this may register a click with the sender. */
|
|
44
|
+
export async function resolveMonitoringEmailArticleLink(receiptId: string, candidateIndex: number): Promise<MonitoringEmailArticleResolution> {
|
|
45
|
+
return query<MonitoringEmailArticleResolution>("monitoringResolveEmailArticleLink",
|
|
46
|
+
`mutation MonitoringResolveEmailArticleLink($receiptId: String!, $candidateIndex: Int!) { monitoringResolveEmailArticleLink(receiptId: $receiptId, candidateIndex: $candidateIndex) }`,
|
|
47
|
+
{ receiptId, candidateIndex });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Use the existing editor-authorized Reference intake; 20 is its request batch size, not a total limit. */
|
|
51
|
+
export async function importMonitoringEmailCandidates(
|
|
52
|
+
receiptId: string,
|
|
53
|
+
items: EmailReferenceImportItem[],
|
|
54
|
+
): Promise<EmailReferenceImportOutcome> {
|
|
55
|
+
const client = generateClient({ authMode: "userPool" });
|
|
56
|
+
const references: EmailReferenceImportOutcome["references"] = [];
|
|
57
|
+
for (const batch of emailReferenceBatches(items)) {
|
|
58
|
+
let result: GraphQLResult;
|
|
59
|
+
try {
|
|
60
|
+
result = (await client.graphql({
|
|
61
|
+
query: `mutation IntakeEmailSubmissionReferences($input: AWSJSON!) { intakeEmailSubmissionReferences(input: $input) }`,
|
|
62
|
+
variables: { input: JSON.stringify({ receiptId, items: batch }) },
|
|
63
|
+
})) as GraphQLResult;
|
|
64
|
+
} catch {
|
|
65
|
+
return { imported: references.length, references, error: "The import stopped. Completed links are safe to retry." };
|
|
66
|
+
}
|
|
67
|
+
if (result.errors?.length) {
|
|
68
|
+
return { imported: references.length, references, error: "The import stopped. Completed links are safe to retry." };
|
|
69
|
+
}
|
|
70
|
+
const raw = result.data?.intakeEmailSubmissionReferences;
|
|
71
|
+
let response: { ok?: boolean; references?: EmailReferenceImportOutcome["references"] } | null | undefined;
|
|
72
|
+
try {
|
|
73
|
+
response = (typeof raw === "string" ? JSON.parse(raw) : raw) as typeof response;
|
|
74
|
+
} catch {
|
|
75
|
+
return { imported: references.length, references, error: "The import response could not be read. Completed links are safe to retry." };
|
|
76
|
+
}
|
|
77
|
+
if (!response?.ok || !Array.isArray(response.references)) {
|
|
78
|
+
return { imported: references.length, references, error: "The import was not accepted. Check your editor access and retry." };
|
|
79
|
+
}
|
|
80
|
+
references.push(...response.references);
|
|
81
|
+
}
|
|
82
|
+
return { imported: references.length, references };
|
|
83
|
+
}
|
|
@@ -40,10 +40,44 @@ export type MonitoringEmailSummary = {
|
|
|
40
40
|
};
|
|
41
41
|
|
|
42
42
|
export type MonitoringEmailDetails = {
|
|
43
|
-
submission: MonitoringEmailSummary; safeBodyText: string;
|
|
43
|
+
submission: MonitoringEmailSummary & { corpusId: string | null }; safeBodyText: string;
|
|
44
44
|
candidateLinks: { url: string; title: string; resolutionStatus?: "unresolved" }[]; sourceMaterialIsUntrusted: true;
|
|
45
45
|
} | { error: "forbidden" | "bad_request" } | { state: "unavailable" | "not_configured" };
|
|
46
46
|
|
|
47
|
+
export type MonitoringEmailArticleResolution = {
|
|
48
|
+
url: string; title: string; resolved: true;
|
|
49
|
+
} | { error: "forbidden" | "bad_request" | "not_resolvable" | "resolution_failed"; reason?: string } | { state: "unavailable" };
|
|
50
|
+
|
|
51
|
+
export type EmailReferenceImportItem = { candidateIndex: number; source_uri: string };
|
|
52
|
+
export type EmailReferenceImportOutcome = {
|
|
53
|
+
imported: number; references: { id: string; lineageId: string; curationStatus: string }[]; error?: string;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/** Split a complete editor selection into API work batches; the batch size is not a total link limit. */
|
|
57
|
+
export function emailReferenceBatches<T>(items: T[], batchSize = 20): T[][] {
|
|
58
|
+
if (!Number.isSafeInteger(batchSize) || batchSize < 1) throw new Error("batchSize must be a positive integer");
|
|
59
|
+
const batches: T[][] = [];
|
|
60
|
+
for (let offset = 0; offset < items.length; offset += batchSize) {
|
|
61
|
+
batches.push(items.slice(offset, offset + batchSize));
|
|
62
|
+
}
|
|
63
|
+
return batches;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Async email actions may only update the receipt that is still open. */
|
|
67
|
+
export function isCurrentEmailSubmissionAction(activeReceiptId: string | null, actionReceiptId: string): boolean {
|
|
68
|
+
return activeReceiptId === actionReceiptId;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function beginEmailSubmissionAction(inFlight: Set<string>, actionKey: string): boolean {
|
|
72
|
+
if (inFlight.has(actionKey)) return false;
|
|
73
|
+
inFlight.add(actionKey);
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function endEmailSubmissionAction(inFlight: Set<string>, actionKey: string): void {
|
|
78
|
+
inFlight.delete(actionKey);
|
|
79
|
+
}
|
|
80
|
+
|
|
47
81
|
export type MonitoringRun = {
|
|
48
82
|
runId: string;
|
|
49
83
|
startedAt: string;
|
package/lib/review-client.ts
CHANGED
|
@@ -100,18 +100,21 @@ export async function markReviewRead(userKey: string, requestId: string, now: Da
|
|
|
100
100
|
|
|
101
101
|
const MAX_TITLE_LOOKUPS = 40;
|
|
102
102
|
|
|
103
|
-
/** Titles of the stories that review requests are about, by lineage id. A story that cannot be read simply has no entry (the card falls back to the id). */
|
|
104
|
-
export async function loadStoryTitles(ids: string[]): Promise<Record<string, string
|
|
103
|
+
/** Titles of the stories that review requests are about, by lineage id, and the ids of stories that no longer exist (no version of the lineage remains). A story that cannot be read simply has no entry (the card falls back to the id). */
|
|
104
|
+
export async function loadStoryTitles(ids: string[]): Promise<{ titles: Record<string, string>; deleted: string[] }> {
|
|
105
105
|
const titles: Record<string, string> = {};
|
|
106
|
+
const deleted: string[] = [];
|
|
106
107
|
await Promise.all([...new Set(ids)].slice(0, MAX_TITLE_LOOKUPS).map(async (id) => {
|
|
107
108
|
const result = await run(
|
|
108
109
|
`query StoryTitle($lineageId: ID!) { listItemsByLineageAndVersion(lineageId: $lineageId, limit: 50) { items { versionNumber headline title } } }`,
|
|
109
110
|
{ lineageId: id },
|
|
110
111
|
);
|
|
112
|
+
// Only a clean, empty answer means the story is gone; a failed lookup says nothing.
|
|
113
|
+
if (!result.errors?.length && result.data?.listItemsByLineageAndVersion && !(result.data.listItemsByLineageAndVersion.items ?? []).filter(Boolean).length) deleted.push(id);
|
|
111
114
|
const items = (result.data?.listItemsByLineageAndVersion?.items ?? []).filter(Boolean) as { versionNumber?: number; headline?: string; title?: string }[];
|
|
112
115
|
const newest = items.sort((a, b) => (b.versionNumber ?? 0) - (a.versionNumber ?? 0))[0];
|
|
113
116
|
const title = newest?.headline || newest?.title;
|
|
114
117
|
if (title) titles[id] = title;
|
|
115
118
|
}));
|
|
116
|
-
return titles;
|
|
119
|
+
return { titles, deleted };
|
|
117
120
|
}
|
package/lib/review-requests.ts
CHANGED
|
@@ -13,7 +13,7 @@ import type { RelevanceDecisionView } from "./relevance-decisions";
|
|
|
13
13
|
|
|
14
14
|
export type ReviewMode = "flag" | "soft_review" | "hard_gate";
|
|
15
15
|
export type ReviewState = "open" | "answered" | "timed_out" | "withdrawn";
|
|
16
|
-
export type ReviewDecision = "approve" | "reject" | "feedback";
|
|
16
|
+
export type ReviewDecision = "approve" | "acknowledge" | "reject" | "feedback";
|
|
17
17
|
export type ReviewSteer = "adjust" | "not_this_kind";
|
|
18
18
|
|
|
19
19
|
export type ReviewAnswer = { decision: ReviewDecision; feedback: string | null; steer: ReviewSteer | null; by: string; at: string };
|
|
@@ -89,6 +89,7 @@ export type AnswerInput = { decision: ReviewDecision; feedback?: string | null;
|
|
|
89
89
|
/** The update for an answer, or an Error saying why it is refused. Applied with the condition state = open. */
|
|
90
90
|
export function answerUpdate(request: ReviewRequest, input: AnswerInput, by: string, now: Date): Record<string, unknown> {
|
|
91
91
|
if (request.state !== "open") throw new Error(`This request is ${request.state.replace("_", " ")}${request.state === "timed_out" ? ` (${PROCEEDED_WITHOUT_REVIEW})` : ""} and can no longer be answered.`);
|
|
92
|
+
if (input.decision === "acknowledge" && request.mode !== "flag") throw new Error("Only a flag is acknowledged; approve or reject this one.");
|
|
92
93
|
const feedback = input.feedback?.trim() || null;
|
|
93
94
|
const steer = input.steer ?? null;
|
|
94
95
|
if (input.decision === "feedback" && !feedback && !steer) throw new Error("Write a note, or choose \"Not this kind of thing\".");
|
|
@@ -126,12 +127,13 @@ export function nextText(request: ReviewRequest): string {
|
|
|
126
127
|
if (request.state === "timed_out") return `Proceeded without review on ${formatWhen(request.updatedAt)}.`;
|
|
127
128
|
if (request.state === "withdrawn") return "Withdrawn by the step that raised it.";
|
|
128
129
|
const answer = request.answer;
|
|
129
|
-
return answer ? `${answerWords(answer)} by ${answer.by} on ${formatWhen(answer.at)}.` : "Answered.";
|
|
130
|
+
return answer ? `${answerWords(answer, request)} by ${answer.by} on ${formatWhen(answer.at)}.` : "Answered.";
|
|
130
131
|
}
|
|
131
132
|
|
|
132
|
-
export function answerWords(answer: ReviewAnswer): string {
|
|
133
|
+
export function answerWords(answer: ReviewAnswer, request?: Pick<ReviewRequest, "mode">): string {
|
|
133
134
|
if (answer.steer === "not_this_kind") return "Steered: not this kind of thing";
|
|
134
|
-
if (answer.decision === "
|
|
135
|
+
if (answer.decision === "acknowledge") return "Acknowledged";
|
|
136
|
+
if (answer.decision === "approve") return request?.mode === "flag" ? "Acknowledged" : "Approved";
|
|
135
137
|
if (answer.decision === "reject") return "Rejected";
|
|
136
138
|
return "Steered with a note";
|
|
137
139
|
}
|
|
@@ -146,8 +148,8 @@ export function steeringText(request: ReviewRequest): string | null {
|
|
|
146
148
|
}
|
|
147
149
|
|
|
148
150
|
/** What a request is about, in words: a story shows its title (the id when the title is not known), anything else its type and id. */
|
|
149
|
-
export function subjectLabel(subject: { type: string; id: string }, titles: Readonly<Record<string, string>> = {}): string {
|
|
150
|
-
if (subject.type === "story") return titles[subject.id] ?? `story ${subject.id}`;
|
|
151
|
+
export function subjectLabel(subject: { type: string; id: string }, titles: Readonly<Record<string, string>> = {}, deleted: ReadonlySet<string> = new Set()): string {
|
|
152
|
+
if (subject.type === "story") return deleted.has(subject.id) ? `story ${subject.id} (story deleted)` : titles[subject.id] ?? `story ${subject.id}`;
|
|
151
153
|
return `${subject.type.replace("_", " ")} ${subject.id}`;
|
|
152
154
|
}
|
|
153
155
|
|
package/package.json
CHANGED
package/routes.manifest.json
CHANGED