@anthusai/papyrus 5.40.0 → 5.41.1

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.
@@ -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_email_store_factory
363
+ receipt_store = default_email_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
  };
@@ -19,11 +19,12 @@ export function monitoringViewParameterArn(region, account) {
19
19
  * Environment and least-privilege IAM for the monitoring-view function. A site outside the monitoring
20
20
  * account simply finds no parameter and the page says monitoring is not configured.
21
21
  */
22
- export function wireMonitoringView(backend) {
22
+ export function wireMonitoringView(backend, emailMonitorId = "hn") {
23
23
  const lambda = backend.monitoringView.resources.lambda;
24
24
  const stack = Stack.of(lambda);
25
25
  lambda.addEnvironment("PAPYRUS_GRAPHQL_ENDPOINT", backend.data.resources.cfnResources.cfnGraphqlApi.attrGraphQlUrl);
26
26
  lambda.addEnvironment("PAPYRUS_MONITORING_TABLE_PARAMETER", MONITORING_TABLE_PARAMETER);
27
+ lambda.addEnvironment("PAPYRUS_EMAIL_MONITOR_ID", emailMonitorId);
27
28
  lambda.addToRolePolicy(new PolicyStatement({
28
29
  effect: Effect.ALLOW,
29
30
  actions: ["ssm:GetParameter"],
@@ -34,4 +35,30 @@ export function wireMonitoringView(backend) {
34
35
  actions: MONITORING_VIEW_TABLE_ACTIONS,
35
36
  resources: monitoringViewTableArns(stack.region, stack.account),
36
37
  }));
38
+ // Resolving a selected newsletter article records its destination in the
39
+ // same receipt partition. The editor-facing resolver validates the receipt
40
+ // and candidate before this single-key write.
41
+ lambda.addToRolePolicy(new PolicyStatement({
42
+ effect: Effect.ALLOW,
43
+ actions: ["dynamodb:PutItem"],
44
+ resources: monitoringViewTableArns(stack.region, stack.account).slice(0, 1),
45
+ conditions: { "ForAllValues:StringEquals": { "dynamodb:LeadingKeys": [`MONITOR#${emailMonitorId}`] } },
46
+ }));
47
+ }
48
+ /** Lets the authenticated Reference-intake action verify a selected email receipt. */
49
+ export function wireEmailReferenceIntake(lambda, emailMonitorId = "hn") {
50
+ const stack = Stack.of(lambda);
51
+ lambda.addEnvironment("PAPYRUS_MONITORING_TABLE_PARAMETER", MONITORING_TABLE_PARAMETER);
52
+ lambda.addEnvironment("PAPYRUS_EMAIL_MONITOR_ID", emailMonitorId);
53
+ lambda.addToRolePolicy(new PolicyStatement({
54
+ effect: Effect.ALLOW,
55
+ actions: ["ssm:GetParameter"],
56
+ resources: [monitoringViewParameterArn(stack.region, stack.account)],
57
+ }));
58
+ lambda.addToRolePolicy(new PolicyStatement({
59
+ effect: Effect.ALLOW,
60
+ actions: MONITORING_VIEW_TABLE_ACTIONS,
61
+ resources: monitoringViewTableArns(stack.region, stack.account).slice(0, 1),
62
+ conditions: { "ForAllValues:StringEquals": { "dynamodb:LeadingKeys": [`MONITOR#${emailMonitorId}`] } },
63
+ }));
37
64
  }
@@ -25,11 +25,12 @@ export function monitoringViewParameterArn(region: string, account: string): str
25
25
  * Environment and least-privilege IAM for the monitoring-view function. A site outside the monitoring
26
26
  * account simply finds no parameter and the page says monitoring is not configured.
27
27
  */
28
- export function wireMonitoringView(backend: any): void {
28
+ export function wireMonitoringView(backend: any, emailMonitorId = "hn"): void {
29
29
  const lambda = backend.monitoringView.resources.lambda as LambdaFunction;
30
30
  const stack = Stack.of(lambda);
31
31
  lambda.addEnvironment("PAPYRUS_GRAPHQL_ENDPOINT", backend.data.resources.cfnResources.cfnGraphqlApi.attrGraphQlUrl as string);
32
32
  lambda.addEnvironment("PAPYRUS_MONITORING_TABLE_PARAMETER", MONITORING_TABLE_PARAMETER);
33
+ lambda.addEnvironment("PAPYRUS_EMAIL_MONITOR_ID", emailMonitorId);
33
34
  lambda.addToRolePolicy(
34
35
  new PolicyStatement({
35
36
  effect: Effect.ALLOW,
@@ -44,4 +45,37 @@ export function wireMonitoringView(backend: any): void {
44
45
  resources: monitoringViewTableArns(stack.region, stack.account),
45
46
  }),
46
47
  );
48
+ // Resolving a selected newsletter article records its destination in the
49
+ // same receipt partition. The editor-facing resolver validates the receipt
50
+ // and candidate before this single-key write.
51
+ lambda.addToRolePolicy(
52
+ new PolicyStatement({
53
+ effect: Effect.ALLOW,
54
+ actions: ["dynamodb:PutItem"],
55
+ resources: monitoringViewTableArns(stack.region, stack.account).slice(0, 1),
56
+ conditions: { "ForAllValues:StringEquals": { "dynamodb:LeadingKeys": [`MONITOR#${emailMonitorId}`] } },
57
+ }),
58
+ );
59
+ }
60
+
61
+ /** Lets the authenticated Reference-intake action verify a selected email receipt. */
62
+ export function wireEmailReferenceIntake(lambda: LambdaFunction, emailMonitorId = "hn"): void {
63
+ const stack = Stack.of(lambda);
64
+ lambda.addEnvironment("PAPYRUS_MONITORING_TABLE_PARAMETER", MONITORING_TABLE_PARAMETER);
65
+ lambda.addEnvironment("PAPYRUS_EMAIL_MONITOR_ID", emailMonitorId);
66
+ lambda.addToRolePolicy(
67
+ new PolicyStatement({
68
+ effect: Effect.ALLOW,
69
+ actions: ["ssm:GetParameter"],
70
+ resources: [monitoringViewParameterArn(stack.region, stack.account)],
71
+ }),
72
+ );
73
+ lambda.addToRolePolicy(
74
+ new PolicyStatement({
75
+ effect: Effect.ALLOW,
76
+ actions: MONITORING_VIEW_TABLE_ACTIONS,
77
+ resources: monitoringViewTableArns(stack.region, stack.account).slice(0, 1),
78
+ conditions: { "ForAllValues:StringEquals": { "dynamodb:LeadingKeys": [`MONITOR#${emailMonitorId}`] } },
79
+ }),
80
+ );
47
81
  }
@@ -1,4 +1,23 @@
1
1
  export const MAXIMUM_HTML_S_MAXAGE_SECONDS = 86400;
2
+ const MONITOR_ID_PATTERN = /^[a-z0-9][a-z0-9-]{0,62}$/;
3
+ export function validateMonitoringConfig(monitoring) {
4
+ if (monitoring === undefined)
5
+ return [];
6
+ if (typeof monitoring !== "object" || monitoring === null || Array.isArray(monitoring)) {
7
+ return ["monitoring must be an object"];
8
+ }
9
+ const config = monitoring;
10
+ if (config.emailMonitorId !== undefined
11
+ && (typeof config.emailMonitorId !== "string" || !MONITOR_ID_PATTERN.test(config.emailMonitorId))) {
12
+ return ["monitoring.emailMonitorId must be a lowercase monitor id (1-63 letters, digits, or hyphens)"];
13
+ }
14
+ return [];
15
+ }
16
+ export function assertValidMonitoringConfig(monitoring) {
17
+ const problems = validateMonitoringConfig(monitoring);
18
+ if (problems.length > 0)
19
+ throw new Error(`Invalid backend.monitoring: ${problems.join("; ")}`);
20
+ }
2
21
  const CLOUDFRONT_DISTRIBUTION_ARN = /^arn:aws[a-z-]*:cloudfront::\d{12}:distribution\/[A-Z0-9]+$/;
3
22
  function isRelativePathWithoutParentSegments(value) {
4
23
  if (value === "" || value.startsWith("/") || value.includes("\\"))
@@ -40,6 +40,31 @@ export type PapyrusPublicationCacheConfig = {
40
40
 
41
41
  export const MAXIMUM_HTML_S_MAXAGE_SECONDS = 86400;
42
42
 
43
+ export type PapyrusMonitoringConfig = {
44
+ /** Existing shared Monitoring partition that stores this site's private email receipts. */
45
+ emailMonitorId?: string;
46
+ };
47
+
48
+ const MONITOR_ID_PATTERN = /^[a-z0-9][a-z0-9-]{0,62}$/;
49
+
50
+ export function validateMonitoringConfig(monitoring: unknown): string[] {
51
+ if (monitoring === undefined) return [];
52
+ if (typeof monitoring !== "object" || monitoring === null || Array.isArray(monitoring)) {
53
+ return ["monitoring must be an object"];
54
+ }
55
+ const config = monitoring as { emailMonitorId?: unknown };
56
+ if (config.emailMonitorId !== undefined
57
+ && (typeof config.emailMonitorId !== "string" || !MONITOR_ID_PATTERN.test(config.emailMonitorId))) {
58
+ return ["monitoring.emailMonitorId must be a lowercase monitor id (1-63 letters, digits, or hyphens)"];
59
+ }
60
+ return [];
61
+ }
62
+
63
+ export function assertValidMonitoringConfig(monitoring: unknown): void {
64
+ const problems = validateMonitoringConfig(monitoring);
65
+ if (problems.length > 0) throw new Error(`Invalid backend.monitoring: ${problems.join("; ")}`);
66
+ }
67
+
43
68
  export type PapyrusPublicationConfig = {
44
69
  kind: "s3-projection";
45
70
  /** Site repo path (relative, `.py`) of the SiteProjector bundled into content-actions as `site_projector`. */
@@ -138,6 +163,8 @@ export type PapyrusSiteBackendConfig = {
138
163
  */
139
164
  productionAppId?: string;
140
165
  auth?: PapyrusAuthConfig;
166
+ /** Shared Monitoring partition used by private email intake for this publication. */
167
+ monitoring?: PapyrusMonitoringConfig;
141
168
  /**
142
169
  * Maximum number of data model nested stacks CloudFormation creates at once.
143
170
  * Stacks are chained in waves of this size so a new AWS account is not hit
@@ -14,7 +14,7 @@ import { planRenditions } from "./publication/renditions-settings.js";
14
14
  import { addProjectionLeaseAccess } from "./publication/projection-lease.js";
15
15
  import { contentActionsPublicationWiring, planS3ProjectionBackend } from "./publication/s3-projection-settings.js";
16
16
  import { rebuildTriggerSettings } from "./rebuild-trigger-settings.js";
17
- import { assertValidPublicationConfig } from "./site-backend-config.js";
17
+ import { assertValidMonitoringConfig, assertValidPublicationConfig } from "./site-backend-config.js";
18
18
  import { revalidateSecretReadStatement } from "./revalidate-secret-policy.js";
19
19
  import { revalidateDistributionEnvironment, revalidateInvalidationStatement, validateRevalidateDistributionIds, } from "./revalidate-invalidation-settings.js";
20
20
  import { addInvalidationPathAlarm } from "./publication/invalidation-alarm.js";
@@ -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";
@@ -87,6 +87,8 @@ export function defineSiteBackend(papyrusSite) {
87
87
  const publication = resolveSitePublication(site, activeBrand?.renderer?.kind);
88
88
  if (publication !== undefined)
89
89
  assertValidPublicationConfig(publication);
90
+ assertValidMonitoringConfig(site.monitoring);
91
+ const emailMonitorId = site.monitoring?.emailMonitorId ?? "hn";
90
92
  const publicationPlan = planS3ProjectionBackend(publication);
91
93
  const renditionsPlan = planRenditions(publication, process.cwd());
92
94
  setSiteFilesForContentActionsBundle(publicationPlan.enabled ? publicationPlan.siteFiles : undefined);
@@ -336,6 +338,7 @@ export function defineSiteBackend(papyrusSite) {
336
338
  // and publication to this deployment's pool and existing API endpoint.
337
339
  contentActionsLambda.addEnvironment("PAPYRUS_USER_POOL_ISSUER", `https://cognito-idp.${Stack.of(contentActionsLambda).region}.amazonaws.com/${backend.auth.resources.userPool.userPoolId}`);
338
340
  contentActionsLambda.addEnvironment("PAPYRUS_GRAPHQL_ENDPOINT", backend.data.resources.cfnResources.cfnGraphqlApi.attrGraphQlUrl);
341
+ wireEmailReferenceIntake(contentActionsLambda, emailMonitorId);
339
342
  contentActionsLambda.addToRolePolicy(new PolicyStatement({
340
343
  actions: ["appsync:GraphQL"],
341
344
  resources: ["*"],
@@ -626,7 +629,7 @@ export function defineSiteBackend(papyrusSite) {
626
629
  },
627
630
  });
628
631
  wireEditorialChecks(backend);
629
- wireMonitoringView(backend);
632
+ wireMonitoringView(backend, emailMonitorId);
630
633
  wireAutomationView(backend);
631
634
  wirePlugins(backend, plugins, contentActionsLambda);
632
635
  return backend;
@@ -17,7 +17,7 @@ import { planRenditions } from "./publication/renditions-settings";
17
17
  import { addProjectionLeaseAccess } from "./publication/projection-lease";
18
18
  import { contentActionsPublicationWiring, planS3ProjectionBackend } from "./publication/s3-projection-settings";
19
19
  import { rebuildTriggerSettings } from "./rebuild-trigger-settings";
20
- import { assertValidPublicationConfig } from "./site-backend-config";
20
+ import { assertValidMonitoringConfig, assertValidPublicationConfig } from "./site-backend-config";
21
21
  import { revalidateSecretReadStatement } from "./revalidate-secret-policy";
22
22
  import {
23
23
  revalidateDistributionEnvironment,
@@ -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";
@@ -108,6 +108,8 @@ export function defineSiteBackend(papyrusSite: PapyrusSite) {
108
108
  };
109
109
  const publication = resolveSitePublication(site, activeBrand?.renderer?.kind);
110
110
  if (publication !== undefined) assertValidPublicationConfig(publication);
111
+ assertValidMonitoringConfig(site.monitoring);
112
+ const emailMonitorId = site.monitoring?.emailMonitorId ?? "hn";
111
113
  const publicationPlan = planS3ProjectionBackend(publication);
112
114
  const renditionsPlan = planRenditions(publication, process.cwd());
113
115
  setSiteFilesForContentActionsBundle(publicationPlan.enabled ? publicationPlan.siteFiles : undefined);
@@ -429,6 +431,7 @@ export function defineSiteBackend(papyrusSite: PapyrusSite) {
429
431
  "PAPYRUS_GRAPHQL_ENDPOINT",
430
432
  backend.data.resources.cfnResources.cfnGraphqlApi.attrGraphQlUrl,
431
433
  );
434
+ wireEmailReferenceIntake(contentActionsLambda, emailMonitorId);
432
435
  contentActionsLambda.addToRolePolicy(
433
436
  new PolicyStatement({
434
437
  actions: ["appsync:GraphQL"],
@@ -772,7 +775,7 @@ export function defineSiteBackend(papyrusSite: PapyrusSite) {
772
775
  },
773
776
  });
774
777
  wireEditorialChecks(backend);
775
- wireMonitoringView(backend);
778
+ wireMonitoringView(backend, emailMonitorId);
776
779
  wireAutomationView(backend);
777
780
  wirePlugins(backend, plugins, contentActionsLambda);
778
781
 
@@ -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,9 +9,14 @@ import {
7
9
  formatUtc,
8
10
  referenceHref,
9
11
  routingLabel,
10
- usageText,
12
+ beginEmailSubmissionAction,
13
+ endEmailSubmissionAction,
14
+ isCurrentEmailSubmissionAction,
11
15
  type MonitoringItem,
12
16
  type MonitoringEmailDetails,
17
+ type MonitoringEmailArticleResolution,
18
+ type EmailReferenceImportItem,
19
+ type EmailReferenceImportOutcome,
13
20
  type MonitoringEmailSummary,
14
21
  type MonitoringOverview,
15
22
  type MonitoringRelevance,
@@ -90,16 +97,110 @@ function QueueItem({ item, ownId, demo }: { item: MonitoringItem; ownId: string
90
97
  );
91
98
  }
92
99
 
93
- function EmailSubmissions({ items, detail, onOpen }: {
100
+ function EmailSubmissions({ items, detail, onOpen, onResolveArticleLink, onImportCandidates }: {
94
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>;
95
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
+
96
196
  return (
97
197
  <article className="news-desk-doctrine-card" data-monitoring-section="email-submissions">
98
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>
99
199
  {items.length ? <ul className="news-desk-monitoring__list">
100
200
  {items.map((item) => <li key={item.receiptId} data-email-submission={item.receiptId}>
101
201
  <strong>{item.subject || "(no subject)"}</strong>
102
- <span>{formatUtc(item.observedAt)} · {item.linkCount} candidate links · {item.processedCount} queued</span>
202
+ <span>{formatUtc(item.observedAt)} · {item.linkCount} candidate links · {item.reviewRequired
203
+ ? `${item.linkCount} await editor import` : `${item.processedCount} queued`}</span>
103
204
  {item.reviewRequired ? <span>Authentication warnings: private review required; nothing was routed automatically.</span> : null}
104
205
  {item.unresolvedLinkCount ? <span>{item.unresolvedLinkCount} tracking links remain unresolved and were not opened.</span> : null}
105
206
  <span>Envelope sender: {item.envelopeSender || "unknown"} · MIME From (unverified): {item.headerFrom || "unknown"}</span>
@@ -116,9 +217,28 @@ function EmailSubmissions({ items, detail, onOpen }: {
116
217
  <pre style={{ whiteSpace: "pre-wrap", overflowWrap: "anywhere" }}>{detail.safeBodyText}</pre>
117
218
  <h5>Candidate references ({detail.candidateLinks.length})</h5>
118
219
  <ul>{detail.candidateLinks.map((link, index) => <li key={`${link.url}-${index}`}>
119
- {link.resolutionStatus === "unresolved" ? <span>{link.title || link.url} (unresolved; not opened)</span>
120
- : <a href={link.url} target="_blank" rel="noreferrer noopener">{link.title || link.url}</a>}
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}
121
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}
122
242
  </section>
123
243
  ) : detail && "error" in detail ? <p role="alert">You cannot read this submission.</p>
124
244
  : detail && "state" in detail ? <p role="alert">Submission details are unavailable.</p> : null}
@@ -126,10 +246,11 @@ function EmailSubmissions({ items, detail, onOpen }: {
126
246
  );
127
247
  }
128
248
 
129
- function Ready({ data, demo, emailDetail, onEmailOpen }: {
249
+ function Ready({ data, demo, emailDetail, onEmailOpen, onResolveArticleLink, onImportCandidates }: {
130
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>;
131
253
  }) {
132
- const ceilings = data.usage?.ceilings ?? null;
133
254
  const router = data.router;
134
255
  return (
135
256
  <>
@@ -137,7 +258,9 @@ function Ready({ data, demo, emailDetail, onEmailOpen }: {
137
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>
138
259
  ) : null}
139
260
 
140
- <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} />
141
264
 
142
265
  <article className="news-desk-doctrine-card" data-monitoring-section="router">
143
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>
@@ -152,15 +275,14 @@ function Ready({ data, demo, emailDetail, onEmailOpen }: {
152
275
  </article>
153
276
 
154
277
  <article className="news-desk-doctrine-card" data-monitoring-section="usage">
155
- <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>
156
279
  {data.usage ? (
157
280
  <>
158
281
  <dl className="news-desk-monitoring__usage">
159
- <div><dt>Decisions today ({data.usage.day})</dt><dd>{usageText(data.usage.decisionsToday, ceilings?.decisionsPerDay)}</dd></div>
160
- <div><dt>Decisions in total</dt><dd>{usageText(data.usage.decisionsTotal, ceilings?.decisionsTotal)}</dd></div>
161
- {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>
162
284
  </dl>
163
- {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}
164
286
  </>
165
287
  ) : <p>No usage recorded yet.</p>}
166
288
  </article>
@@ -236,6 +358,8 @@ export function MonitoringOverviewView({
236
358
  demo = false,
237
359
  emailDetail = null,
238
360
  onEmailOpen = () => {},
361
+ onResolveArticleLink,
362
+ onImportCandidates,
239
363
  }: {
240
364
  result: MonitoringResult | null;
241
365
  canViewAll: boolean;
@@ -244,6 +368,8 @@ export function MonitoringOverviewView({
244
368
  demo?: boolean;
245
369
  emailDetail?: MonitoringEmailDetails | null;
246
370
  onEmailOpen?: (receiptId: string) => void;
371
+ onResolveArticleLink?: (receiptId: string, candidateIndex: number) => Promise<MonitoringEmailArticleResolution>;
372
+ onImportCandidates?: (receiptId: string, items: EmailReferenceImportItem[]) => Promise<EmailReferenceImportOutcome>;
247
373
  }) {
248
374
  let body: React.ReactNode;
249
375
  if (result === null) {
@@ -259,7 +385,8 @@ export function MonitoringOverviewView({
259
385
  } else if (result.state === "empty") {
260
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>;
261
387
  } else {
262
- 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} />;
263
390
  }
264
391
  const scopeLine = result && "scope" in result
265
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 { loadMonitoringEmailSubmission, 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 { 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: "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
  );
@@ -1,12 +1,19 @@
1
1
  "use client";
2
2
 
3
3
  import { generateClient } from "aws-amplify/data";
4
- import type { MonitoringEmailDetails, MonitoringResult } from "./monitoring-overview";
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
+ }
@@ -15,7 +15,7 @@ export const FIXTURE_MONITORING_OVERVIEW: MonitoringOverview = {
15
15
  routerRuns: [{ runId: "q2", startedAt: "2026-10-21T02:37:00.000000Z", mode: "live", status: "stopped", stoppedBy: "request_ceiling_reached",
16
16
  counts: { examined: 3, decided: 1, delivered: 1, deliveryFailed: 1, routed: 1 }, errorCount: 0 }],
17
17
  router: { mode: "live", feedback: true, enabled: true, maxItemsPerRun: 5 },
18
- usage: { day: "2026-10-21", decisionsToday: 2, decisionsTotal: 3, ceilings: { decisionsTotal: 300, decisionsPerDay: 100, items: 300 } },
18
+ usage: { day: "2026-10-21", decisionsToday: 2, decisionsTotal: 3, historyComplete: true },
19
19
  hiddenNotRelevant: 1,
20
20
  items: [
21
21
  { sourceId: "104", revision: 1, title: "Fresh Show HN", url: "https://example.org/104", discussionUrl: "https://news.ycombinator.com/item?id=104",
@@ -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;
@@ -91,7 +125,7 @@ export type MonitoringOverview = {
91
125
  day: string;
92
126
  decisionsToday: number;
93
127
  decisionsTotal: number;
94
- ceilings: { decisionsTotal?: number; decisionsPerDay?: number; items?: number } | null;
128
+ historyComplete: boolean;
95
129
  } | null;
96
130
  items: MonitoringItem[];
97
131
  emailSubmissions?: MonitoringEmailSummary[];
@@ -129,10 +163,6 @@ export function confidenceText(confidence: number | null): string {
129
163
  return confidence === null ? "confidence not reported" : `${Math.round(confidence * 100)}% confidence`;
130
164
  }
131
165
 
132
- export function usageText(used: number, ceiling: number | undefined): string {
133
- return ceiling === undefined ? `${used} used, no ceiling set` : `${used} of ${ceiling}`;
134
- }
135
-
136
166
  export function everyText(seconds: number | null): string {
137
167
  if (!seconds) return "on a schedule";
138
168
  return seconds % 3600 === 0 ? `every ${seconds / 3600} h` : seconds % 60 === 0 ? `every ${seconds / 60} min` : `every ${seconds} s`;
@@ -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
  }
@@ -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 === "approve") return "Approved";
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
@@ -456,7 +456,7 @@
456
456
  "optional": true
457
457
  }
458
458
  },
459
- "version": "5.40.0",
459
+ "version": "5.41.1",
460
460
  "dependencies": {
461
461
  "@aws-amplify/adapter-nextjs": "^1.7.3",
462
462
  "@aws-crypto/sha256-js": "5.2.0",
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "5.40.0",
2
+ "version": "5.41.1",
3
3
  "routes": [
4
4
  {
5
5
  "file": "app/%5F_preview/[[...path]]/route.ts",