@anthusai/papyrus 5.39.0 → 5.41.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.
Files changed (40) hide show
  1. package/amplify/data/resource.js +6 -0
  2. package/amplify/data/resource.ts +7 -0
  3. package/amplify/data/schema.js +6 -0
  4. package/amplify/data/schema.ts +7 -0
  5. package/amplify/functions/content-actions/handler.py +10 -1
  6. package/amplify/functions/monitoring-view/schema.js +12 -0
  7. package/amplify/functions/monitoring-view/schema.ts +12 -0
  8. package/amplify/functions/monitoring-view/wire.js +25 -0
  9. package/amplify/functions/monitoring-view/wire.ts +32 -0
  10. package/amplify/site-backend.js +2 -1
  11. package/amplify/site-backend.ts +2 -1
  12. package/app/api/newsroom/chat/[...path]/route.ts +17 -0
  13. package/app/layout.tsx +5 -1
  14. package/components/monitoring-overview-view.tsx +182 -9
  15. package/components/monitoring-panel.tsx +23 -3
  16. package/components/notification-center.tsx +12 -8
  17. package/components/papyrus-console-conversation.css +14 -0
  18. package/components/papyrus-console-conversation.tsx +55 -0
  19. package/components/papyrus-console-shell.tsx +62 -420
  20. package/components/papyrus-private-chat-panel.tsx +134 -0
  21. package/infra/amplify-app-shell.js +2 -0
  22. package/infra/amplify-app-shell.ts +2 -0
  23. package/infra/site-config.js +4 -0
  24. package/infra/site-config.ts +4 -0
  25. package/lib/monitoring-client.ts +69 -15
  26. package/lib/monitoring-overview-fixture.ts +1 -1
  27. package/lib/monitoring-overview.ts +48 -5
  28. package/lib/papyrus-chat-admission.ts +65 -0
  29. package/lib/papyrus-hosted-chat.ts +217 -0
  30. package/lib/papyrus-private-chat-client.ts +81 -0
  31. package/lib/prospectus-session.ts +15 -0
  32. package/lib/prospectus-sign-in-routes.ts +2 -12
  33. package/lib/review-client.ts +6 -3
  34. package/lib/review-requests.ts +8 -6
  35. package/lib/shared-chat-admission.ts +182 -0
  36. package/lib/site-origin-value.ts +50 -0
  37. package/lib/site-origin.ts +3 -62
  38. package/package.json +10 -1
  39. package/routes.manifest.json +13 -1
  40. package/src/with-papyrus.mjs +1 -1
@@ -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() })
@@ -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() })
@@ -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() })
@@ -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":
@@ -14,4 +14,16 @@ export const monitoringViewSchemaDefinitions = {
14
14
  .returns(a.json())
15
15
  .authorization((allow) => [allow.groups(editors)])
16
16
  .handler(functionHandler("monitoringView", monitoringView)),
17
+ monitoringEmailSubmission: a
18
+ .query()
19
+ .arguments({ receiptId: a.string().required() })
20
+ .returns(a.json())
21
+ .authorization((allow) => [allow.groups(editors)])
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)),
17
29
  };
@@ -16,4 +16,16 @@ export const monitoringViewSchemaDefinitions = {
16
16
  .returns(a.json())
17
17
  .authorization((allow) => [allow.groups(editors)])
18
18
  .handler(functionHandler("monitoringView", monitoringView)),
19
+ monitoringEmailSubmission: a
20
+ .query()
21
+ .arguments({ receiptId: a.string().required() })
22
+ .returns(a.json())
23
+ .authorization((allow) => [allow.groups(editors)])
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)),
19
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
  }
@@ -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: ["*"],
@@ -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"],
@@ -0,0 +1,17 @@
1
+ import type { NextRequest } from "next/server";
2
+ import { handlePapyrusHostedChatRequest } from "../../../../../lib/papyrus-hosted-chat";
3
+
4
+ export const dynamic = "force-dynamic";
5
+ export const runtime = "nodejs";
6
+
7
+ type RouteContext = { params: Promise<{ path: string[] }> };
8
+
9
+ export async function GET(request: NextRequest, context: RouteContext) {
10
+ const { path } = await context.params;
11
+ return handlePapyrusHostedChatRequest(request, path);
12
+ }
13
+
14
+ export async function POST(request: NextRequest, context: RouteContext) {
15
+ const { path } = await context.params;
16
+ return handlePapyrusHostedChatRequest(request, path);
17
+ }
package/app/layout.tsx CHANGED
@@ -1,3 +1,5 @@
1
+ import "chatticus/chat.css";
2
+ import "../components/papyrus-console-conversation.css";
1
3
  import type { Metadata } from "next";
2
4
  import { Playfair_Display, Plus_Jakarta_Sans } from "next/font/google";
3
5
  import Script from "next/script";
@@ -152,7 +154,9 @@ export default async function RootLayout({ children }: Readonly<{ children: Reac
152
154
  {showStagingBanner() ? <StagingBanner /> : null}
153
155
  <AmplifyClientProvider>
154
156
  <TooltipProvider>
155
- <PapyrusConsoleShell>{children}</PapyrusConsoleShell>
157
+ <PapyrusConsoleShell privateChatEnabled={process.env.PAPYRUS_PRIVATE_CHAT_ENABLED === "true"}>
158
+ {children}
159
+ </PapyrusConsoleShell>
156
160
  </TooltipProvider>
157
161
  </AmplifyClientProvider>
158
162
  </body>
@@ -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,15 @@ import {
7
9
  formatUtc,
8
10
  referenceHref,
9
11
  routingLabel,
10
- usageText,
12
+ beginEmailSubmissionAction,
13
+ endEmailSubmissionAction,
14
+ isCurrentEmailSubmissionAction,
11
15
  type MonitoringItem,
16
+ type MonitoringEmailDetails,
17
+ type MonitoringEmailArticleResolution,
18
+ type EmailReferenceImportItem,
19
+ type EmailReferenceImportOutcome,
20
+ type MonitoringEmailSummary,
12
21
  type MonitoringOverview,
13
22
  type MonitoringRelevance,
14
23
  type MonitoringResult,
@@ -88,8 +97,160 @@ function QueueItem({ item, ownId, demo }: { item: MonitoringItem; ownId: string
88
97
  );
89
98
  }
90
99
 
91
- function Ready({ data, demo }: { data: MonitoringOverview; demo: boolean }) {
92
- const ceilings = data.usage?.ceilings ?? null;
100
+ function EmailSubmissions({ items, detail, onOpen, onResolveArticleLink, onImportCandidates }: {
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>;
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
+
196
+ return (
197
+ <article className="news-desk-doctrine-card" data-monitoring-section="email-submissions">
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>
199
+ {items.length ? <ul className="news-desk-monitoring__list">
200
+ {items.map((item) => <li key={item.receiptId} data-email-submission={item.receiptId}>
201
+ <strong>{item.subject || "(no subject)"}</strong>
202
+ <span>{formatUtc(item.observedAt)} · {item.linkCount} candidate links · {item.reviewRequired
203
+ ? `${item.linkCount} await editor import` : `${item.processedCount} queued`}</span>
204
+ {item.reviewRequired ? <span>Authentication warnings: private review required; nothing was routed automatically.</span> : null}
205
+ {item.unresolvedLinkCount ? <span>{item.unresolvedLinkCount} tracking links remain unresolved and were not opened.</span> : null}
206
+ <span>Envelope sender: {item.envelopeSender || "unknown"} · MIME From (unverified): {item.headerFrom || "unknown"}</span>
207
+ <span>SES signals — SPF {item.verdicts.spf}, DKIM {item.verdicts.dkim}, DMARC {item.verdicts.dmarc}; spam {item.verdicts.spam}, virus {item.verdicts.virus}</span>
208
+ {item.warnings.length ? <span>Warnings: {item.warnings.join(", ")}</span> : null}
209
+ {item.errorCode ? <span>State: {item.status} ({item.errorCode})</span> : <span>State: {item.status}</span>}
210
+ <button type="button" onClick={() => onOpen(item.receiptId)}>Read email and review candidate links</button>
211
+ </li>)}
212
+ </ul> : <p>No email submissions are available for this publication.</p>}
213
+ {detail && "submission" in detail ? (
214
+ <section aria-label="Email submission details" data-email-submission-details={detail.submission.receiptId}>
215
+ <h4>{detail.submission.subject || "Submitted email"}</h4>
216
+ <p>Original content is shown as inert text. Sender and message content are untrusted; no instructions are executed.</p>
217
+ <pre style={{ whiteSpace: "pre-wrap", overflowWrap: "anywhere" }}>{detail.safeBodyText}</pre>
218
+ <h5>Candidate references ({detail.candidateLinks.length})</h5>
219
+ <ul>{detail.candidateLinks.map((link, index) => <li key={`${link.url}-${index}`}>
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}
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}
242
+ </section>
243
+ ) : detail && "error" in detail ? <p role="alert">You cannot read this submission.</p>
244
+ : detail && "state" in detail ? <p role="alert">Submission details are unavailable.</p> : null}
245
+ </article>
246
+ );
247
+ }
248
+
249
+ function Ready({ data, demo, emailDetail, onEmailOpen, onResolveArticleLink, onImportCandidates }: {
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>;
253
+ }) {
93
254
  const router = data.router;
94
255
  return (
95
256
  <>
@@ -97,6 +258,10 @@ function Ready({ data, demo }: { data: MonitoringOverview; demo: boolean }) {
97
258
  <p className="news-desk-delegation-note" data-monitoring-notice>This publication is not one of the router&apos;s outputs, so no relevance is shown for it here.</p>
98
259
  ) : null}
99
260
 
261
+ <EmailSubmissions items={data.emailSubmissions ?? []} detail={emailDetail} onOpen={onEmailOpen}
262
+ onResolveArticleLink={demo ? undefined : onResolveArticleLink}
263
+ onImportCandidates={demo ? undefined : onImportCandidates} />
264
+
100
265
  <article className="news-desk-doctrine-card" data-monitoring-section="router">
101
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>
102
267
  {router ? (
@@ -110,15 +275,14 @@ function Ready({ data, demo }: { data: MonitoringOverview; demo: boolean }) {
110
275
  </article>
111
276
 
112
277
  <article className="news-desk-doctrine-card" data-monitoring-section="usage">
113
- <div className="news-desk-doctrine-card__header"><div><h3>Usage</h3><span>Decisions the router recorded, against the shared Cyclotron&apos;s ceilings (UTC days)</span></div></div>
278
+ <div className="news-desk-doctrine-card__header"><div><h3>Usage</h3><span>Observed router decisions, not a Cyclotron quota (UTC days)</span></div></div>
114
279
  {data.usage ? (
115
280
  <>
116
281
  <dl className="news-desk-monitoring__usage">
117
- <div><dt>Decisions today ({data.usage.day})</dt><dd>{usageText(data.usage.decisionsToday, ceilings?.decisionsPerDay)}</dd></div>
118
- <div><dt>Decisions in total</dt><dd>{usageText(data.usage.decisionsTotal, ceilings?.decisionsTotal)}</dd></div>
119
- {ceilings?.items !== undefined ? <div><dt>Items judged (ceiling)</dt><dd>{usageText(data.usage.decisionsTotal, ceilings.items)}</dd></div> : null}
282
+ <div><dt>Decisions today ({data.usage.day})</dt><dd>{data.usage.historyComplete ? data.usage.decisionsToday : `At least ${data.usage.decisionsToday}`}</dd></div>
283
+ <div><dt>Decisions in inspected history</dt><dd>{data.usage.historyComplete ? data.usage.decisionsTotal : `At least ${data.usage.decisionsTotal}`}</dd></div>
120
284
  </dl>
121
- {ceilings === null ? <p className="news-desk-delegation-note">No ceilings are configured here, so only usage is shown.</p> : null}
285
+ {!data.usage.historyComplete ? <p className="news-desk-delegation-note">Counts are lower bounds from a bounded history read.</p> : null}
122
286
  </>
123
287
  ) : <p>No usage recorded yet.</p>}
124
288
  </article>
@@ -192,12 +356,20 @@ export function MonitoringOverviewView({
192
356
  mode,
193
357
  onModeChange,
194
358
  demo = false,
359
+ emailDetail = null,
360
+ onEmailOpen = () => {},
361
+ onResolveArticleLink,
362
+ onImportCandidates,
195
363
  }: {
196
364
  result: MonitoringResult | null;
197
365
  canViewAll: boolean;
198
366
  mode: MonitoringMode;
199
367
  onModeChange: (mode: MonitoringMode) => void;
200
368
  demo?: boolean;
369
+ emailDetail?: MonitoringEmailDetails | null;
370
+ onEmailOpen?: (receiptId: string) => void;
371
+ onResolveArticleLink?: (receiptId: string, candidateIndex: number) => Promise<MonitoringEmailArticleResolution>;
372
+ onImportCandidates?: (receiptId: string, items: EmailReferenceImportItem[]) => Promise<EmailReferenceImportOutcome>;
201
373
  }) {
202
374
  let body: React.ReactNode;
203
375
  if (result === null) {
@@ -213,7 +385,8 @@ export function MonitoringOverviewView({
213
385
  } else if (result.state === "empty") {
214
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&apos;s first run.</Message>;
215
387
  } else {
216
- body = <Ready data={result} demo={demo} />;
388
+ body = <Ready data={result} demo={demo} emailDetail={emailDetail} onEmailOpen={onEmailOpen}
389
+ onResolveArticleLink={onResolveArticleLink} onImportCandidates={onImportCandidates} />;
217
390
  }
218
391
  const scopeLine = result && "scope" in result
219
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 { loadMonitoringOverview } from "../lib/monitoring-client";
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 { 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;
@@ -12,6 +17,7 @@ const REFRESH_MILLISECONDS = 60_000;
12
17
  export function MonitoringPanel({ isAdmin, isDemo }: { isAdmin: boolean; isDemo: boolean }) {
13
18
  const [mode, setMode] = useState<MonitoringMode>("publication");
14
19
  const [result, setResult] = useState<MonitoringResult | null>(null);
20
+ const [emailDetail, setEmailDetail] = useState<MonitoringEmailDetails | null>(null);
15
21
 
16
22
  useEffect(() => {
17
23
  if (isDemo) {
@@ -36,5 +42,19 @@ export function MonitoringPanel({ isAdmin, isDemo }: { isAdmin: boolean; isDemo:
36
42
  };
37
43
  }, [mode, isDemo]);
38
44
 
39
- return <MonitoringOverviewView result={result} canViewAll={isAdmin} mode={mode} onModeChange={setMode} demo={isDemo} />;
45
+ const openEmail = async (receiptId: string) => {
46
+ setEmailDetail({ state: "unavailable" });
47
+ const next = await loadMonitoringEmailSubmission(receiptId);
48
+ setEmailDetail(next);
49
+ };
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
+
57
+ return <MonitoringOverviewView result={result} canViewAll={isAdmin} mode={mode} onModeChange={setMode} demo={isDemo}
58
+ emailDetail={emailDetail} onEmailOpen={isDemo ? () => {} : openEmail} onResolveArticleLink={isDemo ? undefined : resolveEmailArticleLink}
59
+ onImportCandidates={importEmailCandidates} />;
40
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: "approve" })} size="sm" variant="outline">Acknowledge</Button>
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
- setStoryTitles(await loadStoryTitles(loaded.requests.filter((request) => request.subject.type === "story").map((request) => request.subject.id)).catch(() => ({})));
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
  );