@anthusai/papyrus 5.43.0 → 5.44.0
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 +8 -0
- package/amplify/functions/monitoring-view/schema.ts +8 -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 +95 -19
- package/components/monitoring-panel.tsx +4 -2
- 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 +14 -1
- package/lib/monitoring-overview.ts +33 -1
- 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 +4 -1
- package/routes.manifest.json +1 -1
package/infra/site-config.js
CHANGED
|
@@ -28,6 +28,9 @@ const HOST_NAME_PATTERN = /^([a-z0-9]([a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}$/;
|
|
|
28
28
|
const SITE_ORIGIN_PATTERN = /^https:\/\/([a-z0-9]([a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}(:\d{1,5})?$/;
|
|
29
29
|
const DOMAIN_PREFIX_PATTERN = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
|
|
30
30
|
const APP_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9 _.-]*$/;
|
|
31
|
+
const CHAT_MODEL_KEY_PARAMETER_PATTERN = /^\/[A-Za-z0-9_.-]+(\/[A-Za-z0-9_.-]+)+$/;
|
|
32
|
+
const CHAT_LOG_GROUP_PREFIX_PATTERN = /^\/aws\/lambda\/[A-Za-z0-9_.-]{1,100}$/;
|
|
33
|
+
const CHAT_WORKER_CODE_KEY_PATTERN = /^[0-9a-f]{64}\.zip$/;
|
|
31
34
|
const STACK_NAME_PATTERN = /^[A-Za-z][A-Za-z0-9-]{0,127}$/;
|
|
32
35
|
const BRANCH_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9/_.-]*$/;
|
|
33
36
|
const PAPYRUS_VERSION_PATTERN = /^\d+\.\d+\.\d+(-[0-9A-Za-z-]+\.\d+)?$/;
|
|
@@ -211,6 +214,58 @@ export function resolveStagingDomainName(config) {
|
|
|
211
214
|
export function resolveStackName(config) {
|
|
212
215
|
return config.stackName ?? `amplify-app-shell-${config.siteId}`;
|
|
213
216
|
}
|
|
217
|
+
/** The chat data stack of a site; its construct id and CloudFormation name. Nothing when chat is not enabled. */
|
|
218
|
+
export function resolveChatStackName(config) {
|
|
219
|
+
if (!config.chat?.enabled)
|
|
220
|
+
return undefined;
|
|
221
|
+
return config.chat.stackName ?? `papyrus-chat-${config.siteId}`;
|
|
222
|
+
}
|
|
223
|
+
export function resolveChatLogGroupPrefix(config) {
|
|
224
|
+
return config.chat?.logGroupPrefix ?? `/aws/lambda/papyrus-chat-${config.siteId}`;
|
|
225
|
+
}
|
|
226
|
+
/** CloudFormation export names the chat stack publishes and the app shell imports (no stack ever references the other directly). */
|
|
227
|
+
export const CHAT_EXPORTED_RESOURCES = ["MessagingTableName", "ConversationIndexTableName", "SessionBucketName", "TurnRunsQueueUrl", "TurnProbesQueueUrl"];
|
|
228
|
+
export const CHAT_ENVIRONMENT_NAMES = {
|
|
229
|
+
MessagingTableName: "PAPYRUS_CHAT_MESSAGING_TABLE",
|
|
230
|
+
ConversationIndexTableName: "PAPYRUS_CHAT_CONVERSATIONS_TABLE",
|
|
231
|
+
SessionBucketName: "PAPYRUS_CHAT_SESSION_BUCKET",
|
|
232
|
+
TurnRunsQueueUrl: "PAPYRUS_CHAT_TURN_RUNS_QUEUE_URL",
|
|
233
|
+
TurnProbesQueueUrl: "PAPYRUS_CHAT_TURN_PROBES_QUEUE_URL",
|
|
234
|
+
};
|
|
235
|
+
export const CHAT_PRIVATE_ASSISTANT_ID = "papyrus-private-assistant";
|
|
236
|
+
export const CHAT_PRIVATE_ASSISTANT_NAME = "Papyrus Assistant";
|
|
237
|
+
export function chatExportName(stackName, resource) {
|
|
238
|
+
return `${stackName}:${resource}`;
|
|
239
|
+
}
|
|
240
|
+
function parseChat(value, cms) {
|
|
241
|
+
if (value === undefined)
|
|
242
|
+
return undefined;
|
|
243
|
+
const record = requireObject(value, "chat");
|
|
244
|
+
rejectUnknownKeys(record, ["enabled", "modelKeyParameter", "stackName", "logGroupPrefix", "workerCodeS3Key"], "chat");
|
|
245
|
+
if (typeof record.enabled !== "boolean")
|
|
246
|
+
fail("chat.enabled", "must be a boolean");
|
|
247
|
+
const chat = {
|
|
248
|
+
enabled: record.enabled,
|
|
249
|
+
modelKeyParameter: requireString(record.modelKeyParameter, "chat.modelKeyParameter", CHAT_MODEL_KEY_PARAMETER_PATTERN, "SSM parameter name such as /papyrus/<site>/openai-api-key"),
|
|
250
|
+
stackName: optionalString(record.stackName, "chat.stackName", STACK_NAME_PATTERN, "CloudFormation stack name"),
|
|
251
|
+
logGroupPrefix: optionalString(record.logGroupPrefix, "chat.logGroupPrefix", CHAT_LOG_GROUP_PREFIX_PATTERN, "/aws/lambda/<name>"),
|
|
252
|
+
workerCodeS3Key: optionalString(record.workerCodeS3Key, "chat.workerCodeS3Key", CHAT_WORKER_CODE_KEY_PATTERN, "<64 hex>.zip CDK asset key"),
|
|
253
|
+
};
|
|
254
|
+
for (const key of ["stackName", "logGroupPrefix", "workerCodeS3Key"]) {
|
|
255
|
+
if (chat[key] === undefined)
|
|
256
|
+
delete chat[key];
|
|
257
|
+
}
|
|
258
|
+
if (chat.enabled && cms === undefined)
|
|
259
|
+
fail("chat.enabled", "requires cms (the chat gateway runs in the CMS compute role)");
|
|
260
|
+
if (chat.enabled && cms) {
|
|
261
|
+
for (const key of Object.keys(cms.environment)) {
|
|
262
|
+
if (key === "PAPYRUS_PRIVATE_CHAT_ENABLED" || key.startsWith("PAPYRUS_CHAT_")) {
|
|
263
|
+
fail(`cms.environment.${key}`, "is managed by the chat block when chat is enabled: remove it and use chat.enabled");
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
return chat;
|
|
268
|
+
}
|
|
214
269
|
export function resolveStoragePreviewPrefix(config) {
|
|
215
270
|
return config.storagePreviewPrefix ?? "preview/";
|
|
216
271
|
}
|
|
@@ -355,7 +410,7 @@ function parseAws(value) {
|
|
|
355
410
|
}
|
|
356
411
|
export function parseSiteConfig(raw) {
|
|
357
412
|
const record = requireObject(raw, "site");
|
|
358
|
-
rejectUnknownKeys(record, ["siteId", "repository", "repositoryAccessTokenSecretArn", "brand", "frontend", "hostedZoneId", "stackName", "aws", "cms", "email", "reader", "papyrusVersion", "storagePreviewPrefix", "knowledgeRun", "autoDeploy", "github"], "site");
|
|
413
|
+
rejectUnknownKeys(record, ["siteId", "repository", "repositoryAccessTokenSecretArn", "brand", "frontend", "hostedZoneId", "stackName", "aws", "cms", "email", "reader", "papyrusVersion", "storagePreviewPrefix", "chat", "knowledgeRun", "autoDeploy", "github"], "site");
|
|
359
414
|
const siteId = requireString(record.siteId, "siteId", SITE_ID_PATTERN, "kebab-case");
|
|
360
415
|
const repository = requireString(record.repository, "repository", REPOSITORY_PATTERN, "https://github.com/<org>/<repo>");
|
|
361
416
|
const repositoryAccessTokenSecretArn = record.repositoryAccessTokenSecretArn === undefined
|
|
@@ -448,6 +503,7 @@ export function parseSiteConfig(raw) {
|
|
|
448
503
|
fail("github", "ownerId and repoId must be set together");
|
|
449
504
|
github = { owner, repo, branches, ciCanDeployInfra: ciCanDeployInfra, ...(ownerId !== undefined ? { ownerId, repoId } : {}) };
|
|
450
505
|
}
|
|
506
|
+
const chat = parseChat(record.chat, cms);
|
|
451
507
|
const hasDomain = cms?.domainName !== undefined || reader?.domainName !== undefined;
|
|
452
508
|
if (hasDomain && hostedZoneId === undefined) {
|
|
453
509
|
fail("hostedZoneId", "is required when cms.domainName or reader.domainName is set");
|
|
@@ -469,6 +525,7 @@ export function parseSiteConfig(raw) {
|
|
|
469
525
|
reader,
|
|
470
526
|
papyrusVersion,
|
|
471
527
|
storagePreviewPrefix,
|
|
528
|
+
...(chat ? { chat } : {}),
|
|
472
529
|
autoDeploy: autoDeploy,
|
|
473
530
|
github,
|
|
474
531
|
...(record.knowledgeRun !== undefined ? { knowledgeRun: record.knowledgeRun } : {}),
|
package/infra/site-config.ts
CHANGED
|
@@ -55,6 +55,21 @@ export type CmsConfig = {
|
|
|
55
55
|
deployStackInBuild?: boolean;
|
|
56
56
|
};
|
|
57
57
|
|
|
58
|
+
export type ChatConfig = {
|
|
59
|
+
enabled: boolean;
|
|
60
|
+
/** SSM SecureString parameter holding the site's OpenAI key; only the chat run worker may read it. */
|
|
61
|
+
modelKeyParameter: string;
|
|
62
|
+
/** Chat data stack name (also its CDK construct id). Default `papyrus-chat-<siteId>`. */
|
|
63
|
+
stackName?: string;
|
|
64
|
+
/** CloudWatch log group prefix of the two workers (`<prefix>-run-worker`, `<prefix>-probe-worker`). Default `/aws/lambda/papyrus-chat-<siteId>`. */
|
|
65
|
+
logGroupPrefix?: string;
|
|
66
|
+
/**
|
|
67
|
+
* Pins the worker code to an already uploaded CDK asset object (key in the CDK assets bucket) instead of
|
|
68
|
+
* bundling and uploading new bytes. Only for adopting a stack whose workers are already verified live.
|
|
69
|
+
*/
|
|
70
|
+
workerCodeS3Key?: string;
|
|
71
|
+
};
|
|
72
|
+
|
|
58
73
|
export type AmplifyAppShellSiteConfig = {
|
|
59
74
|
siteId: string;
|
|
60
75
|
repository: string;
|
|
@@ -82,6 +97,11 @@ export type AmplifyAppShellSiteConfig = {
|
|
|
82
97
|
};
|
|
83
98
|
papyrusVersion: string;
|
|
84
99
|
storagePreviewPrefix?: string;
|
|
100
|
+
/**
|
|
101
|
+
* Private newsroom chat for this publication (docs/private-newsroom-chat-per-site.md). Absent or
|
|
102
|
+
* `enabled: false` means the site gets neither the chat data stack nor the chat environment on its CMS.
|
|
103
|
+
*/
|
|
104
|
+
chat?: ChatConfig;
|
|
85
105
|
/**
|
|
86
106
|
* The scheduled cloud knowledge run for this publication. The app-shell stack ignores it; it is validated by
|
|
87
107
|
* infra/scheduled-jobs/lib/knowledge-run-config.ts, which owns the schema.
|
|
@@ -153,6 +173,9 @@ const HOST_NAME_PATTERN = /^([a-z0-9]([a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}$/;
|
|
|
153
173
|
const SITE_ORIGIN_PATTERN = /^https:\/\/([a-z0-9]([a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}(:\d{1,5})?$/;
|
|
154
174
|
const DOMAIN_PREFIX_PATTERN = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
|
|
155
175
|
const APP_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9 _.-]*$/;
|
|
176
|
+
const CHAT_MODEL_KEY_PARAMETER_PATTERN = /^\/[A-Za-z0-9_.-]+(\/[A-Za-z0-9_.-]+)+$/;
|
|
177
|
+
const CHAT_LOG_GROUP_PREFIX_PATTERN = /^\/aws\/lambda\/[A-Za-z0-9_.-]{1,100}$/;
|
|
178
|
+
const CHAT_WORKER_CODE_KEY_PATTERN = /^[0-9a-f]{64}\.zip$/;
|
|
156
179
|
const STACK_NAME_PATTERN = /^[A-Za-z][A-Za-z0-9-]{0,127}$/;
|
|
157
180
|
const BRANCH_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9/_.-]*$/;
|
|
158
181
|
const PAPYRUS_VERSION_PATTERN = /^\d+\.\d+\.\d+(-[0-9A-Za-z-]+\.\d+)?$/;
|
|
@@ -339,6 +362,57 @@ export function resolveStackName(config: Pick<AmplifyAppShellSiteConfig, "siteId
|
|
|
339
362
|
return config.stackName ?? `amplify-app-shell-${config.siteId}`;
|
|
340
363
|
}
|
|
341
364
|
|
|
365
|
+
/** The chat data stack of a site; its construct id and CloudFormation name. Nothing when chat is not enabled. */
|
|
366
|
+
export function resolveChatStackName(config: Pick<AmplifyAppShellSiteConfig, "siteId" | "chat">): string | undefined {
|
|
367
|
+
if (!config.chat?.enabled) return undefined;
|
|
368
|
+
return config.chat.stackName ?? `papyrus-chat-${config.siteId}`;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
export function resolveChatLogGroupPrefix(config: Pick<AmplifyAppShellSiteConfig, "siteId" | "chat">): string {
|
|
372
|
+
return config.chat?.logGroupPrefix ?? `/aws/lambda/papyrus-chat-${config.siteId}`;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** CloudFormation export names the chat stack publishes and the app shell imports (no stack ever references the other directly). */
|
|
376
|
+
export const CHAT_EXPORTED_RESOURCES = ["MessagingTableName", "ConversationIndexTableName", "SessionBucketName", "TurnRunsQueueUrl", "TurnProbesQueueUrl"] as const;
|
|
377
|
+
export const CHAT_ENVIRONMENT_NAMES: Record<(typeof CHAT_EXPORTED_RESOURCES)[number], string> = {
|
|
378
|
+
MessagingTableName: "PAPYRUS_CHAT_MESSAGING_TABLE",
|
|
379
|
+
ConversationIndexTableName: "PAPYRUS_CHAT_CONVERSATIONS_TABLE",
|
|
380
|
+
SessionBucketName: "PAPYRUS_CHAT_SESSION_BUCKET",
|
|
381
|
+
TurnRunsQueueUrl: "PAPYRUS_CHAT_TURN_RUNS_QUEUE_URL",
|
|
382
|
+
TurnProbesQueueUrl: "PAPYRUS_CHAT_TURN_PROBES_QUEUE_URL",
|
|
383
|
+
};
|
|
384
|
+
export const CHAT_PRIVATE_ASSISTANT_ID = "papyrus-private-assistant";
|
|
385
|
+
export const CHAT_PRIVATE_ASSISTANT_NAME = "Papyrus Assistant";
|
|
386
|
+
export function chatExportName(stackName: string, resource: (typeof CHAT_EXPORTED_RESOURCES)[number]): string {
|
|
387
|
+
return `${stackName}:${resource}`;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function parseChat(value: unknown, cms: CmsConfig | undefined): ChatConfig | undefined {
|
|
391
|
+
if (value === undefined) return undefined;
|
|
392
|
+
const record = requireObject(value, "chat");
|
|
393
|
+
rejectUnknownKeys(record, ["enabled", "modelKeyParameter", "stackName", "logGroupPrefix", "workerCodeS3Key"], "chat");
|
|
394
|
+
if (typeof record.enabled !== "boolean") fail("chat.enabled", "must be a boolean");
|
|
395
|
+
const chat: ChatConfig = {
|
|
396
|
+
enabled: record.enabled,
|
|
397
|
+
modelKeyParameter: requireString(record.modelKeyParameter, "chat.modelKeyParameter", CHAT_MODEL_KEY_PARAMETER_PATTERN, "SSM parameter name such as /papyrus/<site>/openai-api-key"),
|
|
398
|
+
stackName: optionalString(record.stackName, "chat.stackName", STACK_NAME_PATTERN, "CloudFormation stack name"),
|
|
399
|
+
logGroupPrefix: optionalString(record.logGroupPrefix, "chat.logGroupPrefix", CHAT_LOG_GROUP_PREFIX_PATTERN, "/aws/lambda/<name>"),
|
|
400
|
+
workerCodeS3Key: optionalString(record.workerCodeS3Key, "chat.workerCodeS3Key", CHAT_WORKER_CODE_KEY_PATTERN, "<64 hex>.zip CDK asset key"),
|
|
401
|
+
};
|
|
402
|
+
for (const key of ["stackName", "logGroupPrefix", "workerCodeS3Key"] as const) {
|
|
403
|
+
if (chat[key] === undefined) delete chat[key];
|
|
404
|
+
}
|
|
405
|
+
if (chat.enabled && cms === undefined) fail("chat.enabled", "requires cms (the chat gateway runs in the CMS compute role)");
|
|
406
|
+
if (chat.enabled && cms) {
|
|
407
|
+
for (const key of Object.keys(cms.environment)) {
|
|
408
|
+
if (key === "PAPYRUS_PRIVATE_CHAT_ENABLED" || key.startsWith("PAPYRUS_CHAT_")) {
|
|
409
|
+
fail(`cms.environment.${key}`, "is managed by the chat block when chat is enabled: remove it and use chat.enabled");
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
return chat;
|
|
414
|
+
}
|
|
415
|
+
|
|
342
416
|
export function resolveStoragePreviewPrefix(config: Pick<AmplifyAppShellSiteConfig, "storagePreviewPrefix">): string {
|
|
343
417
|
return config.storagePreviewPrefix ?? "preview/";
|
|
344
418
|
}
|
|
@@ -498,7 +572,7 @@ export function parseSiteConfig(raw: unknown): AmplifyAppShellSiteConfig {
|
|
|
498
572
|
const record = requireObject(raw, "site");
|
|
499
573
|
rejectUnknownKeys(
|
|
500
574
|
record,
|
|
501
|
-
["siteId", "repository", "repositoryAccessTokenSecretArn", "brand", "frontend", "hostedZoneId", "stackName", "aws", "cms", "email", "reader", "papyrusVersion", "storagePreviewPrefix", "knowledgeRun", "autoDeploy", "github"],
|
|
575
|
+
["siteId", "repository", "repositoryAccessTokenSecretArn", "brand", "frontend", "hostedZoneId", "stackName", "aws", "cms", "email", "reader", "papyrusVersion", "storagePreviewPrefix", "chat", "knowledgeRun", "autoDeploy", "github"],
|
|
502
576
|
"site",
|
|
503
577
|
);
|
|
504
578
|
|
|
@@ -590,6 +664,8 @@ export function parseSiteConfig(raw: unknown): AmplifyAppShellSiteConfig {
|
|
|
590
664
|
github = { owner, repo, branches, ciCanDeployInfra: ciCanDeployInfra as boolean, ...(ownerId !== undefined ? { ownerId, repoId } : {}) };
|
|
591
665
|
}
|
|
592
666
|
|
|
667
|
+
const chat = parseChat(record.chat, cms);
|
|
668
|
+
|
|
593
669
|
const hasDomain = cms?.domainName !== undefined || reader?.domainName !== undefined;
|
|
594
670
|
if (hasDomain && hostedZoneId === undefined) {
|
|
595
671
|
fail("hostedZoneId", "is required when cms.domainName or reader.domainName is set");
|
|
@@ -612,6 +688,7 @@ export function parseSiteConfig(raw: unknown): AmplifyAppShellSiteConfig {
|
|
|
612
688
|
reader,
|
|
613
689
|
papyrusVersion,
|
|
614
690
|
storagePreviewPrefix,
|
|
691
|
+
...(chat ? { chat } : {}),
|
|
615
692
|
autoDeploy: autoDeploy as boolean,
|
|
616
693
|
github,
|
|
617
694
|
...(record.knowledgeRun !== undefined ? { knowledgeRun: record.knowledgeRun } : {}),
|
package/lib/monitoring-client.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { generateClient } from "aws-amplify/data";
|
|
4
4
|
import { emailReferenceBatches } from "./monitoring-overview";
|
|
5
5
|
import type {
|
|
6
|
+
EmailDecisionResult,
|
|
6
7
|
EmailReferenceImportItem,
|
|
7
8
|
EmailReferenceImportOutcome,
|
|
8
9
|
MonitoringEmailArticleResolution,
|
|
@@ -10,7 +11,7 @@ import type {
|
|
|
10
11
|
MonitoringResult,
|
|
11
12
|
} from "./monitoring-overview";
|
|
12
13
|
|
|
13
|
-
type QueryField = "monitoringOverview" | "monitoringEmailSubmission" | "monitoringResolveEmailArticleLink" | "intakeEmailSubmissionReferences" | "monitoringQueueEmailReference";
|
|
14
|
+
type QueryField = "monitoringOverview" | "monitoringEmailSubmission" | "monitoringDecideEmailSubmission" | "monitoringResolveEmailArticleLink" | "intakeEmailSubmissionReferences" | "monitoringQueueEmailReference";
|
|
14
15
|
type GraphQLResult = { data?: Partial<Record<QueryField, unknown>>; errors?: { message?: string; errorType?: string }[] };
|
|
15
16
|
|
|
16
17
|
async function query<T>(field: QueryField, queryText: string, variables: Record<string, string | number>): Promise<T | { error: "forbidden" } | { state: "unavailable" }> {
|
|
@@ -40,6 +41,18 @@ export async function loadMonitoringEmailSubmission(receiptId: string): Promise<
|
|
|
40
41
|
`query MonitoringEmailSubmission($receiptId: String!) { monitoringEmailSubmission(receiptId: $receiptId) }`, { receiptId });
|
|
41
42
|
}
|
|
42
43
|
|
|
44
|
+
/**
|
|
45
|
+
* Accept or Reject one held email submission. "Accept" releases it into the normal intake and
|
|
46
|
+
* "Reject" discards it; the editor and the optional reason are recorded server-side.
|
|
47
|
+
*/
|
|
48
|
+
export async function decideMonitoringEmailSubmission(
|
|
49
|
+
receiptId: string, decision: "accept" | "reject", reason: string,
|
|
50
|
+
): Promise<EmailDecisionResult> {
|
|
51
|
+
return query<EmailDecisionResult>("monitoringDecideEmailSubmission",
|
|
52
|
+
`mutation MonitoringDecideEmailSubmission($receiptId: String!, $decision: String!, $reason: String) { monitoringDecideEmailSubmission(receiptId: $receiptId, decision: $decision, reason: $reason) }`,
|
|
53
|
+
{ receiptId, decision: decision === "accept" ? "release" : "discard", reason });
|
|
54
|
+
}
|
|
55
|
+
|
|
43
56
|
/** Resolve one explicitly selected Beehiiv article redirect; this may register a click with the sender. */
|
|
44
57
|
export async function resolveMonitoringEmailArticleLink(receiptId: string, candidateIndex: number): Promise<MonitoringEmailArticleResolution> {
|
|
45
58
|
return query<MonitoringEmailArticleResolution>("monitoringResolveEmailArticleLink",
|
|
@@ -37,9 +37,39 @@ export type MonitoringEmailSummary = {
|
|
|
37
37
|
receiptId: string; observedAt: string | null; status: string; subject: string | null;
|
|
38
38
|
envelopeSender: string | null; headerFrom: string | null; warnings: string[];
|
|
39
39
|
verdicts: Record<string, string>; linkCount: number; processedCount: number; unresolvedLinkCount: number;
|
|
40
|
-
reviewRequired: boolean; errorCode: string | null;
|
|
40
|
+
reviewRequired: boolean; errorCode: string | null; quarantine: EmailQuarantine | null;
|
|
41
41
|
};
|
|
42
42
|
|
|
43
|
+
/** Where a held email submission stands. `null` means it never needed review. */
|
|
44
|
+
export type EmailQuarantineState =
|
|
45
|
+
| "quarantined" | "release_requested" | "released" | "discard_requested" | "discarded"
|
|
46
|
+
| "expired" | "decision_failed" | "release_failed";
|
|
47
|
+
export type EmailQuarantine = {
|
|
48
|
+
state: EmailQuarantineState;
|
|
49
|
+
decision: { action: "accept" | "reject"; by: string; at: string | null; reason: string } | null;
|
|
50
|
+
failure: string | null;
|
|
51
|
+
};
|
|
52
|
+
export type EmailDecisionResult =
|
|
53
|
+
| { ok: true; already: boolean; quarantine: EmailQuarantine }
|
|
54
|
+
| { error: "forbidden" | "bad_request" | "already_decided" | "not_quarantined" | "expired"; quarantine?: EmailQuarantine }
|
|
55
|
+
| { state: "unavailable" | "not_configured" };
|
|
56
|
+
|
|
57
|
+
export const QUARANTINE_LABELS: Record<EmailQuarantineState, string> = {
|
|
58
|
+
quarantined: "Held for review",
|
|
59
|
+
release_requested: "Accepted, being queued",
|
|
60
|
+
released: "Accepted",
|
|
61
|
+
discard_requested: "Rejected, being closed",
|
|
62
|
+
discarded: "Rejected",
|
|
63
|
+
expired: "Expired",
|
|
64
|
+
decision_failed: "Decision could not be applied",
|
|
65
|
+
release_failed: "Accepted, retrying",
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/** Links and import are available only once a message is accepted (or never needed review). */
|
|
69
|
+
export function emailSubmissionAccepted(quarantine: EmailQuarantine | null): boolean {
|
|
70
|
+
return quarantine === null || quarantine.state === "released";
|
|
71
|
+
}
|
|
72
|
+
|
|
43
73
|
export type MonitoringEmailDetails = {
|
|
44
74
|
submission: MonitoringEmailSummary & { corpusId: string | null }; safeBodyText: string;
|
|
45
75
|
candidateLinks: { url: string; title: string; resolutionStatus?: "unresolved" }[]; sourceMaterialIsUntrusted: true;
|
|
@@ -47,6 +77,8 @@ export type MonitoringEmailDetails = {
|
|
|
47
77
|
|
|
48
78
|
export type MonitoringEmailArticleResolution = {
|
|
49
79
|
url: string; title: string; resolved: true;
|
|
80
|
+
/** Present when the original link could not be followed and the destination was found by searching the headline. */
|
|
81
|
+
foundVia?: "search"; search?: { query: string; matchScore: number; provider: string; matchedTitle?: string };
|
|
50
82
|
} | { error: "forbidden" | "bad_request" | "not_resolvable" | "resolution_failed"; reason?: string } | { state: "unavailable" };
|
|
51
83
|
|
|
52
84
|
export type EmailReferenceImportItem = { candidateIndex: number; source_uri: string };
|
|
@@ -2,6 +2,7 @@ import { CognitoJwtVerifier } from "aws-jwt-verify";
|
|
|
2
2
|
import type { JwksCache } from "aws-jwt-verify/jwk";
|
|
3
3
|
import type { NextRequest } from "next/server";
|
|
4
4
|
import { readAmplifyOutputsFile } from "./amplify-outputs-path";
|
|
5
|
+
import { STAFF_GROUPS } from "./staff-groups";
|
|
5
6
|
|
|
6
7
|
export type NewsroomChatIdentity = { subject: string; issuer: string; clientId: string; expiresAt: number };
|
|
7
8
|
export type NewsroomChatIdentityResolver = (request: NextRequest) => Promise<NewsroomChatIdentity | null>;
|
|
@@ -14,7 +15,7 @@ export function newsroomChatIdentityResolver(
|
|
|
14
15
|
userPoolId: auth.user_pool_id,
|
|
15
16
|
clientId: auth.user_pool_client_id,
|
|
16
17
|
tokenUse: "access",
|
|
17
|
-
groups: [
|
|
18
|
+
groups: [...STAFF_GROUPS],
|
|
18
19
|
}, jwksCache ? { jwksCache } : undefined);
|
|
19
20
|
return async (request) => {
|
|
20
21
|
const token = request.headers.get("authorization")?.match(/^Bearer ([^\s]+)$/i)?.[1];
|
|
@@ -11,6 +11,7 @@ export const ORGANIZER_NAV_GROUPS: readonly { title: string; items: readonly Org
|
|
|
11
11
|
{ key: "overview", label: "Overview", description: "See notifications and work needing attention.", path: "/newsroom", primary: true },
|
|
12
12
|
{ key: "articles", label: getContentItemName().plural, description: `Follow ${contentItemPluralLowercase} across their stages and open one to write or revise it.`, path: `/newsroom/${contentItemBoardPathSegment()}`, alsoActiveOnPaths: ["/newsroom/articles"], primary: true },
|
|
13
13
|
{ key: "assignments", label: "Assignments", description: "Follow research and reporting work across its statuses and claim it.", path: "/newsroom/assignments" },
|
|
14
|
+
{ key: "drafts", label: "Drafts", description: "Read the story drafts that AI proposes, then accept or reject them.", path: "/newsroom/administration/proposals" },
|
|
14
15
|
{ key: "messages", label: "Messages", description: "Read and reply to newsroom conversations.", path: "/newsroom/messages", primary: true },
|
|
15
16
|
] },
|
|
16
17
|
{ title: "Research & knowledge", items: [
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { generateClient } from "aws-amplify/data";
|
|
4
|
+
import { EDIT_PROPOSAL, TRANSITION_PROPOSAL, type ProposalMove, type ProposalReceipt } from "./proposals-admin";
|
|
5
|
+
|
|
6
|
+
type GraphResult = { data?: Record<string, ProposalReceipt | null>; errors?: { message?: string }[] };
|
|
7
|
+
|
|
8
|
+
async function run(query: string, field: string, variables: Record<string, string | undefined>): Promise<ProposalReceipt> {
|
|
9
|
+
const client = generateClient({ authMode: "userPool" });
|
|
10
|
+
const result = (await client.graphql({ query, variables })) as GraphResult;
|
|
11
|
+
if (result.errors?.length) return { status: "refused", message: result.errors[0]?.message ?? "The action failed." };
|
|
12
|
+
return result.data?.[field] ?? { status: "refused", message: "The action did not answer." };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Moves a proposal through `path`, one `transitionProposal` call per state, stopping at the first refusal. */
|
|
16
|
+
export async function transitionProposalPath(proposalId: string, path: readonly string[], reason = ""): Promise<ProposalReceipt> {
|
|
17
|
+
let receipt: ProposalReceipt = { status: "refused", message: "Nothing to do." };
|
|
18
|
+
for (const state of path) {
|
|
19
|
+
receipt = await run(TRANSITION_PROPOSAL, "transitionProposal", { proposalId, state, ...(reason ? { reason } : {}) });
|
|
20
|
+
if (receipt.status !== "ok") break;
|
|
21
|
+
}
|
|
22
|
+
return receipt;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** One button of the Proposals panel: a path of states, or `editProposal` for pin and hold. */
|
|
26
|
+
export async function performProposalMove(proposalId: string, move: ProposalMove, reason: string): Promise<ProposalReceipt> {
|
|
27
|
+
if (move.edit) return run(EDIT_PROPOSAL, "editProposal", { proposalId, action: move.edit });
|
|
28
|
+
return transitionProposalPath(proposalId, move.path ?? [], reason);
|
|
29
|
+
}
|
package/lib/reader-subject.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { createHmac } from "node:crypto";
|
|
2
2
|
import type { ProspectusSignedInSession } from "./prospectus-session";
|
|
3
|
+
import { hasEditorRights, STAFF_GROUPS } from "./staff-groups";
|
|
3
4
|
|
|
4
5
|
export const SITE_SUBJECT_PATTERN = /^ss1_[A-Za-z0-9_-]{43}$/;
|
|
5
6
|
export const INTERIM_SUBJECT_PATTERN = /^ssdev_[A-Za-z0-9_-]{43}$/;
|
|
6
7
|
export const READER_DEV_SECRET_ENV = "PAPYRUS_READER_DEV_SECRET";
|
|
7
|
-
export const READER_STAFF_GROUPS =
|
|
8
|
+
export const READER_STAFF_GROUPS = STAFF_GROUPS;
|
|
8
9
|
|
|
9
10
|
const MINIMUM_DEV_SECRET_LENGTH = 16;
|
|
10
11
|
const INTERIM_SUBJECT_PREFIX = "ssdev_";
|
|
@@ -46,7 +47,7 @@ export function readerIsStaff(session: Pick<ProspectusSignedInSession, "accessTo
|
|
|
46
47
|
try {
|
|
47
48
|
const claims = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as { "cognito:groups"?: unknown };
|
|
48
49
|
const groups = claims["cognito:groups"];
|
|
49
|
-
return Array.isArray(groups) && groups
|
|
50
|
+
return Array.isArray(groups) && hasEditorRights(groups);
|
|
50
51
|
} catch {
|
|
51
52
|
return false;
|
|
52
53
|
}
|
package/lib/review-client.ts
CHANGED
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
import { generateClient } from "aws-amplify/data";
|
|
4
4
|
import { getCurrentUser } from "aws-amplify/auth";
|
|
5
|
+
import { draftDecisionUpdate, draftView, type DraftRow, type DraftView } from "./review-drafts";
|
|
5
6
|
import {
|
|
6
7
|
answerUpdate, isDue, normalizeReviewRow, timeoutUpdate,
|
|
7
|
-
type AnswerInput, type ReviewRequest,
|
|
8
|
+
type AnswerInput, type DraftDecision, type ReviewRequest,
|
|
8
9
|
} from "./review-requests";
|
|
9
10
|
|
|
10
11
|
/**
|
|
@@ -118,3 +119,39 @@ export async function loadStoryTitles(ids: string[]): Promise<{ titles: Record<s
|
|
|
118
119
|
}));
|
|
119
120
|
return { titles, deleted };
|
|
120
121
|
}
|
|
122
|
+
|
|
123
|
+
const MAX_DRAFT_LOOKUPS = 60;
|
|
124
|
+
const DRAFT_FIELDS = "id kind title summary body state held evidence history override createdAt";
|
|
125
|
+
|
|
126
|
+
/** Records one draft's decision on the request while it stays open. Same condition as an answer: state = open. */
|
|
127
|
+
export async function saveDraftDecision(request: ReviewRequest, draftId: string, decision: DraftDecision): Promise<ReviewRequest> {
|
|
128
|
+
const result = await run(
|
|
129
|
+
`mutation Decide($input: UpdateReviewRequestInput!, $condition: ModelReviewRequestConditionInput) { updateReviewRequest(input: $input, condition: $condition) { ${FIELDS} } }`,
|
|
130
|
+
{ input: draftDecisionUpdate(request, draftId, decision), condition: { state: { eq: "open" } } },
|
|
131
|
+
);
|
|
132
|
+
if (result.errors?.some((entry) => /conditional/i.test(`${entry.errorType ?? ""} ${entry.message ?? ""}`))) {
|
|
133
|
+
throw new Error("Someone else answered this, or it closed at its deadline. Refresh to see the result.");
|
|
134
|
+
}
|
|
135
|
+
if (result.errors?.length || !result.data?.updateReviewRequest) throw new Error("The draft was decided, but the request could not record it. Try again.");
|
|
136
|
+
return normalizeReviewRow(result.data.updateReviewRequest);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export type DraftLoad = { drafts: Record<string, DraftView>; missing: string[]; failed: string[] };
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The Proposals that review requests are about, by id (editors and admins may read them). `missing` ids have no proposal (an idea kept
|
|
143
|
+
* only as JSON has no proposal to open); `failed` ids could not be read right now, which says nothing about whether they exist.
|
|
144
|
+
*/
|
|
145
|
+
export async function loadDrafts(ids: string[], demo = false): Promise<DraftLoad> {
|
|
146
|
+
const drafts: Record<string, DraftView> = {};
|
|
147
|
+
const missing: string[] = [];
|
|
148
|
+
const failed: string[] = [];
|
|
149
|
+
await Promise.all([...new Set(ids)].slice(0, MAX_DRAFT_LOOKUPS).map(async (id) => {
|
|
150
|
+
const result = await run(`query Draft($id: ID!) { getProposal(id: $id) { ${DRAFT_FIELDS} } }`, { id });
|
|
151
|
+
const row = result.data?.getProposal as DraftRow | null | undefined;
|
|
152
|
+
if (row) drafts[id] = draftView(row, demo);
|
|
153
|
+
else if (result.errors?.length || !result.data) failed.push(id);
|
|
154
|
+
else missing.push(id);
|
|
155
|
+
}));
|
|
156
|
+
return { drafts, missing, failed };
|
|
157
|
+
}
|