@anthusai/papyrus 5.33.1 → 5.34.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.
Files changed (40) hide show
  1. package/amplify/site-backend-config.ts +1 -1
  2. package/amplify/site-backend-names.js +4 -2
  3. package/amplify/site-backend-names.ts +4 -2
  4. package/amplify/site-backend.js +1 -1
  5. package/amplify/site-backend.ts +1 -1
  6. package/components/news-desk-client-provider.tsx +18 -18
  7. package/components/news-desk-taxonomy-client.ts +28 -8
  8. package/components/newsroom-access-gate.tsx +15 -1
  9. package/components/newsroom-article-editor.tsx +4 -2
  10. package/components/newsroom-articles-view.tsx +6 -6
  11. package/components/newsroom-board-view.tsx +8 -3
  12. package/components/newsroom-ops-overview.tsx +12 -100
  13. package/components/newsroom-ops-shell.tsx +2 -2
  14. package/components/newsroom-organizer.css +2 -0
  15. package/components/newsroom-overview-skeleton.tsx +25 -0
  16. package/components/newsroom-references-view.tsx +179 -36
  17. package/components/notification-center.tsx +13 -2
  18. package/components/reader-auth-state.ts +1 -1
  19. package/components/reference-cyclotron-suggestion.tsx +9 -5
  20. package/components/reference-relevance-review.tsx +3 -3
  21. package/components/topic-steering-workspace.tsx +144 -123
  22. package/infra/amplify-app-shell.js +4 -0
  23. package/infra/amplify-app-shell.ts +4 -0
  24. package/infra/cloud-email-config.js +32 -0
  25. package/infra/cloud-email-config.ts +39 -0
  26. package/infra/cloud-email.js +57 -0
  27. package/infra/cloud-email.test.js +69 -0
  28. package/infra/cloud-email.test.ts +66 -0
  29. package/infra/cloud-email.ts +60 -0
  30. package/infra/site-config.js +4 -2
  31. package/infra/site-config.ts +7 -2
  32. package/lib/cyclotron-suggestions.ts +4 -4
  33. package/lib/news-desk-session.ts +12 -21
  34. package/lib/newsroom-organizer-nav.ts +2 -1
  35. package/lib/reference-review-details.ts +71 -0
  36. package/lib/reference-review-request.ts +14 -0
  37. package/lib/relevance-decisions.ts +42 -10
  38. package/lib/use-reference-review-data.ts +44 -0
  39. package/package.json +5 -1
  40. package/routes.manifest.json +1 -1
@@ -0,0 +1,66 @@
1
+ import assert from "node:assert/strict";
2
+ import { App } from "aws-cdk-lib";
3
+ import { Template } from "aws-cdk-lib/assertions";
4
+ import { AmplifyAppShellStack } from "./amplify-app-shell";
5
+ import { parseSiteConfig } from "./site-config";
6
+
7
+ const base = () => ({
8
+ siteId: "example", repository: "https://github.com/example/publication", brand: "example",
9
+ frontend: "markus-static", papyrusVersion: "5.32.0", autoDeploy: false,
10
+ cms: { cognitoDomainPrefix: "example-cms", staging: false,
11
+ environment: { PAPYRUS_OAUTH_REDIRECT_URLS: "http://localhost:3001/" } },
12
+ });
13
+ function synth(raw: unknown) {
14
+ const app = new App();
15
+ return Template.fromStack(new AmplifyAppShellStack(app, "EmailTest", parseSiteConfig(raw), {
16
+ env: { account: "123456789012", region: "us-east-1" },
17
+ }));
18
+ }
19
+ const checks: Record<string, () => void> = {
20
+ managed() {
21
+ const template = synth({ ...base(), email: { mode: "managed", sendingDomain: "news.example.test" } });
22
+ template.resourceCountIs("AWS::SES::EmailIdentity", 1);
23
+ template.resourceCountIs("AWS::SES::ConfigurationSet", 1);
24
+ template.resourceCountIs("AWS::Route53::RecordSet", 0);
25
+ template.resourceCountIs("AWS::SES::ReceiptRuleSet", 0);
26
+ template.hasResourceProperties("AWS::SES::EmailIdentity", { EmailIdentity: "news.example.test" });
27
+ template.hasResourceProperties("AWS::SNS::Topic", { TopicName: "papyrus-newsletter-events-example" });
28
+ template.hasResourceProperties("AWS::SES::ConfigurationSetEventDestination", {
29
+ EventDestination: { Name: "cloudwatch-metrics", MatchingEventTypes: ["SEND", "BOUNCE", "COMPLAINT"],
30
+ CloudWatchDestination: { DimensionConfigurations: [{ DimensionName: "ses:configuration-set",
31
+ DimensionValueSource: "MESSAGE_TAG", DefaultDimensionValue: "papyrus-example" }] } },
32
+ });
33
+ const text = JSON.stringify(template.toJSON());
34
+ assert.ok(text.includes("PAPYRUS_EMAIL_FROM_ADDRESS"));
35
+ assert.ok(text.includes("notifications@news.example.test"));
36
+ assert.ok(text.includes("EmailDkimRecord1Name"));
37
+ assert.ok(!text.includes("ses:SendEmail"), "identity setup does not grant unsolicited sending authority");
38
+ },
39
+ existing() {
40
+ const template = synth({ ...base(), email: { mode: "existing", sendingDomain: "news.example.test",
41
+ configurationSet: "already-owned", eventTopicName: "already-owned-events" } });
42
+ template.resourceCountIs("AWS::SES::EmailIdentity", 0);
43
+ template.resourceCountIs("AWS::SES::ConfigurationSet", 0);
44
+ template.resourceCountIs("AWS::SNS::Topic", 0);
45
+ assert.ok(JSON.stringify(template.toJSON()).includes("already-owned"));
46
+ },
47
+ static() {
48
+ const raw: Record<string, unknown> = { ...base(), reader: { buildCommand: "make site", baseDirectory: "dist" } };
49
+ delete raw.cms;
50
+ const template = synth(raw);
51
+ template.resourceCountIs("AWS::SES::EmailIdentity", 0);
52
+ template.resourceCountIs("AWS::SES::ConfigurationSet", 0);
53
+ template.resourceCountIs("AWS::SES::ReceiptRule", 0);
54
+ },
55
+ missing() {
56
+ assert.throws(() => parseSiteConfig(base()), /email.*required.*cloud/i);
57
+ assert.throws(() => parseSiteConfig({ ...base(), email: { mode: "existing", sendingDomain: "news.example.test" } }), /email.configurationSet/);
58
+ },
59
+ };
60
+ export function runCloudEmailTests(selected?: string): void {
61
+ if (selected) { assert.ok(checks[selected], selected); checks[selected](); }
62
+ else for (const check of Object.values(checks)) check();
63
+ console.log("cloud email infrastructure: ok");
64
+ }
65
+
66
+ runCloudEmailTests(process.argv[2]);
@@ -0,0 +1,60 @@
1
+ import { ArnFormat, CfnOutput, RemovalPolicy, Stack } from "aws-cdk-lib";
2
+ import * as iam from "aws-cdk-lib/aws-iam";
3
+ import * as ses from "aws-cdk-lib/aws-ses";
4
+ import * as sns from "aws-cdk-lib/aws-sns";
5
+
6
+ import type { CloudEmailConfig } from "./cloud-email-config";
7
+
8
+ /** No DNS mutation, sending grant or receipt activation. Existing identities remain with their owner. */
9
+ export function addCloudEmail(stack: Stack, config: CloudEmailConfig): Record<string, string> {
10
+ if (config.mode === "managed") {
11
+ const configurationSet = new ses.CfnConfigurationSet(stack, "CloudEmailConfigurationSet", {
12
+ name: config.configurationSet,
13
+ reputationOptions: { reputationMetricsEnabled: true },
14
+ suppressionOptions: { suppressedReasons: ["BOUNCE", "COMPLAINT"] },
15
+ sendingOptions: { sendingEnabled: true },
16
+ });
17
+ configurationSet.applyRemovalPolicy(RemovalPolicy.RETAIN);
18
+ const identity = new ses.CfnEmailIdentity(stack, "CloudEmailIdentity", {
19
+ emailIdentity: config.sendingDomain,
20
+ dkimSigningAttributes: { nextSigningKeyLength: "RSA_2048_BIT" },
21
+ configurationSetAttributes: { configurationSetName: configurationSet.ref },
22
+ });
23
+ identity.applyRemovalPolicy(RemovalPolicy.RETAIN);
24
+ const topic = new sns.Topic(stack, "CloudEmailEvents", { topicName: config.eventTopicName });
25
+ topic.applyRemovalPolicy(RemovalPolicy.RETAIN);
26
+ topic.addToResourcePolicy(new iam.PolicyStatement({
27
+ principals: [new iam.ServicePrincipal("ses.amazonaws.com")], actions: ["sns:Publish"],
28
+ resources: [topic.topicArn], conditions: { StringEquals: { "aws:SourceAccount": stack.account } },
29
+ }));
30
+ const events = new ses.CfnConfigurationSetEventDestination(stack, "CloudEmailEventDestination", {
31
+ configurationSetName: configurationSet.ref,
32
+ eventDestination: { name: "delivery-events", enabled: true,
33
+ matchingEventTypes: ["SEND", "DELIVERY", "BOUNCE", "COMPLAINT", "REJECT", "RENDERING_FAILURE", "DELIVERY_DELAY"],
34
+ snsDestination: { topicArn: topic.topicArn } },
35
+ });
36
+ events.node.addDependency(topic);
37
+ // These dimensions also feed the newsletter consumer's existing bounce/complaint alarms.
38
+ new ses.CfnConfigurationSetEventDestination(stack, "CloudEmailMetrics", {
39
+ configurationSetName: configurationSet.ref,
40
+ eventDestination: { name: "cloudwatch-metrics", enabled: true,
41
+ matchingEventTypes: ["SEND", "BOUNCE", "COMPLAINT"],
42
+ cloudWatchDestination: { dimensionConfigurations: [{ dimensionName: "ses:configuration-set",
43
+ dimensionValueSource: "MESSAGE_TAG", defaultDimensionValue: config.configurationSet }] } },
44
+ });
45
+ const records = [[identity.attrDkimDnsTokenName1, identity.attrDkimDnsTokenValue1],
46
+ [identity.attrDkimDnsTokenName2, identity.attrDkimDnsTokenValue2],
47
+ [identity.attrDkimDnsTokenName3, identity.attrDkimDnsTokenValue3]];
48
+ records.forEach(([name, value], index) => {
49
+ new CfnOutput(stack, `EmailDkimRecord${index + 1}Name`, { value: name, description: "CNAME name; DNS change requires separate approval" });
50
+ new CfnOutput(stack, `EmailDkimRecord${index + 1}Value`, { value });
51
+ });
52
+ }
53
+ const identityArn = stack.formatArn({ service: "ses", resource: "identity", resourceName: config.sendingDomain, arnFormat: ArnFormat.SLASH_RESOURCE_NAME });
54
+ new CfnOutput(stack, "EmailIdentityArn", { value: identityArn });
55
+ new CfnOutput(stack, "EmailConfigurationSetName", { value: config.configurationSet });
56
+ new CfnOutput(stack, "EmailEventTopicName", { value: config.eventTopicName });
57
+ return { PAPYRUS_EMAIL_FROM_ADDRESS: `${config.senderLocalPart}@${config.sendingDomain}`,
58
+ PAPYRUS_EMAIL_IDENTITY_ARN: identityArn, PAPYRUS_EMAIL_CONFIGURATION_SET: config.configurationSet,
59
+ PAPYRUS_EMAIL_EVENT_TOPIC_NAME: config.eventTopicName };
60
+ }
@@ -1,3 +1,4 @@
1
+ import { parseCloudEmail } from "./cloud-email-config.js";
1
2
  export const READER_REDIRECT_STATUSES = ["301", "302", "200", "404", "404-200"];
2
3
  export function hasCmsApp(config) {
3
4
  return config.cms !== undefined;
@@ -35,7 +36,7 @@ const GITHUB_REPO_PATTERN = /^[A-Za-z0-9_.-]+$/;
35
36
  const COGNITO_DOMAIN_PREFIX_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
36
37
  const COGNITO_RESERVED_WORDS = ["aws", "amazon", "cognito"];
37
38
  const PROSPECTUS_ACCOUNTS_API_STAGE_ARN_PATTERN = /^arn:aws:execute-api:[a-z0-9-]+:\d{12}:[a-z0-9]+\/api$/;
38
- const TEMPLATE_MANAGED_ENVIRONMENT_KEYS = ["PAPYRUS_COGNITO_DOMAIN_PREFIX", "PAPYRUS_APPLY_COGNITO_DOMAIN_PREFIX", "PAPYRUS_DISABLE_GOOGLE_OAUTH", "PAPYRUS_REVALIDATE_SECRET_PARAMETER", "PAPYRUS_PROSPECTUS_ACCOUNTS_ENDPOINT", "PAPYRUS_PROSPECTUS_SESSION_SECRET_PARAMETER", "PAPYRUS_PROSPECTUS_SITE_PRODUCT", "PAPYRUS_READER_POLL_SECRET_PARAMETER", "PAPYRUS_SITE_ORIGIN"];
39
+ const TEMPLATE_MANAGED_ENVIRONMENT_KEYS = ["PAPYRUS_EMAIL_FROM_ADDRESS", "PAPYRUS_EMAIL_IDENTITY_ARN", "PAPYRUS_EMAIL_CONFIGURATION_SET", "PAPYRUS_EMAIL_EVENT_TOPIC_NAME", "PAPYRUS_COGNITO_DOMAIN_PREFIX", "PAPYRUS_APPLY_COGNITO_DOMAIN_PREFIX", "PAPYRUS_DISABLE_GOOGLE_OAUTH", "PAPYRUS_REVALIDATE_SECRET_PARAMETER", "PAPYRUS_PROSPECTUS_ACCOUNTS_ENDPOINT", "PAPYRUS_PROSPECTUS_SESSION_SECRET_PARAMETER", "PAPYRUS_PROSPECTUS_SITE_PRODUCT", "PAPYRUS_READER_POLL_SECRET_PARAMETER", "PAPYRUS_SITE_ORIGIN"];
39
40
  /** Routes Prospectus admits for a registered Papyrus site (Prospectus accounts/papyrus-sites.ts). */
40
41
  export const PROSPECTUS_PAPYRUS_SITE_ROUTES = [
41
42
  "GET/v1/identities/me",
@@ -350,7 +351,7 @@ function parseAws(value) {
350
351
  }
351
352
  export function parseSiteConfig(raw) {
352
353
  const record = requireObject(raw, "site");
353
- rejectUnknownKeys(record, ["siteId", "repository", "repositoryAccessTokenSecretArn", "brand", "frontend", "hostedZoneId", "stackName", "aws", "cms", "reader", "papyrusVersion", "storagePreviewPrefix", "knowledgeRun", "autoDeploy", "github"], "site");
354
+ rejectUnknownKeys(record, ["siteId", "repository", "repositoryAccessTokenSecretArn", "brand", "frontend", "hostedZoneId", "stackName", "aws", "cms", "email", "reader", "papyrusVersion", "storagePreviewPrefix", "knowledgeRun", "autoDeploy", "github"], "site");
354
355
  const siteId = requireString(record.siteId, "siteId", SITE_ID_PATTERN, "kebab-case");
355
356
  const repository = requireString(record.repository, "repository", REPOSITORY_PATTERN, "https://github.com/<org>/<repo>");
356
357
  const repositoryAccessTokenSecretArn = record.repositoryAccessTokenSecretArn === undefined
@@ -460,6 +461,7 @@ export function parseSiteConfig(raw) {
460
461
  stackName,
461
462
  aws,
462
463
  cms,
464
+ email: parseCloudEmail(record.email, cms !== undefined, siteId),
463
465
  reader,
464
466
  papyrusVersion,
465
467
  storagePreviewPrefix,
@@ -1,3 +1,5 @@
1
+ import { parseCloudEmail, type CloudEmailConfig } from "./cloud-email-config";
2
+
1
3
  export type CmsRedirect = {
2
4
  source: string;
3
5
  status: 301 | 302;
@@ -63,6 +65,8 @@ export type AmplifyAppShellSiteConfig = {
63
65
  stackName?: string;
64
66
  aws?: SiteAwsConfig;
65
67
  cms?: CmsConfig;
68
+ /** Required for cloud CMS sites; independent of newsletter feature enablement. */
69
+ email?: CloudEmailConfig;
66
70
  reader?: {
67
71
  appName?: string;
68
72
  domainName?: string;
@@ -157,7 +161,7 @@ const GITHUB_REPO_PATTERN = /^[A-Za-z0-9_.-]+$/;
157
161
  const COGNITO_DOMAIN_PREFIX_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
158
162
  const COGNITO_RESERVED_WORDS = ["aws", "amazon", "cognito"];
159
163
  const PROSPECTUS_ACCOUNTS_API_STAGE_ARN_PATTERN = /^arn:aws:execute-api:[a-z0-9-]+:\d{12}:[a-z0-9]+\/api$/;
160
- const TEMPLATE_MANAGED_ENVIRONMENT_KEYS = ["PAPYRUS_COGNITO_DOMAIN_PREFIX", "PAPYRUS_APPLY_COGNITO_DOMAIN_PREFIX", "PAPYRUS_DISABLE_GOOGLE_OAUTH", "PAPYRUS_REVALIDATE_SECRET_PARAMETER", "PAPYRUS_PROSPECTUS_ACCOUNTS_ENDPOINT", "PAPYRUS_PROSPECTUS_SESSION_SECRET_PARAMETER", "PAPYRUS_PROSPECTUS_SITE_PRODUCT", "PAPYRUS_READER_POLL_SECRET_PARAMETER", "PAPYRUS_SITE_ORIGIN"];
164
+ const TEMPLATE_MANAGED_ENVIRONMENT_KEYS = ["PAPYRUS_EMAIL_FROM_ADDRESS", "PAPYRUS_EMAIL_IDENTITY_ARN", "PAPYRUS_EMAIL_CONFIGURATION_SET", "PAPYRUS_EMAIL_EVENT_TOPIC_NAME", "PAPYRUS_COGNITO_DOMAIN_PREFIX", "PAPYRUS_APPLY_COGNITO_DOMAIN_PREFIX", "PAPYRUS_DISABLE_GOOGLE_OAUTH", "PAPYRUS_REVALIDATE_SECRET_PARAMETER", "PAPYRUS_PROSPECTUS_ACCOUNTS_ENDPOINT", "PAPYRUS_PROSPECTUS_SESSION_SECRET_PARAMETER", "PAPYRUS_PROSPECTUS_SITE_PRODUCT", "PAPYRUS_READER_POLL_SECRET_PARAMETER", "PAPYRUS_SITE_ORIGIN"];
161
165
 
162
166
  /** Routes Prospectus admits for a registered Papyrus site (Prospectus accounts/papyrus-sites.ts). */
163
167
  export const PROSPECTUS_PAPYRUS_SITE_ROUTES = [
@@ -490,7 +494,7 @@ export function parseSiteConfig(raw: unknown): AmplifyAppShellSiteConfig {
490
494
  const record = requireObject(raw, "site");
491
495
  rejectUnknownKeys(
492
496
  record,
493
- ["siteId", "repository", "repositoryAccessTokenSecretArn", "brand", "frontend", "hostedZoneId", "stackName", "aws", "cms", "reader", "papyrusVersion", "storagePreviewPrefix", "knowledgeRun", "autoDeploy", "github"],
497
+ ["siteId", "repository", "repositoryAccessTokenSecretArn", "brand", "frontend", "hostedZoneId", "stackName", "aws", "cms", "email", "reader", "papyrusVersion", "storagePreviewPrefix", "knowledgeRun", "autoDeploy", "github"],
494
498
  "site",
495
499
  );
496
500
 
@@ -600,6 +604,7 @@ export function parseSiteConfig(raw: unknown): AmplifyAppShellSiteConfig {
600
604
  stackName,
601
605
  aws,
602
606
  cms,
607
+ email: parseCloudEmail(record.email, cms !== undefined, siteId),
603
608
  reader,
604
609
  papyrusVersion,
605
610
  storagePreviewPrefix,
@@ -187,8 +187,8 @@ export function curationForSuggestionChoice(decision: RelevanceDecisionView, cho
187
187
  }
188
188
 
189
189
  export type ProvenanceSummary = {
190
- teachingLabelsSentence: string;
191
- primingExamplesSentence: string;
190
+ teachingLabelsSentence: string | null;
191
+ primingExamplesSentence: string | null;
192
192
  doctrineShortHash: string | null;
193
193
  modelVersion: string | null;
194
194
  decidedAt: string | null;
@@ -202,8 +202,8 @@ export function provenanceSummary(decision: RelevanceDecisionView): ProvenanceSu
202
202
  if (!provenance) return null;
203
203
  const plural = (count: number, singular: string) => `${count} ${singular}${count === 1 ? "" : "s"}`;
204
204
  return {
205
- teachingLabelsSentence: `Based on ${plural(provenance.teachingLabels, "decision")} that editors made themselves`,
206
- primingExamplesSentence: `${plural(provenance.primingExamples, "example reference")} supplied as starting examples`,
205
+ teachingLabelsSentence: provenance.teachingLabels === null ? null : `Based on ${plural(provenance.teachingLabels, "decision")} that editors made themselves`,
206
+ primingExamplesSentence: provenance.primingExamples === null ? null : `${plural(provenance.primingExamples, "example reference")} supplied as starting examples`,
207
207
  doctrineShortHash: provenance.doctrineFingerprint ? provenance.doctrineFingerprint.slice(0, SHORT_HASH_LENGTH) : null,
208
208
  modelVersion: decision.version === null ? null : `revision ${decision.version}`,
209
209
  decidedAt: decision.decidedAt,
@@ -1,5 +1,4 @@
1
1
  import type { CategorySteeringDashboard } from "./category-repository";
2
- import { createEmptyCategorySteeringDashboard } from "./category-dashboard";
3
2
  import type { ReaderAuthSnapshot } from "../components/reader-auth-state";
4
3
 
5
4
  export type NewsDeskShellPhase =
@@ -59,26 +58,6 @@ export function beginDeskLoad(state: NewsDeskShellState, auth: ReaderAuthSnapsho
59
58
  };
60
59
  }
61
60
 
62
- export function beginAuthenticatedDeskHydration(
63
- state: NewsDeskShellState,
64
- auth: ReaderAuthSnapshot,
65
- options: { canManageUsers: boolean },
66
- ): NewsDeskShellState {
67
- const dashboard = state.dashboard ?? {
68
- ...createEmptyCategorySteeringDashboard(),
69
- isPublicSkeleton: false,
70
- canManageUsers: options.canManageUsers,
71
- };
72
- return {
73
- ...state,
74
- phase: state.dashboard ? "refreshing" : "loadingDesk",
75
- auth,
76
- dashboard,
77
- stale: false,
78
- error: null,
79
- };
80
- }
81
-
82
61
  export function resolveSignedOut(state: NewsDeskShellState, auth: ReaderAuthSnapshot): NewsDeskShellState {
83
62
  return {
84
63
  ...state,
@@ -143,6 +122,18 @@ export function resolveDashboardFailure(
143
122
  };
144
123
  }
145
124
 
125
+ /** An unverified session must never leave private records on screen. */
126
+ export function resolveAccessFailure(state: NewsDeskShellState, error: string): NewsDeskShellState {
127
+ return {
128
+ ...state,
129
+ phase: "error",
130
+ auth: { status: "signedOut", label: "Signed out" },
131
+ dashboard: null,
132
+ stale: false,
133
+ error,
134
+ };
135
+ }
136
+
146
137
  export function patchDashboard(
147
138
  state: NewsDeskShellState,
148
139
  patch: (dashboard: CategorySteeringDashboard) => CategorySteeringDashboard,
@@ -8,6 +8,7 @@ export type OrganizerDestination = {
8
8
  };
9
9
  export const ORGANIZER_NAV_GROUPS: readonly { title: string; items: readonly OrganizerDestination[] }[] = [
10
10
  { title: "Editorial work", items: [
11
+ { key: "overview", label: "Overview", description: "See notifications and work needing attention.", path: "/newsroom", primary: true },
11
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 },
12
13
  { key: "assignments", label: "Assignments", description: "Follow research and reporting work across its statuses and claim it.", path: "/newsroom/assignments" },
13
14
  { key: "messages", label: "Messages", description: "Read and reply to newsroom conversations.", path: "/newsroom/messages", primary: true },
@@ -23,7 +24,7 @@ export const ORGANIZER_NAV_GROUPS: readonly { title: string; items: readonly Org
23
24
  ] },
24
25
  ];
25
26
  const all = ORGANIZER_NAV_GROUPS.flatMap(group => group.items);
26
- export const ORGANIZER_PRIMARY_NAV = ["articles", "references", "messages"].map(key => all.find(item => item.key === key)!);
27
+ export const ORGANIZER_PRIMARY_NAV = ["overview", "articles", "references", "messages"].map(key => all.find(item => item.key === key)!);
27
28
 
28
29
  export const ORGANIZER_SETTINGS_DESTINATION = {
29
30
  label: "Settings", path: "/newsroom/settings", description: "Theme, motion and interface preferences.",
@@ -0,0 +1,71 @@
1
+ import type { HydratedModelPayload, MessageRecord, SemanticRelationRecord } from "./category-repository";
2
+ import { normalizeReferenceRejectionReasonCode, type ReferenceRejectionReasonCode } from "./reference-policy";
3
+
4
+ function metadataRecord(value: unknown): Record<string, unknown> {
5
+ if (typeof value === "string") {
6
+ try { return metadataRecord(JSON.parse(value)); } catch { return {}; }
7
+ }
8
+ return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
9
+ }
10
+
11
+ /** The selected Reference's index is authoritative even when the global feed is truncated. */
12
+ export async function loadReferenceReviewDetails(lineageId: string, read: {
13
+ relations: (direction: "incoming" | "outgoing") => Promise<SemanticRelationRecord[]>;
14
+ message: (id: string) => Promise<MessageRecord | null>;
15
+ payloads: (id: string) => Promise<HydratedModelPayload[]>;
16
+ }): Promise<{ relations: SemanticRelationRecord[]; messages: MessageRecord[] }> {
17
+ const [outgoing, incoming] = await Promise.all([read.relations("outgoing"), read.relations("incoming")]);
18
+ const relations = outgoing.filter((relation) => relation.subjectKind === "reference" && relation.subjectLineageId === lineageId);
19
+ const latest = incoming.filter((relation) => relation.predicate === "comment" && relation.relationState === "current"
20
+ && relation.subjectKind === "message" && relation.objectKind === "reference" && relation.objectLineageId === lineageId
21
+ && metadataRecord(relation.metadata).messageKind === "reference_curation")
22
+ .sort((a, b) => String(b.createdAt ?? b.importedAt ?? "").localeCompare(String(a.createdAt ?? a.importedAt ?? "")))[0];
23
+ if (!latest) return { relations, messages: [] };
24
+ const message = await read.message(latest.subjectId);
25
+ if (!message || message.messageKind !== "reference_curation") throw new Error("The latest reference review could not be loaded.");
26
+ const payload = (await read.payloads(message.id))
27
+ .filter((entry) => entry.attachment.role === "metadata" && entry.attachment.ownerKind === "message" && entry.attachment.ownerId === message.id)
28
+ .sort((a, b) => String(b.attachment.updatedAt ?? "").localeCompare(String(a.attachment.updatedAt ?? "")))[0];
29
+ if (!payload) throw new Error("The latest reference review metadata could not be loaded.");
30
+ if (payload.error) throw new Error(payload.error);
31
+ if (!payload.json || typeof payload.json !== "object" || Array.isArray(payload.json)) {
32
+ throw new Error("The latest reference review metadata is invalid.");
33
+ }
34
+ const hydrated = { ...message, metadata: payload.json };
35
+ const recordedLineage = metadataRecord(hydrated.metadata).referenceLineageId;
36
+ if (recordedLineage !== lineageId) throw new Error("The latest review metadata does not match this reference lineage.");
37
+ return { relations, messages: [hydrated] };
38
+ }
39
+
40
+ function latestReferenceReviews(messages: MessageRecord[]): Map<string, MessageRecord> {
41
+ const latest = new Map<string, MessageRecord>();
42
+ for (const message of messages) {
43
+ if (message.messageKind !== "reference_curation") continue;
44
+ const lineage = metadataRecord(message.metadata).referenceLineageId;
45
+ if (typeof lineage !== "string" || !lineage) continue;
46
+ if (!latest.has(lineage) || String(message.createdAt).localeCompare(String(latest.get(lineage)!.createdAt)) > 0) latest.set(lineage, message);
47
+ }
48
+ return latest;
49
+ }
50
+
51
+ /** A later acceptance or archive clears any earlier rejection reason. */
52
+ export function latestReferenceReviewReasons(messages: MessageRecord[]): Map<string, ReferenceRejectionReasonCode> {
53
+ const reasons = new Map<string, ReferenceRejectionReasonCode>();
54
+ for (const [lineage, message] of latestReferenceReviews(messages)) {
55
+ const metadata = metadataRecord(message.metadata);
56
+ const reason = metadata.action === "reject" ? normalizeReferenceRejectionReasonCode(metadata.reasonCode) : null;
57
+ if (reason) reasons.set(lineage, reason);
58
+ }
59
+ return reasons;
60
+ }
61
+
62
+ /** Preserve only a Section explicitly recorded on the newest acceptance, never a prediction. */
63
+ export function latestReferenceReviewSections(messages: MessageRecord[]): Map<string, string> {
64
+ const sections = new Map<string, string>();
65
+ for (const [lineage, message] of latestReferenceReviews(messages)) {
66
+ const metadata = metadataRecord(message.metadata);
67
+ const sectionId = metadata.action === "accept" && typeof metadata.sectionId === "string" ? metadata.sectionId.trim() : "";
68
+ if (sectionId) sections.set(lineage, sectionId);
69
+ }
70
+ return sections;
71
+ }
@@ -0,0 +1,14 @@
1
+ /** Hold the lock before invoking a save, including before React can rerender. */
2
+ export function runReferenceReviewRequest(
3
+ pending: Map<string, Promise<void>>,
4
+ lineageId: string,
5
+ save: () => Promise<void>,
6
+ ): Promise<void> {
7
+ const existing = pending.get(lineageId);
8
+ if (existing) return existing;
9
+ const request = Promise.resolve().then(save).finally(() => {
10
+ if (pending.get(lineageId) === request) pending.delete(lineageId);
11
+ });
12
+ pending.set(lineageId, request);
13
+ return request;
14
+ }
@@ -7,8 +7,12 @@ export type RelevanceProvenance = {
7
7
  labelSet: string[];
8
8
  doctrineFingerprint: string | null;
9
9
  teachingFingerprint: string | null;
10
- teachingLabels: number;
11
- primingExamples: number;
10
+ teachingLabels: number | null;
11
+ primingExamples: number | null;
12
+ /** Recorded by the shared monitoring router, absent on local sweep provenance. */
13
+ system?: string;
14
+ publicationId?: string;
15
+ output?: string;
12
16
  };
13
17
 
14
18
  /** The relevance cyclotron's current decision about one reference, as the References tab shows it. */
@@ -28,6 +32,7 @@ export type RelevanceDecisionView = {
28
32
  /** Thumbs for a two-class decision with a positive label; per-class buttons otherwise. */
29
33
  reviewControl: "thumbs" | "labels";
30
34
  cyclotronId: string | null;
35
+ classifierId?: string;
31
36
  /** When the cyclotron made the suggestion, if the decision recorded it. */
32
37
  decidedAt: string | null;
33
38
  /** Null for decisions made before provenance was recorded. */
@@ -44,6 +49,7 @@ export type RelevanceReview = {
44
49
 
45
50
  /** The existing curation action an editor's review becomes. */
46
51
  export type RelevanceCuration = {
52
+ sectionId?: string | null;
47
53
  action: "accept" | "reject";
48
54
  reasonCode: string | null;
49
55
  note: string | null;
@@ -65,7 +71,7 @@ function parseMetadata(value: unknown): Record<string, unknown> {
65
71
  function parseProvenance(value: unknown): RelevanceProvenance | null {
66
72
  const record = parseMetadata(value);
67
73
  if (!Object.keys(record).length) return null;
68
- const count = (entry: unknown) => (Number.isFinite(Number(entry)) ? Number(entry) : 0);
74
+ const count = (entry: unknown) => typeof entry === "number" && Number.isInteger(entry) && entry >= 0 ? entry : null;
69
75
  const text = (entry: unknown) => (typeof entry === "string" && entry ? entry : null);
70
76
  return {
71
77
  labelSet: Array.isArray(record.labelSet) ? record.labelSet.map(String) : [],
@@ -73,27 +79,52 @@ function parseProvenance(value: unknown): RelevanceProvenance | null {
73
79
  teachingFingerprint: text(record.teachingFingerprint),
74
80
  teachingLabels: count(record.teachingLabels),
75
81
  primingExamples: count(record.primingExamples),
82
+ ...(text(record.system) ? { system: text(record.system)! } : {}),
83
+ ...(text(record.publicationId) ? { publicationId: text(record.publicationId)! } : {}),
84
+ ...(text(record.output) ? { output: text(record.output)! } : {}),
76
85
  };
77
86
  }
78
87
 
88
+ function recordedVersion(value: unknown): number | null {
89
+ if ((typeof value !== "number" && typeof value !== "string") || String(value).trim() === "") return null;
90
+ const version = Number(value);
91
+ return Number.isInteger(version) && version >= 0 ? version : null;
92
+ }
93
+
94
+ function decisionTime(relation: SemanticRelationRecord): number {
95
+ const metadata = parseMetadata(relation.metadata);
96
+ for (const value of [metadata.decidedAt, relation.importedAt, relation.createdAt]) {
97
+ if (typeof value !== "string") continue;
98
+ const timestamp = Date.parse(value);
99
+ if (Number.isFinite(timestamp)) return timestamp;
100
+ }
101
+ return 0;
102
+ }
103
+
79
104
  /**
80
105
  * Current decisions keyed by reference lineage, from relations the Newsroom
81
106
  * already loads: no extra request per row.
82
107
  */
83
108
  export function currentRelevanceDecisions(relations: SemanticRelationRecord[]): Map<string, RelevanceDecisionView> {
84
109
  const decisions = new Map<string, RelevanceDecisionView>();
85
- for (const relation of relations) {
86
- if (relation.predicate !== RELEVANCE_RELATION || relation.relationState !== "current") continue;
110
+ // Realtime events can briefly expose both sides of a superseding write. The
111
+ // result must not depend on list pagination or subscription arrival order.
112
+ const ordered = [...relations].sort((left, right) => decisionTime(left) - decisionTime(right) || left.id.localeCompare(right.id));
113
+ for (const relation of ordered) {
114
+ if (relation.predicate !== RELEVANCE_RELATION || relation.relationState !== "current" || relation.subjectKind !== "reference") continue;
87
115
  const metadata = parseMetadata(relation.metadata);
116
+ if (!relation.subjectLineageId || typeof metadata.label !== "string" || !metadata.label.trim()
117
+ || typeof metadata.decisionId !== "string" || !metadata.decisionId.trim()) continue;
88
118
  const probabilities = parseMetadata(metadata.probabilities);
89
119
  const reason = metadata.reviewReason === "program" || metadata.reviewReason === "audit" ? metadata.reviewReason : null;
90
- const version = Number(relation.modelVersion ?? metadata.version);
120
+ const version = recordedVersion(relation.modelVersion ?? metadata.version);
91
121
  decisions.set(relation.subjectLineageId, {
92
122
  decisionRelationId: relation.id,
93
- decisionId: String(metadata.decisionId ?? ""),
94
- label: String(metadata.label ?? ""),
95
- confidence: typeof relation.confidence === "number" ? relation.confidence : null,
96
- version: Number.isFinite(version) ? version : null,
123
+ decisionId: metadata.decisionId,
124
+ label: metadata.label,
125
+ confidence: typeof relation.confidence === "number" && Number.isFinite(relation.confidence)
126
+ && relation.confidence >= 0 && relation.confidence <= 1 ? relation.confidence : null,
127
+ version,
97
128
  reviewRecommended: Boolean(relation.reviewRecommended),
98
129
  reviewReason: reason,
99
130
  reviewDetail: typeof metadata.reviewDetail === "string" ? metadata.reviewDetail : null,
@@ -104,6 +135,7 @@ export function currentRelevanceDecisions(relations: SemanticRelationRecord[]):
104
135
  question: typeof metadata.question === "string" ? metadata.question : null,
105
136
  reviewControl: metadata.reviewControl === "thumbs" ? "thumbs" : "labels",
106
137
  cyclotronId: typeof metadata.cyclotronId === "string" ? metadata.cyclotronId : null,
138
+ ...(relation.classifierId ? { classifierId: relation.classifierId } : {}),
107
139
  decidedAt: typeof metadata.decidedAt === "string" ? metadata.decidedAt : null,
108
140
  provenance: parseProvenance(metadata.provenance),
109
141
  });
@@ -0,0 +1,44 @@
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useState } from "react";
4
+
5
+ /** Load persisted predictions and attributed review history without cancelling on a loading rerender. */
6
+ export function useReferenceReviewData<Relation, Message>({
7
+ enabled,
8
+ loadRelations,
9
+ loadMessages,
10
+ onRelations,
11
+ onMessages,
12
+ }: {
13
+ enabled: boolean;
14
+ loadRelations: () => Promise<Relation[]>;
15
+ loadMessages: () => Promise<Message[]>;
16
+ onRelations: (relations: Relation[]) => void;
17
+ onMessages: (messages: Message[]) => void;
18
+ }) {
19
+ const [state, setState] = useState<"loading" | "ready" | "error">(enabled ? "loading" : "ready");
20
+ const [error, setError] = useState<string | null>(null);
21
+ const [attempt, setAttempt] = useState(0);
22
+ const retry = useCallback(() => setAttempt((current) => current + 1), []);
23
+ useEffect(() => {
24
+ if (!enabled) return;
25
+ let active = true;
26
+ setState("loading");
27
+ setError(null);
28
+ void Promise.allSettled([loadRelations(), loadMessages()]).then(([relations, history]) => {
29
+ if (!active) return;
30
+ if (relations.status === "fulfilled") onRelations(relations.value);
31
+ if (history.status === "fulfilled") onMessages(history.value);
32
+ const failure = relations.status === "rejected" ? relations.reason : history.status === "rejected" ? history.reason : null;
33
+ if (failure) {
34
+ const subject = relations.status === "rejected" ? "Predictions" : "Review history";
35
+ setError(`${subject} could not be loaded. ${failure instanceof Error ? failure.message : "Try again."}`);
36
+ setState("error");
37
+ } else {
38
+ setState("ready");
39
+ }
40
+ });
41
+ return () => { active = false; };
42
+ }, [enabled, loadRelations, loadMessages, onRelations, onMessages, attempt]);
43
+ return { state, error, retry };
44
+ }
package/package.json CHANGED
@@ -108,6 +108,7 @@
108
108
  "./components/newsroom-curation-provider": "./components/newsroom-curation-provider.tsx",
109
109
  "./components/newsroom-ops-overview": "./components/newsroom-ops-overview.tsx",
110
110
  "./components/newsroom-ops-shell": "./components/newsroom-ops-shell.tsx",
111
+ "./components/newsroom-overview-skeleton": "./components/newsroom-overview-skeleton.tsx",
111
112
  "./components/newsroom-references-view": "./components/newsroom-references-view.tsx",
112
113
  "./components/newsroom-theme-fonts/broadsheet-green": "./components/newsroom-theme-fonts/broadsheet-green.tsx",
113
114
  "./components/newsroom-theme-fonts/newspaper-gothic": "./components/newsroom-theme-fonts/newspaper-gothic.tsx",
@@ -298,6 +299,8 @@
298
299
  "./lib/reader-subject": "./lib/reader-subject.ts",
299
300
  "./lib/reference-display": "./lib/reference-display.ts",
300
301
  "./lib/reference-policy": "./lib/reference-policy.ts",
302
+ "./lib/reference-review-details": "./lib/reference-review-details.ts",
303
+ "./lib/reference-review-request": "./lib/reference-review-request.ts",
301
304
  "./lib/reference-source-preview": "./lib/reference-source-preview.ts",
302
305
  "./lib/relevance-decisions": "./lib/relevance-decisions.ts",
303
306
  "./lib/relevance-status": "./lib/relevance-status.ts",
@@ -329,6 +332,7 @@
329
332
  "./lib/test-editor-override": "./lib/test-editor-override.ts",
330
333
  "./lib/text-columns": "./lib/text-columns.ts",
331
334
  "./lib/themed-image": "./lib/themed-image.ts",
335
+ "./lib/use-reference-review-data": "./lib/use-reference-review-data.ts",
332
336
  "./lib/utils": "./lib/utils.ts",
333
337
  "./lib/video-loading": "./lib/video-loading.ts",
334
338
  "./lib/video-mode": "./lib/video-mode.ts",
@@ -444,7 +448,7 @@
444
448
  "optional": true
445
449
  }
446
450
  },
447
- "version": "5.33.1",
451
+ "version": "5.34.0",
448
452
  "dependencies": {
449
453
  "@aws-amplify/adapter-nextjs": "^1.7.3",
450
454
  "@aws-crypto/sha256-js": "5.2.0",
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "5.33.1",
2
+ "version": "5.34.0",
3
3
  "routes": [
4
4
  {
5
5
  "file": "app/%5F_preview/[[...path]]/route.ts",