@anthusai/papyrus 5.43.0-next.1 → 5.43.0-next.10
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/category-action/handler.ts +2 -1
- 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-infra.mjs +21 -1
- package/components/dev-sandbox-editor-sign-in.tsx +2 -2
- package/components/monitoring-overview-view.tsx +133 -21
- package/components/monitoring-panel.tsx +64 -4
- package/components/news-desk-client-provider.tsx +8 -2
- package/components/news-desk-taxonomy-client.ts +2 -1
- 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/papyrus-console-shell.tsx +2 -1
- 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 +29 -2
- package/lib/monitoring-overview.ts +46 -1
- package/lib/monitoring-paging.ts +58 -0
- package/lib/newsroom-chat-identity.ts +2 -1
- package/lib/newsroom-organizer-nav.ts +1 -0
- package/lib/proposals-client.ts +29 -0
- package/lib/reader-subject.ts +3 -2
- 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/lib/staff-groups.ts +23 -0
- package/lib/staging-gate.ts +2 -2
- package/package.json +5 -1
- package/routes.manifest.json +1 -1
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
requestDelegation,
|
|
25
25
|
type DelegationStore,
|
|
26
26
|
} from "../shared/curation-delegation-flow";
|
|
27
|
+
import { claimGroups } from "../../../lib/staff-groups";
|
|
27
28
|
|
|
28
29
|
type ReviewHandler = Schema["reviewSteeringProposal"]["functionHandler"];
|
|
29
30
|
type ReferenceCurationHandler = Schema["reviewReferenceCuration"]["functionHandler"];
|
|
@@ -360,7 +361,7 @@ function requireSignedInPerson(event: { identity?: unknown }): { sub: string; la
|
|
|
360
361
|
}
|
|
361
362
|
const identity = event.identity as { sub?: unknown; claims?: Record<string, unknown> };
|
|
362
363
|
const claims = identity.claims ?? {};
|
|
363
|
-
const groups =
|
|
364
|
+
const groups = claimGroups(claims["cognito:groups"]);
|
|
364
365
|
return {
|
|
365
366
|
sub: normalizeRequiredString(claims.sub ?? identity.sub, "identity.sub"),
|
|
366
367
|
label: normalizeOptionalString(claims.email) ?? normalizeOptionalString(claims.name) ?? "Newsroom editor",
|
|
@@ -14,6 +14,14 @@ export const monitoringViewSchemaDefinitions = {
|
|
|
14
14
|
.returns(a.json())
|
|
15
15
|
.authorization((allow) => [allow.groups(editors)])
|
|
16
16
|
.handler(functionHandler("monitoringView", monitoringView)),
|
|
17
|
+
// One more page of one Monitoring list (runs, routerRuns, items, emailSubmissions), after a cursor from
|
|
18
|
+
// the overview or a previous page. filter "held" lists only held email submissions, restarting from the newest.
|
|
19
|
+
monitoringPage: a
|
|
20
|
+
.query()
|
|
21
|
+
.arguments({ list: a.string().required(), cursor: a.string(), filter: a.string(), scope: a.string() })
|
|
22
|
+
.returns(a.json())
|
|
23
|
+
.authorization((allow) => [allow.groups(editors)])
|
|
24
|
+
.handler(functionHandler("monitoringView", monitoringView)),
|
|
17
25
|
monitoringEmailSubmission: a
|
|
18
26
|
.query()
|
|
19
27
|
.arguments({ receiptId: a.string().required() })
|
|
@@ -26,6 +34,14 @@ export const monitoringViewSchemaDefinitions = {
|
|
|
26
34
|
.returns(a.json())
|
|
27
35
|
.authorization((allow) => [allow.groups(editors)])
|
|
28
36
|
.handler(functionHandler("monitoringView", monitoringView)),
|
|
37
|
+
// Accept ("release") or Reject ("discard") one held email submission. The function validates the
|
|
38
|
+
// signed-in editor and that the message is routed to this publication; the receiver applies it.
|
|
39
|
+
monitoringDecideEmailSubmission: a
|
|
40
|
+
.mutation()
|
|
41
|
+
.arguments({ receiptId: a.string().required(), decision: a.string().required(), reason: a.string() })
|
|
42
|
+
.returns(a.json())
|
|
43
|
+
.authorization((allow) => [allow.groups(editors)])
|
|
44
|
+
.handler(functionHandler("monitoringView", monitoringView)),
|
|
29
45
|
monitoringResolveEmailArticleLink: a
|
|
30
46
|
.mutation()
|
|
31
47
|
.arguments({ receiptId: a.string().required(), candidateIndex: a.integer().required() })
|
|
@@ -16,6 +16,14 @@ export const monitoringViewSchemaDefinitions = {
|
|
|
16
16
|
.returns(a.json())
|
|
17
17
|
.authorization((allow) => [allow.groups(editors)])
|
|
18
18
|
.handler(functionHandler("monitoringView", monitoringView)),
|
|
19
|
+
// One more page of one Monitoring list (runs, routerRuns, items, emailSubmissions), after a cursor from
|
|
20
|
+
// the overview or a previous page. filter "held" lists only held email submissions, restarting from the newest.
|
|
21
|
+
monitoringPage: a
|
|
22
|
+
.query()
|
|
23
|
+
.arguments({ list: a.string().required(), cursor: a.string(), filter: a.string(), scope: a.string() })
|
|
24
|
+
.returns(a.json())
|
|
25
|
+
.authorization((allow) => [allow.groups(editors)])
|
|
26
|
+
.handler(functionHandler("monitoringView", monitoringView)),
|
|
19
27
|
monitoringEmailSubmission: a
|
|
20
28
|
.query()
|
|
21
29
|
.arguments({ receiptId: a.string().required() })
|
|
@@ -28,6 +36,14 @@ export const monitoringViewSchemaDefinitions = {
|
|
|
28
36
|
.returns(a.json())
|
|
29
37
|
.authorization((allow) => [allow.groups(editors)])
|
|
30
38
|
.handler(functionHandler("monitoringView", monitoringView)),
|
|
39
|
+
// Accept ("release") or Reject ("discard") one held email submission. The function validates the
|
|
40
|
+
// signed-in editor and that the message is routed to this publication; the receiver applies it.
|
|
41
|
+
monitoringDecideEmailSubmission: a
|
|
42
|
+
.mutation()
|
|
43
|
+
.arguments({ receiptId: a.string().required(), decision: a.string().required(), reason: a.string() })
|
|
44
|
+
.returns(a.json())
|
|
45
|
+
.authorization((allow) => [allow.groups(editors)])
|
|
46
|
+
.handler(functionHandler("monitoringView", monitoringView)),
|
|
31
47
|
monitoringResolveEmailArticleLink: a
|
|
32
48
|
.mutation()
|
|
33
49
|
.arguments({ receiptId: a.string().required(), candidateIndex: a.integer().required() })
|
|
@@ -3,6 +3,7 @@ import { getAmplifyDataClientConfig } from "@aws-amplify/backend/function/runtim
|
|
|
3
3
|
import { Amplify } from "aws-amplify";
|
|
4
4
|
import { generateClient } from "aws-amplify/data";
|
|
5
5
|
import type { Schema } from "../../data/resource";
|
|
6
|
+
import { claimGroups } from "../../../lib/staff-groups";
|
|
6
7
|
|
|
7
8
|
type DataClient = ReturnType<typeof generateClient<Schema>>;
|
|
8
9
|
type DataClientErrors = Array<{ message?: string | null } | string | null> | null | undefined;
|
|
@@ -474,20 +475,11 @@ function validateJsonSchemaInput(schema: Record<string, unknown>, input: Record<
|
|
|
474
475
|
|
|
475
476
|
function requireAdmin(event: ProcedureEvent): void {
|
|
476
477
|
const claims = objectValue(event.identity?.claims);
|
|
477
|
-
const groups = [
|
|
478
|
-
...normalizeClaimGroups(claims["cognito:groups"]),
|
|
479
|
-
...normalizeClaimGroups(claims.groups),
|
|
480
|
-
];
|
|
478
|
+
const groups = [...claimGroups(claims["cognito:groups"]), ...claimGroups(claims.groups)];
|
|
481
479
|
if (groups.includes("admin")) return;
|
|
482
480
|
throw new Error("Admin role required.");
|
|
483
481
|
}
|
|
484
482
|
|
|
485
|
-
function normalizeClaimGroups(value: unknown): string[] {
|
|
486
|
-
if (Array.isArray(value)) return value.map((entry) => String(entry).trim()).filter(Boolean);
|
|
487
|
-
if (typeof value === "string") return value.split(",").map((entry) => entry.trim()).filter(Boolean);
|
|
488
|
-
return [];
|
|
489
|
-
}
|
|
490
|
-
|
|
491
483
|
async function getDataClient(): Promise<DataClient> {
|
|
492
484
|
if (!clientPromise) {
|
|
493
485
|
clientPromise = (async () => {
|
|
@@ -92,6 +92,25 @@ def is_appsync_event(event: dict[str, Any]) -> bool:
|
|
|
92
92
|
return "arguments" in event and ("info" in event or event_field_name(event) is not None)
|
|
93
93
|
PROPOSER_AI = "ai"
|
|
94
94
|
STAFF_GROUPS = frozenset({"editor", "admin"})
|
|
95
|
+
|
|
96
|
+
_GROUP_SEPARATORS = re.compile(r"[\s,]+")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def claim_groups(value: Any) -> list[str]:
|
|
100
|
+
"""Copy of ``papyrus_content.staff_groups.claim_groups`` (this function ships without that package; a test keeps them equal)."""
|
|
101
|
+
if isinstance(value, (list, tuple, set, frozenset)):
|
|
102
|
+
raw = [item for item in value if isinstance(item, str)]
|
|
103
|
+
elif isinstance(value, str):
|
|
104
|
+
raw = _GROUP_SEPARATORS.split(value)
|
|
105
|
+
else:
|
|
106
|
+
return []
|
|
107
|
+
cleaned = (item.strip().strip("\"'[](){}").strip() for item in raw)
|
|
108
|
+
return [item for item in cleaned if item]
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def has_editor_rights(groups: Any) -> bool:
|
|
112
|
+
"""``admin`` has every editor right: an admin who is not in the ``editor`` group is staff."""
|
|
113
|
+
return any(group.lower() in STAFF_GROUPS for group in claim_groups(groups))
|
|
95
114
|
#: The only moves an operator's IAM role may make, as (from, to). Everything after the draft is an editor's decision.
|
|
96
115
|
OPERATOR_TRANSITIONS = frozenset({(DRAFT, IN_STEERING), (DRAFT, DECLINED)})
|
|
97
116
|
BY_STAFF, BY_OPERATOR = "staff", "operator"
|
|
@@ -140,10 +159,8 @@ def staff_subject(identity: dict[str, Any]) -> str | None:
|
|
|
140
159
|
"""The Cognito subject of an editor or admin, or ``None`` for anyone else."""
|
|
141
160
|
claims = identity.get("claims") or {}
|
|
142
161
|
groups = claims.get("cognito:groups") or identity.get("groups") or []
|
|
143
|
-
if isinstance(groups, str):
|
|
144
|
-
groups = [group.strip() for group in groups.strip("[]").replace(",", " ").split()]
|
|
145
162
|
subject = identity.get("sub")
|
|
146
|
-
if not isinstance(subject, str) or not subject or not
|
|
163
|
+
if not isinstance(subject, str) or not subject or not has_editor_rights(groups):
|
|
147
164
|
return None
|
|
148
165
|
return subject
|
|
149
166
|
|
|
@@ -1468,10 +1468,8 @@ def handle_staff_event(event: dict[str, Any], *, store: Any, settings: ReaderSet
|
|
|
1468
1468
|
identity = event.get("identity") or {}
|
|
1469
1469
|
claims = identity.get("claims") or {}
|
|
1470
1470
|
groups = claims.get("cognito:groups") or identity.get("groups") or []
|
|
1471
|
-
if isinstance(groups, str):
|
|
1472
|
-
groups = [group.strip() for group in groups.strip("[]").replace(",", " ").split()]
|
|
1473
1471
|
subject = identity.get("sub")
|
|
1474
|
-
if not isinstance(subject, str) or not subject or not moderation_rules.
|
|
1472
|
+
if not isinstance(subject, str) or not subject or not moderation_rules.has_editor_rights(groups):
|
|
1475
1473
|
return moderation_rules.refused("not_staff", "Only editors and admins can moderate.").to_dict()
|
|
1476
1474
|
arguments = event.get("arguments") or {}
|
|
1477
1475
|
outcome = moderation_rules.apply_action(
|
|
@@ -37,6 +37,25 @@ READER_ID = re.compile(r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f
|
|
|
37
37
|
ACTION_ID = re.compile(r"mod-[0-9a-f]{32}")
|
|
38
38
|
MAXIMUM_REASON = 500
|
|
39
39
|
STAFF_GROUPS = frozenset({"editor", "admin"})
|
|
40
|
+
|
|
41
|
+
_GROUP_SEPARATORS = re.compile(r"[\s,]+")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def claim_groups(value: Any) -> list[str]:
|
|
45
|
+
"""Copy of ``papyrus_content.staff_groups.claim_groups`` (this function ships without that package; a test keeps them equal)."""
|
|
46
|
+
if isinstance(value, (list, tuple, set, frozenset)):
|
|
47
|
+
raw = [item for item in value if isinstance(item, str)]
|
|
48
|
+
elif isinstance(value, str):
|
|
49
|
+
raw = _GROUP_SEPARATORS.split(value)
|
|
50
|
+
else:
|
|
51
|
+
return []
|
|
52
|
+
cleaned = (item.strip().strip("\"'[](){}").strip() for item in raw)
|
|
53
|
+
return [item for item in cleaned if item]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def has_editor_rights(groups: Any) -> bool:
|
|
57
|
+
"""``admin`` has every editor right: an admin who is not in the ``editor`` group is staff."""
|
|
58
|
+
return any(group.lower() in STAFF_GROUPS for group in claim_groups(groups))
|
|
40
59
|
ITEM_ID_PREFIX = "item-"
|
|
41
60
|
|
|
42
61
|
|
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),
|
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
import { fetchAuthSession, signIn, signOut } from "aws-amplify/auth";
|
|
4
4
|
import { configureAmplifyClient } from "./amplify-client-provider";
|
|
5
|
+
import { hasEditorRights } from "../lib/staff-groups";
|
|
5
6
|
import type { DevSandboxEditorAuth } from "../lib/dev-sandbox-editor-auth";
|
|
6
7
|
|
|
7
|
-
const EDITOR_GROUPS = new Set(["editor", "admin"]);
|
|
8
8
|
|
|
9
9
|
function readSessionGroups(session: Awaited<ReturnType<typeof fetchAuthSession>>): string[] {
|
|
10
10
|
const accessPayload = session.tokens?.accessToken?.payload ?? {};
|
|
@@ -29,7 +29,7 @@ function readSessionGroups(session: Awaited<ReturnType<typeof fetchAuthSession>>
|
|
|
29
29
|
|
|
30
30
|
function sessionHasEditorAccess(session: Awaited<ReturnType<typeof fetchAuthSession>>): boolean {
|
|
31
31
|
if (!session.tokens?.accessToken) return false;
|
|
32
|
-
return readSessionGroups(session)
|
|
32
|
+
return hasEditorRights(readSessionGroups(session));
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
export async function ensureDevSandboxEditorSession(
|
|
@@ -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,15 @@ 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 reason = reasonByReceipt[receiptKey] ?? "";
|
|
148
|
+
const busyDecision = busyDecisionByReceipt[receiptKey] ?? false;
|
|
113
149
|
const selected = selectedByReceipt[receiptKey] ?? {};
|
|
114
150
|
const resolved = resolvedByReceipt[receiptKey] ?? {};
|
|
115
151
|
const busyIndex = busyIndexByReceipt[receiptKey] ?? null;
|
|
@@ -139,7 +175,7 @@ export function EmailSubmissions({ items, detail, onOpen, onResolveArticleLink,
|
|
|
139
175
|
if (!isCurrentEmailSubmissionAction(activeReceiptId.current, requestReceiptId)) return;
|
|
140
176
|
if (!("url" in result)) {
|
|
141
177
|
const reason = "reason" in result && typeof result.reason === "string"
|
|
142
|
-
&& /^(?:redirect_[a-z_]+|resolution_record_invalid|resolution_not_recorded)$/.test(result.reason)
|
|
178
|
+
&& /^(?: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
179
|
? ` (${result.reason})` : "";
|
|
144
180
|
setNoticeByReceipt((current) => ({ ...current, [requestReceiptId]: `The article destination could not be resolved safely${reason}. The original link remains private and unchanged.` }));
|
|
145
181
|
return;
|
|
@@ -147,7 +183,9 @@ export function EmailSubmissions({ items, detail, onOpen, onResolveArticleLink,
|
|
|
147
183
|
const candidate = detail && "submission" in detail ? detail.candidateLinks[index] : null;
|
|
148
184
|
setResolvedByReceipt((current) => ({ ...current, [requestReceiptId]: { ...current[requestReceiptId], [index]: { url: result.url, title: result.title || candidate?.title || "Article" } } }));
|
|
149
185
|
setSelectedByReceipt((current) => ({ ...current, [requestReceiptId]: { ...current[requestReceiptId], [index]: true } }));
|
|
150
|
-
setNoticeByReceipt((current) => ({ ...current, [requestReceiptId]:
|
|
186
|
+
setNoticeByReceipt((current) => ({ ...current, [requestReceiptId]: result.foundVia === "search"
|
|
187
|
+
? `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.`
|
|
188
|
+
: "Resolved. This request may be recorded as a click by the newsletter sender." }));
|
|
151
189
|
} catch {
|
|
152
190
|
if (isCurrentEmailSubmissionAction(activeReceiptId.current, requestReceiptId)) {
|
|
153
191
|
setNoticeByReceipt((current) => ({ ...current, [requestReceiptId]: "The article destination could not be resolved. Try again later." }));
|
|
@@ -158,6 +196,34 @@ export function EmailSubmissions({ items, detail, onOpen, onResolveArticleLink,
|
|
|
158
196
|
}
|
|
159
197
|
};
|
|
160
198
|
|
|
199
|
+
const decide = async (decision: "accept" | "reject") => {
|
|
200
|
+
if (!receiptId || !onDecide) return;
|
|
201
|
+
const requestReceiptId = receiptId;
|
|
202
|
+
const actionKey = `decide:${requestReceiptId}`;
|
|
203
|
+
if (!beginEmailSubmissionAction(inFlightActions.current, actionKey)) return;
|
|
204
|
+
setBusyDecisionByReceipt((current) => ({ ...current, [requestReceiptId]: true }));
|
|
205
|
+
setNoticeByReceipt((current) => ({ ...current, [requestReceiptId]: "" }));
|
|
206
|
+
try {
|
|
207
|
+
const result = await onDecide(requestReceiptId, decision, reason.trim());
|
|
208
|
+
if ("ok" in result) {
|
|
209
|
+
setDecidedByReceipt((current) => ({ ...current, [requestReceiptId]: result.quarantine }));
|
|
210
|
+
setNoticeByReceipt((current) => ({ ...current, [requestReceiptId]: decision === "accept"
|
|
211
|
+
? "Accepted. The receiver queues it within a minute; the links can be added to References once it is queued."
|
|
212
|
+
: "Rejected. The message is closed and kept privately until it expires." }));
|
|
213
|
+
} else if ("error" in result && result.quarantine) {
|
|
214
|
+
setDecidedByReceipt((current) => ({ ...current, [requestReceiptId]: result.quarantine as EmailQuarantine }));
|
|
215
|
+
setNoticeByReceipt((current) => ({ ...current, [requestReceiptId]: "Another decision was already recorded for this message; it cannot be changed." }));
|
|
216
|
+
} else {
|
|
217
|
+
setNoticeByReceipt((current) => ({ ...current, [requestReceiptId]: "The decision was not recorded. Check your editor access and try again." }));
|
|
218
|
+
}
|
|
219
|
+
} catch {
|
|
220
|
+
setNoticeByReceipt((current) => ({ ...current, [requestReceiptId]: "The decision was not recorded. It is safe to try again." }));
|
|
221
|
+
} finally {
|
|
222
|
+
endEmailSubmissionAction(inFlightActions.current, actionKey);
|
|
223
|
+
setBusyDecisionByReceipt((current) => ({ ...current, [requestReceiptId]: false }));
|
|
224
|
+
}
|
|
225
|
+
};
|
|
226
|
+
|
|
161
227
|
const importSelected = async () => {
|
|
162
228
|
if (!receiptId || !detail || !("submission" in detail) || !onImportCandidates) return;
|
|
163
229
|
const requestReceiptId = receiptId;
|
|
@@ -199,6 +265,12 @@ export function EmailSubmissions({ items, detail, onOpen, onResolveArticleLink,
|
|
|
199
265
|
return (
|
|
200
266
|
<article className="news-desk-doctrine-card" data-monitoring-section="email-submissions">
|
|
201
267
|
<div className="news-desk-doctrine-card__header"><div><h3>Email submissions</h3><span>Untrusted source material held privately for editor review</span></div></div>
|
|
268
|
+
{paging?.onHeldOnlyChange ? (
|
|
269
|
+
<div className="news-desk-monitoring__scope" role="group" aria-label="Filter email submissions">
|
|
270
|
+
<button type="button" aria-pressed={!paging.heldOnly} data-email-filter="all" onClick={() => paging.onHeldOnlyChange?.(false)}>All</button>
|
|
271
|
+
<button type="button" aria-pressed={paging.heldOnly === true} data-email-filter="held" onClick={() => paging.onHeldOnlyChange?.(true)}>Held for review</button>
|
|
272
|
+
</div>
|
|
273
|
+
) : null}
|
|
202
274
|
{items.length ? <ul className="news-desk-monitoring__list">
|
|
203
275
|
{items.map((item) => <li key={item.receiptId} data-email-submission={item.receiptId}>
|
|
204
276
|
<strong>{item.subject || "(no subject)"}</strong>
|
|
@@ -210,40 +282,70 @@ export function EmailSubmissions({ items, detail, onOpen, onResolveArticleLink,
|
|
|
210
282
|
<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
283
|
{item.warnings.length ? <span>Warnings: {item.warnings.join(", ")}</span> : null}
|
|
212
284
|
{item.errorCode ? <span>State: {item.status} ({item.errorCode})</span> : <span>State: {item.status}</span>}
|
|
285
|
+
{item.quarantine ? <span data-email-quarantine-state={item.quarantine.state}>{QUARANTINE_LABELS[item.quarantine.state]}</span> : null}
|
|
213
286
|
{onOpen ? <button type="button" onClick={() => onOpen(item.receiptId)}>Read email and review candidate links</button> : null}
|
|
214
287
|
</li>)}
|
|
215
|
-
</ul> : <p>No email submissions are available for this publication
|
|
288
|
+
</ul> : <p>{paging?.heldOnly ? "No email submissions are held for review." : "No email submissions are available for this publication."}</p>}
|
|
289
|
+
<LoadMore list="emailSubmissions" cursor={cursor} paging={paging} />
|
|
216
290
|
{detail && "submission" in detail ? (
|
|
217
291
|
<section aria-label="Email submission details" data-email-submission-details={detail.submission.receiptId}>
|
|
218
292
|
<h4>{detail.submission.subject || "Submitted email"}</h4>
|
|
219
293
|
<p>Original content is shown as inert text. Sender and message content are untrusted; no instructions are executed.</p>
|
|
220
294
|
<pre style={{ whiteSpace: "pre-wrap", overflowWrap: "anywhere" }}>{detail.safeBodyText}</pre>
|
|
295
|
+
{quarantine ? <section aria-label="Held for review" data-email-quarantine={quarantine.state}>
|
|
296
|
+
<h5>{QUARANTINE_LABELS[quarantine.state]}</h5>
|
|
297
|
+
{quarantine.state === "quarantined" ? <>
|
|
298
|
+
<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>
|
|
299
|
+
<label>Why? (optional)
|
|
300
|
+
<textarea value={reason} rows={3} maxLength={500} style={{ display: "block", width: "100%" }}
|
|
301
|
+
onChange={(event) => setReasonByReceipt((current) => ({ ...current, [receiptKey]: event.target.value }))} />
|
|
302
|
+
</label>
|
|
303
|
+
<button type="button" data-email-decision="accept" disabled={busyDecision || !onDecide} onClick={() => void decide("accept")}>
|
|
304
|
+
<CheckIcon aria-hidden="true" size={16} /> Accept
|
|
305
|
+
</button>
|
|
306
|
+
<button type="button" data-email-decision="reject" disabled={busyDecision || !onDecide} onClick={() => void decide("reject")}>
|
|
307
|
+
<XIcon aria-hidden="true" size={16} /> Reject
|
|
308
|
+
</button>
|
|
309
|
+
</> : null}
|
|
310
|
+
{quarantine.decision ? <p data-email-decision-record>
|
|
311
|
+
{quarantine.decision.action === "accept" ? "Accepted" : "Rejected"}
|
|
312
|
+
{quarantine.decision.by ? ` by ${quarantine.decision.by}` : ""}{quarantine.decision.at ? ` on ${formatUtc(quarantine.decision.at)}` : ""}
|
|
313
|
+
{quarantine.decision.reason ? `. Why: ${quarantine.decision.reason}` : "."}
|
|
314
|
+
</p> : null}
|
|
315
|
+
{quarantine.failure ? <p role="alert">The decision could not be applied ({quarantine.failure}).</p> : null}
|
|
316
|
+
{quarantine.state === "expired" ? <p>This message expired and can no longer be accepted.</p> : null}
|
|
317
|
+
{notice && !accepted ? <p role="status" data-email-decision-result>{notice}</p> : null}
|
|
318
|
+
</section> : null}
|
|
221
319
|
<h5>Candidate references ({detail.candidateLinks.length})</h5>
|
|
222
320
|
<ul>{detail.candidateLinks.map((link, index) => <li key={`${link.url}-${index}`}>
|
|
223
|
-
{
|
|
321
|
+
{!accepted
|
|
322
|
+
? <span>{link.title || "Source link"} <code>{link.url}</code></span>
|
|
323
|
+
: link.resolutionStatus === "unresolved" && !resolved[index]
|
|
224
324
|
? <span>{link.title || "Newsletter article link"} (tracking redirect not opened)</span>
|
|
225
325
|
: <a href={(resolved[index]?.url ?? link.url)} target="_blank" rel="noreferrer noopener">{resolved[index]?.title ?? (link.title || "Source link")}</a>}
|
|
226
|
-
<label>
|
|
326
|
+
{accepted ? <label>
|
|
227
327
|
<input type="checkbox" checked={selected[index] ?? false}
|
|
228
328
|
disabled={link.resolutionStatus === "unresolved" && !resolved[index]}
|
|
229
329
|
onChange={(event) => setSelectedByReceipt((current) => ({ ...current, [receiptKey]: { ...current[receiptKey], [index]: event.target.checked } }))} />
|
|
230
330
|
Add as pending Reference
|
|
231
|
-
</label>
|
|
232
|
-
{link.resolutionStatus === "unresolved" && !resolved[index] ? (
|
|
331
|
+
</label> : null}
|
|
332
|
+
{accepted && link.resolutionStatus === "unresolved" && !resolved[index] ? (
|
|
233
333
|
<button type="button" disabled={busyIndex !== null || !onResolveArticleLink} onClick={() => void resolveLink(index)}>
|
|
234
334
|
{busyIndex === index ? "Resolving…" : "Resolve article redirect (may register a click)"}
|
|
235
335
|
</button>
|
|
236
336
|
) : null}
|
|
237
337
|
</li>)}</ul>
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
338
|
+
{accepted ? <>
|
|
339
|
+
<p>Adding links creates or reuses pending References. It does not approve, label, classify, or publish them.</p>
|
|
340
|
+
<button type="button" disabled={busyImport || !onImportCandidates || !detail.submission.corpusId}
|
|
341
|
+
onClick={() => void importSelected()}>
|
|
342
|
+
{busyImport ? "Adding References…" : "Add selected links to References"}
|
|
343
|
+
</button>
|
|
344
|
+
{notice ? <p role="status" data-email-import-result>{notice}</p> : null}
|
|
345
|
+
{importedReferences.length ? <ul>{importedReferences.map((reference, index) => (
|
|
346
|
+
<li key={`${reference.id}-${index}`}><Link href={getNewsroomNavHref(`/newsroom/references/${encodeURIComponent(reference.lineageId)}`, false)}>Review imported Reference {index + 1}</Link></li>
|
|
347
|
+
))}</ul> : null}
|
|
348
|
+
</> : <p>Links stay inert text, and nothing can be added to References, until this message is accepted.</p>}
|
|
247
349
|
</section>
|
|
248
350
|
) : detail && "error" in detail ? <p role="alert">You cannot read this submission.</p>
|
|
249
351
|
: detail && "state" in detail ? <p role="alert">Submission details are unavailable.</p> : null}
|
|
@@ -251,8 +353,9 @@ export function EmailSubmissions({ items, detail, onOpen, onResolveArticleLink,
|
|
|
251
353
|
);
|
|
252
354
|
}
|
|
253
355
|
|
|
254
|
-
function Ready({ data, demo, emailDetail, onEmailOpen, onResolveArticleLink, onImportCandidates }: {
|
|
255
|
-
data: MonitoringOverview; demo: boolean; emailDetail: MonitoringEmailDetails | null; onEmailOpen: (id: string) => void;
|
|
356
|
+
function Ready({ data, demo, emailDetail, onEmailOpen, onResolveArticleLink, onImportCandidates, onDecide, paging }: {
|
|
357
|
+
data: MonitoringOverview; demo: boolean; paging?: MonitoringPaging; emailDetail: MonitoringEmailDetails | null; onEmailOpen: (id: string) => void;
|
|
358
|
+
onDecide?: (receiptId: string, decision: "accept" | "reject", reason: string) => Promise<EmailDecisionResult>;
|
|
256
359
|
onResolveArticleLink?: (receiptId: string, candidateIndex: number) => Promise<MonitoringEmailArticleResolution>;
|
|
257
360
|
onImportCandidates?: (receiptId: string, items: EmailReferenceImportItem[]) => Promise<EmailReferenceImportOutcome>;
|
|
258
361
|
}) {
|
|
@@ -264,8 +367,10 @@ function Ready({ data, demo, emailDetail, onEmailOpen, onResolveArticleLink, onI
|
|
|
264
367
|
) : null}
|
|
265
368
|
|
|
266
369
|
<EmailSubmissions items={data.emailSubmissions ?? []} detail={emailDetail} onOpen={onEmailOpen}
|
|
370
|
+
cursor={data.cursors?.emailSubmissions} paging={paging}
|
|
267
371
|
onResolveArticleLink={demo ? undefined : onResolveArticleLink}
|
|
268
|
-
onImportCandidates={demo ? undefined : onImportCandidates}
|
|
372
|
+
onImportCandidates={demo ? undefined : onImportCandidates}
|
|
373
|
+
onDecide={demo ? undefined : onDecide} />
|
|
269
374
|
|
|
270
375
|
<article className="news-desk-doctrine-card" data-monitoring-section="router">
|
|
271
376
|
<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 +428,7 @@ function Ready({ data, demo, emailDetail, onEmailOpen, onResolveArticleLink, onI
|
|
|
323
428
|
))}
|
|
324
429
|
</ul>
|
|
325
430
|
) : <p>No collector runs recorded yet.</p>}
|
|
431
|
+
<LoadMore list="runs" cursor={data.cursors?.runs} paging={paging} />
|
|
326
432
|
{data.routerRuns.length ? (
|
|
327
433
|
<ul className="news-desk-monitoring__list" data-monitoring-router-runs>
|
|
328
434
|
{data.routerRuns.map((run) => (
|
|
@@ -335,6 +441,7 @@ function Ready({ data, demo, emailDetail, onEmailOpen, onResolveArticleLink, onI
|
|
|
335
441
|
))}
|
|
336
442
|
</ul>
|
|
337
443
|
) : null}
|
|
444
|
+
<LoadMore list="routerRuns" cursor={data.cursors?.routerRuns} paging={paging} />
|
|
338
445
|
</article>
|
|
339
446
|
|
|
340
447
|
<article className="news-desk-doctrine-card" data-monitoring-section="queue">
|
|
@@ -344,6 +451,7 @@ function Ready({ data, demo, emailDetail, onEmailOpen, onResolveArticleLink, onI
|
|
|
344
451
|
{data.items.map((item) => <QueueItem key={`${item.monitorId ?? ""}-${item.sourceId}-${item.revision}`} item={item} ownId={data.scope.publicationId} demo={demo} />)}
|
|
345
452
|
</ul>
|
|
346
453
|
) : <p>No items in the queue.</p>}
|
|
454
|
+
<LoadMore list="items" cursor={data.cursors?.items} paging={paging} />
|
|
347
455
|
{data.hiddenNotRelevant > 0 ? (
|
|
348
456
|
<p className="news-desk-delegation-note" data-monitoring-hidden>
|
|
349
457
|
{data.hiddenNotRelevant} {data.hiddenNotRelevant === 1 ? "item" : "items"} judged not relevant to this publication {data.hiddenNotRelevant === 1 ? "is" : "are"} not listed.
|
|
@@ -373,7 +481,10 @@ export function MonitoringOverviewView({
|
|
|
373
481
|
onEmailOpen = () => {},
|
|
374
482
|
onResolveArticleLink,
|
|
375
483
|
onImportCandidates,
|
|
484
|
+
onDecide,
|
|
485
|
+
paging,
|
|
376
486
|
}: {
|
|
487
|
+
paging?: MonitoringPaging;
|
|
377
488
|
result: MonitoringResult | null;
|
|
378
489
|
canViewAll: boolean;
|
|
379
490
|
mode: MonitoringMode;
|
|
@@ -383,6 +494,7 @@ export function MonitoringOverviewView({
|
|
|
383
494
|
onEmailOpen?: (receiptId: string) => void;
|
|
384
495
|
onResolveArticleLink?: (receiptId: string, candidateIndex: number) => Promise<MonitoringEmailArticleResolution>;
|
|
385
496
|
onImportCandidates?: (receiptId: string, items: EmailReferenceImportItem[]) => Promise<EmailReferenceImportOutcome>;
|
|
497
|
+
onDecide?: (receiptId: string, decision: "accept" | "reject", reason: string) => Promise<EmailDecisionResult>;
|
|
386
498
|
}) {
|
|
387
499
|
let body: React.ReactNode;
|
|
388
500
|
if (result === null) {
|
|
@@ -399,7 +511,7 @@ export function MonitoringOverviewView({
|
|
|
399
511
|
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
512
|
} else {
|
|
401
513
|
body = <Ready data={result} demo={demo} emailDetail={emailDetail} onEmailOpen={onEmailOpen}
|
|
402
|
-
onResolveArticleLink={onResolveArticleLink} onImportCandidates={onImportCandidates} />;
|
|
514
|
+
onResolveArticleLink={onResolveArticleLink} onImportCandidates={onImportCandidates} onDecide={onDecide} paging={paging} />;
|
|
403
515
|
}
|
|
404
516
|
const scopeLine = result && "scope" in result
|
|
405
517
|
? (result.scope.mode === "all" ? "Showing the whole shared pipeline, every publication." : `Showing what is relevant to ${result.scope.publicationId ?? "this publication"}.`)
|