@anthusai/papyrus 5.43.0-next.7 → 5.43.0-next.9
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/functions/monitoring-view/schema.js +8 -0
- package/amplify/functions/monitoring-view/schema.ts +8 -0
- package/bin/papyrus-infra.mjs +21 -1
- package/components/monitoring-overview-view.tsx +95 -19
- package/components/monitoring-panel.tsx +4 -2
- package/components/newsroom-ops-overview.tsx +6 -1
- package/components/newsroom-references-view.tsx +2 -4
- package/components/notification-center.tsx +345 -63
- package/components/proposals-panel.tsx +21 -24
- package/components/proposals-view.tsx +4 -2
- package/components/reference-review-card.tsx +80 -63
- 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/monitoring-client.ts +14 -1
- package/lib/monitoring-overview.ts +33 -1
- package/lib/newsroom-organizer-nav.ts +1 -0
- package/lib/proposals-client.ts +29 -0
- package/lib/review-client.ts +38 -1
- package/lib/review-drafts.ts +231 -0
- package/lib/review-fixture.ts +36 -2
- package/lib/review-requests.ts +64 -24
- package/package.json +3 -1
- package/routes.manifest.json +1 -1
|
@@ -26,6 +26,14 @@ export const monitoringViewSchemaDefinitions = {
|
|
|
26
26
|
.returns(a.json())
|
|
27
27
|
.authorization((allow) => [allow.groups(editors)])
|
|
28
28
|
.handler(functionHandler("monitoringView", monitoringView)),
|
|
29
|
+
// Accept ("release") or Reject ("discard") one held email submission. The function validates the
|
|
30
|
+
// signed-in editor and that the message is routed to this publication; the receiver applies it.
|
|
31
|
+
monitoringDecideEmailSubmission: a
|
|
32
|
+
.mutation()
|
|
33
|
+
.arguments({ receiptId: a.string().required(), decision: a.string().required(), reason: a.string() })
|
|
34
|
+
.returns(a.json())
|
|
35
|
+
.authorization((allow) => [allow.groups(editors)])
|
|
36
|
+
.handler(functionHandler("monitoringView", monitoringView)),
|
|
29
37
|
monitoringResolveEmailArticleLink: a
|
|
30
38
|
.mutation()
|
|
31
39
|
.arguments({ receiptId: a.string().required(), candidateIndex: a.integer().required() })
|
|
@@ -28,6 +28,14 @@ export const monitoringViewSchemaDefinitions = {
|
|
|
28
28
|
.returns(a.json())
|
|
29
29
|
.authorization((allow) => [allow.groups(editors)])
|
|
30
30
|
.handler(functionHandler("monitoringView", monitoringView)),
|
|
31
|
+
// Accept ("release") or Reject ("discard") one held email submission. The function validates the
|
|
32
|
+
// signed-in editor and that the message is routed to this publication; the receiver applies it.
|
|
33
|
+
monitoringDecideEmailSubmission: a
|
|
34
|
+
.mutation()
|
|
35
|
+
.arguments({ receiptId: a.string().required(), decision: a.string().required(), reason: a.string() })
|
|
36
|
+
.returns(a.json())
|
|
37
|
+
.authorization((allow) => [allow.groups(editors)])
|
|
38
|
+
.handler(functionHandler("monitoringView", monitoringView)),
|
|
31
39
|
monitoringResolveEmailArticleLink: a
|
|
32
40
|
.mutation()
|
|
33
41
|
.arguments({ receiptId: a.string().required(), candidateIndex: a.integer().required() })
|
package/bin/papyrus-infra.mjs
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* papyrus-infra synth --site infra/site.json [--out cdk.out] [--account A] [--region R]
|
|
4
4
|
* papyrus-infra synth --account-stack github-oidc [--out cdk.out] [--account A] [--region R]
|
|
5
5
|
* papyrus-infra print-buildspec --site infra/site.json --app cms-production|cms-staging|reader
|
|
6
|
+
* papyrus-infra synth --site infra/site.json --part chat [--chat-lambdas DIR] [--out cdk.out] (the private chat data stack)
|
|
6
7
|
* papyrus-infra deploy --site infra/site.json [--only-branch main] [--only-app-id ID] [--out cdk.out] [--account A] [--region R]
|
|
7
8
|
* Synthesizes the app-shell stack for a publication from its own site.json,
|
|
8
9
|
* prints one of the generated Amplify build specs, or deploys the stack from
|
|
@@ -16,6 +17,7 @@ import { resolveCdkEnvironment } from "../src/cdk-environment.mjs";
|
|
|
16
17
|
|
|
17
18
|
const USAGE = [
|
|
18
19
|
"Usage: papyrus-infra synth --site infra/site.json [--out cdk.out] [--account A] [--region R]",
|
|
20
|
+
" papyrus-infra synth --site infra/site.json --part chat [--chat-lambdas DIR] [--out cdk.out] [--account A] [--region R]",
|
|
19
21
|
" papyrus-infra synth --account-stack github-oidc [--out cdk.out] [--account A] [--region R]",
|
|
20
22
|
" papyrus-infra print-buildspec --site infra/site.json --app cms-production|cms-staging|reader",
|
|
21
23
|
" papyrus-infra deploy-edge --stack NAME --template FILE [--adoption-template FILE --adopt-resources FILE] [--parameter KEY=VALUE]... [--delete-function NAME] [--only-branch main] [--only-app-id ID] [--region R]",
|
|
@@ -186,8 +188,9 @@ if (command === "deploy") {
|
|
|
186
188
|
}
|
|
187
189
|
|
|
188
190
|
let AmplifyAppShellStack;
|
|
191
|
+
let PapyrusChatStack;
|
|
189
192
|
try {
|
|
190
|
-
({ AmplifyAppShellStack } = await import("../infra/index.js"));
|
|
193
|
+
({ AmplifyAppShellStack, PapyrusChatStack } = await import("../infra/index.js"));
|
|
191
194
|
} catch (error) {
|
|
192
195
|
console.error(`papyrus-infra: ${error instanceof Error ? error.message : error}`);
|
|
193
196
|
process.exit(1);
|
|
@@ -195,6 +198,23 @@ try {
|
|
|
195
198
|
|
|
196
199
|
const { App } = createRequire(path.join(process.cwd(), "noop.js"))("aws-cdk-lib");
|
|
197
200
|
const app = new App({ outdir: path.resolve(opt("out", "cdk.out")) });
|
|
201
|
+
if (opt("part", "shell") === "chat") {
|
|
202
|
+
// The site's private newsroom chat data stack (docs/private-newsroom-chat-per-site.md); deployed before the app shell enables chat.
|
|
203
|
+
if (!config.chat?.enabled) {
|
|
204
|
+
console.error(`papyrus-infra: site ${config.siteId} has no enabled chat block`);
|
|
205
|
+
process.exit(1);
|
|
206
|
+
}
|
|
207
|
+
const lambdaAssetDirectory = opt("chat-lambdas");
|
|
208
|
+
const chatStack = new PapyrusChatStack(app, {
|
|
209
|
+
config,
|
|
210
|
+
env: resolveEnvironmentOrExit(),
|
|
211
|
+
lambdaAssetDirectory: lambdaAssetDirectory ? path.resolve(lambdaAssetDirectory) : undefined,
|
|
212
|
+
description: `Private Papyrus newsroom chat data plane for ${config.siteId}.`,
|
|
213
|
+
});
|
|
214
|
+
app.synth();
|
|
215
|
+
console.log(`synthesized ${chatStack.stackName} -> ${path.resolve(opt("out", "cdk.out"))}`);
|
|
216
|
+
process.exit(0);
|
|
217
|
+
}
|
|
198
218
|
new AmplifyAppShellStack(app, `AmplifyAppShell-${config.siteId}`, config, {
|
|
199
219
|
env: resolveEnvironmentOrExit(),
|
|
200
220
|
stackName: resolveStackName(config),
|
|
@@ -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,6 +13,10 @@ 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,
|
|
16
21
|
type MonitoringEmailDetails,
|
|
17
22
|
type MonitoringEmailArticleResolution,
|
|
@@ -97,8 +102,9 @@ function QueueItem({ item, ownId, demo }: { item: MonitoringItem; ownId: string
|
|
|
97
102
|
);
|
|
98
103
|
}
|
|
99
104
|
|
|
100
|
-
export function EmailSubmissions({ items, detail, onOpen, onResolveArticleLink, onImportCandidates }: {
|
|
105
|
+
export function EmailSubmissions({ items, detail, onOpen, onResolveArticleLink, onImportCandidates, onDecide }: {
|
|
101
106
|
items: MonitoringEmailSummary[]; detail: MonitoringEmailDetails | null; onOpen?: (receiptId: string) => void;
|
|
107
|
+
onDecide?: (receiptId: string, decision: "accept" | "reject", reason: string) => Promise<EmailDecisionResult>;
|
|
102
108
|
onResolveArticleLink?: (receiptId: string, candidateIndex: number) => Promise<MonitoringEmailArticleResolution>;
|
|
103
109
|
onImportCandidates?: (receiptId: string, items: EmailReferenceImportItem[]) => Promise<EmailReferenceImportOutcome>;
|
|
104
110
|
}) {
|
|
@@ -108,8 +114,15 @@ export function EmailSubmissions({ items, detail, onOpen, onResolveArticleLink,
|
|
|
108
114
|
const [busyImportByReceipt, setBusyImportByReceipt] = useState<Record<string, boolean>>({});
|
|
109
115
|
const [noticeByReceipt, setNoticeByReceipt] = useState<Record<string, string>>({});
|
|
110
116
|
const [importedByReceipt, setImportedByReceipt] = useState<Record<string, EmailReferenceImportOutcome["references"]>>({});
|
|
117
|
+
const [decidedByReceipt, setDecidedByReceipt] = useState<Record<string, EmailQuarantine>>({});
|
|
118
|
+
const [reasonByReceipt, setReasonByReceipt] = useState<Record<string, string>>({});
|
|
119
|
+
const [busyDecisionByReceipt, setBusyDecisionByReceipt] = useState<Record<string, boolean>>({});
|
|
111
120
|
const receiptId = detail && "submission" in detail ? detail.submission.receiptId : null;
|
|
112
121
|
const receiptKey = receiptId ?? "";
|
|
122
|
+
const quarantine = (detail && "submission" in detail ? decidedByReceipt[receiptKey] ?? detail.submission.quarantine : null);
|
|
123
|
+
const accepted = emailSubmissionAccepted(quarantine);
|
|
124
|
+
const reason = reasonByReceipt[receiptKey] ?? "";
|
|
125
|
+
const busyDecision = busyDecisionByReceipt[receiptKey] ?? false;
|
|
113
126
|
const selected = selectedByReceipt[receiptKey] ?? {};
|
|
114
127
|
const resolved = resolvedByReceipt[receiptKey] ?? {};
|
|
115
128
|
const busyIndex = busyIndexByReceipt[receiptKey] ?? null;
|
|
@@ -139,7 +152,7 @@ export function EmailSubmissions({ items, detail, onOpen, onResolveArticleLink,
|
|
|
139
152
|
if (!isCurrentEmailSubmissionAction(activeReceiptId.current, requestReceiptId)) return;
|
|
140
153
|
if (!("url" in result)) {
|
|
141
154
|
const reason = "reason" in result && typeof result.reason === "string"
|
|
142
|
-
&& /^(?:redirect_[a-z_]+|resolution_record_invalid|resolution_not_recorded)$/.test(result.reason)
|
|
155
|
+
&& /^(?: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
156
|
? ` (${result.reason})` : "";
|
|
144
157
|
setNoticeByReceipt((current) => ({ ...current, [requestReceiptId]: `The article destination could not be resolved safely${reason}. The original link remains private and unchanged.` }));
|
|
145
158
|
return;
|
|
@@ -147,7 +160,9 @@ export function EmailSubmissions({ items, detail, onOpen, onResolveArticleLink,
|
|
|
147
160
|
const candidate = detail && "submission" in detail ? detail.candidateLinks[index] : null;
|
|
148
161
|
setResolvedByReceipt((current) => ({ ...current, [requestReceiptId]: { ...current[requestReceiptId], [index]: { url: result.url, title: result.title || candidate?.title || "Article" } } }));
|
|
149
162
|
setSelectedByReceipt((current) => ({ ...current, [requestReceiptId]: { ...current[requestReceiptId], [index]: true } }));
|
|
150
|
-
setNoticeByReceipt((current) => ({ ...current, [requestReceiptId]:
|
|
163
|
+
setNoticeByReceipt((current) => ({ ...current, [requestReceiptId]: result.foundVia === "search"
|
|
164
|
+
? `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.`
|
|
165
|
+
: "Resolved. This request may be recorded as a click by the newsletter sender." }));
|
|
151
166
|
} catch {
|
|
152
167
|
if (isCurrentEmailSubmissionAction(activeReceiptId.current, requestReceiptId)) {
|
|
153
168
|
setNoticeByReceipt((current) => ({ ...current, [requestReceiptId]: "The article destination could not be resolved. Try again later." }));
|
|
@@ -158,6 +173,34 @@ export function EmailSubmissions({ items, detail, onOpen, onResolveArticleLink,
|
|
|
158
173
|
}
|
|
159
174
|
};
|
|
160
175
|
|
|
176
|
+
const decide = async (decision: "accept" | "reject") => {
|
|
177
|
+
if (!receiptId || !onDecide) return;
|
|
178
|
+
const requestReceiptId = receiptId;
|
|
179
|
+
const actionKey = `decide:${requestReceiptId}`;
|
|
180
|
+
if (!beginEmailSubmissionAction(inFlightActions.current, actionKey)) return;
|
|
181
|
+
setBusyDecisionByReceipt((current) => ({ ...current, [requestReceiptId]: true }));
|
|
182
|
+
setNoticeByReceipt((current) => ({ ...current, [requestReceiptId]: "" }));
|
|
183
|
+
try {
|
|
184
|
+
const result = await onDecide(requestReceiptId, decision, reason.trim());
|
|
185
|
+
if ("ok" in result) {
|
|
186
|
+
setDecidedByReceipt((current) => ({ ...current, [requestReceiptId]: result.quarantine }));
|
|
187
|
+
setNoticeByReceipt((current) => ({ ...current, [requestReceiptId]: decision === "accept"
|
|
188
|
+
? "Accepted. The receiver queues it within a minute; the links can be added to References once it is queued."
|
|
189
|
+
: "Rejected. The message is closed and kept privately until it expires." }));
|
|
190
|
+
} else if ("error" in result && result.quarantine) {
|
|
191
|
+
setDecidedByReceipt((current) => ({ ...current, [requestReceiptId]: result.quarantine as EmailQuarantine }));
|
|
192
|
+
setNoticeByReceipt((current) => ({ ...current, [requestReceiptId]: "Another decision was already recorded for this message; it cannot be changed." }));
|
|
193
|
+
} else {
|
|
194
|
+
setNoticeByReceipt((current) => ({ ...current, [requestReceiptId]: "The decision was not recorded. Check your editor access and try again." }));
|
|
195
|
+
}
|
|
196
|
+
} catch {
|
|
197
|
+
setNoticeByReceipt((current) => ({ ...current, [requestReceiptId]: "The decision was not recorded. It is safe to try again." }));
|
|
198
|
+
} finally {
|
|
199
|
+
endEmailSubmissionAction(inFlightActions.current, actionKey);
|
|
200
|
+
setBusyDecisionByReceipt((current) => ({ ...current, [requestReceiptId]: false }));
|
|
201
|
+
}
|
|
202
|
+
};
|
|
203
|
+
|
|
161
204
|
const importSelected = async () => {
|
|
162
205
|
if (!receiptId || !detail || !("submission" in detail) || !onImportCandidates) return;
|
|
163
206
|
const requestReceiptId = receiptId;
|
|
@@ -210,6 +253,7 @@ export function EmailSubmissions({ items, detail, onOpen, onResolveArticleLink,
|
|
|
210
253
|
<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
254
|
{item.warnings.length ? <span>Warnings: {item.warnings.join(", ")}</span> : null}
|
|
212
255
|
{item.errorCode ? <span>State: {item.status} ({item.errorCode})</span> : <span>State: {item.status}</span>}
|
|
256
|
+
{item.quarantine ? <span data-email-quarantine-state={item.quarantine.state}>{QUARANTINE_LABELS[item.quarantine.state]}</span> : null}
|
|
213
257
|
{onOpen ? <button type="button" onClick={() => onOpen(item.receiptId)}>Read email and review candidate links</button> : null}
|
|
214
258
|
</li>)}
|
|
215
259
|
</ul> : <p>No email submissions are available for this publication.</p>}
|
|
@@ -218,32 +262,60 @@ export function EmailSubmissions({ items, detail, onOpen, onResolveArticleLink,
|
|
|
218
262
|
<h4>{detail.submission.subject || "Submitted email"}</h4>
|
|
219
263
|
<p>Original content is shown as inert text. Sender and message content are untrusted; no instructions are executed.</p>
|
|
220
264
|
<pre style={{ whiteSpace: "pre-wrap", overflowWrap: "anywhere" }}>{detail.safeBodyText}</pre>
|
|
265
|
+
{quarantine ? <section aria-label="Held for review" data-email-quarantine={quarantine.state}>
|
|
266
|
+
<h5>{QUARANTINE_LABELS[quarantine.state]}</h5>
|
|
267
|
+
{quarantine.state === "quarantined" ? <>
|
|
268
|
+
<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>
|
|
269
|
+
<label>Why? (optional)
|
|
270
|
+
<textarea value={reason} rows={3} maxLength={500} style={{ display: "block", width: "100%" }}
|
|
271
|
+
onChange={(event) => setReasonByReceipt((current) => ({ ...current, [receiptKey]: event.target.value }))} />
|
|
272
|
+
</label>
|
|
273
|
+
<button type="button" data-email-decision="accept" disabled={busyDecision || !onDecide} onClick={() => void decide("accept")}>
|
|
274
|
+
<CheckIcon aria-hidden="true" size={16} /> Accept
|
|
275
|
+
</button>
|
|
276
|
+
<button type="button" data-email-decision="reject" disabled={busyDecision || !onDecide} onClick={() => void decide("reject")}>
|
|
277
|
+
<XIcon aria-hidden="true" size={16} /> Reject
|
|
278
|
+
</button>
|
|
279
|
+
</> : null}
|
|
280
|
+
{quarantine.decision ? <p data-email-decision-record>
|
|
281
|
+
{quarantine.decision.action === "accept" ? "Accepted" : "Rejected"}
|
|
282
|
+
{quarantine.decision.by ? ` by ${quarantine.decision.by}` : ""}{quarantine.decision.at ? ` on ${formatUtc(quarantine.decision.at)}` : ""}
|
|
283
|
+
{quarantine.decision.reason ? `. Why: ${quarantine.decision.reason}` : "."}
|
|
284
|
+
</p> : null}
|
|
285
|
+
{quarantine.failure ? <p role="alert">The decision could not be applied ({quarantine.failure}).</p> : null}
|
|
286
|
+
{quarantine.state === "expired" ? <p>This message expired and can no longer be accepted.</p> : null}
|
|
287
|
+
{notice && !accepted ? <p role="status" data-email-decision-result>{notice}</p> : null}
|
|
288
|
+
</section> : null}
|
|
221
289
|
<h5>Candidate references ({detail.candidateLinks.length})</h5>
|
|
222
290
|
<ul>{detail.candidateLinks.map((link, index) => <li key={`${link.url}-${index}`}>
|
|
223
|
-
{
|
|
291
|
+
{!accepted
|
|
292
|
+
? <span>{link.title || "Source link"} <code>{link.url}</code></span>
|
|
293
|
+
: link.resolutionStatus === "unresolved" && !resolved[index]
|
|
224
294
|
? <span>{link.title || "Newsletter article link"} (tracking redirect not opened)</span>
|
|
225
295
|
: <a href={(resolved[index]?.url ?? link.url)} target="_blank" rel="noreferrer noopener">{resolved[index]?.title ?? (link.title || "Source link")}</a>}
|
|
226
|
-
<label>
|
|
296
|
+
{accepted ? <label>
|
|
227
297
|
<input type="checkbox" checked={selected[index] ?? false}
|
|
228
298
|
disabled={link.resolutionStatus === "unresolved" && !resolved[index]}
|
|
229
299
|
onChange={(event) => setSelectedByReceipt((current) => ({ ...current, [receiptKey]: { ...current[receiptKey], [index]: event.target.checked } }))} />
|
|
230
300
|
Add as pending Reference
|
|
231
|
-
</label>
|
|
232
|
-
{link.resolutionStatus === "unresolved" && !resolved[index] ? (
|
|
301
|
+
</label> : null}
|
|
302
|
+
{accepted && link.resolutionStatus === "unresolved" && !resolved[index] ? (
|
|
233
303
|
<button type="button" disabled={busyIndex !== null || !onResolveArticleLink} onClick={() => void resolveLink(index)}>
|
|
234
304
|
{busyIndex === index ? "Resolving…" : "Resolve article redirect (may register a click)"}
|
|
235
305
|
</button>
|
|
236
306
|
) : null}
|
|
237
307
|
</li>)}</ul>
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
308
|
+
{accepted ? <>
|
|
309
|
+
<p>Adding links creates or reuses pending References. It does not approve, label, classify, or publish them.</p>
|
|
310
|
+
<button type="button" disabled={busyImport || !onImportCandidates || !detail.submission.corpusId}
|
|
311
|
+
onClick={() => void importSelected()}>
|
|
312
|
+
{busyImport ? "Adding References…" : "Add selected links to References"}
|
|
313
|
+
</button>
|
|
314
|
+
{notice ? <p role="status" data-email-import-result>{notice}</p> : null}
|
|
315
|
+
{importedReferences.length ? <ul>{importedReferences.map((reference, index) => (
|
|
316
|
+
<li key={`${reference.id}-${index}`}><Link href={getNewsroomNavHref(`/newsroom/references/${encodeURIComponent(reference.lineageId)}`, false)}>Review imported Reference {index + 1}</Link></li>
|
|
317
|
+
))}</ul> : null}
|
|
318
|
+
</> : <p>Links stay inert text, and nothing can be added to References, until this message is accepted.</p>}
|
|
247
319
|
</section>
|
|
248
320
|
) : detail && "error" in detail ? <p role="alert">You cannot read this submission.</p>
|
|
249
321
|
: detail && "state" in detail ? <p role="alert">Submission details are unavailable.</p> : null}
|
|
@@ -251,8 +323,9 @@ export function EmailSubmissions({ items, detail, onOpen, onResolveArticleLink,
|
|
|
251
323
|
);
|
|
252
324
|
}
|
|
253
325
|
|
|
254
|
-
function Ready({ data, demo, emailDetail, onEmailOpen, onResolveArticleLink, onImportCandidates }: {
|
|
326
|
+
function Ready({ data, demo, emailDetail, onEmailOpen, onResolveArticleLink, onImportCandidates, onDecide }: {
|
|
255
327
|
data: MonitoringOverview; demo: boolean; emailDetail: MonitoringEmailDetails | null; onEmailOpen: (id: string) => void;
|
|
328
|
+
onDecide?: (receiptId: string, decision: "accept" | "reject", reason: string) => Promise<EmailDecisionResult>;
|
|
256
329
|
onResolveArticleLink?: (receiptId: string, candidateIndex: number) => Promise<MonitoringEmailArticleResolution>;
|
|
257
330
|
onImportCandidates?: (receiptId: string, items: EmailReferenceImportItem[]) => Promise<EmailReferenceImportOutcome>;
|
|
258
331
|
}) {
|
|
@@ -265,7 +338,8 @@ function Ready({ data, demo, emailDetail, onEmailOpen, onResolveArticleLink, onI
|
|
|
265
338
|
|
|
266
339
|
<EmailSubmissions items={data.emailSubmissions ?? []} detail={emailDetail} onOpen={onEmailOpen}
|
|
267
340
|
onResolveArticleLink={demo ? undefined : onResolveArticleLink}
|
|
268
|
-
onImportCandidates={demo ? undefined : onImportCandidates}
|
|
341
|
+
onImportCandidates={demo ? undefined : onImportCandidates}
|
|
342
|
+
onDecide={demo ? undefined : onDecide} />
|
|
269
343
|
|
|
270
344
|
<article className="news-desk-doctrine-card" data-monitoring-section="router">
|
|
271
345
|
<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>
|
|
@@ -373,6 +447,7 @@ export function MonitoringOverviewView({
|
|
|
373
447
|
onEmailOpen = () => {},
|
|
374
448
|
onResolveArticleLink,
|
|
375
449
|
onImportCandidates,
|
|
450
|
+
onDecide,
|
|
376
451
|
}: {
|
|
377
452
|
result: MonitoringResult | null;
|
|
378
453
|
canViewAll: boolean;
|
|
@@ -383,6 +458,7 @@ export function MonitoringOverviewView({
|
|
|
383
458
|
onEmailOpen?: (receiptId: string) => void;
|
|
384
459
|
onResolveArticleLink?: (receiptId: string, candidateIndex: number) => Promise<MonitoringEmailArticleResolution>;
|
|
385
460
|
onImportCandidates?: (receiptId: string, items: EmailReferenceImportItem[]) => Promise<EmailReferenceImportOutcome>;
|
|
461
|
+
onDecide?: (receiptId: string, decision: "accept" | "reject", reason: string) => Promise<EmailDecisionResult>;
|
|
386
462
|
}) {
|
|
387
463
|
let body: React.ReactNode;
|
|
388
464
|
if (result === null) {
|
|
@@ -399,7 +475,7 @@ export function MonitoringOverviewView({
|
|
|
399
475
|
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
476
|
} else {
|
|
401
477
|
body = <Ready data={result} demo={demo} emailDetail={emailDetail} onEmailOpen={onEmailOpen}
|
|
402
|
-
onResolveArticleLink={onResolveArticleLink} onImportCandidates={onImportCandidates} />;
|
|
478
|
+
onResolveArticleLink={onResolveArticleLink} onImportCandidates={onImportCandidates} onDecide={onDecide} />;
|
|
403
479
|
}
|
|
404
480
|
const scopeLine = result && "scope" in result
|
|
405
481
|
? (result.scope.mode === "all" ? "Showing the whole shared pipeline, every publication." : `Showing what is relevant to ${result.scope.publicationId ?? "this publication"}.`)
|
|
@@ -3,6 +3,7 @@
|
|
|
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,
|
|
@@ -39,7 +40,8 @@ function MonitoringEmailReceipt({ receiptId, isDemo }: { receiptId: string; isDe
|
|
|
39
40
|
return <div data-news-desk-admin-panel="monitoring">
|
|
40
41
|
{detail === null ? <p role="status">Loading email submission…</p> :
|
|
41
42
|
<EmailSubmissions items={"submission" in detail ? [detail.submission] : []} detail={detail}
|
|
42
|
-
onResolveArticleLink={resolveMonitoringEmailArticleLink} onImportCandidates={importMonitoringEmailCandidates}
|
|
43
|
+
onResolveArticleLink={resolveMonitoringEmailArticleLink} onImportCandidates={importMonitoringEmailCandidates}
|
|
44
|
+
onDecide={decideMonitoringEmailSubmission} />}
|
|
43
45
|
</div>;
|
|
44
46
|
}
|
|
45
47
|
|
|
@@ -86,5 +88,5 @@ function MonitoringOverviewPanel({ isAdmin, isDemo }: { isAdmin: boolean; isDemo
|
|
|
86
88
|
|
|
87
89
|
return <MonitoringOverviewView result={result} canViewAll={isAdmin} mode={mode} onModeChange={setMode} demo={isDemo}
|
|
88
90
|
emailDetail={emailDetail} onEmailOpen={isDemo ? () => {} : openEmail} onResolveArticleLink={isDemo ? undefined : resolveEmailArticleLink}
|
|
89
|
-
onImportCandidates={importEmailCandidates} />;
|
|
91
|
+
onImportCandidates={importEmailCandidates} onDecide={isDemo ? undefined : decideMonitoringEmailSubmission} />;
|
|
90
92
|
}
|
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
import { newsroomInboxRowClassName } from "../lib/newsroom-list-selection";
|
|
25
25
|
import type { RelevanceDecisionView } from "../lib/relevance-decisions";
|
|
26
26
|
import { NotificationCenter } from "./notification-center";
|
|
27
|
+
import { CyclotronRow, predictionFromDecision } from "./reference-review-card";
|
|
27
28
|
|
|
28
29
|
type NewsroomOpsOverviewProps = {
|
|
29
30
|
assignments: AssignmentRecord[];
|
|
@@ -105,9 +106,13 @@ export function NewsroomOpsOverview({
|
|
|
105
106
|
href={href}
|
|
106
107
|
key={lineageId}
|
|
107
108
|
>
|
|
108
|
-
<div className="min-w-0 flex-1">
|
|
109
|
+
<div className="min-w-0 flex-1 space-y-1">
|
|
109
110
|
<p className="truncate text-sm font-medium text-foreground">{referenceDisplayTitle(reference)}</p>
|
|
110
111
|
<p className="text-xs text-muted-foreground">Review source intake · {formatReferenceListDate(reference)}</p>
|
|
112
|
+
<div data-needs-attention-suggestion={lineageId}>
|
|
113
|
+
<CyclotronRow prediction={decisions.get(lineageId) ? predictionFromDecision(decisions.get(lineageId)!) : { state: "none" }} />
|
|
114
|
+
</div>
|
|
115
|
+
<p className="text-xs font-medium text-foreground">Open it to Accept or Reject in the review queue</p>
|
|
111
116
|
</div>
|
|
112
117
|
<Badge className="mt-0.5 shrink-0" variant="outline">Pending</Badge>
|
|
113
118
|
</Link>
|
|
@@ -44,6 +44,7 @@ import { newsroomListRowClassName } from "../lib/newsroom-list-selection";
|
|
|
44
44
|
import { ReferenceSourcePreview } from "./reference-source-preview";
|
|
45
45
|
import {
|
|
46
46
|
ReviewCard,
|
|
47
|
+
predictionFromDecision,
|
|
47
48
|
ReviewQueueBar,
|
|
48
49
|
useQueueGestures,
|
|
49
50
|
type CardPrediction,
|
|
@@ -269,10 +270,7 @@ function summaryOf(reference: ReferenceRecord): string | null {
|
|
|
269
270
|
}
|
|
270
271
|
|
|
271
272
|
function cardPrediction(decision: RelevanceDecisionView | undefined, predictionState: PredictionState): CardPrediction {
|
|
272
|
-
if (decision)
|
|
273
|
-
const positive = decision.positiveLabel ?? "include";
|
|
274
|
-
return { state: "ready", choice: decision.label === positive ? "accept" : "reject", confidence: decision.confidence };
|
|
275
|
-
}
|
|
273
|
+
if (decision) return predictionFromDecision(decision);
|
|
276
274
|
if (predictionState === "loading") return { state: "loading" };
|
|
277
275
|
if (predictionState === "error") return { state: "error" };
|
|
278
276
|
return { state: "none" };
|