@anthusai/papyrus 5.43.0-next.2 → 5.43.0-next.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/amplify/data/resource.js +6 -0
- package/amplify/data/resource.ts +7 -0
- package/amplify/data/schema.js +6 -0
- package/amplify/data/schema.ts +7 -0
- package/amplify/functions/category-action/handler.ts +2 -1
- package/amplify/functions/content-actions/handler.py +8 -0
- package/amplify/functions/model-attachment-upload/handler.ts +1 -0
- package/amplify/functions/monitoring-view/schema.js +16 -0
- package/amplify/functions/monitoring-view/schema.ts +16 -0
- package/amplify/functions/procedure-action/handler.ts +2 -10
- package/amplify/functions/proposal-actions/proposals.py +20 -3
- package/amplify/plugins/reader-steering/function/handler.py +1 -3
- package/amplify/plugins/reader-steering/function/moderation.py +19 -0
- package/bin/papyrus-app.mjs +12 -1
- package/bin/papyrus-infra.mjs +21 -1
- package/components/dev-sandbox-editor-sign-in.tsx +2 -2
- package/components/monitoring-overview-view.tsx +143 -21
- package/components/monitoring-panel.tsx +64 -4
- package/components/needs-me-bodies.tsx +290 -0
- package/components/needs-me-detail.tsx +23 -0
- package/components/needs-me-feed.tsx +561 -0
- package/components/needs-me-reference-detail.tsx +77 -0
- package/components/needs-me-shared.tsx +39 -0
- package/components/news-desk-client-provider.tsx +24 -3
- package/components/news-desk-page.tsx +2 -1
- package/components/news-desk-taxonomy-client.ts +2 -1
- package/components/newsroom-access-gate.tsx +7 -5
- package/components/newsroom-client-shell.tsx +2 -1
- package/components/newsroom-ops-overview.tsx +64 -55
- package/components/newsroom-ops-shell.tsx +4 -2
- package/components/newsroom-organizer.css +60 -2
- package/components/newsroom-references-view.tsx +301 -138
- package/components/notification-cards.tsx +369 -0
- package/components/notification-center.tsx +324 -213
- package/components/papyrus-console-shell.tsx +2 -1
- package/components/proposals-panel.tsx +21 -24
- package/components/proposals-view.tsx +4 -2
- package/components/reference-capture-client.ts +110 -0
- package/components/reference-capture-panel.tsx +180 -0
- package/components/reference-feed.tsx +503 -0
- package/components/reference-review-card.tsx +110 -141
- package/components/references-page-loading.tsx +61 -0
- package/components/references-prefetch.ts +52 -0
- package/components/review-rating-grid.tsx +180 -0
- package/components/topic-steering-workspace.tsx +44 -9
- package/components/use-media-query.ts +16 -0
- package/components/use-reference-preview-attachments.ts +101 -0
- package/components/use-reference-ratings.ts +134 -0
- package/infra/amplify-app-shell.js +18 -2
- package/infra/amplify-app-shell.ts +18 -2
- package/infra/chat-stack.js +222 -0
- package/infra/chat-stack.ts +250 -0
- package/infra/chat.test.js +142 -0
- package/infra/chat.test.ts +151 -0
- package/infra/index.js +1 -0
- package/infra/site-config.js +58 -1
- package/infra/site-config.ts +78 -1
- package/lib/category-repository.ts +6 -1
- package/lib/content-actions-client.ts +18 -0
- package/lib/local-preview-feed-fixture.ts +63 -0
- package/lib/local-preview-synthetic-rejects.ts +41 -0
- package/lib/monitoring-client.ts +29 -2
- package/lib/monitoring-overview.ts +52 -1
- package/lib/monitoring-paging.ts +58 -0
- package/lib/needs-me-items.ts +289 -0
- package/lib/needs-me-ranking.ts +315 -0
- package/lib/newsroom-chat-identity.ts +2 -1
- package/lib/newsroom-nav.ts +1 -1
- package/lib/newsroom-organizer-nav.ts +2 -1
- package/lib/notification-feed.ts +212 -0
- package/lib/proposals-client.ts +29 -0
- package/lib/reader-subject.ts +3 -2
- package/lib/reference-capture.ts +293 -0
- package/lib/reference-feed-cache.ts +170 -0
- package/lib/reference-feed-ranking.ts +235 -0
- package/lib/reference-preview.ts +112 -0
- package/lib/review-client.ts +55 -5
- package/lib/review-drafts.ts +252 -0
- package/lib/review-fixture.ts +56 -2
- package/lib/review-requests.ts +64 -24
- package/lib/site-brand.ts +2 -0
- package/lib/staff-groups.ts +23 -0
- package/lib/staging-gate.ts +2 -2
- package/package.json +29 -1
- package/routes.manifest.json +1 -1
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useEffect, useRef, useState } from "react";
|
|
2
2
|
import Link from "next/link";
|
|
3
|
+
import { CheckIcon, XIcon } from "lucide-react";
|
|
3
4
|
import { getNewsroomNavHref } from "../lib/newsroom-nav";
|
|
4
5
|
import {
|
|
5
6
|
confidenceText,
|
|
@@ -12,7 +13,12 @@ import {
|
|
|
12
13
|
beginEmailSubmissionAction,
|
|
13
14
|
endEmailSubmissionAction,
|
|
14
15
|
isCurrentEmailSubmissionAction,
|
|
16
|
+
emailSubmissionAccepted,
|
|
17
|
+
QUARANTINE_LABELS,
|
|
18
|
+
type EmailDecisionResult,
|
|
19
|
+
type EmailQuarantine,
|
|
15
20
|
type MonitoringItem,
|
|
21
|
+
type MonitoringListName,
|
|
16
22
|
type MonitoringEmailDetails,
|
|
17
23
|
type MonitoringEmailArticleResolution,
|
|
18
24
|
type EmailReferenceImportItem,
|
|
@@ -25,6 +31,27 @@ import {
|
|
|
25
31
|
|
|
26
32
|
export type MonitoringMode = "publication" | "all";
|
|
27
33
|
|
|
34
|
+
/** How a paged list loads more: the page owns the cursors and the requests, the view only shows the button. */
|
|
35
|
+
export type MonitoringPaging = {
|
|
36
|
+
/** Loads the next page of a list; the page appends it. */
|
|
37
|
+
onLoadMore?: (list: MonitoringListName) => void;
|
|
38
|
+
/** The list whose next page is being fetched. */
|
|
39
|
+
loadingList?: MonitoringListName | null;
|
|
40
|
+
/** Show only email submissions held for review (restarts the email list from its newest held message). */
|
|
41
|
+
heldOnly?: boolean;
|
|
42
|
+
onHeldOnlyChange?: (heldOnly: boolean) => void;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
function LoadMore({ list, cursor, paging }: { list: MonitoringListName; cursor: string | null | undefined; paging?: MonitoringPaging }) {
|
|
46
|
+
if (!cursor || !paging?.onLoadMore) return null;
|
|
47
|
+
const loading = paging.loadingList === list;
|
|
48
|
+
return (
|
|
49
|
+
<button type="button" data-monitoring-load-more={list} disabled={loading} onClick={() => paging.onLoadMore?.(list)}>
|
|
50
|
+
{loading ? "Loading…" : "Load more"}
|
|
51
|
+
</button>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
28
55
|
const STATUS_WORDS: Record<string, string> = { complete: "Complete", partial: "Partial", stopped: "Stopped", failed: "Failed", error: "Error" };
|
|
29
56
|
const status = (value: string) => STATUS_WORDS[value] ?? value;
|
|
30
57
|
const STOP_WORDS: Record<string, string> = {
|
|
@@ -97,8 +124,10 @@ function QueueItem({ item, ownId, demo }: { item: MonitoringItem; ownId: string
|
|
|
97
124
|
);
|
|
98
125
|
}
|
|
99
126
|
|
|
100
|
-
export function EmailSubmissions({ items, detail, onOpen, onResolveArticleLink, onImportCandidates }: {
|
|
127
|
+
export function EmailSubmissions({ items, detail, onOpen, onResolveArticleLink, onImportCandidates, onDecide, cursor, paging }: {
|
|
101
128
|
items: MonitoringEmailSummary[]; detail: MonitoringEmailDetails | null; onOpen?: (receiptId: string) => void;
|
|
129
|
+
cursor?: string | null; paging?: MonitoringPaging;
|
|
130
|
+
onDecide?: (receiptId: string, decision: "accept" | "reject", reason: string) => Promise<EmailDecisionResult>;
|
|
102
131
|
onResolveArticleLink?: (receiptId: string, candidateIndex: number) => Promise<MonitoringEmailArticleResolution>;
|
|
103
132
|
onImportCandidates?: (receiptId: string, items: EmailReferenceImportItem[]) => Promise<EmailReferenceImportOutcome>;
|
|
104
133
|
}) {
|
|
@@ -108,8 +137,16 @@ export function EmailSubmissions({ items, detail, onOpen, onResolveArticleLink,
|
|
|
108
137
|
const [busyImportByReceipt, setBusyImportByReceipt] = useState<Record<string, boolean>>({});
|
|
109
138
|
const [noticeByReceipt, setNoticeByReceipt] = useState<Record<string, string>>({});
|
|
110
139
|
const [importedByReceipt, setImportedByReceipt] = useState<Record<string, EmailReferenceImportOutcome["references"]>>({});
|
|
140
|
+
const [decidedByReceipt, setDecidedByReceipt] = useState<Record<string, EmailQuarantine>>({});
|
|
141
|
+
const [reasonByReceipt, setReasonByReceipt] = useState<Record<string, string>>({});
|
|
142
|
+
const [busyDecisionByReceipt, setBusyDecisionByReceipt] = useState<Record<string, boolean>>({});
|
|
111
143
|
const receiptId = detail && "submission" in detail ? detail.submission.receiptId : null;
|
|
112
144
|
const receiptKey = receiptId ?? "";
|
|
145
|
+
const quarantine = (detail && "submission" in detail ? decidedByReceipt[receiptKey] ?? detail.submission.quarantine : null);
|
|
146
|
+
const accepted = emailSubmissionAccepted(quarantine);
|
|
147
|
+
const groupInfo = detail && "submission" in detail ? items.find((item) => item.receiptId === detail.submission.receiptId)?.group : undefined;
|
|
148
|
+
const reason = reasonByReceipt[receiptKey] ?? "";
|
|
149
|
+
const busyDecision = busyDecisionByReceipt[receiptKey] ?? false;
|
|
113
150
|
const selected = selectedByReceipt[receiptKey] ?? {};
|
|
114
151
|
const resolved = resolvedByReceipt[receiptKey] ?? {};
|
|
115
152
|
const busyIndex = busyIndexByReceipt[receiptKey] ?? null;
|
|
@@ -139,7 +176,7 @@ export function EmailSubmissions({ items, detail, onOpen, onResolveArticleLink,
|
|
|
139
176
|
if (!isCurrentEmailSubmissionAction(activeReceiptId.current, requestReceiptId)) return;
|
|
140
177
|
if (!("url" in result)) {
|
|
141
178
|
const reason = "reason" in result && typeof result.reason === "string"
|
|
142
|
-
&& /^(?:redirect_[a-z_]+|resolution_record_invalid|resolution_not_recorded)$/.test(result.reason)
|
|
179
|
+
&& /^(?:redirect_[a-z_]+|resolution_record_invalid|resolution_not_recorded|invalid_url|not_public|dns|timeout|connection|tls|loop|http_4xx|http_5xx|blocked|too_large|unreadable|unsafe_destination|no_query|search_unavailable|search_error|no_match)$/.test(result.reason)
|
|
143
180
|
? ` (${result.reason})` : "";
|
|
144
181
|
setNoticeByReceipt((current) => ({ ...current, [requestReceiptId]: `The article destination could not be resolved safely${reason}. The original link remains private and unchanged.` }));
|
|
145
182
|
return;
|
|
@@ -147,7 +184,9 @@ export function EmailSubmissions({ items, detail, onOpen, onResolveArticleLink,
|
|
|
147
184
|
const candidate = detail && "submission" in detail ? detail.candidateLinks[index] : null;
|
|
148
185
|
setResolvedByReceipt((current) => ({ ...current, [requestReceiptId]: { ...current[requestReceiptId], [index]: { url: result.url, title: result.title || candidate?.title || "Article" } } }));
|
|
149
186
|
setSelectedByReceipt((current) => ({ ...current, [requestReceiptId]: { ...current[requestReceiptId], [index]: true } }));
|
|
150
|
-
setNoticeByReceipt((current) => ({ ...current, [requestReceiptId]:
|
|
187
|
+
setNoticeByReceipt((current) => ({ ...current, [requestReceiptId]: result.foundVia === "search"
|
|
188
|
+
? `The newsletter link could not be followed, so this article was found by searching the headline (match ${Math.round((result.search?.matchScore ?? 0) * 100)}%). Check that it is the right article before importing it.`
|
|
189
|
+
: "Resolved. This request may be recorded as a click by the newsletter sender." }));
|
|
151
190
|
} catch {
|
|
152
191
|
if (isCurrentEmailSubmissionAction(activeReceiptId.current, requestReceiptId)) {
|
|
153
192
|
setNoticeByReceipt((current) => ({ ...current, [requestReceiptId]: "The article destination could not be resolved. Try again later." }));
|
|
@@ -158,6 +197,34 @@ export function EmailSubmissions({ items, detail, onOpen, onResolveArticleLink,
|
|
|
158
197
|
}
|
|
159
198
|
};
|
|
160
199
|
|
|
200
|
+
const decide = async (decision: "accept" | "reject") => {
|
|
201
|
+
if (!receiptId || !onDecide) return;
|
|
202
|
+
const requestReceiptId = receiptId;
|
|
203
|
+
const actionKey = `decide:${requestReceiptId}`;
|
|
204
|
+
if (!beginEmailSubmissionAction(inFlightActions.current, actionKey)) return;
|
|
205
|
+
setBusyDecisionByReceipt((current) => ({ ...current, [requestReceiptId]: true }));
|
|
206
|
+
setNoticeByReceipt((current) => ({ ...current, [requestReceiptId]: "" }));
|
|
207
|
+
try {
|
|
208
|
+
const result = await onDecide(requestReceiptId, decision, reason.trim());
|
|
209
|
+
if ("ok" in result) {
|
|
210
|
+
setDecidedByReceipt((current) => ({ ...current, [requestReceiptId]: result.quarantine }));
|
|
211
|
+
setNoticeByReceipt((current) => ({ ...current, [requestReceiptId]: decision === "accept"
|
|
212
|
+
? "Accepted. The receiver queues it within a minute; the links can be added to References once it is queued."
|
|
213
|
+
: "Rejected. The message is closed and kept privately until it expires." }));
|
|
214
|
+
} else if ("error" in result && result.quarantine) {
|
|
215
|
+
setDecidedByReceipt((current) => ({ ...current, [requestReceiptId]: result.quarantine as EmailQuarantine }));
|
|
216
|
+
setNoticeByReceipt((current) => ({ ...current, [requestReceiptId]: "Another decision was already recorded for this message; it cannot be changed." }));
|
|
217
|
+
} else {
|
|
218
|
+
setNoticeByReceipt((current) => ({ ...current, [requestReceiptId]: "The decision was not recorded. Check your editor access and try again." }));
|
|
219
|
+
}
|
|
220
|
+
} catch {
|
|
221
|
+
setNoticeByReceipt((current) => ({ ...current, [requestReceiptId]: "The decision was not recorded. It is safe to try again." }));
|
|
222
|
+
} finally {
|
|
223
|
+
endEmailSubmissionAction(inFlightActions.current, actionKey);
|
|
224
|
+
setBusyDecisionByReceipt((current) => ({ ...current, [requestReceiptId]: false }));
|
|
225
|
+
}
|
|
226
|
+
};
|
|
227
|
+
|
|
161
228
|
const importSelected = async () => {
|
|
162
229
|
if (!receiptId || !detail || !("submission" in detail) || !onImportCandidates) return;
|
|
163
230
|
const requestReceiptId = receiptId;
|
|
@@ -199,9 +266,23 @@ export function EmailSubmissions({ items, detail, onOpen, onResolveArticleLink,
|
|
|
199
266
|
return (
|
|
200
267
|
<article className="news-desk-doctrine-card" data-monitoring-section="email-submissions">
|
|
201
268
|
<div className="news-desk-doctrine-card__header"><div><h3>Email submissions</h3><span>Untrusted source material held privately for editor review</span></div></div>
|
|
269
|
+
{paging?.onHeldOnlyChange ? (
|
|
270
|
+
<div className="news-desk-monitoring__scope" role="group" aria-label="Filter email submissions">
|
|
271
|
+
<button type="button" aria-pressed={!paging.heldOnly} data-email-filter="all" onClick={() => paging.onHeldOnlyChange?.(false)}>All</button>
|
|
272
|
+
<button type="button" aria-pressed={paging.heldOnly === true} data-email-filter="held" onClick={() => paging.onHeldOnlyChange?.(true)}>Held for review</button>
|
|
273
|
+
</div>
|
|
274
|
+
) : null}
|
|
202
275
|
{items.length ? <ul className="news-desk-monitoring__list">
|
|
203
276
|
{items.map((item) => <li key={item.receiptId} data-email-submission={item.receiptId}>
|
|
204
277
|
<strong>{item.subject || "(no subject)"}</strong>
|
|
278
|
+
{item.group ? <details data-email-group={item.group.fingerprint}>
|
|
279
|
+
<summary>Received {item.group.count} times · latest {formatUtc(item.observedAt)}</summary>
|
|
280
|
+
<ol>
|
|
281
|
+
{item.group.copies.map((copy) => <li key={copy.receiptId} data-email-copy={copy.receiptId}>
|
|
282
|
+
{formatUtc(copy.observedAt)} · {copy.quarantine ? QUARANTINE_LABELS[copy.quarantine.state] : copy.status}
|
|
283
|
+
</li>)}
|
|
284
|
+
</ol>
|
|
285
|
+
</details> : null}
|
|
205
286
|
<span>{formatUtc(item.observedAt)} · {item.linkCount} candidate links · {item.reviewRequired
|
|
206
287
|
? `${item.linkCount} await editor import` : `${item.processedCount} queued`}</span>
|
|
207
288
|
{item.reviewRequired ? <span>Authentication warnings: private review required; nothing was routed automatically.</span> : null}
|
|
@@ -210,40 +291,71 @@ export function EmailSubmissions({ items, detail, onOpen, onResolveArticleLink,
|
|
|
210
291
|
<span>SES signals — SPF {item.verdicts.spf}, DKIM {item.verdicts.dkim}, DMARC {item.verdicts.dmarc}; spam {item.verdicts.spam}, virus {item.verdicts.virus}</span>
|
|
211
292
|
{item.warnings.length ? <span>Warnings: {item.warnings.join(", ")}</span> : null}
|
|
212
293
|
{item.errorCode ? <span>State: {item.status} ({item.errorCode})</span> : <span>State: {item.status}</span>}
|
|
294
|
+
{item.quarantine ? <span data-email-quarantine-state={item.quarantine.state}>{QUARANTINE_LABELS[item.quarantine.state]}</span> : null}
|
|
213
295
|
{onOpen ? <button type="button" onClick={() => onOpen(item.receiptId)}>Read email and review candidate links</button> : null}
|
|
214
296
|
</li>)}
|
|
215
|
-
</ul> : <p>No email submissions are available for this publication
|
|
297
|
+
</ul> : <p>{paging?.heldOnly ? "No email submissions are held for review." : "No email submissions are available for this publication."}</p>}
|
|
298
|
+
<LoadMore list="emailSubmissions" cursor={cursor} paging={paging} />
|
|
216
299
|
{detail && "submission" in detail ? (
|
|
217
300
|
<section aria-label="Email submission details" data-email-submission-details={detail.submission.receiptId}>
|
|
218
301
|
<h4>{detail.submission.subject || "Submitted email"}</h4>
|
|
219
302
|
<p>Original content is shown as inert text. Sender and message content are untrusted; no instructions are executed.</p>
|
|
220
303
|
<pre style={{ whiteSpace: "pre-wrap", overflowWrap: "anywhere" }}>{detail.safeBodyText}</pre>
|
|
304
|
+
{quarantine ? <section aria-label="Held for review" data-email-quarantine={quarantine.state}>
|
|
305
|
+
<h5>{QUARANTINE_LABELS[quarantine.state]}</h5>
|
|
306
|
+
{quarantine.state === "quarantined" ? <>
|
|
307
|
+
<p>This message was held privately and nothing was routed. Accept it to queue its links as if it had been accepted, or Reject it to close it. Sender identity and authentication signals never grant access; the decision is yours and is recorded.</p>
|
|
308
|
+
{groupInfo ? <p data-email-group-note>One decision applies to all {groupInfo.count} copies of this email.</p> : null}
|
|
309
|
+
<label>Why? (optional)
|
|
310
|
+
<textarea value={reason} rows={3} maxLength={500} style={{ display: "block", width: "100%" }}
|
|
311
|
+
onChange={(event) => setReasonByReceipt((current) => ({ ...current, [receiptKey]: event.target.value }))} />
|
|
312
|
+
</label>
|
|
313
|
+
<button type="button" data-email-decision="accept" disabled={busyDecision || !onDecide} onClick={() => void decide("accept")}>
|
|
314
|
+
<CheckIcon aria-hidden="true" size={16} /> Accept
|
|
315
|
+
</button>
|
|
316
|
+
<button type="button" data-email-decision="reject" disabled={busyDecision || !onDecide} onClick={() => void decide("reject")}>
|
|
317
|
+
<XIcon aria-hidden="true" size={16} /> Reject
|
|
318
|
+
</button>
|
|
319
|
+
</> : null}
|
|
320
|
+
{quarantine.decision ? <p data-email-decision-record>
|
|
321
|
+
{quarantine.decision.action === "accept" ? "Accepted" : "Rejected"}{quarantine.decision.viaGroup ? " (one decision for every copy of this email)" : ""}
|
|
322
|
+
{quarantine.decision.by ? ` by ${quarantine.decision.by}` : ""}{quarantine.decision.at ? ` on ${formatUtc(quarantine.decision.at)}` : ""}
|
|
323
|
+
{quarantine.decision.reason ? `. Why: ${quarantine.decision.reason}` : "."}
|
|
324
|
+
</p> : null}
|
|
325
|
+
{quarantine.failure ? <p role="alert">The decision could not be applied ({quarantine.failure}).</p> : null}
|
|
326
|
+
{quarantine.state === "expired" ? <p>This message expired and can no longer be accepted.</p> : null}
|
|
327
|
+
{notice && !accepted ? <p role="status" data-email-decision-result>{notice}</p> : null}
|
|
328
|
+
</section> : null}
|
|
221
329
|
<h5>Candidate references ({detail.candidateLinks.length})</h5>
|
|
222
330
|
<ul>{detail.candidateLinks.map((link, index) => <li key={`${link.url}-${index}`}>
|
|
223
|
-
{
|
|
331
|
+
{!accepted
|
|
332
|
+
? <span>{link.title || "Source link"} <code>{link.url}</code></span>
|
|
333
|
+
: link.resolutionStatus === "unresolved" && !resolved[index]
|
|
224
334
|
? <span>{link.title || "Newsletter article link"} (tracking redirect not opened)</span>
|
|
225
335
|
: <a href={(resolved[index]?.url ?? link.url)} target="_blank" rel="noreferrer noopener">{resolved[index]?.title ?? (link.title || "Source link")}</a>}
|
|
226
|
-
<label>
|
|
336
|
+
{accepted ? <label>
|
|
227
337
|
<input type="checkbox" checked={selected[index] ?? false}
|
|
228
338
|
disabled={link.resolutionStatus === "unresolved" && !resolved[index]}
|
|
229
339
|
onChange={(event) => setSelectedByReceipt((current) => ({ ...current, [receiptKey]: { ...current[receiptKey], [index]: event.target.checked } }))} />
|
|
230
340
|
Add as pending Reference
|
|
231
|
-
</label>
|
|
232
|
-
{link.resolutionStatus === "unresolved" && !resolved[index] ? (
|
|
341
|
+
</label> : null}
|
|
342
|
+
{accepted && link.resolutionStatus === "unresolved" && !resolved[index] ? (
|
|
233
343
|
<button type="button" disabled={busyIndex !== null || !onResolveArticleLink} onClick={() => void resolveLink(index)}>
|
|
234
344
|
{busyIndex === index ? "Resolving…" : "Resolve article redirect (may register a click)"}
|
|
235
345
|
</button>
|
|
236
346
|
) : null}
|
|
237
347
|
</li>)}</ul>
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
348
|
+
{accepted ? <>
|
|
349
|
+
<p>Adding links creates or reuses pending References. It does not approve, label, classify, or publish them.</p>
|
|
350
|
+
<button type="button" disabled={busyImport || !onImportCandidates || !detail.submission.corpusId}
|
|
351
|
+
onClick={() => void importSelected()}>
|
|
352
|
+
{busyImport ? "Adding References…" : "Add selected links to References"}
|
|
353
|
+
</button>
|
|
354
|
+
{notice ? <p role="status" data-email-import-result>{notice}</p> : null}
|
|
355
|
+
{importedReferences.length ? <ul>{importedReferences.map((reference, index) => (
|
|
356
|
+
<li key={`${reference.id}-${index}`}><Link href={getNewsroomNavHref(`/newsroom/references/${encodeURIComponent(reference.lineageId)}`, false)}>Review imported Reference {index + 1}</Link></li>
|
|
357
|
+
))}</ul> : null}
|
|
358
|
+
</> : <p>Links stay inert text, and nothing can be added to References, until this message is accepted.</p>}
|
|
247
359
|
</section>
|
|
248
360
|
) : detail && "error" in detail ? <p role="alert">You cannot read this submission.</p>
|
|
249
361
|
: detail && "state" in detail ? <p role="alert">Submission details are unavailable.</p> : null}
|
|
@@ -251,8 +363,9 @@ export function EmailSubmissions({ items, detail, onOpen, onResolveArticleLink,
|
|
|
251
363
|
);
|
|
252
364
|
}
|
|
253
365
|
|
|
254
|
-
function Ready({ data, demo, emailDetail, onEmailOpen, onResolveArticleLink, onImportCandidates }: {
|
|
255
|
-
data: MonitoringOverview; demo: boolean; emailDetail: MonitoringEmailDetails | null; onEmailOpen: (id: string) => void;
|
|
366
|
+
function Ready({ data, demo, emailDetail, onEmailOpen, onResolveArticleLink, onImportCandidates, onDecide, paging }: {
|
|
367
|
+
data: MonitoringOverview; demo: boolean; paging?: MonitoringPaging; emailDetail: MonitoringEmailDetails | null; onEmailOpen: (id: string) => void;
|
|
368
|
+
onDecide?: (receiptId: string, decision: "accept" | "reject", reason: string) => Promise<EmailDecisionResult>;
|
|
256
369
|
onResolveArticleLink?: (receiptId: string, candidateIndex: number) => Promise<MonitoringEmailArticleResolution>;
|
|
257
370
|
onImportCandidates?: (receiptId: string, items: EmailReferenceImportItem[]) => Promise<EmailReferenceImportOutcome>;
|
|
258
371
|
}) {
|
|
@@ -264,8 +377,10 @@ function Ready({ data, demo, emailDetail, onEmailOpen, onResolveArticleLink, onI
|
|
|
264
377
|
) : null}
|
|
265
378
|
|
|
266
379
|
<EmailSubmissions items={data.emailSubmissions ?? []} detail={emailDetail} onOpen={onEmailOpen}
|
|
380
|
+
cursor={data.cursors?.emailSubmissions} paging={paging}
|
|
267
381
|
onResolveArticleLink={demo ? undefined : onResolveArticleLink}
|
|
268
|
-
onImportCandidates={demo ? undefined : onImportCandidates}
|
|
382
|
+
onImportCandidates={demo ? undefined : onImportCandidates}
|
|
383
|
+
onDecide={demo ? undefined : onDecide} />
|
|
269
384
|
|
|
270
385
|
<article className="news-desk-doctrine-card" data-monitoring-section="router">
|
|
271
386
|
<div className="news-desk-doctrine-card__header"><div><h3>Router</h3><span>Sends collected items to the shared Cyclotron and delivers relevant ones to References</span></div></div>
|
|
@@ -323,6 +438,7 @@ function Ready({ data, demo, emailDetail, onEmailOpen, onResolveArticleLink, onI
|
|
|
323
438
|
))}
|
|
324
439
|
</ul>
|
|
325
440
|
) : <p>No collector runs recorded yet.</p>}
|
|
441
|
+
<LoadMore list="runs" cursor={data.cursors?.runs} paging={paging} />
|
|
326
442
|
{data.routerRuns.length ? (
|
|
327
443
|
<ul className="news-desk-monitoring__list" data-monitoring-router-runs>
|
|
328
444
|
{data.routerRuns.map((run) => (
|
|
@@ -335,6 +451,7 @@ function Ready({ data, demo, emailDetail, onEmailOpen, onResolveArticleLink, onI
|
|
|
335
451
|
))}
|
|
336
452
|
</ul>
|
|
337
453
|
) : null}
|
|
454
|
+
<LoadMore list="routerRuns" cursor={data.cursors?.routerRuns} paging={paging} />
|
|
338
455
|
</article>
|
|
339
456
|
|
|
340
457
|
<article className="news-desk-doctrine-card" data-monitoring-section="queue">
|
|
@@ -344,6 +461,7 @@ function Ready({ data, demo, emailDetail, onEmailOpen, onResolveArticleLink, onI
|
|
|
344
461
|
{data.items.map((item) => <QueueItem key={`${item.monitorId ?? ""}-${item.sourceId}-${item.revision}`} item={item} ownId={data.scope.publicationId} demo={demo} />)}
|
|
345
462
|
</ul>
|
|
346
463
|
) : <p>No items in the queue.</p>}
|
|
464
|
+
<LoadMore list="items" cursor={data.cursors?.items} paging={paging} />
|
|
347
465
|
{data.hiddenNotRelevant > 0 ? (
|
|
348
466
|
<p className="news-desk-delegation-note" data-monitoring-hidden>
|
|
349
467
|
{data.hiddenNotRelevant} {data.hiddenNotRelevant === 1 ? "item" : "items"} judged not relevant to this publication {data.hiddenNotRelevant === 1 ? "is" : "are"} not listed.
|
|
@@ -373,7 +491,10 @@ export function MonitoringOverviewView({
|
|
|
373
491
|
onEmailOpen = () => {},
|
|
374
492
|
onResolveArticleLink,
|
|
375
493
|
onImportCandidates,
|
|
494
|
+
onDecide,
|
|
495
|
+
paging,
|
|
376
496
|
}: {
|
|
497
|
+
paging?: MonitoringPaging;
|
|
377
498
|
result: MonitoringResult | null;
|
|
378
499
|
canViewAll: boolean;
|
|
379
500
|
mode: MonitoringMode;
|
|
@@ -383,6 +504,7 @@ export function MonitoringOverviewView({
|
|
|
383
504
|
onEmailOpen?: (receiptId: string) => void;
|
|
384
505
|
onResolveArticleLink?: (receiptId: string, candidateIndex: number) => Promise<MonitoringEmailArticleResolution>;
|
|
385
506
|
onImportCandidates?: (receiptId: string, items: EmailReferenceImportItem[]) => Promise<EmailReferenceImportOutcome>;
|
|
507
|
+
onDecide?: (receiptId: string, decision: "accept" | "reject", reason: string) => Promise<EmailDecisionResult>;
|
|
386
508
|
}) {
|
|
387
509
|
let body: React.ReactNode;
|
|
388
510
|
if (result === null) {
|
|
@@ -399,7 +521,7 @@ export function MonitoringOverviewView({
|
|
|
399
521
|
body = <Message kind="empty" title="Nothing has been collected yet">No feed is configured and no run has been recorded. Items appear here after the collector's first run.</Message>;
|
|
400
522
|
} else {
|
|
401
523
|
body = <Ready data={result} demo={demo} emailDetail={emailDetail} onEmailOpen={onEmailOpen}
|
|
402
|
-
onResolveArticleLink={onResolveArticleLink} onImportCandidates={onImportCandidates} />;
|
|
524
|
+
onResolveArticleLink={onResolveArticleLink} onImportCandidates={onImportCandidates} onDecide={onDecide} paging={paging} />;
|
|
403
525
|
}
|
|
404
526
|
const scopeLine = result && "scope" in result
|
|
405
527
|
? (result.scope.mode === "all" ? "Showing the whole shared pipeline, every publication." : `Showing what is relevant to ${result.scope.publicationId ?? "this publication"}.`)
|
|
@@ -3,13 +3,22 @@
|
|
|
3
3
|
import { useEffect, useState } from "react";
|
|
4
4
|
import { useSearchParams } from "next/navigation";
|
|
5
5
|
import {
|
|
6
|
+
decideMonitoringEmailSubmission,
|
|
6
7
|
importMonitoringEmailCandidates,
|
|
7
8
|
loadMonitoringEmailSubmission,
|
|
8
9
|
loadMonitoringOverview,
|
|
10
|
+
loadMonitoringPage,
|
|
9
11
|
resolveMonitoringEmailArticleLink,
|
|
10
12
|
} from "../lib/monitoring-client";
|
|
11
13
|
import { FIXTURE_ALL_PIPELINE_OVERVIEW, FIXTURE_MONITORING_OVERVIEW } from "../lib/monitoring-overview-fixture";
|
|
12
|
-
import
|
|
14
|
+
import { withLoadedPages } from "../lib/monitoring-paging";
|
|
15
|
+
import type {
|
|
16
|
+
EmailReferenceImportItem,
|
|
17
|
+
MonitoringEmailDetails,
|
|
18
|
+
MonitoringListName,
|
|
19
|
+
MonitoringResult,
|
|
20
|
+
} from "../lib/monitoring-overview";
|
|
21
|
+
import type { MonitoringLoadedPages } from "../lib/monitoring-paging";
|
|
13
22
|
import { EmailSubmissions, MonitoringOverviewView, type MonitoringMode } from "./monitoring-overview-view";
|
|
14
23
|
|
|
15
24
|
const REFRESH_MILLISECONDS = 60_000;
|
|
@@ -39,7 +48,8 @@ function MonitoringEmailReceipt({ receiptId, isDemo }: { receiptId: string; isDe
|
|
|
39
48
|
return <div data-news-desk-admin-panel="monitoring">
|
|
40
49
|
{detail === null ? <p role="status">Loading email submission…</p> :
|
|
41
50
|
<EmailSubmissions items={"submission" in detail ? [detail.submission] : []} detail={detail}
|
|
42
|
-
onResolveArticleLink={resolveMonitoringEmailArticleLink} onImportCandidates={importMonitoringEmailCandidates}
|
|
51
|
+
onResolveArticleLink={resolveMonitoringEmailArticleLink} onImportCandidates={importMonitoringEmailCandidates}
|
|
52
|
+
onDecide={decideMonitoringEmailSubmission} />}
|
|
43
53
|
</div>;
|
|
44
54
|
}
|
|
45
55
|
|
|
@@ -48,6 +58,18 @@ function MonitoringOverviewPanel({ isAdmin, isDemo }: { isAdmin: boolean; isDemo
|
|
|
48
58
|
const [mode, setMode] = useState<MonitoringMode>("publication");
|
|
49
59
|
const [result, setResult] = useState<MonitoringResult | null>(null);
|
|
50
60
|
const [emailDetail, setEmailDetail] = useState<MonitoringEmailDetails | null>(null);
|
|
61
|
+
// Pages the editor loaded beyond the newest page of each list. The 60 second refresh re-reads only the newest
|
|
62
|
+
// page, so what was loaded stays on screen.
|
|
63
|
+
const [loaded, setLoaded] = useState<MonitoringLoadedPages>({});
|
|
64
|
+
const [heldOnly, setHeldOnly] = useState(false);
|
|
65
|
+
const [held, setHeld] = useState<MonitoringLoadedPages["emailSubmissions"] | null>(null);
|
|
66
|
+
const [loadingList, setLoadingList] = useState<MonitoringListName | null>(null);
|
|
67
|
+
|
|
68
|
+
useEffect(() => {
|
|
69
|
+
setLoaded({});
|
|
70
|
+
setHeldOnly(false);
|
|
71
|
+
setHeld(null);
|
|
72
|
+
}, [mode]);
|
|
51
73
|
|
|
52
74
|
useEffect(() => {
|
|
53
75
|
if (isDemo) {
|
|
@@ -78,13 +100,51 @@ function MonitoringOverviewPanel({ isAdmin, isDemo }: { isAdmin: boolean; isDemo
|
|
|
78
100
|
setEmailDetail(next);
|
|
79
101
|
};
|
|
80
102
|
|
|
103
|
+
// Appends the next page of a list, or (held filter) the next page of held messages.
|
|
104
|
+
const loadMore = async (list: MonitoringListName) => {
|
|
105
|
+
if (isDemo || !result || !("scope" in result) || loadingList) return;
|
|
106
|
+
const current = withLoadedPages(result as never, loaded, heldOnly ? held : null);
|
|
107
|
+
const cursor = current.cursors?.[list];
|
|
108
|
+
if (!cursor) return;
|
|
109
|
+
setLoadingList(list);
|
|
110
|
+
try {
|
|
111
|
+
const page = await loadMonitoringPage(list, cursor, mode, heldOnly && list === "emailSubmissions");
|
|
112
|
+
if (!("rows" in page)) return;
|
|
113
|
+
if (heldOnly && list === "emailSubmissions") {
|
|
114
|
+
setHeld((existing) => ({ rows: [...(existing?.rows ?? []), ...page.rows] as never, cursor: page.cursor }));
|
|
115
|
+
} else {
|
|
116
|
+
setLoaded((existing) => ({ ...existing, [list]: { rows: [...(existing[list]?.rows ?? []), ...page.rows], cursor: page.cursor } }) as MonitoringLoadedPages);
|
|
117
|
+
}
|
|
118
|
+
} finally {
|
|
119
|
+
setLoadingList(null);
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
// Held for review restarts the email list at its newest held message, however old.
|
|
124
|
+
const changeHeldOnly = async (next: boolean) => {
|
|
125
|
+
setHeldOnly(next);
|
|
126
|
+
setHeld(null);
|
|
127
|
+
if (!next || isDemo) return;
|
|
128
|
+
setLoadingList("emailSubmissions");
|
|
129
|
+
try {
|
|
130
|
+
const page = await loadMonitoringPage("emailSubmissions", null, mode, true);
|
|
131
|
+
if ("rows" in page) setHeld({ rows: page.rows as never, cursor: page.cursor });
|
|
132
|
+
else setHeldOnly(false);
|
|
133
|
+
} finally {
|
|
134
|
+
setLoadingList(null);
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
const shown = result && "scope" in result ? withLoadedPages(result as never, loaded, heldOnly ? held ?? { rows: [], cursor: null } : null) : result;
|
|
139
|
+
|
|
81
140
|
const resolveEmailArticleLink = (receiptId: string, candidateIndex: number) =>
|
|
82
141
|
resolveMonitoringEmailArticleLink(receiptId, candidateIndex);
|
|
83
142
|
|
|
84
143
|
const importEmailCandidates = (receiptId: string, candidates: EmailReferenceImportItem[]) =>
|
|
85
144
|
importMonitoringEmailCandidates(receiptId, candidates);
|
|
86
145
|
|
|
87
|
-
return <MonitoringOverviewView result={
|
|
146
|
+
return <MonitoringOverviewView result={shown} canViewAll={isAdmin} mode={mode} onModeChange={setMode} demo={isDemo}
|
|
88
147
|
emailDetail={emailDetail} onEmailOpen={isDemo ? () => {} : openEmail} onResolveArticleLink={isDemo ? undefined : resolveEmailArticleLink}
|
|
89
|
-
onImportCandidates={importEmailCandidates}
|
|
148
|
+
onImportCandidates={importEmailCandidates} onDecide={isDemo ? undefined : decideMonitoringEmailSubmission}
|
|
149
|
+
paging={isDemo ? undefined : { onLoadMore: (list) => void loadMore(list), loadingList, heldOnly, onHeldOnlyChange: (next) => void changeHeldOnly(next) }} />;
|
|
90
150
|
}
|