@anthusai/papyrus 2.0.0-next.11 → 2.0.0-next.110
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/amplify/auth/resource.js +16 -1
- package/amplify/auth/resource.ts +19 -1
- package/amplify/data/function-handler.js +20 -0
- package/amplify/data/function-handler.ts +40 -0
- package/amplify/data/resource.js +152 -62
- package/amplify/data/resource.ts +158 -62
- package/amplify/data/schema.js +2101 -1987
- package/amplify/data/schema.ts +197 -70
- package/amplify/functions/assignment-action/data-access.js +2 -0
- package/amplify/functions/assignment-action/data-access.ts +2 -0
- package/amplify/functions/assignment-action/handler.ts +1 -1
- package/amplify/functions/automation-view/handler.py +17 -0
- package/amplify/functions/automation-view/resource.js +24 -0
- package/amplify/functions/automation-view/resource.ts +34 -0
- package/amplify/functions/automation-view/schema.js +26 -0
- package/amplify/functions/automation-view/schema.ts +28 -0
- package/amplify/functions/automation-view/wire.js +56 -0
- package/amplify/functions/automation-view/wire.ts +76 -0
- package/amplify/functions/content-actions/handler.py +64 -3
- package/amplify/functions/content-actions/requirements.txt +1 -0
- package/amplify/functions/content-actions/resource.js +1 -1
- package/amplify/functions/content-actions/resource.ts +1 -1
- package/amplify/functions/editorial-checks/handler.py +2 -2
- package/amplify/functions/editorial-checks/requirements.txt +1 -1
- package/amplify/functions/editorial-checks/resource.js +3 -3
- package/amplify/functions/editorial-checks/resource.ts +3 -3
- package/amplify/functions/editorial-checks/schema.js +38 -8
- package/amplify/functions/editorial-checks/schema.ts +38 -8
- package/amplify/functions/editorial-checks/wire.js +4 -0
- package/amplify/functions/editorial-checks/wire.ts +4 -0
- package/amplify/functions/monitoring-view/handler.py +11 -0
- package/amplify/functions/monitoring-view/resource.js +24 -0
- package/amplify/functions/monitoring-view/resource.ts +34 -0
- package/amplify/functions/monitoring-view/schema.js +29 -0
- package/amplify/functions/monitoring-view/schema.ts +31 -0
- package/amplify/functions/monitoring-view/wire.js +64 -0
- package/amplify/functions/monitoring-view/wire.ts +81 -0
- package/amplify/functions/public-forms/core.js +247 -0
- package/amplify/functions/public-forms/core.ts +310 -0
- package/amplify/functions/public-forms/handler.ts +41 -0
- package/amplify/functions/public-forms/origins.js +35 -0
- package/amplify/functions/public-forms/origins.ts +43 -0
- package/amplify/functions/public-forms/resource.js +9 -0
- package/amplify/functions/public-forms/resource.ts +10 -0
- package/amplify/functions/public-forms/wire.js +25 -0
- package/amplify/functions/public-forms/wire.ts +32 -0
- package/amplify/functions/shared/editorial-context.ts +63 -4
- package/amplify/functions/shared/python-bundle.js +20 -14
- package/amplify/functions/shared/python-bundle.ts +25 -18
- package/amplify/plugins/newsletter/config.js +245 -0
- package/amplify/plugins/newsletter/config.ts +305 -0
- package/amplify/plugins/newsletter/function/handler.py +6 -0
- package/amplify/plugins/newsletter/function/requirements.txt +5 -0
- package/amplify/plugins/newsletter/ops/handler.py +12 -0
- package/amplify/plugins/newsletter/ops/resource.js +27 -0
- package/amplify/plugins/newsletter/ops/resource.ts +37 -0
- package/amplify/plugins/newsletter/papyrus.plugin.js +263 -0
- package/amplify/plugins/newsletter/papyrus.plugin.ts +311 -0
- package/amplify/plugins/newsletter/schema.js +57 -0
- package/amplify/plugins/newsletter/schema.ts +62 -0
- package/amplify/plugins/newsletter/secure-string-parameter.js +107 -0
- package/amplify/plugins/newsletter/secure-string-parameter.ts +120 -0
- package/amplify/plugins/newsletter/sending.js +140 -0
- package/amplify/plugins/newsletter/sending.ts +186 -0
- package/amplify/plugins/reader-steering/config.js +182 -0
- package/amplify/plugins/reader-steering/config.ts +290 -0
- package/amplify/plugins/reader-steering/function/ai_moderation.py +246 -0
- package/amplify/plugins/reader-steering/function/handler.py +1589 -0
- package/amplify/plugins/reader-steering/function/moderation-prompt.md +27 -0
- package/amplify/plugins/reader-steering/function/moderation.py +316 -0
- package/amplify/plugins/reader-steering/function/proposals.py +487 -0
- package/amplify/plugins/reader-steering/function/resource.js +27 -0
- package/amplify/plugins/reader-steering/function/resource.ts +35 -0
- package/amplify/plugins/reader-steering/papyrus.plugin.js +187 -0
- package/amplify/plugins/reader-steering/papyrus.plugin.ts +213 -0
- package/amplify/plugins/reader-steering/schema.js +252 -0
- package/amplify/plugins/reader-steering/schema.ts +254 -0
- package/amplify/plugins/registry.js +4 -0
- package/amplify/plugins/registry.ts +4 -0
- package/amplify/plugins/signup/schema.js +2 -1
- package/amplify/plugins/signup/schema.ts +2 -1
- package/amplify/plugins/types.ts +5 -0
- package/amplify/plugins/wire.js +1 -0
- package/amplify/plugins/wire.ts +1 -0
- package/amplify/publication/projection-lease.js +30 -0
- package/amplify/publication/projection-lease.ts +39 -0
- package/amplify/publication/redirect-and-index.js +105 -0
- package/amplify/publication/redirect-rules.js +73 -0
- package/amplify/publication/redirect-rules.ts +93 -0
- package/amplify/publication/s3-projection.js +7 -1
- package/amplify/publication/s3-projection.ts +9 -1
- package/amplify/site-backend-config.js +19 -0
- package/amplify/site-backend-config.ts +45 -1
- package/amplify/site-backend-names.js +4 -2
- package/amplify/site-backend-names.ts +4 -2
- package/amplify/site-backend.js +42 -4
- package/amplify/site-backend.ts +45 -4
- package/app/.well-known/papyrus-cli.json/route.ts +9 -0
- package/app/api/auth/prospectus/sign-in/route.ts +4 -0
- package/app/api/newsletter/confirm/route.ts +13 -0
- package/app/api/newsletter/preferences/route.ts +13 -0
- package/app/api/newsletter/state/route.ts +9 -0
- package/app/api/newsletter/subscribe/route.ts +9 -0
- package/app/api/newsletter/unsubscribe/route.ts +13 -0
- package/app/api/newsroom/chat/[...path]/route.ts +17 -0
- package/app/api/newsroom/curation/context/route.ts +9 -0
- package/app/api/newsroom/curation/priming/route.ts +9 -0
- package/app/api/newsroom/curation/publication/route.ts +9 -0
- package/app/api/reader/comment/delete/route.ts +9 -0
- package/app/api/reader/comment/edit/route.ts +9 -0
- package/app/api/reader/comment/route.ts +9 -0
- package/app/api/reader/flag/route.ts +9 -0
- package/app/api/reader/handle/route.ts +9 -0
- package/app/api/reader/me/route.ts +9 -0
- package/app/api/reader/rating/route.ts +9 -0
- package/app/api/reader/report/route.ts +9 -0
- package/app/api/reader/retired/route.ts +9 -0
- package/app/api/reader/submit/route.ts +9 -0
- package/app/api/reader/vote/route.ts +9 -0
- package/app/layout.tsx +5 -1
- package/bin/papyrus-infra.mjs +20 -1
- package/components/automation-panel.tsx +49 -0
- package/components/automation-run-now.tsx +97 -0
- package/components/automation-view.tsx +195 -0
- package/components/editorial-checks-admin-panel.tsx +32 -17
- package/components/moderation-panel.tsx +248 -0
- package/components/monitoring-overview-view.tsx +422 -0
- package/components/monitoring-panel.tsx +60 -0
- package/components/news-desk-client-provider.tsx +18 -18
- package/components/news-desk-taxonomy-client.ts +28 -8
- package/components/newsletter-panel.tsx +474 -0
- package/components/newsletter-sections.tsx +163 -0
- package/components/newsroom-access-gate.tsx +16 -2
- package/components/newsroom-article-editor.tsx +9 -7
- package/components/newsroom-articles-view.tsx +6 -6
- package/components/newsroom-assignment-detail.tsx +6 -6
- package/components/newsroom-board-view.tsx +8 -3
- package/components/newsroom-curation-bootstrap.tsx +45 -0
- package/components/newsroom-ops-overview.tsx +26 -96
- package/components/newsroom-ops-shell.tsx +2 -2
- package/components/newsroom-organizer.css +33 -0
- package/components/newsroom-overview-skeleton.tsx +25 -0
- package/components/newsroom-references-view.tsx +192 -49
- package/components/notification-center.tsx +281 -0
- package/components/papyrus-console-conversation.css +14 -0
- package/components/papyrus-console-conversation.tsx +55 -0
- package/components/papyrus-console-shell.tsx +62 -420
- package/components/papyrus-private-chat-panel.tsx +134 -0
- package/components/publication-cyclotron-panel.tsx +15 -10
- package/components/reader-auth-state.ts +1 -1
- package/components/reference-add-entry.tsx +75 -0
- package/components/reference-curation-controls.tsx +31 -22
- package/components/reference-cyclotron-priming.tsx +58 -19
- package/components/reference-cyclotron-suggestion.tsx +10 -6
- package/components/reference-priming.tsx +14 -10
- package/components/reference-relevance-review.tsx +3 -3
- package/components/reference-section-suggestion.tsx +1 -1
- package/components/topic-steering-workspace.tsx +230 -144
- package/infra/amplify-app-shell.js +96 -16
- package/infra/amplify-app-shell.ts +108 -18
- package/infra/cloud-email-config.js +32 -0
- package/infra/cloud-email-config.ts +39 -0
- package/infra/cloud-email.js +57 -0
- package/infra/cloud-email.test.js +69 -0
- package/infra/cloud-email.test.ts +66 -0
- package/infra/cloud-email.ts +60 -0
- package/infra/index.js +1 -0
- package/infra/secure-string-parameter-handler.js +41 -0
- package/infra/secure-string-parameter-handler.ts +41 -0
- package/infra/secure-string-parameter.js +79 -0
- package/infra/secure-string-parameter.ts +91 -0
- package/infra/site-config.js +92 -6
- package/infra/site-config.ts +138 -6
- package/lib/automation-client.ts +54 -0
- package/lib/automation-fixture.ts +93 -0
- package/lib/automation-overview.ts +252 -0
- package/lib/category-repository.ts +14 -12
- package/lib/cli-login-discovery.ts +78 -0
- package/lib/console-web-context.ts +1 -1
- package/lib/curation-routes.ts +345 -0
- package/lib/cyclotron-suggestions.ts +4 -4
- package/lib/editorial-checks-client.ts +18 -0
- package/lib/editorial-checks-demo.ts +1 -1
- package/lib/editorial-checks.ts +8 -4
- package/lib/editorial-preview.ts +5 -5
- package/lib/monitoring-client.ts +83 -0
- package/lib/monitoring-overview-fixture.ts +51 -0
- package/lib/monitoring-overview.ts +191 -0
- package/lib/news-desk-session.ts +12 -21
- package/lib/newsletter-api.ts +418 -0
- package/lib/newsletter-client.ts +46 -0
- package/lib/newsletter-fixture.ts +184 -0
- package/lib/newsletter-model.ts +202 -0
- package/lib/newsroom-demo-dashboard.ts +1 -1
- package/lib/newsroom-nav.ts +1 -1
- package/lib/newsroom-organizer-nav.ts +2 -1
- package/lib/papyrus-chat-admission.ts +65 -0
- package/lib/papyrus-hosted-chat.ts +217 -0
- package/lib/papyrus-private-chat-client.ts +81 -0
- package/lib/prospectus-session.ts +30 -14
- package/lib/prospectus-sign-in-routes.ts +106 -31
- package/lib/prospectus-site-identity.ts +46 -6
- package/lib/reader-api.ts +145 -0
- package/lib/reader-moderation.ts +220 -0
- package/lib/reader-retirement-route.ts +89 -0
- package/lib/reader-subject.md +29 -0
- package/lib/reader-subject.ts +53 -0
- package/lib/reference-review-details.ts +71 -0
- package/lib/reference-review-request.ts +14 -0
- package/lib/relevance-decisions.ts +42 -10
- package/lib/relevance-status.ts +2 -2
- package/lib/review-client.ts +120 -0
- package/lib/review-fixture.ts +45 -0
- package/lib/review-requests.ts +246 -0
- package/lib/semantic-graph.ts +2 -0
- package/lib/shared-chat-admission.ts +182 -0
- package/lib/site-brand-tenant.ts +11 -6
- package/lib/site-brand.ts +6 -3
- package/lib/site-origin-value.ts +50 -0
- package/lib/site-origin.ts +8 -0
- package/lib/use-reference-review-data.ts +44 -0
- package/middleware.ts +14 -3
- package/package.json +67 -3
- package/routes.manifest.json +238 -2
- package/src/sync.mjs +11 -2
- package/src/with-papyrus.d.mts +1 -0
- package/src/with-papyrus.mjs +17 -1
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from "react";
|
|
2
|
+
import Link from "next/link";
|
|
3
|
+
import { getNewsroomNavHref } from "../lib/newsroom-nav";
|
|
4
|
+
import {
|
|
5
|
+
confidenceText,
|
|
6
|
+
describeDelivery,
|
|
7
|
+
describeRelevance,
|
|
8
|
+
everyText,
|
|
9
|
+
formatUtc,
|
|
10
|
+
referenceHref,
|
|
11
|
+
routingLabel,
|
|
12
|
+
beginEmailSubmissionAction,
|
|
13
|
+
endEmailSubmissionAction,
|
|
14
|
+
isCurrentEmailSubmissionAction,
|
|
15
|
+
type MonitoringItem,
|
|
16
|
+
type MonitoringEmailDetails,
|
|
17
|
+
type MonitoringEmailArticleResolution,
|
|
18
|
+
type EmailReferenceImportItem,
|
|
19
|
+
type EmailReferenceImportOutcome,
|
|
20
|
+
type MonitoringEmailSummary,
|
|
21
|
+
type MonitoringOverview,
|
|
22
|
+
type MonitoringRelevance,
|
|
23
|
+
type MonitoringResult,
|
|
24
|
+
} from "../lib/monitoring-overview";
|
|
25
|
+
|
|
26
|
+
export type MonitoringMode = "publication" | "all";
|
|
27
|
+
|
|
28
|
+
const STATUS_WORDS: Record<string, string> = { complete: "Complete", partial: "Partial", stopped: "Stopped", failed: "Failed", error: "Error" };
|
|
29
|
+
const status = (value: string) => STATUS_WORDS[value] ?? value;
|
|
30
|
+
const STOP_WORDS: Record<string, string> = {
|
|
31
|
+
request_ceiling_reached: "stopped at the request ceiling",
|
|
32
|
+
standing_capacity_reached: "stopped: the standing run is full",
|
|
33
|
+
provider_key_unavailable: "stopped: the Cyclotron has no model key",
|
|
34
|
+
run_busy: "stopped: the Cyclotron run was busy",
|
|
35
|
+
time_budget: "stopped for time",
|
|
36
|
+
};
|
|
37
|
+
const MARKS: Record<string, string> = { delivered: "✓", failed: "✕", pending: "…", not_applicable: "–" };
|
|
38
|
+
|
|
39
|
+
function feedHost(url: string | null): string {
|
|
40
|
+
try {
|
|
41
|
+
return url ? new URL(url).host : "unknown source";
|
|
42
|
+
} catch {
|
|
43
|
+
return "unknown source";
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function Message({ title, children, kind }: { title: string; children?: React.ReactNode; kind: string }) {
|
|
48
|
+
return (
|
|
49
|
+
<div className="category-steering-empty" role={kind === "loading" ? "status" : "alert"} data-monitoring-state={kind}>
|
|
50
|
+
<strong>{title}</strong>
|
|
51
|
+
{children ? <p>{children}</p> : null}
|
|
52
|
+
</div>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function RelevanceLine({ entry, linkable, demo }: { entry: MonitoringRelevance; linkable: boolean; demo: boolean }) {
|
|
57
|
+
const lineage = entry.delivery.referenceLineageId ?? entry.delivery.referenceId ?? null;
|
|
58
|
+
const delivery = describeDelivery(entry);
|
|
59
|
+
return (
|
|
60
|
+
<li data-relevance={entry.relevant === null ? "unknown" : entry.relevant ? "yes" : "no"} data-delivery={entry.delivery.state ?? "none"}>
|
|
61
|
+
<strong>{entry.publicationId}</strong>
|
|
62
|
+
<span>{describeRelevance(entry)}{entry.relevant !== null ? `, ${confidenceText(entry.confidence)}` : ""}</span>
|
|
63
|
+
{delivery ? (
|
|
64
|
+
<span>
|
|
65
|
+
<span aria-hidden="true">{MARKS[entry.delivery.state ?? ""] ?? ""} </span>
|
|
66
|
+
{delivery}
|
|
67
|
+
{entry.delivery.state === "delivered" && lineage && linkable ? (
|
|
68
|
+
<> <Link href={referenceHref(lineage, demo)} data-monitoring-reference-link>Open in References</Link></>
|
|
69
|
+
) : null}
|
|
70
|
+
{entry.delivery.state === "failed" && entry.delivery.error ? <> ({entry.delivery.error})</> : null}
|
|
71
|
+
</span>
|
|
72
|
+
) : null}
|
|
73
|
+
</li>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function QueueItem({ item, ownId, demo }: { item: MonitoringItem; ownId: string | null; demo: boolean }) {
|
|
78
|
+
return (
|
|
79
|
+
<li data-monitoring-item={item.sourceId} data-routing={item.routing}>
|
|
80
|
+
<div>
|
|
81
|
+
<strong>{item.url ? <a href={item.url} target="_blank" rel="noreferrer noopener">{item.title}</a> : item.title}</strong>
|
|
82
|
+
<span>
|
|
83
|
+
{formatUtc(item.observedAt)}
|
|
84
|
+
{item.discussionUrl ? <> · <a href={item.discussionUrl} target="_blank" rel="noreferrer noopener">Hacker News thread</a></> : null}
|
|
85
|
+
</span>
|
|
86
|
+
</div>
|
|
87
|
+
<p className="news-desk-monitoring__routing">
|
|
88
|
+
{routingLabel(item.routing)}
|
|
89
|
+
{item.routingNote ? <>: {item.routingNote}</> : null}
|
|
90
|
+
</p>
|
|
91
|
+
{item.relevance.length ? (
|
|
92
|
+
<ul className="news-desk-monitoring__relevance">
|
|
93
|
+
{item.relevance.map((entry) => <RelevanceLine key={entry.publicationId} entry={entry} linkable={entry.publicationId === ownId} demo={demo} />)}
|
|
94
|
+
</ul>
|
|
95
|
+
) : null}
|
|
96
|
+
</li>
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function EmailSubmissions({ items, detail, onOpen, onResolveArticleLink, onImportCandidates }: {
|
|
101
|
+
items: MonitoringEmailSummary[]; detail: MonitoringEmailDetails | null; onOpen: (receiptId: string) => void;
|
|
102
|
+
onResolveArticleLink?: (receiptId: string, candidateIndex: number) => Promise<MonitoringEmailArticleResolution>;
|
|
103
|
+
onImportCandidates?: (receiptId: string, items: EmailReferenceImportItem[]) => Promise<EmailReferenceImportOutcome>;
|
|
104
|
+
}) {
|
|
105
|
+
const [selectedByReceipt, setSelectedByReceipt] = useState<Record<string, Record<number, boolean>>>({});
|
|
106
|
+
const [resolvedByReceipt, setResolvedByReceipt] = useState<Record<string, Record<number, { url: string; title: string }>>>({});
|
|
107
|
+
const [busyIndexByReceipt, setBusyIndexByReceipt] = useState<Record<string, number | null>>({});
|
|
108
|
+
const [busyImportByReceipt, setBusyImportByReceipt] = useState<Record<string, boolean>>({});
|
|
109
|
+
const [noticeByReceipt, setNoticeByReceipt] = useState<Record<string, string>>({});
|
|
110
|
+
const [importedByReceipt, setImportedByReceipt] = useState<Record<string, EmailReferenceImportOutcome["references"]>>({});
|
|
111
|
+
const receiptId = detail && "submission" in detail ? detail.submission.receiptId : null;
|
|
112
|
+
const receiptKey = receiptId ?? "";
|
|
113
|
+
const selected = selectedByReceipt[receiptKey] ?? {};
|
|
114
|
+
const resolved = resolvedByReceipt[receiptKey] ?? {};
|
|
115
|
+
const busyIndex = busyIndexByReceipt[receiptKey] ?? null;
|
|
116
|
+
const busyImport = busyImportByReceipt[receiptKey] ?? false;
|
|
117
|
+
const notice = noticeByReceipt[receiptKey] ?? "";
|
|
118
|
+
const importedReferences = importedByReceipt[receiptKey] ?? [];
|
|
119
|
+
const activeReceiptId = useRef<string | null>(receiptId);
|
|
120
|
+
activeReceiptId.current = receiptId;
|
|
121
|
+
const inFlightActions = useRef(new Set<string>());
|
|
122
|
+
useEffect(() => {
|
|
123
|
+
if (!detail || !("submission" in detail)) return;
|
|
124
|
+
setSelectedByReceipt((current) => current[receiptKey] ? current : {
|
|
125
|
+
...current,
|
|
126
|
+
[receiptKey]: Object.fromEntries(detail.candidateLinks.map((link, index) => [index, link.resolutionStatus !== "unresolved"])),
|
|
127
|
+
});
|
|
128
|
+
}, [receiptKey, detail]);
|
|
129
|
+
|
|
130
|
+
const resolveLink = async (index: number) => {
|
|
131
|
+
if (!receiptId || !onResolveArticleLink) return;
|
|
132
|
+
const requestReceiptId = receiptId;
|
|
133
|
+
const actionKey = `resolve:${requestReceiptId}:${index}`;
|
|
134
|
+
if (!beginEmailSubmissionAction(inFlightActions.current, actionKey)) return;
|
|
135
|
+
setBusyIndexByReceipt((current) => ({ ...current, [requestReceiptId]: index }));
|
|
136
|
+
setNoticeByReceipt((current) => ({ ...current, [requestReceiptId]: "" }));
|
|
137
|
+
try {
|
|
138
|
+
const result = await onResolveArticleLink(requestReceiptId, index);
|
|
139
|
+
if (!isCurrentEmailSubmissionAction(activeReceiptId.current, requestReceiptId)) return;
|
|
140
|
+
if (!("url" in result)) {
|
|
141
|
+
setNoticeByReceipt((current) => ({ ...current, [requestReceiptId]: "The article destination could not be resolved safely. The original link remains private and unchanged." }));
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
const candidate = detail && "submission" in detail ? detail.candidateLinks[index] : null;
|
|
145
|
+
setResolvedByReceipt((current) => ({ ...current, [requestReceiptId]: { ...current[requestReceiptId], [index]: { url: result.url, title: result.title || candidate?.title || "Article" } } }));
|
|
146
|
+
setSelectedByReceipt((current) => ({ ...current, [requestReceiptId]: { ...current[requestReceiptId], [index]: true } }));
|
|
147
|
+
setNoticeByReceipt((current) => ({ ...current, [requestReceiptId]: "Resolved. This request may be recorded as a click by the newsletter sender." }));
|
|
148
|
+
} catch {
|
|
149
|
+
if (isCurrentEmailSubmissionAction(activeReceiptId.current, requestReceiptId)) {
|
|
150
|
+
setNoticeByReceipt((current) => ({ ...current, [requestReceiptId]: "The article destination could not be resolved. Try again later." }));
|
|
151
|
+
}
|
|
152
|
+
} finally {
|
|
153
|
+
endEmailSubmissionAction(inFlightActions.current, actionKey);
|
|
154
|
+
setBusyIndexByReceipt((current) => ({ ...current, [requestReceiptId]: null }));
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const importSelected = async () => {
|
|
159
|
+
if (!receiptId || !detail || !("submission" in detail) || !onImportCandidates) return;
|
|
160
|
+
const requestReceiptId = receiptId;
|
|
161
|
+
const actionKey = `import:${requestReceiptId}`;
|
|
162
|
+
if (inFlightActions.current.has(actionKey)) return;
|
|
163
|
+
const candidates = detail.candidateLinks.flatMap((link, index) => {
|
|
164
|
+
if (!selected[index]) return [];
|
|
165
|
+
const resolvedLink = resolved[index];
|
|
166
|
+
if (!resolvedLink && link.resolutionStatus === "unresolved") return [];
|
|
167
|
+
const url = resolvedLink?.url ?? link.url;
|
|
168
|
+
return [{ candidateIndex: index, source_uri: url }];
|
|
169
|
+
});
|
|
170
|
+
if (!candidates.length) {
|
|
171
|
+
setNoticeByReceipt((current) => ({ ...current, [requestReceiptId]: "Select at least one available candidate link. Resolve tracking links before importing them." }));
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
if (!beginEmailSubmissionAction(inFlightActions.current, actionKey)) return;
|
|
175
|
+
setBusyImportByReceipt((current) => ({ ...current, [requestReceiptId]: true }));
|
|
176
|
+
setNoticeByReceipt((current) => ({ ...current, [requestReceiptId]: "" }));
|
|
177
|
+
try {
|
|
178
|
+
const result = await onImportCandidates(requestReceiptId, candidates);
|
|
179
|
+
if (!isCurrentEmailSubmissionAction(activeReceiptId.current, requestReceiptId)) return;
|
|
180
|
+
setImportedByReceipt((current) => ({ ...current, [requestReceiptId]: result.references }));
|
|
181
|
+
if (result.error) {
|
|
182
|
+
setNoticeByReceipt((current) => ({ ...current, [requestReceiptId]: `${result.imported} of ${candidates.length} selected candidates were registered or matched. ${result.error}` }));
|
|
183
|
+
} else {
|
|
184
|
+
setNoticeByReceipt((current) => ({ ...current, [requestReceiptId]: `${result.imported} selected candidates are available as References. Import does not approve or publish them.` }));
|
|
185
|
+
}
|
|
186
|
+
} catch {
|
|
187
|
+
if (isCurrentEmailSubmissionAction(activeReceiptId.current, requestReceiptId)) {
|
|
188
|
+
setNoticeByReceipt((current) => ({ ...current, [requestReceiptId]: "The import stopped. Any completed references are safe to retry; no review decision was made." }));
|
|
189
|
+
}
|
|
190
|
+
} finally {
|
|
191
|
+
endEmailSubmissionAction(inFlightActions.current, actionKey);
|
|
192
|
+
setBusyImportByReceipt((current) => ({ ...current, [requestReceiptId]: false }));
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
return (
|
|
197
|
+
<article className="news-desk-doctrine-card" data-monitoring-section="email-submissions">
|
|
198
|
+
<div className="news-desk-doctrine-card__header"><div><h3>Email submissions</h3><span>Untrusted source material held privately for editor review</span></div></div>
|
|
199
|
+
{items.length ? <ul className="news-desk-monitoring__list">
|
|
200
|
+
{items.map((item) => <li key={item.receiptId} data-email-submission={item.receiptId}>
|
|
201
|
+
<strong>{item.subject || "(no subject)"}</strong>
|
|
202
|
+
<span>{formatUtc(item.observedAt)} · {item.linkCount} candidate links · {item.reviewRequired
|
|
203
|
+
? `${item.linkCount} await editor import` : `${item.processedCount} queued`}</span>
|
|
204
|
+
{item.reviewRequired ? <span>Authentication warnings: private review required; nothing was routed automatically.</span> : null}
|
|
205
|
+
{item.unresolvedLinkCount ? <span>{item.unresolvedLinkCount} tracking links remain unresolved and were not opened.</span> : null}
|
|
206
|
+
<span>Envelope sender: {item.envelopeSender || "unknown"} · MIME From (unverified): {item.headerFrom || "unknown"}</span>
|
|
207
|
+
<span>SES signals — SPF {item.verdicts.spf}, DKIM {item.verdicts.dkim}, DMARC {item.verdicts.dmarc}; spam {item.verdicts.spam}, virus {item.verdicts.virus}</span>
|
|
208
|
+
{item.warnings.length ? <span>Warnings: {item.warnings.join(", ")}</span> : null}
|
|
209
|
+
{item.errorCode ? <span>State: {item.status} ({item.errorCode})</span> : <span>State: {item.status}</span>}
|
|
210
|
+
<button type="button" onClick={() => onOpen(item.receiptId)}>Read email and review candidate links</button>
|
|
211
|
+
</li>)}
|
|
212
|
+
</ul> : <p>No email submissions are available for this publication.</p>}
|
|
213
|
+
{detail && "submission" in detail ? (
|
|
214
|
+
<section aria-label="Email submission details" data-email-submission-details={detail.submission.receiptId}>
|
|
215
|
+
<h4>{detail.submission.subject || "Submitted email"}</h4>
|
|
216
|
+
<p>Original content is shown as inert text. Sender and message content are untrusted; no instructions are executed.</p>
|
|
217
|
+
<pre style={{ whiteSpace: "pre-wrap", overflowWrap: "anywhere" }}>{detail.safeBodyText}</pre>
|
|
218
|
+
<h5>Candidate references ({detail.candidateLinks.length})</h5>
|
|
219
|
+
<ul>{detail.candidateLinks.map((link, index) => <li key={`${link.url}-${index}`}>
|
|
220
|
+
{link.resolutionStatus === "unresolved" && !resolved[index]
|
|
221
|
+
? <span>{link.title || "Newsletter article link"} (tracking redirect not opened)</span>
|
|
222
|
+
: <a href={(resolved[index]?.url ?? link.url)} target="_blank" rel="noreferrer noopener">{resolved[index]?.title ?? (link.title || "Source link")}</a>}
|
|
223
|
+
<label>
|
|
224
|
+
<input type="checkbox" checked={selected[index] ?? false}
|
|
225
|
+
disabled={link.resolutionStatus === "unresolved" && !resolved[index]}
|
|
226
|
+
onChange={(event) => setSelectedByReceipt((current) => ({ ...current, [receiptKey]: { ...current[receiptKey], [index]: event.target.checked } }))} />
|
|
227
|
+
Add as pending Reference
|
|
228
|
+
</label>
|
|
229
|
+
{link.resolutionStatus === "unresolved" && !resolved[index] ? (
|
|
230
|
+
<button type="button" disabled={busyIndex !== null || !onResolveArticleLink} onClick={() => void resolveLink(index)}>
|
|
231
|
+
{busyIndex === index ? "Resolving…" : "Resolve article redirect (may register a click)"}
|
|
232
|
+
</button>
|
|
233
|
+
) : null}
|
|
234
|
+
</li>)}</ul>
|
|
235
|
+
<p>Adding links creates or reuses pending References. It does not approve, label, classify, or publish them.</p>
|
|
236
|
+
<button type="button" disabled={busyImport || !onImportCandidates || !detail.submission.corpusId}
|
|
237
|
+
onClick={() => void importSelected()}>
|
|
238
|
+
{busyImport ? "Adding References…" : "Add selected links to References"}
|
|
239
|
+
</button>
|
|
240
|
+
{notice ? <p role="status" data-email-import-result>{notice}</p> : null}
|
|
241
|
+
{importedReferences.length ? <p><Link href={getNewsroomNavHref("/newsroom/references", false)}>Review these References</Link></p> : null}
|
|
242
|
+
</section>
|
|
243
|
+
) : detail && "error" in detail ? <p role="alert">You cannot read this submission.</p>
|
|
244
|
+
: detail && "state" in detail ? <p role="alert">Submission details are unavailable.</p> : null}
|
|
245
|
+
</article>
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function Ready({ data, demo, emailDetail, onEmailOpen, onResolveArticleLink, onImportCandidates }: {
|
|
250
|
+
data: MonitoringOverview; demo: boolean; emailDetail: MonitoringEmailDetails | null; onEmailOpen: (id: string) => void;
|
|
251
|
+
onResolveArticleLink?: (receiptId: string, candidateIndex: number) => Promise<MonitoringEmailArticleResolution>;
|
|
252
|
+
onImportCandidates?: (receiptId: string, items: EmailReferenceImportItem[]) => Promise<EmailReferenceImportOutcome>;
|
|
253
|
+
}) {
|
|
254
|
+
const router = data.router;
|
|
255
|
+
return (
|
|
256
|
+
<>
|
|
257
|
+
{data.notice === "publication_not_routed" ? (
|
|
258
|
+
<p className="news-desk-delegation-note" data-monitoring-notice>This publication is not one of the router's outputs, so no relevance is shown for it here.</p>
|
|
259
|
+
) : null}
|
|
260
|
+
|
|
261
|
+
<EmailSubmissions items={data.emailSubmissions ?? []} detail={emailDetail} onOpen={onEmailOpen}
|
|
262
|
+
onResolveArticleLink={demo ? undefined : onResolveArticleLink}
|
|
263
|
+
onImportCandidates={demo ? undefined : onImportCandidates} />
|
|
264
|
+
|
|
265
|
+
<article className="news-desk-doctrine-card" data-monitoring-section="router">
|
|
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>
|
|
267
|
+
{router ? (
|
|
268
|
+
<p data-monitoring-router>
|
|
269
|
+
<strong>{router.mode === "live" ? "Live" : "Dry run"}</strong>
|
|
270
|
+
{router.mode === "live" ? " · decisions are made and delivered" : " · requests are checked but nothing is sent"}
|
|
271
|
+
{" · "}{router.feedback ? "Feedback on" : "Feedback off"}
|
|
272
|
+
{router.enabled ? "" : " · Paused"}
|
|
273
|
+
</p>
|
|
274
|
+
) : <p>The router has not been configured.</p>}
|
|
275
|
+
</article>
|
|
276
|
+
|
|
277
|
+
<article className="news-desk-doctrine-card" data-monitoring-section="usage">
|
|
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>
|
|
279
|
+
{data.usage ? (
|
|
280
|
+
<>
|
|
281
|
+
<dl className="news-desk-monitoring__usage">
|
|
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>
|
|
284
|
+
</dl>
|
|
285
|
+
{!data.usage.historyComplete ? <p className="news-desk-delegation-note">Counts are lower bounds from a bounded history read.</p> : null}
|
|
286
|
+
</>
|
|
287
|
+
) : <p>No usage recorded yet.</p>}
|
|
288
|
+
</article>
|
|
289
|
+
|
|
290
|
+
<article className="news-desk-doctrine-card" data-monitoring-section="feeds">
|
|
291
|
+
<div className="news-desk-doctrine-card__header"><div><h3>Feeds watched</h3><span>What the collector reads</span></div></div>
|
|
292
|
+
{data.feeds.length ? (
|
|
293
|
+
<ul className="news-desk-monitoring__list">
|
|
294
|
+
{data.feeds.map((feed) => (
|
|
295
|
+
<li key={feed.id} data-monitoring-feed={feed.id}>
|
|
296
|
+
<div>
|
|
297
|
+
<strong>{feed.id}</strong>
|
|
298
|
+
<span>{feedHost(feed.sourceUrl)}</span>
|
|
299
|
+
</div>
|
|
300
|
+
<span>{feed.enabled ? "Enabled" : "Disabled"} · {everyText(feed.intervalSeconds)}{feed.limit ? ` · up to ${feed.limit} items` : ""}</span>
|
|
301
|
+
<span>Last run {formatUtc(feed.lastFinishedAt)}{feed.lastStatus ? ` (${status(feed.lastStatus)})` : ""} · next {formatUtc(feed.nextDueAt)}</span>
|
|
302
|
+
</li>
|
|
303
|
+
))}
|
|
304
|
+
</ul>
|
|
305
|
+
) : <p>No feed is configured.</p>}
|
|
306
|
+
</article>
|
|
307
|
+
|
|
308
|
+
<article className="news-desk-doctrine-card" data-monitoring-section="runs">
|
|
309
|
+
<div className="news-desk-doctrine-card__header"><div><h3>Recent runs</h3><span>Collector receipts, newest first</span></div></div>
|
|
310
|
+
{data.runs.length ? (
|
|
311
|
+
<ul className="news-desk-monitoring__list">
|
|
312
|
+
{data.runs.map((run) => (
|
|
313
|
+
<li key={run.runId} data-monitoring-run={run.runId}>
|
|
314
|
+
<strong>{formatUtc(run.startedAt)}</strong>
|
|
315
|
+
<span>{status(run.status)}</span>
|
|
316
|
+
<span>{run.new} new · {run.revised} revised · {run.unchanged} unchanged · {run.errorCount} {run.errorCount === 1 ? "error" : "errors"}{run.errors.length ? ` (${run.errors.join("; ")})` : ""}</span>
|
|
317
|
+
</li>
|
|
318
|
+
))}
|
|
319
|
+
</ul>
|
|
320
|
+
) : <p>No collector runs recorded yet.</p>}
|
|
321
|
+
{data.routerRuns.length ? (
|
|
322
|
+
<ul className="news-desk-monitoring__list" data-monitoring-router-runs>
|
|
323
|
+
{data.routerRuns.map((run) => (
|
|
324
|
+
<li key={run.runId} data-monitoring-router-run={run.runId}>
|
|
325
|
+
<strong>{formatUtc(run.startedAt)}</strong>
|
|
326
|
+
<span>Router, {run.mode === "live" ? "live" : "dry run"}: {status(run.status)}{run.stoppedBy ? `, ${STOP_WORDS[run.stoppedBy] ?? run.stoppedBy}` : ""}</span>
|
|
327
|
+
<span>{run.counts.examined ?? 0} examined · {run.counts.decided ?? 0} judged · {run.counts.delivered ?? 0} delivered · {run.counts.deliveryFailed ?? 0} {(run.counts.deliveryFailed ?? 0) === 1 ? "delivery failure" : "delivery failures"} · {run.errorCount} {run.errorCount === 1 ? "error" : "errors"}</span>
|
|
328
|
+
{run.waitingForClassifier?.length ? <span data-monitoring-waiting-for-classifier>Waiting for classifier: {run.waitingForClassifier.join(", ")} (skipped, not an error; probed on a 30 minute to 6 hour backoff)</span> : null}
|
|
329
|
+
</li>
|
|
330
|
+
))}
|
|
331
|
+
</ul>
|
|
332
|
+
) : null}
|
|
333
|
+
</article>
|
|
334
|
+
|
|
335
|
+
<article className="news-desk-doctrine-card" data-monitoring-section="queue">
|
|
336
|
+
<div className="news-desk-doctrine-card__header"><div><h3>Collected queue</h3><span>Newest first, with what the shared Cyclotron decided</span></div></div>
|
|
337
|
+
{data.items.length ? (
|
|
338
|
+
<ul className="news-desk-monitoring__queue">
|
|
339
|
+
{data.items.map((item) => <QueueItem key={`${item.monitorId ?? ""}-${item.sourceId}-${item.revision}`} item={item} ownId={data.scope.publicationId} demo={demo} />)}
|
|
340
|
+
</ul>
|
|
341
|
+
) : <p>No items in the queue.</p>}
|
|
342
|
+
{data.hiddenNotRelevant > 0 ? (
|
|
343
|
+
<p className="news-desk-delegation-note" data-monitoring-hidden>
|
|
344
|
+
{data.hiddenNotRelevant} {data.hiddenNotRelevant === 1 ? "item" : "items"} judged not relevant to this publication {data.hiddenNotRelevant === 1 ? "is" : "are"} not listed.
|
|
345
|
+
</p>
|
|
346
|
+
) : null}
|
|
347
|
+
</article>
|
|
348
|
+
|
|
349
|
+
{data.otherMonitors?.length ? (
|
|
350
|
+
<details className="news-desk-delegation-note" data-monitoring-other-monitors>
|
|
351
|
+
<summary>Not routed to this site ({data.otherMonitors.length})</summary>
|
|
352
|
+
<p>These monitors collect items, but none of them is routed to this site, so their items are not shown here.</p>
|
|
353
|
+
<ul>{data.otherMonitors.map((id) => <li key={id} data-monitoring-other-monitor={id}>{id}</li>)}</ul>
|
|
354
|
+
</details>
|
|
355
|
+
) : null}
|
|
356
|
+
</>
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** Presentation only: the panel fetches, this renders any result, so every state is testable from fixtures. */
|
|
361
|
+
export function MonitoringOverviewView({
|
|
362
|
+
result,
|
|
363
|
+
canViewAll,
|
|
364
|
+
mode,
|
|
365
|
+
onModeChange,
|
|
366
|
+
demo = false,
|
|
367
|
+
emailDetail = null,
|
|
368
|
+
onEmailOpen = () => {},
|
|
369
|
+
onResolveArticleLink,
|
|
370
|
+
onImportCandidates,
|
|
371
|
+
}: {
|
|
372
|
+
result: MonitoringResult | null;
|
|
373
|
+
canViewAll: boolean;
|
|
374
|
+
mode: MonitoringMode;
|
|
375
|
+
onModeChange: (mode: MonitoringMode) => void;
|
|
376
|
+
demo?: boolean;
|
|
377
|
+
emailDetail?: MonitoringEmailDetails | null;
|
|
378
|
+
onEmailOpen?: (receiptId: string) => void;
|
|
379
|
+
onResolveArticleLink?: (receiptId: string, candidateIndex: number) => Promise<MonitoringEmailArticleResolution>;
|
|
380
|
+
onImportCandidates?: (receiptId: string, items: EmailReferenceImportItem[]) => Promise<EmailReferenceImportOutcome>;
|
|
381
|
+
}) {
|
|
382
|
+
let body: React.ReactNode;
|
|
383
|
+
if (result === null) {
|
|
384
|
+
body = <Message kind="loading" title="Loading monitoring" />;
|
|
385
|
+
} else if ("error" in result) {
|
|
386
|
+
body = result.error === "forbidden"
|
|
387
|
+
? <Message kind="forbidden" title="Monitoring is for editors and admins">Your account does not have access to this page. Ask an admin for the editor role.</Message>
|
|
388
|
+
: <Message kind="error" title="Monitoring could not be read">The request was not understood. Reload the page.</Message>;
|
|
389
|
+
} else if (result.state === "not_configured") {
|
|
390
|
+
body = <Message kind="not-configured" title="Monitoring is not set up for this publication">The monitoring pipeline is not connected to this site's account, so there is nothing to show.</Message>;
|
|
391
|
+
} else if (result.state === "unavailable") {
|
|
392
|
+
body = <Message kind="unavailable" title="Monitoring could not be read right now">The pipeline is not affected. Try again in a minute.</Message>;
|
|
393
|
+
} else if (result.state === "empty") {
|
|
394
|
+
body = <Message kind="empty" title="Nothing has been collected yet">No feed is configured and no run has been recorded. Items appear here after the collector's first run.</Message>;
|
|
395
|
+
} else {
|
|
396
|
+
body = <Ready data={result} demo={demo} emailDetail={emailDetail} onEmailOpen={onEmailOpen}
|
|
397
|
+
onResolveArticleLink={onResolveArticleLink} onImportCandidates={onImportCandidates} />;
|
|
398
|
+
}
|
|
399
|
+
const scopeLine = result && "scope" in result
|
|
400
|
+
? (result.scope.mode === "all" ? "Showing the whole shared pipeline, every publication." : `Showing what is relevant to ${result.scope.publicationId ?? "this publication"}.`)
|
|
401
|
+
: null;
|
|
402
|
+
return (
|
|
403
|
+
<div data-news-desk-admin-panel="monitoring">
|
|
404
|
+
<article className="news-desk-doctrine-card">
|
|
405
|
+
<div className="news-desk-doctrine-card__header">
|
|
406
|
+
<div>
|
|
407
|
+
<h3>Monitoring</h3>
|
|
408
|
+
<span>The automated research pipeline: collection, judging and delivery to References. Read only.</span>
|
|
409
|
+
</div>
|
|
410
|
+
</div>
|
|
411
|
+
{canViewAll ? (
|
|
412
|
+
<div className="news-desk-monitoring__scope" role="group" aria-label="Scope">
|
|
413
|
+
<button type="button" aria-pressed={mode === "publication"} data-monitoring-scope="publication" onClick={() => onModeChange("publication")}>This publication</button>
|
|
414
|
+
<button type="button" aria-pressed={mode === "all"} data-monitoring-scope="all" onClick={() => onModeChange("all")}>Whole shared pipeline</button>
|
|
415
|
+
</div>
|
|
416
|
+
) : null}
|
|
417
|
+
{scopeLine ? <p className="news-desk-delegation-note" data-monitoring-scope-line>{scopeLine} {result && "asOf" in result ? `As of ${formatUtc(result.asOf)}.` : ""}</p> : null}
|
|
418
|
+
</article>
|
|
419
|
+
{body}
|
|
420
|
+
</div>
|
|
421
|
+
);
|
|
422
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
|
+
import {
|
|
5
|
+
importMonitoringEmailCandidates,
|
|
6
|
+
loadMonitoringEmailSubmission,
|
|
7
|
+
loadMonitoringOverview,
|
|
8
|
+
resolveMonitoringEmailArticleLink,
|
|
9
|
+
} from "../lib/monitoring-client";
|
|
10
|
+
import { FIXTURE_ALL_PIPELINE_OVERVIEW, FIXTURE_MONITORING_OVERVIEW } from "../lib/monitoring-overview-fixture";
|
|
11
|
+
import type { EmailReferenceImportItem, MonitoringEmailDetails, MonitoringResult } from "../lib/monitoring-overview";
|
|
12
|
+
import { MonitoringOverviewView, type MonitoringMode } from "./monitoring-overview-view";
|
|
13
|
+
|
|
14
|
+
const REFRESH_MILLISECONDS = 60_000;
|
|
15
|
+
|
|
16
|
+
/** Administration, Monitoring: loads the overview, refreshes it each minute, and lets an admin widen it to the whole pipeline. */
|
|
17
|
+
export function MonitoringPanel({ isAdmin, isDemo }: { isAdmin: boolean; isDemo: boolean }) {
|
|
18
|
+
const [mode, setMode] = useState<MonitoringMode>("publication");
|
|
19
|
+
const [result, setResult] = useState<MonitoringResult | null>(null);
|
|
20
|
+
const [emailDetail, setEmailDetail] = useState<MonitoringEmailDetails | null>(null);
|
|
21
|
+
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
if (isDemo) {
|
|
24
|
+
setResult(mode === "all" ? FIXTURE_ALL_PIPELINE_OVERVIEW : FIXTURE_MONITORING_OVERVIEW);
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
let live = true;
|
|
28
|
+
const refresh = async () => {
|
|
29
|
+
try {
|
|
30
|
+
const next = await loadMonitoringOverview(mode);
|
|
31
|
+
if (live) setResult(next);
|
|
32
|
+
} catch {
|
|
33
|
+
if (live) setResult({ state: "unavailable" });
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
setResult(null);
|
|
37
|
+
void refresh();
|
|
38
|
+
const timer = window.setInterval(() => void refresh(), REFRESH_MILLISECONDS);
|
|
39
|
+
return () => {
|
|
40
|
+
live = false;
|
|
41
|
+
window.clearInterval(timer);
|
|
42
|
+
};
|
|
43
|
+
}, [mode, isDemo]);
|
|
44
|
+
|
|
45
|
+
const openEmail = async (receiptId: string) => {
|
|
46
|
+
setEmailDetail({ state: "unavailable" });
|
|
47
|
+
const next = await loadMonitoringEmailSubmission(receiptId);
|
|
48
|
+
setEmailDetail(next);
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const resolveEmailArticleLink = (receiptId: string, candidateIndex: number) =>
|
|
52
|
+
resolveMonitoringEmailArticleLink(receiptId, candidateIndex);
|
|
53
|
+
|
|
54
|
+
const importEmailCandidates = (receiptId: string, candidates: EmailReferenceImportItem[]) =>
|
|
55
|
+
importMonitoringEmailCandidates(receiptId, candidates);
|
|
56
|
+
|
|
57
|
+
return <MonitoringOverviewView result={result} canViewAll={isAdmin} mode={mode} onModeChange={setMode} demo={isDemo}
|
|
58
|
+
emailDetail={emailDetail} onEmailOpen={isDemo ? () => {} : openEmail} onResolveArticleLink={isDemo ? undefined : resolveEmailArticleLink}
|
|
59
|
+
onImportCandidates={importEmailCandidates} />;
|
|
60
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import { Hub } from "aws-amplify/utils";
|
|
4
|
+
import { isUnauthenticatedError } from "./reader-auth-state";
|
|
4
5
|
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
|
5
6
|
import type { DevSandboxEditorAuth } from "../lib/dev-sandbox-editor-auth";
|
|
6
7
|
import type { CategorySteeringDashboard } from "../lib/category-repository";
|
|
@@ -14,9 +15,10 @@ import {
|
|
|
14
15
|
} from "./news-desk-taxonomy-client";
|
|
15
16
|
import {
|
|
16
17
|
beginAccessCheck,
|
|
17
|
-
|
|
18
|
+
beginDeskLoad,
|
|
18
19
|
createInitialNewsDeskShellState,
|
|
19
20
|
patchDashboard,
|
|
21
|
+
resolveAccessFailure,
|
|
20
22
|
resolveDashboardFailure,
|
|
21
23
|
resolveDashboardReady,
|
|
22
24
|
resolveForbidden,
|
|
@@ -94,11 +96,11 @@ export function NewsDeskClientProvider({
|
|
|
94
96
|
return;
|
|
95
97
|
}
|
|
96
98
|
if (access.status === "error") {
|
|
97
|
-
setShell((current) =>
|
|
99
|
+
setShell((current) => resolveAccessFailure(current, access.error));
|
|
98
100
|
return;
|
|
99
101
|
}
|
|
100
102
|
|
|
101
|
-
setShell((current) =>
|
|
103
|
+
setShell((current) => beginDeskLoad(current, access.auth));
|
|
102
104
|
|
|
103
105
|
try {
|
|
104
106
|
const dashboard = await loadEditorNewsDeskDashboard({ isAdmin: access.isAdmin });
|
|
@@ -106,6 +108,10 @@ export function NewsDeskClientProvider({
|
|
|
106
108
|
setShell((current) => resolveDashboardReady(current, dashboard, access.auth, new Date().toISOString()));
|
|
107
109
|
} catch (error) {
|
|
108
110
|
if (bootstrapSequenceRef.current !== sequence) return;
|
|
111
|
+
if (isUnauthenticatedError(error)) {
|
|
112
|
+
setShell((current) => resolveSignedOut(current, { status: "signedOut", label: "Signed out" }));
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
109
115
|
if (isAuthorizationError(error)) {
|
|
110
116
|
setShell((current) => resolveForbidden(current, access.auth));
|
|
111
117
|
return;
|
|
@@ -132,11 +138,7 @@ export function NewsDeskClientProvider({
|
|
|
132
138
|
));
|
|
133
139
|
} catch (error) {
|
|
134
140
|
const auth = shellRef.current.auth;
|
|
135
|
-
setShell((current) =>
|
|
136
|
-
current,
|
|
137
|
-
auth,
|
|
138
|
-
error instanceof Error ? error.message : "Could not refresh assignments.",
|
|
139
|
-
));
|
|
141
|
+
setShell((current) => resolvePrivateReadFailure(current, auth, error, "Could not refresh assignments."));
|
|
140
142
|
}
|
|
141
143
|
}, []);
|
|
142
144
|
|
|
@@ -153,11 +155,7 @@ export function NewsDeskClientProvider({
|
|
|
153
155
|
));
|
|
154
156
|
} catch (error) {
|
|
155
157
|
const auth = shellRef.current.auth;
|
|
156
|
-
setShell((current) =>
|
|
157
|
-
current,
|
|
158
|
-
auth,
|
|
159
|
-
error instanceof Error ? error.message : "Could not refresh doctrine records.",
|
|
160
|
-
));
|
|
158
|
+
setShell((current) => resolvePrivateReadFailure(current, auth, error, "Could not refresh doctrine records."));
|
|
161
159
|
}
|
|
162
160
|
}, []);
|
|
163
161
|
|
|
@@ -175,11 +173,7 @@ export function NewsDeskClientProvider({
|
|
|
175
173
|
));
|
|
176
174
|
} catch (error) {
|
|
177
175
|
const auth = shellRef.current.auth;
|
|
178
|
-
setShell((current) =>
|
|
179
|
-
current,
|
|
180
|
-
auth,
|
|
181
|
-
error instanceof Error ? error.message : "Could not refresh the user directory.",
|
|
182
|
-
));
|
|
176
|
+
setShell((current) => resolvePrivateReadFailure(current, auth, error, "Could not refresh the user directory."));
|
|
183
177
|
}
|
|
184
178
|
}, []);
|
|
185
179
|
|
|
@@ -221,6 +215,12 @@ function isAuthorizationError(error: unknown): boolean {
|
|
|
221
215
|
return /not authorized|unauthorized|access denied|forbidden/i.test(message);
|
|
222
216
|
}
|
|
223
217
|
|
|
218
|
+
function resolvePrivateReadFailure(state: NewsDeskShellState, auth: NewsDeskShellState["auth"], error: unknown, fallback: string): NewsDeskShellState {
|
|
219
|
+
if (isUnauthenticatedError(error)) return resolveSignedOut(state, { status: "signedOut", label: "Signed out" });
|
|
220
|
+
if (isAuthorizationError(error)) return resolveForbidden(state, auth);
|
|
221
|
+
return resolveDashboardFailure(state, auth, error instanceof Error ? error.message : fallback);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
224
|
export function useOptionalNewsDeskClient() {
|
|
225
225
|
return useContext(NewsDeskClientContext);
|
|
226
226
|
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
+
import { loadReferenceReviewDetails } from "../lib/reference-review-details";
|
|
4
|
+
|
|
3
5
|
import { isEditorialWorkAssignment } from "../lib/newsroom-assignments";
|
|
4
6
|
import { isLocalPreviewEnabled } from "../lib/local-preview";
|
|
5
7
|
import { createLocalPreviewRealPublishedVersions, createLocalPreviewAssignments, createLocalPreviewContentItemVersions, createLocalPreviewReferences } from "../lib/local-preview-seed";
|
|
@@ -830,10 +832,8 @@ export async function loadEditorResolvedAccessState(): Promise<EditorAccessState
|
|
|
830
832
|
};
|
|
831
833
|
}
|
|
832
834
|
try {
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
return { status: "signedOut", isEditor: false, isAdmin: false, auth: snapshot.auth, error: null };
|
|
836
|
-
}
|
|
835
|
+
// One refreshed session supplies both identity and current group claims.
|
|
836
|
+
// A second, sequential session read only extends the blocking access check.
|
|
837
837
|
const refreshed = await loadReaderSessionSnapshot({ forceRefresh: true });
|
|
838
838
|
if (!refreshed.hasSession || refreshed.auth.status === "signedOut") {
|
|
839
839
|
return { status: "signedOut", isEditor: false, isAdmin: false, auth: refreshed.auth, error: null };
|
|
@@ -881,9 +881,8 @@ export async function loadEditorNewsDeskDashboard({ isAdmin }: { isAdmin: boolea
|
|
|
881
881
|
isAdmin ? loadUserDirectory() : Promise.resolve([]),
|
|
882
882
|
]);
|
|
883
883
|
const summaryError = summaryResult.status === "rejected" ? summaryResult.reason : null;
|
|
884
|
-
if (summaryError
|
|
885
|
-
|
|
886
|
-
}
|
|
884
|
+
if (summaryError) throw summaryError;
|
|
885
|
+
if (userDirectoryResult.status === "rejected") throw userDirectoryResult.reason;
|
|
887
886
|
const summary = summaryResult.status === "fulfilled" ? summaryResult.value : null;
|
|
888
887
|
const userDirectory = userDirectoryResult.status === "fulfilled" ? userDirectoryResult.value : [];
|
|
889
888
|
|
|
@@ -1852,9 +1851,29 @@ export async function loadEditorSemanticRelationsData(): Promise<SemanticRelatio
|
|
|
1852
1851
|
pageCount += 1;
|
|
1853
1852
|
} while (nextToken && pageCount < 40);
|
|
1854
1853
|
|
|
1854
|
+
if (nextToken) throw new Error("The activity feed is incomplete. Select a reference to load its prediction directly.");
|
|
1855
1855
|
return rows;
|
|
1856
1856
|
}
|
|
1857
1857
|
|
|
1858
|
+
/** Selected-reference details use indexes, not the bounded global activity feed. */
|
|
1859
|
+
export async function loadEditorReferenceReviewDetails(lineageId: string) {
|
|
1860
|
+
configureAmplifyClient();
|
|
1861
|
+
const testMock = getTestEditorNewsroomMock();
|
|
1862
|
+
return loadReferenceReviewDetails(lineageId, {
|
|
1863
|
+
relations: async (direction) => {
|
|
1864
|
+
if (testMock?.semanticRelations) return testMock.semanticRelations;
|
|
1865
|
+
return loadSemanticRelationsByState({
|
|
1866
|
+
field: direction === "outgoing" ? "listSemanticRelationsBySubjectState" : "listSemanticRelationsByObjectState",
|
|
1867
|
+
keyName: direction === "outgoing" ? "subjectStateKey" : "objectStateKey",
|
|
1868
|
+
keyValue: `reference#${lineageId}#current`,
|
|
1869
|
+
query: direction === "outgoing" ? LIST_SEMANTIC_RELATIONS_BY_SUBJECT_STATE_QUERY : LIST_SEMANTIC_RELATIONS_BY_OBJECT_STATE_QUERY,
|
|
1870
|
+
});
|
|
1871
|
+
},
|
|
1872
|
+
message: async (id) => testMock?.messages ? testMock.messages.find((message) => message.id === id) ?? null : getMessageRecordById(id),
|
|
1873
|
+
payloads: (id) => loadModelPayloadsForOwner("message", id, ["metadata"]),
|
|
1874
|
+
});
|
|
1875
|
+
}
|
|
1876
|
+
|
|
1858
1877
|
export async function loadNewsroomSemanticRelationPage(options: NewsroomSemanticRelationPageOptions = {}): Promise<NewsroomRecordPage<SemanticRelationRecord>> {
|
|
1859
1878
|
return loadNewsroomFeedPage<SemanticRelationRecord>({
|
|
1860
1879
|
query: NEWSROOM_SEMANTIC_RELATION_FEED_QUERY,
|
|
@@ -3134,7 +3153,7 @@ async function loadSemanticRelationsByState({
|
|
|
3134
3153
|
});
|
|
3135
3154
|
assertNoGraphQLErrors(response.errors);
|
|
3136
3155
|
const connection = response.data?.[field];
|
|
3137
|
-
if (!connection)
|
|
3156
|
+
if (!connection) throw new Error(`Missing GraphQL connection payload for ${field}.`);
|
|
3138
3157
|
for (const item of (connection.items ?? []).filter(Boolean) as SemanticRelationRecord[]) {
|
|
3139
3158
|
if (!item.id || seenIds.has(item.id)) continue;
|
|
3140
3159
|
seenIds.add(item.id);
|
|
@@ -3142,6 +3161,7 @@ async function loadSemanticRelationsByState({
|
|
|
3142
3161
|
}
|
|
3143
3162
|
nextToken = connection.nextToken ?? null;
|
|
3144
3163
|
} while (nextToken && pageCount < 200);
|
|
3164
|
+
if (nextToken) throw new Error("Reference details are incomplete. Try again before reviewing its prediction.");
|
|
3145
3165
|
return rows;
|
|
3146
3166
|
}
|
|
3147
3167
|
|