@anthusai/papyrus 5.43.0 → 5.45.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 (61) hide show
  1. package/amplify/functions/category-action/handler.ts +2 -1
  2. package/amplify/functions/model-attachment-upload/handler.ts +1 -0
  3. package/amplify/functions/monitoring-view/schema.js +16 -0
  4. package/amplify/functions/monitoring-view/schema.ts +16 -0
  5. package/amplify/functions/procedure-action/handler.ts +2 -10
  6. package/amplify/functions/proposal-actions/proposals.py +20 -3
  7. package/amplify/plugins/reader-steering/function/handler.py +1 -3
  8. package/amplify/plugins/reader-steering/function/moderation.py +19 -0
  9. package/bin/papyrus-infra.mjs +21 -1
  10. package/components/dev-sandbox-editor-sign-in.tsx +2 -2
  11. package/components/monitoring-overview-view.tsx +143 -21
  12. package/components/monitoring-panel.tsx +64 -4
  13. package/components/news-desk-client-provider.tsx +24 -3
  14. package/components/news-desk-page.tsx +2 -1
  15. package/components/news-desk-taxonomy-client.ts +2 -1
  16. package/components/newsroom-access-gate.tsx +7 -5
  17. package/components/newsroom-client-shell.tsx +2 -1
  18. package/components/newsroom-ops-overview.tsx +6 -1
  19. package/components/newsroom-ops-shell.tsx +1 -1
  20. package/components/newsroom-organizer.css +48 -0
  21. package/components/newsroom-references-view.tsx +183 -24
  22. package/components/notification-center.tsx +345 -63
  23. package/components/papyrus-console-shell.tsx +2 -1
  24. package/components/proposals-panel.tsx +21 -24
  25. package/components/proposals-view.tsx +4 -2
  26. package/components/reference-feed.tsx +469 -0
  27. package/components/reference-review-card.tsx +90 -76
  28. package/components/references-page-loading.tsx +61 -0
  29. package/components/references-prefetch.ts +47 -0
  30. package/components/review-rating-grid.tsx +164 -0
  31. package/components/topic-steering-workspace.tsx +32 -8
  32. package/infra/amplify-app-shell.js +18 -2
  33. package/infra/amplify-app-shell.ts +18 -2
  34. package/infra/chat-stack.js +222 -0
  35. package/infra/chat-stack.ts +250 -0
  36. package/infra/chat.test.js +142 -0
  37. package/infra/chat.test.ts +151 -0
  38. package/infra/index.js +1 -0
  39. package/infra/site-config.js +58 -1
  40. package/infra/site-config.ts +78 -1
  41. package/lib/category-repository.ts +4 -1
  42. package/lib/local-preview-feed-fixture.ts +43 -0
  43. package/lib/local-preview-synthetic-rejects.ts +41 -0
  44. package/lib/monitoring-client.ts +29 -2
  45. package/lib/monitoring-overview.ts +52 -1
  46. package/lib/monitoring-paging.ts +58 -0
  47. package/lib/newsroom-chat-identity.ts +2 -1
  48. package/lib/newsroom-organizer-nav.ts +1 -0
  49. package/lib/proposals-client.ts +29 -0
  50. package/lib/reader-subject.ts +3 -2
  51. package/lib/reference-feed-cache.ts +133 -0
  52. package/lib/reference-feed-ranking.ts +191 -0
  53. package/lib/reference-preview.ts +112 -0
  54. package/lib/review-client.ts +38 -1
  55. package/lib/review-drafts.ts +231 -0
  56. package/lib/review-fixture.ts +36 -2
  57. package/lib/review-requests.ts +64 -24
  58. package/lib/staff-groups.ts +23 -0
  59. package/lib/staging-gate.ts +2 -2
  60. package/package.json +14 -1
  61. package/routes.manifest.json +1 -1
@@ -24,6 +24,7 @@ import {
24
24
  requestDelegation,
25
25
  type DelegationStore,
26
26
  } from "../shared/curation-delegation-flow";
27
+ import { claimGroups } from "../../../lib/staff-groups";
27
28
 
28
29
  type ReviewHandler = Schema["reviewSteeringProposal"]["functionHandler"];
29
30
  type ReferenceCurationHandler = Schema["reviewReferenceCuration"]["functionHandler"];
@@ -360,7 +361,7 @@ function requireSignedInPerson(event: { identity?: unknown }): { sub: string; la
360
361
  }
361
362
  const identity = event.identity as { sub?: unknown; claims?: Record<string, unknown> };
362
363
  const claims = identity.claims ?? {};
363
- const groups = Array.isArray(claims["cognito:groups"]) ? (claims["cognito:groups"] as unknown[]) : [];
364
+ const groups = claimGroups(claims["cognito:groups"]);
364
365
  return {
365
366
  sub: normalizeRequiredString(claims.sub ?? identity.sub, "identity.sub"),
366
367
  label: normalizeOptionalString(claims.email) ?? normalizeOptionalString(claims.name) ?? "Newsroom editor",
@@ -41,6 +41,7 @@ const ALLOWED_ROLES = new Set([
41
41
  "item_excerpt",
42
42
  "message_body",
43
43
  "metadata",
44
+ "preview",
44
45
  "ontology_concept_profile",
45
46
  "ontology_relation_explanation",
46
47
  "published_item_body",
@@ -14,6 +14,14 @@ export const monitoringViewSchemaDefinitions = {
14
14
  .returns(a.json())
15
15
  .authorization((allow) => [allow.groups(editors)])
16
16
  .handler(functionHandler("monitoringView", monitoringView)),
17
+ // One more page of one Monitoring list (runs, routerRuns, items, emailSubmissions), after a cursor from
18
+ // the overview or a previous page. filter "held" lists only held email submissions, restarting from the newest.
19
+ monitoringPage: a
20
+ .query()
21
+ .arguments({ list: a.string().required(), cursor: a.string(), filter: a.string(), scope: a.string() })
22
+ .returns(a.json())
23
+ .authorization((allow) => [allow.groups(editors)])
24
+ .handler(functionHandler("monitoringView", monitoringView)),
17
25
  monitoringEmailSubmission: a
18
26
  .query()
19
27
  .arguments({ receiptId: a.string().required() })
@@ -26,6 +34,14 @@ export const monitoringViewSchemaDefinitions = {
26
34
  .returns(a.json())
27
35
  .authorization((allow) => [allow.groups(editors)])
28
36
  .handler(functionHandler("monitoringView", monitoringView)),
37
+ // Accept ("release") or Reject ("discard") one held email submission. The function validates the
38
+ // signed-in editor and that the message is routed to this publication; the receiver applies it.
39
+ monitoringDecideEmailSubmission: a
40
+ .mutation()
41
+ .arguments({ receiptId: a.string().required(), decision: a.string().required(), reason: a.string() })
42
+ .returns(a.json())
43
+ .authorization((allow) => [allow.groups(editors)])
44
+ .handler(functionHandler("monitoringView", monitoringView)),
29
45
  monitoringResolveEmailArticleLink: a
30
46
  .mutation()
31
47
  .arguments({ receiptId: a.string().required(), candidateIndex: a.integer().required() })
@@ -16,6 +16,14 @@ export const monitoringViewSchemaDefinitions = {
16
16
  .returns(a.json())
17
17
  .authorization((allow) => [allow.groups(editors)])
18
18
  .handler(functionHandler("monitoringView", monitoringView)),
19
+ // One more page of one Monitoring list (runs, routerRuns, items, emailSubmissions), after a cursor from
20
+ // the overview or a previous page. filter "held" lists only held email submissions, restarting from the newest.
21
+ monitoringPage: a
22
+ .query()
23
+ .arguments({ list: a.string().required(), cursor: a.string(), filter: a.string(), scope: a.string() })
24
+ .returns(a.json())
25
+ .authorization((allow) => [allow.groups(editors)])
26
+ .handler(functionHandler("monitoringView", monitoringView)),
19
27
  monitoringEmailSubmission: a
20
28
  .query()
21
29
  .arguments({ receiptId: a.string().required() })
@@ -28,6 +36,14 @@ export const monitoringViewSchemaDefinitions = {
28
36
  .returns(a.json())
29
37
  .authorization((allow) => [allow.groups(editors)])
30
38
  .handler(functionHandler("monitoringView", monitoringView)),
39
+ // Accept ("release") or Reject ("discard") one held email submission. The function validates the
40
+ // signed-in editor and that the message is routed to this publication; the receiver applies it.
41
+ monitoringDecideEmailSubmission: a
42
+ .mutation()
43
+ .arguments({ receiptId: a.string().required(), decision: a.string().required(), reason: a.string() })
44
+ .returns(a.json())
45
+ .authorization((allow) => [allow.groups(editors)])
46
+ .handler(functionHandler("monitoringView", monitoringView)),
31
47
  monitoringResolveEmailArticleLink: a
32
48
  .mutation()
33
49
  .arguments({ receiptId: a.string().required(), candidateIndex: a.integer().required() })
@@ -3,6 +3,7 @@ import { getAmplifyDataClientConfig } from "@aws-amplify/backend/function/runtim
3
3
  import { Amplify } from "aws-amplify";
4
4
  import { generateClient } from "aws-amplify/data";
5
5
  import type { Schema } from "../../data/resource";
6
+ import { claimGroups } from "../../../lib/staff-groups";
6
7
 
7
8
  type DataClient = ReturnType<typeof generateClient<Schema>>;
8
9
  type DataClientErrors = Array<{ message?: string | null } | string | null> | null | undefined;
@@ -474,20 +475,11 @@ function validateJsonSchemaInput(schema: Record<string, unknown>, input: Record<
474
475
 
475
476
  function requireAdmin(event: ProcedureEvent): void {
476
477
  const claims = objectValue(event.identity?.claims);
477
- const groups = [
478
- ...normalizeClaimGroups(claims["cognito:groups"]),
479
- ...normalizeClaimGroups(claims.groups),
480
- ];
478
+ const groups = [...claimGroups(claims["cognito:groups"]), ...claimGroups(claims.groups)];
481
479
  if (groups.includes("admin")) return;
482
480
  throw new Error("Admin role required.");
483
481
  }
484
482
 
485
- function normalizeClaimGroups(value: unknown): string[] {
486
- if (Array.isArray(value)) return value.map((entry) => String(entry).trim()).filter(Boolean);
487
- if (typeof value === "string") return value.split(",").map((entry) => entry.trim()).filter(Boolean);
488
- return [];
489
- }
490
-
491
483
  async function getDataClient(): Promise<DataClient> {
492
484
  if (!clientPromise) {
493
485
  clientPromise = (async () => {
@@ -92,6 +92,25 @@ def is_appsync_event(event: dict[str, Any]) -> bool:
92
92
  return "arguments" in event and ("info" in event or event_field_name(event) is not None)
93
93
  PROPOSER_AI = "ai"
94
94
  STAFF_GROUPS = frozenset({"editor", "admin"})
95
+
96
+ _GROUP_SEPARATORS = re.compile(r"[\s,]+")
97
+
98
+
99
+ def claim_groups(value: Any) -> list[str]:
100
+ """Copy of ``papyrus_content.staff_groups.claim_groups`` (this function ships without that package; a test keeps them equal)."""
101
+ if isinstance(value, (list, tuple, set, frozenset)):
102
+ raw = [item for item in value if isinstance(item, str)]
103
+ elif isinstance(value, str):
104
+ raw = _GROUP_SEPARATORS.split(value)
105
+ else:
106
+ return []
107
+ cleaned = (item.strip().strip("\"'[](){}").strip() for item in raw)
108
+ return [item for item in cleaned if item]
109
+
110
+
111
+ def has_editor_rights(groups: Any) -> bool:
112
+ """``admin`` has every editor right: an admin who is not in the ``editor`` group is staff."""
113
+ return any(group.lower() in STAFF_GROUPS for group in claim_groups(groups))
95
114
  #: The only moves an operator's IAM role may make, as (from, to). Everything after the draft is an editor's decision.
96
115
  OPERATOR_TRANSITIONS = frozenset({(DRAFT, IN_STEERING), (DRAFT, DECLINED)})
97
116
  BY_STAFF, BY_OPERATOR = "staff", "operator"
@@ -140,10 +159,8 @@ def staff_subject(identity: dict[str, Any]) -> str | None:
140
159
  """The Cognito subject of an editor or admin, or ``None`` for anyone else."""
141
160
  claims = identity.get("claims") or {}
142
161
  groups = claims.get("cognito:groups") or identity.get("groups") or []
143
- if isinstance(groups, str):
144
- groups = [group.strip() for group in groups.strip("[]").replace(",", " ").split()]
145
162
  subject = identity.get("sub")
146
- if not isinstance(subject, str) or not subject or not STAFF_GROUPS.intersection(groups):
163
+ if not isinstance(subject, str) or not subject or not has_editor_rights(groups):
147
164
  return None
148
165
  return subject
149
166
 
@@ -1468,10 +1468,8 @@ def handle_staff_event(event: dict[str, Any], *, store: Any, settings: ReaderSet
1468
1468
  identity = event.get("identity") or {}
1469
1469
  claims = identity.get("claims") or {}
1470
1470
  groups = claims.get("cognito:groups") or identity.get("groups") or []
1471
- if isinstance(groups, str):
1472
- groups = [group.strip() for group in groups.strip("[]").replace(",", " ").split()]
1473
1471
  subject = identity.get("sub")
1474
- if not isinstance(subject, str) or not subject or not moderation_rules.STAFF_GROUPS.intersection(groups):
1472
+ if not isinstance(subject, str) or not subject or not moderation_rules.has_editor_rights(groups):
1475
1473
  return moderation_rules.refused("not_staff", "Only editors and admins can moderate.").to_dict()
1476
1474
  arguments = event.get("arguments") or {}
1477
1475
  outcome = moderation_rules.apply_action(
@@ -37,6 +37,25 @@ READER_ID = re.compile(r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f
37
37
  ACTION_ID = re.compile(r"mod-[0-9a-f]{32}")
38
38
  MAXIMUM_REASON = 500
39
39
  STAFF_GROUPS = frozenset({"editor", "admin"})
40
+
41
+ _GROUP_SEPARATORS = re.compile(r"[\s,]+")
42
+
43
+
44
+ def claim_groups(value: Any) -> list[str]:
45
+ """Copy of ``papyrus_content.staff_groups.claim_groups`` (this function ships without that package; a test keeps them equal)."""
46
+ if isinstance(value, (list, tuple, set, frozenset)):
47
+ raw = [item for item in value if isinstance(item, str)]
48
+ elif isinstance(value, str):
49
+ raw = _GROUP_SEPARATORS.split(value)
50
+ else:
51
+ return []
52
+ cleaned = (item.strip().strip("\"'[](){}").strip() for item in raw)
53
+ return [item for item in cleaned if item]
54
+
55
+
56
+ def has_editor_rights(groups: Any) -> bool:
57
+ """``admin`` has every editor right: an admin who is not in the ``editor`` group is staff."""
58
+ return any(group.lower() in STAFF_GROUPS for group in claim_groups(groups))
40
59
  ITEM_ID_PREFIX = "item-"
41
60
 
42
61
 
@@ -3,6 +3,7 @@
3
3
  * papyrus-infra synth --site infra/site.json [--out cdk.out] [--account A] [--region R]
4
4
  * papyrus-infra synth --account-stack github-oidc [--out cdk.out] [--account A] [--region R]
5
5
  * papyrus-infra print-buildspec --site infra/site.json --app cms-production|cms-staging|reader
6
+ * papyrus-infra synth --site infra/site.json --part chat [--chat-lambdas DIR] [--out cdk.out] (the private chat data stack)
6
7
  * papyrus-infra deploy --site infra/site.json [--only-branch main] [--only-app-id ID] [--out cdk.out] [--account A] [--region R]
7
8
  * Synthesizes the app-shell stack for a publication from its own site.json,
8
9
  * prints one of the generated Amplify build specs, or deploys the stack from
@@ -16,6 +17,7 @@ import { resolveCdkEnvironment } from "../src/cdk-environment.mjs";
16
17
 
17
18
  const USAGE = [
18
19
  "Usage: papyrus-infra synth --site infra/site.json [--out cdk.out] [--account A] [--region R]",
20
+ " papyrus-infra synth --site infra/site.json --part chat [--chat-lambdas DIR] [--out cdk.out] [--account A] [--region R]",
19
21
  " papyrus-infra synth --account-stack github-oidc [--out cdk.out] [--account A] [--region R]",
20
22
  " papyrus-infra print-buildspec --site infra/site.json --app cms-production|cms-staging|reader",
21
23
  " papyrus-infra deploy-edge --stack NAME --template FILE [--adoption-template FILE --adopt-resources FILE] [--parameter KEY=VALUE]... [--delete-function NAME] [--only-branch main] [--only-app-id ID] [--region R]",
@@ -186,8 +188,9 @@ if (command === "deploy") {
186
188
  }
187
189
 
188
190
  let AmplifyAppShellStack;
191
+ let PapyrusChatStack;
189
192
  try {
190
- ({ AmplifyAppShellStack } = await import("../infra/index.js"));
193
+ ({ AmplifyAppShellStack, PapyrusChatStack } = await import("../infra/index.js"));
191
194
  } catch (error) {
192
195
  console.error(`papyrus-infra: ${error instanceof Error ? error.message : error}`);
193
196
  process.exit(1);
@@ -195,6 +198,23 @@ try {
195
198
 
196
199
  const { App } = createRequire(path.join(process.cwd(), "noop.js"))("aws-cdk-lib");
197
200
  const app = new App({ outdir: path.resolve(opt("out", "cdk.out")) });
201
+ if (opt("part", "shell") === "chat") {
202
+ // The site's private newsroom chat data stack (docs/private-newsroom-chat-per-site.md); deployed before the app shell enables chat.
203
+ if (!config.chat?.enabled) {
204
+ console.error(`papyrus-infra: site ${config.siteId} has no enabled chat block`);
205
+ process.exit(1);
206
+ }
207
+ const lambdaAssetDirectory = opt("chat-lambdas");
208
+ const chatStack = new PapyrusChatStack(app, {
209
+ config,
210
+ env: resolveEnvironmentOrExit(),
211
+ lambdaAssetDirectory: lambdaAssetDirectory ? path.resolve(lambdaAssetDirectory) : undefined,
212
+ description: `Private Papyrus newsroom chat data plane for ${config.siteId}.`,
213
+ });
214
+ app.synth();
215
+ console.log(`synthesized ${chatStack.stackName} -> ${path.resolve(opt("out", "cdk.out"))}`);
216
+ process.exit(0);
217
+ }
198
218
  new AmplifyAppShellStack(app, `AmplifyAppShell-${config.siteId}`, config, {
199
219
  env: resolveEnvironmentOrExit(),
200
220
  stackName: resolveStackName(config),
@@ -2,9 +2,9 @@
2
2
 
3
3
  import { fetchAuthSession, signIn, signOut } from "aws-amplify/auth";
4
4
  import { configureAmplifyClient } from "./amplify-client-provider";
5
+ import { hasEditorRights } from "../lib/staff-groups";
5
6
  import type { DevSandboxEditorAuth } from "../lib/dev-sandbox-editor-auth";
6
7
 
7
- const EDITOR_GROUPS = new Set(["editor", "admin"]);
8
8
 
9
9
  function readSessionGroups(session: Awaited<ReturnType<typeof fetchAuthSession>>): string[] {
10
10
  const accessPayload = session.tokens?.accessToken?.payload ?? {};
@@ -29,7 +29,7 @@ function readSessionGroups(session: Awaited<ReturnType<typeof fetchAuthSession>>
29
29
 
30
30
  function sessionHasEditorAccess(session: Awaited<ReturnType<typeof fetchAuthSession>>): boolean {
31
31
  if (!session.tokens?.accessToken) return false;
32
- return readSessionGroups(session).some((group) => EDITOR_GROUPS.has(group));
32
+ return hasEditorRights(readSessionGroups(session));
33
33
  }
34
34
 
35
35
  export async function ensureDevSandboxEditorSession(
@@ -1,5 +1,6 @@
1
1
  import { useEffect, useRef, useState } from "react";
2
2
  import Link from "next/link";
3
+ import { CheckIcon, XIcon } from "lucide-react";
3
4
  import { getNewsroomNavHref } from "../lib/newsroom-nav";
4
5
  import {
5
6
  confidenceText,
@@ -12,7 +13,12 @@ import {
12
13
  beginEmailSubmissionAction,
13
14
  endEmailSubmissionAction,
14
15
  isCurrentEmailSubmissionAction,
16
+ emailSubmissionAccepted,
17
+ QUARANTINE_LABELS,
18
+ type EmailDecisionResult,
19
+ type EmailQuarantine,
15
20
  type MonitoringItem,
21
+ type MonitoringListName,
16
22
  type MonitoringEmailDetails,
17
23
  type MonitoringEmailArticleResolution,
18
24
  type EmailReferenceImportItem,
@@ -25,6 +31,27 @@ import {
25
31
 
26
32
  export type MonitoringMode = "publication" | "all";
27
33
 
34
+ /** How a paged list loads more: the page owns the cursors and the requests, the view only shows the button. */
35
+ export type MonitoringPaging = {
36
+ /** Loads the next page of a list; the page appends it. */
37
+ onLoadMore?: (list: MonitoringListName) => void;
38
+ /** The list whose next page is being fetched. */
39
+ loadingList?: MonitoringListName | null;
40
+ /** Show only email submissions held for review (restarts the email list from its newest held message). */
41
+ heldOnly?: boolean;
42
+ onHeldOnlyChange?: (heldOnly: boolean) => void;
43
+ };
44
+
45
+ function LoadMore({ list, cursor, paging }: { list: MonitoringListName; cursor: string | null | undefined; paging?: MonitoringPaging }) {
46
+ if (!cursor || !paging?.onLoadMore) return null;
47
+ const loading = paging.loadingList === list;
48
+ return (
49
+ <button type="button" data-monitoring-load-more={list} disabled={loading} onClick={() => paging.onLoadMore?.(list)}>
50
+ {loading ? "Loading…" : "Load more"}
51
+ </button>
52
+ );
53
+ }
54
+
28
55
  const STATUS_WORDS: Record<string, string> = { complete: "Complete", partial: "Partial", stopped: "Stopped", failed: "Failed", error: "Error" };
29
56
  const status = (value: string) => STATUS_WORDS[value] ?? value;
30
57
  const STOP_WORDS: Record<string, string> = {
@@ -97,8 +124,10 @@ function QueueItem({ item, ownId, demo }: { item: MonitoringItem; ownId: string
97
124
  );
98
125
  }
99
126
 
100
- export function EmailSubmissions({ items, detail, onOpen, onResolveArticleLink, onImportCandidates }: {
127
+ export function EmailSubmissions({ items, detail, onOpen, onResolveArticleLink, onImportCandidates, onDecide, cursor, paging }: {
101
128
  items: MonitoringEmailSummary[]; detail: MonitoringEmailDetails | null; onOpen?: (receiptId: string) => void;
129
+ cursor?: string | null; paging?: MonitoringPaging;
130
+ onDecide?: (receiptId: string, decision: "accept" | "reject", reason: string) => Promise<EmailDecisionResult>;
102
131
  onResolveArticleLink?: (receiptId: string, candidateIndex: number) => Promise<MonitoringEmailArticleResolution>;
103
132
  onImportCandidates?: (receiptId: string, items: EmailReferenceImportItem[]) => Promise<EmailReferenceImportOutcome>;
104
133
  }) {
@@ -108,8 +137,16 @@ export function EmailSubmissions({ items, detail, onOpen, onResolveArticleLink,
108
137
  const [busyImportByReceipt, setBusyImportByReceipt] = useState<Record<string, boolean>>({});
109
138
  const [noticeByReceipt, setNoticeByReceipt] = useState<Record<string, string>>({});
110
139
  const [importedByReceipt, setImportedByReceipt] = useState<Record<string, EmailReferenceImportOutcome["references"]>>({});
140
+ const [decidedByReceipt, setDecidedByReceipt] = useState<Record<string, EmailQuarantine>>({});
141
+ const [reasonByReceipt, setReasonByReceipt] = useState<Record<string, string>>({});
142
+ const [busyDecisionByReceipt, setBusyDecisionByReceipt] = useState<Record<string, boolean>>({});
111
143
  const receiptId = detail && "submission" in detail ? detail.submission.receiptId : null;
112
144
  const receiptKey = receiptId ?? "";
145
+ const quarantine = (detail && "submission" in detail ? decidedByReceipt[receiptKey] ?? detail.submission.quarantine : null);
146
+ const accepted = emailSubmissionAccepted(quarantine);
147
+ const groupInfo = detail && "submission" in detail ? items.find((item) => item.receiptId === detail.submission.receiptId)?.group : undefined;
148
+ const reason = reasonByReceipt[receiptKey] ?? "";
149
+ const busyDecision = busyDecisionByReceipt[receiptKey] ?? false;
113
150
  const selected = selectedByReceipt[receiptKey] ?? {};
114
151
  const resolved = resolvedByReceipt[receiptKey] ?? {};
115
152
  const busyIndex = busyIndexByReceipt[receiptKey] ?? null;
@@ -139,7 +176,7 @@ export function EmailSubmissions({ items, detail, onOpen, onResolveArticleLink,
139
176
  if (!isCurrentEmailSubmissionAction(activeReceiptId.current, requestReceiptId)) return;
140
177
  if (!("url" in result)) {
141
178
  const reason = "reason" in result && typeof result.reason === "string"
142
- && /^(?:redirect_[a-z_]+|resolution_record_invalid|resolution_not_recorded)$/.test(result.reason)
179
+ && /^(?:redirect_[a-z_]+|resolution_record_invalid|resolution_not_recorded|invalid_url|not_public|dns|timeout|connection|tls|loop|http_4xx|http_5xx|blocked|too_large|unreadable|unsafe_destination|no_query|search_unavailable|search_error|no_match)$/.test(result.reason)
143
180
  ? ` (${result.reason})` : "";
144
181
  setNoticeByReceipt((current) => ({ ...current, [requestReceiptId]: `The article destination could not be resolved safely${reason}. The original link remains private and unchanged.` }));
145
182
  return;
@@ -147,7 +184,9 @@ export function EmailSubmissions({ items, detail, onOpen, onResolveArticleLink,
147
184
  const candidate = detail && "submission" in detail ? detail.candidateLinks[index] : null;
148
185
  setResolvedByReceipt((current) => ({ ...current, [requestReceiptId]: { ...current[requestReceiptId], [index]: { url: result.url, title: result.title || candidate?.title || "Article" } } }));
149
186
  setSelectedByReceipt((current) => ({ ...current, [requestReceiptId]: { ...current[requestReceiptId], [index]: true } }));
150
- setNoticeByReceipt((current) => ({ ...current, [requestReceiptId]: "Resolved. This request may be recorded as a click by the newsletter sender." }));
187
+ setNoticeByReceipt((current) => ({ ...current, [requestReceiptId]: result.foundVia === "search"
188
+ ? `The newsletter link could not be followed, so this article was found by searching the headline (match ${Math.round((result.search?.matchScore ?? 0) * 100)}%). Check that it is the right article before importing it.`
189
+ : "Resolved. This request may be recorded as a click by the newsletter sender." }));
151
190
  } catch {
152
191
  if (isCurrentEmailSubmissionAction(activeReceiptId.current, requestReceiptId)) {
153
192
  setNoticeByReceipt((current) => ({ ...current, [requestReceiptId]: "The article destination could not be resolved. Try again later." }));
@@ -158,6 +197,34 @@ export function EmailSubmissions({ items, detail, onOpen, onResolveArticleLink,
158
197
  }
159
198
  };
160
199
 
200
+ const decide = async (decision: "accept" | "reject") => {
201
+ if (!receiptId || !onDecide) return;
202
+ const requestReceiptId = receiptId;
203
+ const actionKey = `decide:${requestReceiptId}`;
204
+ if (!beginEmailSubmissionAction(inFlightActions.current, actionKey)) return;
205
+ setBusyDecisionByReceipt((current) => ({ ...current, [requestReceiptId]: true }));
206
+ setNoticeByReceipt((current) => ({ ...current, [requestReceiptId]: "" }));
207
+ try {
208
+ const result = await onDecide(requestReceiptId, decision, reason.trim());
209
+ if ("ok" in result) {
210
+ setDecidedByReceipt((current) => ({ ...current, [requestReceiptId]: result.quarantine }));
211
+ setNoticeByReceipt((current) => ({ ...current, [requestReceiptId]: decision === "accept"
212
+ ? "Accepted. The receiver queues it within a minute; the links can be added to References once it is queued."
213
+ : "Rejected. The message is closed and kept privately until it expires." }));
214
+ } else if ("error" in result && result.quarantine) {
215
+ setDecidedByReceipt((current) => ({ ...current, [requestReceiptId]: result.quarantine as EmailQuarantine }));
216
+ setNoticeByReceipt((current) => ({ ...current, [requestReceiptId]: "Another decision was already recorded for this message; it cannot be changed." }));
217
+ } else {
218
+ setNoticeByReceipt((current) => ({ ...current, [requestReceiptId]: "The decision was not recorded. Check your editor access and try again." }));
219
+ }
220
+ } catch {
221
+ setNoticeByReceipt((current) => ({ ...current, [requestReceiptId]: "The decision was not recorded. It is safe to try again." }));
222
+ } finally {
223
+ endEmailSubmissionAction(inFlightActions.current, actionKey);
224
+ setBusyDecisionByReceipt((current) => ({ ...current, [requestReceiptId]: false }));
225
+ }
226
+ };
227
+
161
228
  const importSelected = async () => {
162
229
  if (!receiptId || !detail || !("submission" in detail) || !onImportCandidates) return;
163
230
  const requestReceiptId = receiptId;
@@ -199,9 +266,23 @@ export function EmailSubmissions({ items, detail, onOpen, onResolveArticleLink,
199
266
  return (
200
267
  <article className="news-desk-doctrine-card" data-monitoring-section="email-submissions">
201
268
  <div className="news-desk-doctrine-card__header"><div><h3>Email submissions</h3><span>Untrusted source material held privately for editor review</span></div></div>
269
+ {paging?.onHeldOnlyChange ? (
270
+ <div className="news-desk-monitoring__scope" role="group" aria-label="Filter email submissions">
271
+ <button type="button" aria-pressed={!paging.heldOnly} data-email-filter="all" onClick={() => paging.onHeldOnlyChange?.(false)}>All</button>
272
+ <button type="button" aria-pressed={paging.heldOnly === true} data-email-filter="held" onClick={() => paging.onHeldOnlyChange?.(true)}>Held for review</button>
273
+ </div>
274
+ ) : null}
202
275
  {items.length ? <ul className="news-desk-monitoring__list">
203
276
  {items.map((item) => <li key={item.receiptId} data-email-submission={item.receiptId}>
204
277
  <strong>{item.subject || "(no subject)"}</strong>
278
+ {item.group ? <details data-email-group={item.group.fingerprint}>
279
+ <summary>Received {item.group.count} times · latest {formatUtc(item.observedAt)}</summary>
280
+ <ol>
281
+ {item.group.copies.map((copy) => <li key={copy.receiptId} data-email-copy={copy.receiptId}>
282
+ {formatUtc(copy.observedAt)} · {copy.quarantine ? QUARANTINE_LABELS[copy.quarantine.state] : copy.status}
283
+ </li>)}
284
+ </ol>
285
+ </details> : null}
205
286
  <span>{formatUtc(item.observedAt)} · {item.linkCount} candidate links · {item.reviewRequired
206
287
  ? `${item.linkCount} await editor import` : `${item.processedCount} queued`}</span>
207
288
  {item.reviewRequired ? <span>Authentication warnings: private review required; nothing was routed automatically.</span> : null}
@@ -210,40 +291,71 @@ export function EmailSubmissions({ items, detail, onOpen, onResolveArticleLink,
210
291
  <span>SES signals — SPF {item.verdicts.spf}, DKIM {item.verdicts.dkim}, DMARC {item.verdicts.dmarc}; spam {item.verdicts.spam}, virus {item.verdicts.virus}</span>
211
292
  {item.warnings.length ? <span>Warnings: {item.warnings.join(", ")}</span> : null}
212
293
  {item.errorCode ? <span>State: {item.status} ({item.errorCode})</span> : <span>State: {item.status}</span>}
294
+ {item.quarantine ? <span data-email-quarantine-state={item.quarantine.state}>{QUARANTINE_LABELS[item.quarantine.state]}</span> : null}
213
295
  {onOpen ? <button type="button" onClick={() => onOpen(item.receiptId)}>Read email and review candidate links</button> : null}
214
296
  </li>)}
215
- </ul> : <p>No email submissions are available for this publication.</p>}
297
+ </ul> : <p>{paging?.heldOnly ? "No email submissions are held for review." : "No email submissions are available for this publication."}</p>}
298
+ <LoadMore list="emailSubmissions" cursor={cursor} paging={paging} />
216
299
  {detail && "submission" in detail ? (
217
300
  <section aria-label="Email submission details" data-email-submission-details={detail.submission.receiptId}>
218
301
  <h4>{detail.submission.subject || "Submitted email"}</h4>
219
302
  <p>Original content is shown as inert text. Sender and message content are untrusted; no instructions are executed.</p>
220
303
  <pre style={{ whiteSpace: "pre-wrap", overflowWrap: "anywhere" }}>{detail.safeBodyText}</pre>
304
+ {quarantine ? <section aria-label="Held for review" data-email-quarantine={quarantine.state}>
305
+ <h5>{QUARANTINE_LABELS[quarantine.state]}</h5>
306
+ {quarantine.state === "quarantined" ? <>
307
+ <p>This message was held privately and nothing was routed. Accept it to queue its links as if it had been accepted, or Reject it to close it. Sender identity and authentication signals never grant access; the decision is yours and is recorded.</p>
308
+ {groupInfo ? <p data-email-group-note>One decision applies to all {groupInfo.count} copies of this email.</p> : null}
309
+ <label>Why? (optional)
310
+ <textarea value={reason} rows={3} maxLength={500} style={{ display: "block", width: "100%" }}
311
+ onChange={(event) => setReasonByReceipt((current) => ({ ...current, [receiptKey]: event.target.value }))} />
312
+ </label>
313
+ <button type="button" data-email-decision="accept" disabled={busyDecision || !onDecide} onClick={() => void decide("accept")}>
314
+ <CheckIcon aria-hidden="true" size={16} /> Accept
315
+ </button>
316
+ <button type="button" data-email-decision="reject" disabled={busyDecision || !onDecide} onClick={() => void decide("reject")}>
317
+ <XIcon aria-hidden="true" size={16} /> Reject
318
+ </button>
319
+ </> : null}
320
+ {quarantine.decision ? <p data-email-decision-record>
321
+ {quarantine.decision.action === "accept" ? "Accepted" : "Rejected"}{quarantine.decision.viaGroup ? " (one decision for every copy of this email)" : ""}
322
+ {quarantine.decision.by ? ` by ${quarantine.decision.by}` : ""}{quarantine.decision.at ? ` on ${formatUtc(quarantine.decision.at)}` : ""}
323
+ {quarantine.decision.reason ? `. Why: ${quarantine.decision.reason}` : "."}
324
+ </p> : null}
325
+ {quarantine.failure ? <p role="alert">The decision could not be applied ({quarantine.failure}).</p> : null}
326
+ {quarantine.state === "expired" ? <p>This message expired and can no longer be accepted.</p> : null}
327
+ {notice && !accepted ? <p role="status" data-email-decision-result>{notice}</p> : null}
328
+ </section> : null}
221
329
  <h5>Candidate references ({detail.candidateLinks.length})</h5>
222
330
  <ul>{detail.candidateLinks.map((link, index) => <li key={`${link.url}-${index}`}>
223
- {link.resolutionStatus === "unresolved" && !resolved[index]
331
+ {!accepted
332
+ ? <span>{link.title || "Source link"} <code>{link.url}</code></span>
333
+ : link.resolutionStatus === "unresolved" && !resolved[index]
224
334
  ? <span>{link.title || "Newsletter article link"} (tracking redirect not opened)</span>
225
335
  : <a href={(resolved[index]?.url ?? link.url)} target="_blank" rel="noreferrer noopener">{resolved[index]?.title ?? (link.title || "Source link")}</a>}
226
- <label>
336
+ {accepted ? <label>
227
337
  <input type="checkbox" checked={selected[index] ?? false}
228
338
  disabled={link.resolutionStatus === "unresolved" && !resolved[index]}
229
339
  onChange={(event) => setSelectedByReceipt((current) => ({ ...current, [receiptKey]: { ...current[receiptKey], [index]: event.target.checked } }))} />
230
340
  Add as pending Reference
231
- </label>
232
- {link.resolutionStatus === "unresolved" && !resolved[index] ? (
341
+ </label> : null}
342
+ {accepted && link.resolutionStatus === "unresolved" && !resolved[index] ? (
233
343
  <button type="button" disabled={busyIndex !== null || !onResolveArticleLink} onClick={() => void resolveLink(index)}>
234
344
  {busyIndex === index ? "Resolving…" : "Resolve article redirect (may register a click)"}
235
345
  </button>
236
346
  ) : null}
237
347
  </li>)}</ul>
238
- <p>Adding links creates or reuses pending References. It does not approve, label, classify, or publish them.</p>
239
- <button type="button" disabled={busyImport || !onImportCandidates || !detail.submission.corpusId}
240
- onClick={() => void importSelected()}>
241
- {busyImport ? "Adding References…" : "Add selected links to References"}
242
- </button>
243
- {notice ? <p role="status" data-email-import-result>{notice}</p> : null}
244
- {importedReferences.length ? <ul>{importedReferences.map((reference, index) => (
245
- <li key={`${reference.id}-${index}`}><Link href={getNewsroomNavHref(`/newsroom/references/${encodeURIComponent(reference.lineageId)}`, false)}>Review imported Reference {index + 1}</Link></li>
246
- ))}</ul> : null}
348
+ {accepted ? <>
349
+ <p>Adding links creates or reuses pending References. It does not approve, label, classify, or publish them.</p>
350
+ <button type="button" disabled={busyImport || !onImportCandidates || !detail.submission.corpusId}
351
+ onClick={() => void importSelected()}>
352
+ {busyImport ? "Adding References…" : "Add selected links to References"}
353
+ </button>
354
+ {notice ? <p role="status" data-email-import-result>{notice}</p> : null}
355
+ {importedReferences.length ? <ul>{importedReferences.map((reference, index) => (
356
+ <li key={`${reference.id}-${index}`}><Link href={getNewsroomNavHref(`/newsroom/references/${encodeURIComponent(reference.lineageId)}`, false)}>Review imported Reference {index + 1}</Link></li>
357
+ ))}</ul> : null}
358
+ </> : <p>Links stay inert text, and nothing can be added to References, until this message is accepted.</p>}
247
359
  </section>
248
360
  ) : detail && "error" in detail ? <p role="alert">You cannot read this submission.</p>
249
361
  : detail && "state" in detail ? <p role="alert">Submission details are unavailable.</p> : null}
@@ -251,8 +363,9 @@ export function EmailSubmissions({ items, detail, onOpen, onResolveArticleLink,
251
363
  );
252
364
  }
253
365
 
254
- function Ready({ data, demo, emailDetail, onEmailOpen, onResolveArticleLink, onImportCandidates }: {
255
- data: MonitoringOverview; demo: boolean; emailDetail: MonitoringEmailDetails | null; onEmailOpen: (id: string) => void;
366
+ function Ready({ data, demo, emailDetail, onEmailOpen, onResolveArticleLink, onImportCandidates, onDecide, paging }: {
367
+ data: MonitoringOverview; demo: boolean; paging?: MonitoringPaging; emailDetail: MonitoringEmailDetails | null; onEmailOpen: (id: string) => void;
368
+ onDecide?: (receiptId: string, decision: "accept" | "reject", reason: string) => Promise<EmailDecisionResult>;
256
369
  onResolveArticleLink?: (receiptId: string, candidateIndex: number) => Promise<MonitoringEmailArticleResolution>;
257
370
  onImportCandidates?: (receiptId: string, items: EmailReferenceImportItem[]) => Promise<EmailReferenceImportOutcome>;
258
371
  }) {
@@ -264,8 +377,10 @@ function Ready({ data, demo, emailDetail, onEmailOpen, onResolveArticleLink, onI
264
377
  ) : null}
265
378
 
266
379
  <EmailSubmissions items={data.emailSubmissions ?? []} detail={emailDetail} onOpen={onEmailOpen}
380
+ cursor={data.cursors?.emailSubmissions} paging={paging}
267
381
  onResolveArticleLink={demo ? undefined : onResolveArticleLink}
268
- onImportCandidates={demo ? undefined : onImportCandidates} />
382
+ onImportCandidates={demo ? undefined : onImportCandidates}
383
+ onDecide={demo ? undefined : onDecide} />
269
384
 
270
385
  <article className="news-desk-doctrine-card" data-monitoring-section="router">
271
386
  <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>
@@ -323,6 +438,7 @@ function Ready({ data, demo, emailDetail, onEmailOpen, onResolveArticleLink, onI
323
438
  ))}
324
439
  </ul>
325
440
  ) : <p>No collector runs recorded yet.</p>}
441
+ <LoadMore list="runs" cursor={data.cursors?.runs} paging={paging} />
326
442
  {data.routerRuns.length ? (
327
443
  <ul className="news-desk-monitoring__list" data-monitoring-router-runs>
328
444
  {data.routerRuns.map((run) => (
@@ -335,6 +451,7 @@ function Ready({ data, demo, emailDetail, onEmailOpen, onResolveArticleLink, onI
335
451
  ))}
336
452
  </ul>
337
453
  ) : null}
454
+ <LoadMore list="routerRuns" cursor={data.cursors?.routerRuns} paging={paging} />
338
455
  </article>
339
456
 
340
457
  <article className="news-desk-doctrine-card" data-monitoring-section="queue">
@@ -344,6 +461,7 @@ function Ready({ data, demo, emailDetail, onEmailOpen, onResolveArticleLink, onI
344
461
  {data.items.map((item) => <QueueItem key={`${item.monitorId ?? ""}-${item.sourceId}-${item.revision}`} item={item} ownId={data.scope.publicationId} demo={demo} />)}
345
462
  </ul>
346
463
  ) : <p>No items in the queue.</p>}
464
+ <LoadMore list="items" cursor={data.cursors?.items} paging={paging} />
347
465
  {data.hiddenNotRelevant > 0 ? (
348
466
  <p className="news-desk-delegation-note" data-monitoring-hidden>
349
467
  {data.hiddenNotRelevant} {data.hiddenNotRelevant === 1 ? "item" : "items"} judged not relevant to this publication {data.hiddenNotRelevant === 1 ? "is" : "are"} not listed.
@@ -373,7 +491,10 @@ export function MonitoringOverviewView({
373
491
  onEmailOpen = () => {},
374
492
  onResolveArticleLink,
375
493
  onImportCandidates,
494
+ onDecide,
495
+ paging,
376
496
  }: {
497
+ paging?: MonitoringPaging;
377
498
  result: MonitoringResult | null;
378
499
  canViewAll: boolean;
379
500
  mode: MonitoringMode;
@@ -383,6 +504,7 @@ export function MonitoringOverviewView({
383
504
  onEmailOpen?: (receiptId: string) => void;
384
505
  onResolveArticleLink?: (receiptId: string, candidateIndex: number) => Promise<MonitoringEmailArticleResolution>;
385
506
  onImportCandidates?: (receiptId: string, items: EmailReferenceImportItem[]) => Promise<EmailReferenceImportOutcome>;
507
+ onDecide?: (receiptId: string, decision: "accept" | "reject", reason: string) => Promise<EmailDecisionResult>;
386
508
  }) {
387
509
  let body: React.ReactNode;
388
510
  if (result === null) {
@@ -399,7 +521,7 @@ export function MonitoringOverviewView({
399
521
  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>;
400
522
  } else {
401
523
  body = <Ready data={result} demo={demo} emailDetail={emailDetail} onEmailOpen={onEmailOpen}
402
- onResolveArticleLink={onResolveArticleLink} onImportCandidates={onImportCandidates} />;
524
+ onResolveArticleLink={onResolveArticleLink} onImportCandidates={onImportCandidates} onDecide={onDecide} paging={paging} />;
403
525
  }
404
526
  const scopeLine = result && "scope" in result
405
527
  ? (result.scope.mode === "all" ? "Showing the whole shared pipeline, every publication." : `Showing what is relevant to ${result.scope.publicationId ?? "this publication"}.`)