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