@anthusai/papyrus 5.42.0-next.2 → 5.42.0-next.4

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 (36) hide show
  1. package/amplify/data/schema.js +2 -0
  2. package/amplify/data/schema.ts +2 -0
  3. package/amplify/functions/monitoring-view/schema.js +6 -0
  4. package/amplify/functions/monitoring-view/schema.ts +6 -0
  5. package/amplify/functions/monitoring-view/wire.js +1 -0
  6. package/amplify/functions/monitoring-view/wire.ts +1 -0
  7. package/amplify/functions/proposal-actions/handler.py +137 -0
  8. package/amplify/functions/proposal-actions/resource.js +15 -0
  9. package/amplify/functions/proposal-actions/resource.ts +22 -0
  10. package/amplify/functions/proposal-actions/schema.js +106 -0
  11. package/amplify/functions/proposal-actions/schema.ts +108 -0
  12. package/amplify/functions/proposal-actions/wire.js +10 -0
  13. package/amplify/functions/proposal-actions/wire.ts +13 -0
  14. package/amplify/plugins/reader-steering/function/handler.py +12 -69
  15. package/amplify/plugins/reader-steering/papyrus.plugin.js +5 -5
  16. package/amplify/plugins/reader-steering/papyrus.plugin.ts +5 -5
  17. package/amplify/plugins/reader-steering/schema.js +0 -95
  18. package/amplify/plugins/reader-steering/schema.ts +0 -95
  19. package/amplify/site-backend.js +4 -0
  20. package/amplify/site-backend.ts +4 -0
  21. package/app/globals.css +2 -35
  22. package/app/tailwind.css +2 -0
  23. package/components/monitoring-overview-view.tsx +7 -5
  24. package/components/monitoring-panel.tsx +33 -3
  25. package/components/newsroom-references-view.tsx +2 -3
  26. package/components/proposals-panel.tsx +110 -0
  27. package/components/proposals-view.tsx +93 -0
  28. package/components/topic-steering-workspace.tsx +13 -2
  29. package/lib/monitoring-client.ts +11 -2
  30. package/lib/monitoring-overview-fixture.ts +1 -1
  31. package/lib/monitoring-overview.ts +1 -1
  32. package/lib/proposals-admin.ts +115 -0
  33. package/lib/relevance-decisions.ts +9 -0
  34. package/package.json +4 -1
  35. package/routes.manifest.json +1 -1
  36. /package/amplify/{plugins/reader-steering/function → functions/proposal-actions}/proposals.py +0 -0
@@ -14,9 +14,8 @@ export const READER_POLL_SECRET_PARAMETER_ENV = "PAPYRUS_READER_POLL_SECRET_PARA
14
14
  export const readerSteeringManifest = {
15
15
  name: "reader-steering",
16
16
  contract: 1,
17
- models: ["ReaderProfile", "ReaderHandle", "ReaderVote", "ReaderFlag", "ReaderSubmission", "ReaderComment", "ReaderReport", "ModerationAction", "Proposal"],
18
- mutations: ["moderateReader", "fileProposal", "transitionProposal", "editProposal"],
19
- queries: ["listStoryDrafts"],
17
+ models: ["ReaderProfile", "ReaderHandle", "ReaderVote", "ReaderFlag", "ReaderSubmission", "ReaderComment", "ReaderReport", "ModerationAction"],
18
+ mutations: ["moderateReader"],
20
19
  guest: { mutations: [] },
21
20
  newsroomPanels: [{ id: "moderation", label: "Moderation" }],
22
21
  staticAssets: ["_plugins/papyrus-reader@1.js", "_plugins/papyrus-reader@1.css"],
@@ -119,7 +118,6 @@ export const readerSteeringPlugin = {
119
118
  ["PAPYRUS_READER_COMMENT_TABLE", "ReaderComment"],
120
119
  ["PAPYRUS_READER_REPORT_TABLE", "ReaderReport"],
121
120
  ["PAPYRUS_READER_ACTION_TABLE", "ModerationAction"],
122
- ["PAPYRUS_READER_PROPOSAL_TABLE", "Proposal"],
123
121
  ]) {
124
122
  tables[model].grantReadWriteData(lambda);
125
123
  lambda.addEnvironment(variable, tables[model].tableName);
@@ -136,8 +134,10 @@ export const readerSteeringPlugin = {
136
134
  // The snapshot reads submissions, and the review step writes their signals and moderation state.
137
135
  tables.ReaderSubmission.grantReadWriteData(contentActions);
138
136
  tables.ReaderComment.grantReadWriteData(contentActions);
139
- // The snapshot reads proposals and freezes the community result of one that reaches editor review.
137
+ // The snapshot reads proposals and freezes the community result of one that reaches editor review. The Proposal model and the
138
+ // functions that file and move proposals are core (amplify/functions/proposal-actions); this plugin only reads the same table.
140
139
  tables.Proposal.grantReadWriteData(contentActions);
140
+ contentActions.addEnvironment("PAPYRUS_READER_PROPOSAL_TABLE", tables.Proposal.tableName);
141
141
  lambda.addEnvironment("PAPYRUS_READER_SETTINGS", JSON.stringify(readerActionsSettings(config, interimSubjectsAllowedAtSynth())));
142
142
  if (config.reservedConcurrency !== undefined) {
143
143
  lambda.node.defaultChild.addPropertyOverride("ReservedConcurrentExecutions", config.reservedConcurrency);
@@ -21,9 +21,8 @@ export const READER_POLL_SECRET_PARAMETER_ENV = "PAPYRUS_READER_POLL_SECRET_PARA
21
21
  export const readerSteeringManifest: PluginManifest = {
22
22
  name: "reader-steering",
23
23
  contract: 1,
24
- models: ["ReaderProfile", "ReaderHandle", "ReaderVote", "ReaderFlag", "ReaderSubmission", "ReaderComment", "ReaderReport", "ModerationAction", "Proposal"],
25
- mutations: ["moderateReader", "fileProposal", "transitionProposal", "editProposal"],
26
- queries: ["listStoryDrafts"],
24
+ models: ["ReaderProfile", "ReaderHandle", "ReaderVote", "ReaderFlag", "ReaderSubmission", "ReaderComment", "ReaderReport", "ModerationAction"],
25
+ mutations: ["moderateReader"],
27
26
  guest: { mutations: [] },
28
27
  newsroomPanels: [{ id: "moderation", label: "Moderation" }],
29
28
  staticAssets: ["_plugins/papyrus-reader@1.js", "_plugins/papyrus-reader@1.css"],
@@ -138,7 +137,6 @@ export const readerSteeringPlugin: PapyrusPlugin<ReaderSteeringConfig, { readerA
138
137
  ["PAPYRUS_READER_COMMENT_TABLE", "ReaderComment"],
139
138
  ["PAPYRUS_READER_REPORT_TABLE", "ReaderReport"],
140
139
  ["PAPYRUS_READER_ACTION_TABLE", "ModerationAction"],
141
- ["PAPYRUS_READER_PROPOSAL_TABLE", "Proposal"],
142
140
  ] as const) {
143
141
  tables[model].grantReadWriteData(lambda);
144
142
  lambda.addEnvironment(variable, tables[model].tableName);
@@ -155,8 +153,10 @@ export const readerSteeringPlugin: PapyrusPlugin<ReaderSteeringConfig, { readerA
155
153
  // The snapshot reads submissions, and the review step writes their signals and moderation state.
156
154
  tables.ReaderSubmission.grantReadWriteData(contentActions);
157
155
  tables.ReaderComment.grantReadWriteData(contentActions);
158
- // The snapshot reads proposals and freezes the community result of one that reaches editor review.
156
+ // The snapshot reads proposals and freezes the community result of one that reaches editor review. The Proposal model and the
157
+ // functions that file and move proposals are core (amplify/functions/proposal-actions); this plugin only reads the same table.
159
158
  tables.Proposal.grantReadWriteData(contentActions);
159
+ contentActions.addEnvironment("PAPYRUS_READER_PROPOSAL_TABLE", tables.Proposal.tableName);
160
160
 
161
161
  lambda.addEnvironment("PAPYRUS_READER_SETTINGS", JSON.stringify(readerActionsSettings(config, interimSubjectsAllowedAtSynth())));
162
162
  if (config.reservedConcurrency !== undefined) {
@@ -135,101 +135,6 @@ export function readerSteeringSchemaDefinitions() {
135
135
  .disableOperations(["subscriptions"])
136
136
  .secondaryIndexes((index) => [index("target").sortKeys(["at"]).name("actionsByTarget").queryField("listModerationActionsByTargetAndAt")])
137
137
  .authorization(staffOnly),
138
- Proposal: a
139
- .model({
140
- id: a.id().required(),
141
- kind: a.string().required(),
142
- title: a.string().required(),
143
- summary: a.string(),
144
- body: a.string().required(),
145
- targets: a.json().required(),
146
- evidence: a.json(),
147
- proposer: a.string().required(),
148
- state: a.string().required(),
149
- history: a.json().required(),
150
- communityResult: a.json(),
151
- pinned: a.boolean(),
152
- held: a.boolean(),
153
- override: a.json(),
154
- exclusions: a.json(),
155
- editorLog: a.json(),
156
- needsLook: a.json(),
157
- createdAt: a.datetime().required(),
158
- })
159
- .disableOperations(["subscriptions"])
160
- .secondaryIndexes((index) => [index("state").sortKeys(["createdAt"]).name("proposalsByState").queryField("listProposalsByStateAndCreatedAt")])
161
- .authorization(staffOnly),
162
- ProposalReceipt: a.customType({
163
- status: a.string().required(),
164
- id: a.string(),
165
- error: a.string(),
166
- message: a.string(),
167
- }),
168
- // What an operator may read of a story draft: these six fields and nothing else (no body, evidence, history, proposer, vote or log).
169
- StoryDraftSummary: a.customType({
170
- id: a.string().required(),
171
- title: a.string().required(),
172
- summary: a.string(),
173
- targets: a.json(),
174
- state: a.string().required(),
175
- createdAt: a.string().required(),
176
- }),
177
- StoryDraftList: a.customType({
178
- status: a.string().required(),
179
- id: a.string(),
180
- error: a.string(),
181
- message: a.string(),
182
- drafts: a.ref("StoryDraftSummary").array(),
183
- }),
184
- // Editors and admins file as themselves. AppSync also admits an IAM principal whose own policy allows the field (the
185
- // schema accepts IAM callers for the functions that call it); the function files an AI proposal for an IAM principal
186
- // of its own account only, and refuses an identity-pool user and any other account. No IAM rule is declared here.
187
- fileProposal: a
188
- .mutation()
189
- .arguments({
190
- kind: a.string().required(),
191
- title: a.string().required(),
192
- body: a.string().required(),
193
- summary: a.string(),
194
- targets: a.json(),
195
- evidence: a.json(),
196
- })
197
- .returns(a.ref("ProposalReceipt"))
198
- .authorization((allow) => [allow.group("editor"), allow.group("admin")])
199
- .handler(functionHandler("readerActions", readerActions)),
200
- // The same IAM rule as fileProposal, narrower: an operator may move only draft to in_steering or declined.
201
- // Approving, deciding after review and overriding are refused for IAM in the function (`operator_not_allowed`).
202
- transitionProposal: a
203
- .mutation()
204
- .arguments({
205
- proposalId: a.string().required(),
206
- state: a.string().required(),
207
- reason: a.string(),
208
- result: a.string(),
209
- })
210
- .returns(a.ref("ProposalReceipt"))
211
- .authorization((allow) => [allow.group("editor"), allow.group("admin")])
212
- .handler(functionHandler("readerActions", readerActions)),
213
- // Read only, and the same caller rule as fileProposal: editors and admins, or an IAM principal of the function's own account. The function
214
- // answers the story drafts' id, title, summary, targets, state and createdAt so an unattended trend run can dedupe; nothing else.
215
- listStoryDrafts: a
216
- .query()
217
- .returns(a.ref("StoryDraftList"))
218
- .authorization((allow) => [allow.group("editor"), allow.group("admin")])
219
- .handler(functionHandler("readerActions", readerActions)),
220
- // Editors and admins only, and the function refuses an IAM operator: pin, unpin, hold, release, exclude_votes and
221
- // undo_exclusion. `rule` is a batch rule for exclude_votes; `exclusionId` names the exclusion undo_exclusion reverses.
222
- editProposal: a
223
- .mutation()
224
- .arguments({
225
- proposalId: a.string().required(),
226
- action: a.string().required(),
227
- rule: a.string(),
228
- exclusionId: a.string(),
229
- })
230
- .returns(a.ref("ProposalReceipt"))
231
- .authorization((allow) => [allow.group("editor"), allow.group("admin")])
232
- .handler(functionHandler("readerActions", readerActions)),
233
138
  ModerationReceipt: a.customType({
234
139
  status: a.string().required(),
235
140
  id: a.string(),
@@ -137,101 +137,6 @@ export function readerSteeringSchemaDefinitions() {
137
137
  .disableOperations(["subscriptions"])
138
138
  .secondaryIndexes((index) => [index("target").sortKeys(["at"]).name("actionsByTarget").queryField("listModerationActionsByTargetAndAt")])
139
139
  .authorization(staffOnly),
140
- Proposal: a
141
- .model({
142
- id: a.id().required(),
143
- kind: a.string().required(),
144
- title: a.string().required(),
145
- summary: a.string(),
146
- body: a.string().required(),
147
- targets: a.json().required(),
148
- evidence: a.json(),
149
- proposer: a.string().required(),
150
- state: a.string().required(),
151
- history: a.json().required(),
152
- communityResult: a.json(),
153
- pinned: a.boolean(),
154
- held: a.boolean(),
155
- override: a.json(),
156
- exclusions: a.json(),
157
- editorLog: a.json(),
158
- needsLook: a.json(),
159
- createdAt: a.datetime().required(),
160
- })
161
- .disableOperations(["subscriptions"])
162
- .secondaryIndexes((index) => [index("state").sortKeys(["createdAt"]).name("proposalsByState").queryField("listProposalsByStateAndCreatedAt")])
163
- .authorization(staffOnly),
164
- ProposalReceipt: a.customType({
165
- status: a.string().required(),
166
- id: a.string(),
167
- error: a.string(),
168
- message: a.string(),
169
- }),
170
- // What an operator may read of a story draft: these six fields and nothing else (no body, evidence, history, proposer, vote or log).
171
- StoryDraftSummary: a.customType({
172
- id: a.string().required(),
173
- title: a.string().required(),
174
- summary: a.string(),
175
- targets: a.json(),
176
- state: a.string().required(),
177
- createdAt: a.string().required(),
178
- }),
179
- StoryDraftList: a.customType({
180
- status: a.string().required(),
181
- id: a.string(),
182
- error: a.string(),
183
- message: a.string(),
184
- drafts: a.ref("StoryDraftSummary").array(),
185
- }),
186
- // Editors and admins file as themselves. AppSync also admits an IAM principal whose own policy allows the field (the
187
- // schema accepts IAM callers for the functions that call it); the function files an AI proposal for an IAM principal
188
- // of its own account only, and refuses an identity-pool user and any other account. No IAM rule is declared here.
189
- fileProposal: a
190
- .mutation()
191
- .arguments({
192
- kind: a.string().required(),
193
- title: a.string().required(),
194
- body: a.string().required(),
195
- summary: a.string(),
196
- targets: a.json(),
197
- evidence: a.json(),
198
- })
199
- .returns(a.ref("ProposalReceipt"))
200
- .authorization((allow) => [allow.group("editor"), allow.group("admin")])
201
- .handler(functionHandler("readerActions", readerActions)),
202
- // The same IAM rule as fileProposal, narrower: an operator may move only draft to in_steering or declined.
203
- // Approving, deciding after review and overriding are refused for IAM in the function (`operator_not_allowed`).
204
- transitionProposal: a
205
- .mutation()
206
- .arguments({
207
- proposalId: a.string().required(),
208
- state: a.string().required(),
209
- reason: a.string(),
210
- result: a.string(),
211
- })
212
- .returns(a.ref("ProposalReceipt"))
213
- .authorization((allow) => [allow.group("editor"), allow.group("admin")])
214
- .handler(functionHandler("readerActions", readerActions)),
215
- // Read only, and the same caller rule as fileProposal: editors and admins, or an IAM principal of the function's own account. The function
216
- // answers the story drafts' id, title, summary, targets, state and createdAt so an unattended trend run can dedupe; nothing else.
217
- listStoryDrafts: a
218
- .query()
219
- .returns(a.ref("StoryDraftList"))
220
- .authorization((allow) => [allow.group("editor"), allow.group("admin")])
221
- .handler(functionHandler("readerActions", readerActions)),
222
- // Editors and admins only, and the function refuses an IAM operator: pin, unpin, hold, release, exclude_votes and
223
- // undo_exclusion. `rule` is a batch rule for exclude_votes; `exclusionId` names the exclusion undo_exclusion reverses.
224
- editProposal: a
225
- .mutation()
226
- .arguments({
227
- proposalId: a.string().required(),
228
- action: a.string().required(),
229
- rule: a.string(),
230
- exclusionId: a.string(),
231
- })
232
- .returns(a.ref("ProposalReceipt"))
233
- .authorization((allow) => [allow.group("editor"), allow.group("admin")])
234
- .handler(functionHandler("readerActions", readerActions)),
235
140
  ModerationReceipt: a.customType({
236
141
  status: a.string().required(),
237
142
  id: a.string(),
@@ -24,6 +24,8 @@ import { defineSiteData } from "./data/resource.js";
24
24
  import { editorialChecks } from "./functions/editorial-checks/resource.js";
25
25
  import { automationView } from "./functions/automation-view/resource.js";
26
26
  import { wireAutomationView } from "./functions/automation-view/wire.js";
27
+ import { proposalActions } from "./functions/proposal-actions/resource.js";
28
+ import { wireProposalActions } from "./functions/proposal-actions/wire.js";
27
29
  import { monitoringView } from "./functions/monitoring-view/resource.js";
28
30
  import { wireEmailReferenceIntake, wireMonitoringView } from "./functions/monitoring-view/wire.js";
29
31
  import { wireEditorialChecks } from "./functions/editorial-checks/wire.js";
@@ -110,6 +112,7 @@ export function defineSiteBackend(papyrusSite) {
110
112
  modelAttachmentUpload,
111
113
  newsroomSummary,
112
114
  procedureAction,
115
+ proposalActions,
113
116
  publicForms,
114
117
  readerSettings,
115
118
  emailSubmissionProcessor,
@@ -631,6 +634,7 @@ export function defineSiteBackend(papyrusSite) {
631
634
  wireEditorialChecks(backend);
632
635
  wireMonitoringView(backend, emailMonitorId);
633
636
  wireAutomationView(backend);
637
+ wireProposalActions(backend);
634
638
  wirePlugins(backend, plugins, contentActionsLambda);
635
639
  return backend;
636
640
  }
@@ -31,6 +31,8 @@ import { defineSiteData } from "./data/resource";
31
31
  import { editorialChecks } from "./functions/editorial-checks/resource";
32
32
  import { automationView } from "./functions/automation-view/resource";
33
33
  import { wireAutomationView } from "./functions/automation-view/wire";
34
+ import { proposalActions } from "./functions/proposal-actions/resource";
35
+ import { wireProposalActions } from "./functions/proposal-actions/wire";
34
36
  import { monitoringView } from "./functions/monitoring-view/resource";
35
37
  import { wireEmailReferenceIntake, wireMonitoringView } from "./functions/monitoring-view/wire";
36
38
  import { wireEditorialChecks } from "./functions/editorial-checks/wire";
@@ -131,6 +133,7 @@ export function defineSiteBackend(papyrusSite: PapyrusSite) {
131
133
  modelAttachmentUpload,
132
134
  newsroomSummary,
133
135
  procedureAction,
136
+ proposalActions,
134
137
  publicForms,
135
138
  readerSettings,
136
139
  emailSubmissionProcessor,
@@ -777,6 +780,7 @@ export function defineSiteBackend(papyrusSite: PapyrusSite) {
777
780
  wireEditorialChecks(backend);
778
781
  wireMonitoringView(backend, emailMonitorId);
779
782
  wireAutomationView(backend);
783
+ wireProposalActions(backend);
780
784
  wirePlugins(backend, plugins, contentActionsLambda);
781
785
 
782
786
  return backend;
package/app/globals.css CHANGED
@@ -1,7 +1,7 @@
1
1
  @import "./papyrus-theme.css";
2
2
 
3
- @import "tailwindcss";
4
- @import "tw-animate-css";
3
+ /* Tailwind (import, @source globs, @theme tokens) lives only in ./tailwind.css. Importing it again here
4
+ emitted a second full Tailwind build that loaded later and whose `.hidden` overrode `md:flex`. */
5
5
  @import "@radix-ui/colors/slate.css";
6
6
  @import "@radix-ui/colors/slate-alpha.css";
7
7
  @import "@radix-ui/colors/slate-dark.css";
@@ -15,39 +15,6 @@
15
15
  @import "@radix-ui/colors/tomato-dark.css";
16
16
  @import "@radix-ui/colors/tomato-dark-alpha.css";
17
17
 
18
- @theme inline {
19
- --radius-sm: calc(var(--radius) - 4px);
20
- --radius-md: calc(var(--radius) - 2px);
21
- --radius-lg: var(--radius);
22
- --radius-xl: calc(var(--radius) + 4px);
23
- --color-background: var(--background);
24
- --color-foreground: var(--foreground);
25
- --color-card: var(--card);
26
- --color-card-foreground: var(--card-foreground);
27
- --color-popover: var(--popover);
28
- --color-popover-foreground: var(--popover-foreground);
29
- --color-primary: var(--primary);
30
- --color-primary-foreground: var(--primary-foreground);
31
- --color-secondary: var(--secondary);
32
- --color-secondary-foreground: var(--secondary-foreground);
33
- --color-muted: var(--muted);
34
- --color-muted-foreground: var(--muted-foreground);
35
- --color-accent: var(--accent);
36
- --color-accent-foreground: var(--accent-foreground);
37
- --color-destructive: var(--destructive);
38
- --color-destructive-foreground: var(--destructive-foreground);
39
- --color-border: var(--border);
40
- --color-input: var(--input);
41
- --color-ring: var(--ring);
42
- --color-danger: var(--danger);
43
- --color-danger-foreground: var(--danger-foreground);
44
- --color-surface: var(--surface);
45
- --color-surface-foreground: var(--surface-foreground);
46
- --color-foreground-strong: var(--foreground-strong);
47
- --color-foreground-muted: var(--foreground-muted);
48
- --color-foreground-subtle: var(--foreground-subtle);
49
- }
50
-
51
18
  :root {
52
19
  --radius: 0.875rem;
53
20
  --background: var(--slate-2);
package/app/tailwind.css CHANGED
@@ -24,6 +24,8 @@
24
24
  --color-accent: var(--accent);
25
25
  --color-accent-foreground: var(--accent-foreground);
26
26
  --color-destructive: var(--destructive);
27
+ --color-destructive-foreground: var(--destructive-foreground);
28
+ --color-foreground-subtle: var(--foreground-subtle);
27
29
  --color-danger: var(--danger);
28
30
  --color-danger-foreground: var(--danger-foreground);
29
31
  --color-surface: var(--surface);
@@ -97,8 +97,8 @@ function QueueItem({ item, ownId, demo }: { item: MonitoringItem; ownId: string
97
97
  );
98
98
  }
99
99
 
100
- function EmailSubmissions({ items, detail, onOpen, onResolveArticleLink, onImportCandidates }: {
101
- items: MonitoringEmailSummary[]; detail: MonitoringEmailDetails | null; onOpen: (receiptId: string) => void;
100
+ export function EmailSubmissions({ items, detail, onOpen, onResolveArticleLink, onImportCandidates }: {
101
+ items: MonitoringEmailSummary[]; detail: MonitoringEmailDetails | null; onOpen?: (receiptId: string) => void;
102
102
  onResolveArticleLink?: (receiptId: string, candidateIndex: number) => Promise<MonitoringEmailArticleResolution>;
103
103
  onImportCandidates?: (receiptId: string, items: EmailReferenceImportItem[]) => Promise<EmailReferenceImportOutcome>;
104
104
  }) {
@@ -181,7 +181,7 @@ function EmailSubmissions({ items, detail, onOpen, onResolveArticleLink, onImpor
181
181
  if (result.error) {
182
182
  setNoticeByReceipt((current) => ({ ...current, [requestReceiptId]: `${result.imported} of ${candidates.length} selected candidates were registered or matched. ${result.error}` }));
183
183
  } else {
184
- setNoticeByReceipt((current) => ({ ...current, [requestReceiptId]: `${result.imported} selected candidates are available as References. Import does not approve or publish them.` }));
184
+ setNoticeByReceipt((current) => ({ ...current, [requestReceiptId]: `${result.imported} selected candidates are available as References. Predictions are queued in the cloud; review them when ready. Import does not approve or publish them.` }));
185
185
  }
186
186
  } catch {
187
187
  if (isCurrentEmailSubmissionAction(activeReceiptId.current, requestReceiptId)) {
@@ -207,7 +207,7 @@ function EmailSubmissions({ items, detail, onOpen, onResolveArticleLink, onImpor
207
207
  <span>SES signals — SPF {item.verdicts.spf}, DKIM {item.verdicts.dkim}, DMARC {item.verdicts.dmarc}; spam {item.verdicts.spam}, virus {item.verdicts.virus}</span>
208
208
  {item.warnings.length ? <span>Warnings: {item.warnings.join(", ")}</span> : null}
209
209
  {item.errorCode ? <span>State: {item.status} ({item.errorCode})</span> : <span>State: {item.status}</span>}
210
- <button type="button" onClick={() => onOpen(item.receiptId)}>Read email and review candidate links</button>
210
+ {onOpen ? <button type="button" onClick={() => onOpen(item.receiptId)}>Read email and review candidate links</button> : null}
211
211
  </li>)}
212
212
  </ul> : <p>No email submissions are available for this publication.</p>}
213
213
  {detail && "submission" in detail ? (
@@ -238,7 +238,9 @@ function EmailSubmissions({ items, detail, onOpen, onResolveArticleLink, onImpor
238
238
  {busyImport ? "Adding References…" : "Add selected links to References"}
239
239
  </button>
240
240
  {notice ? <p role="status" data-email-import-result>{notice}</p> : null}
241
- {importedReferences.length ? <p><Link href={getNewsroomNavHref("/newsroom/references", false)}>Review these References</Link></p> : null}
241
+ {importedReferences.length ? <ul>{importedReferences.map((reference, index) => (
242
+ <li key={`${reference.id}-${index}`}><Link href={getNewsroomNavHref(`/newsroom/references/${encodeURIComponent(reference.lineageId)}`, false)}>Review imported Reference {index + 1}</Link></li>
243
+ ))}</ul> : null}
242
244
  </section>
243
245
  ) : detail && "error" in detail ? <p role="alert">You cannot read this submission.</p>
244
246
  : detail && "state" in detail ? <p role="alert">Submission details are unavailable.</p> : null}
@@ -1,6 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import { useEffect, useState } from "react";
4
+ import { useSearchParams } from "next/navigation";
4
5
  import {
5
6
  importMonitoringEmailCandidates,
6
7
  loadMonitoringEmailSubmission,
@@ -9,12 +10,41 @@ import {
9
10
  } from "../lib/monitoring-client";
10
11
  import { FIXTURE_ALL_PIPELINE_OVERVIEW, FIXTURE_MONITORING_OVERVIEW } from "../lib/monitoring-overview-fixture";
11
12
  import type { EmailReferenceImportItem, MonitoringEmailDetails, MonitoringResult } from "../lib/monitoring-overview";
12
- import { MonitoringOverviewView, type MonitoringMode } from "./monitoring-overview-view";
13
+ import { EmailSubmissions, MonitoringOverviewView, type MonitoringMode } from "./monitoring-overview-view";
13
14
 
14
15
  const REFRESH_MILLISECONDS = 60_000;
15
16
 
16
- /** Administration, Monitoring: loads the overview, refreshes it each minute, and lets an admin widen it to the whole pipeline. */
17
- export function MonitoringPanel({ isAdmin, isDemo }: { isAdmin: boolean; isDemo: boolean }) {
17
+ /** A receipt URL mounts only the single-submission reader, never the overview. */
18
+ export function MonitoringPanel(props: { isAdmin: boolean; isDemo: boolean }) {
19
+ const searchParams = useSearchParams();
20
+ if (searchParams.has("receipt")) {
21
+ return <MonitoringEmailReceipt key={searchParams.get("receipt")} receiptId={searchParams.get("receipt") ?? ""} isDemo={props.isDemo} />;
22
+ }
23
+ return <MonitoringOverviewPanel {...props} />;
24
+ }
25
+
26
+ function MonitoringEmailReceipt({ receiptId, isDemo }: { receiptId: string; isDemo: boolean }) {
27
+ const [detail, setDetail] = useState<MonitoringEmailDetails | null>(null);
28
+ useEffect(() => {
29
+ if (!receiptId || isDemo) return;
30
+ let live = true;
31
+ void loadMonitoringEmailSubmission(receiptId).then(
32
+ (next) => { if (live) setDetail(next); },
33
+ () => { if (live) setDetail({ state: "unavailable" }); },
34
+ );
35
+ return () => { live = false; };
36
+ }, [receiptId, isDemo]);
37
+ if (!receiptId) return <p role="alert">A receipt is required to open a submission.</p>;
38
+ if (isDemo) return <p role="status">Single email submissions require an editor session.</p>;
39
+ return <div data-news-desk-admin-panel="monitoring">
40
+ {detail === null ? <p role="status">Loading email submission…</p> :
41
+ <EmailSubmissions items={"submission" in detail ? [detail.submission] : []} detail={detail}
42
+ onResolveArticleLink={resolveMonitoringEmailArticleLink} onImportCandidates={importMonitoringEmailCandidates} />}
43
+ </div>;
44
+ }
45
+
46
+ /** Overview refreshes apply only when no receipt was requested. */
47
+ function MonitoringOverviewPanel({ isAdmin, isDemo }: { isAdmin: boolean; isDemo: boolean }) {
18
48
  const [mode, setMode] = useState<MonitoringMode>("publication");
19
49
  const [result, setResult] = useState<MonitoringResult | null>(null);
20
50
  const [emailDetail, setEmailDetail] = useState<MonitoringEmailDetails | null>(null);
@@ -75,7 +75,7 @@ import type { PrimingFormInput } from "./reference-cyclotron-priming";
75
75
  import { ReferenceCyclotronStatus, type ReviewRateChoice } from "./reference-cyclotron-status";
76
76
  import type { PrimingRequestView } from "../lib/relevance-status";
77
77
  import type { CyclotronStatus } from "cyclotron/cyclotron-status";
78
- import type { RelevanceCuration, RelevanceDecisionView } from "../lib/relevance-decisions";
78
+ import { awaitsEditorReview, type RelevanceCuration, type RelevanceDecisionView } from "../lib/relevance-decisions";
79
79
  import {
80
80
  loadReferenceAttachmentsForLineageId,
81
81
  loadStoragePathUrl,
@@ -252,8 +252,7 @@ function useReferencePreviewAttachments(
252
252
  }
253
253
 
254
254
  function needsReview(reference: ReferenceRecord, decision: RelevanceDecisionView | undefined): boolean {
255
- return Boolean(decision?.reviewRecommended)
256
- && normalizeReferenceCurationStatus(reference.curationStatus) === "pending";
255
+ return awaitsEditorReview(reference.curationStatus, decision);
257
256
  }
258
257
 
259
258
  function ReferenceDetailPanel({
@@ -0,0 +1,110 @@
1
+ "use client";
2
+
3
+ import { generateClient } from "aws-amplify/data";
4
+ import amplifyOutputs from "papyrus-amplify-outputs";
5
+ import { useCallback, useEffect, useMemo, useState } from "react";
6
+ import { ProposalsView } from "./proposals-view";
7
+ import { pluginSettings, type PluginOutputs } from "../lib/plugin-settings";
8
+ import {
9
+ EDIT_PROPOSAL,
10
+ LIST_PROPOSALS,
11
+ TRANSITION_PROPOSAL,
12
+ proposalReceiptMessage,
13
+ type ProposalFilter,
14
+ type ProposalMove,
15
+ type ProposalReceipt,
16
+ type ProposalRow,
17
+ } from "../lib/proposals-admin";
18
+
19
+ type GraphResult = { data?: Record<string, { items?: ProposalRow[]; nextToken?: string | null } | ProposalReceipt | null>; errors?: { message?: string }[] };
20
+ const MAXIMUM_PAGES = 20;
21
+
22
+ async function loadProposals(): Promise<ProposalRow[]> {
23
+ const client = generateClient({ authMode: "userPool" });
24
+ const rows: ProposalRow[] = [];
25
+ let nextToken: string | null | undefined;
26
+ for (let page = 0; page < MAXIMUM_PAGES; page += 1) {
27
+ const result = (await client.graphql({ query: LIST_PROPOSALS, variables: { nextToken } })) as GraphResult;
28
+ if (result.errors?.length) throw new Error(result.errors[0]?.message ?? "Could not load proposals.");
29
+ const connection = result.data?.listProposals as { items?: ProposalRow[]; nextToken?: string | null } | undefined;
30
+ rows.push(...(connection?.items ?? []).filter(Boolean));
31
+ nextToken = connection?.nextToken;
32
+ if (!nextToken) break;
33
+ }
34
+ return rows;
35
+ }
36
+
37
+ async function run(query: string, field: string, variables: Record<string, string | undefined>): Promise<ProposalReceipt> {
38
+ const client = generateClient({ authMode: "userPool" });
39
+ const result = (await client.graphql({ query, variables })) as GraphResult;
40
+ if (result.errors?.length) return { status: "refused", message: result.errors[0]?.message ?? "The action failed." };
41
+ return (result.data?.[field] as ProposalReceipt | null) ?? { status: "refused", message: "The action did not answer." };
42
+ }
43
+
44
+ /** One button: a path of states is one `transitionProposal` call per state, stopping at the first refusal; pin and hold are `editProposal`. */
45
+ async function perform(proposalId: string, move: ProposalMove, reason: string): Promise<ProposalReceipt> {
46
+ if (move.edit) return run(EDIT_PROPOSAL, "editProposal", { proposalId, action: move.edit });
47
+ let receipt: ProposalReceipt = { status: "refused", message: "Nothing to do." };
48
+ for (const state of move.path ?? []) {
49
+ receipt = await run(TRANSITION_PROPOSAL, "transitionProposal", { proposalId, state, ...(reason ? { reason } : {}) });
50
+ if (receipt.status !== "ok") break;
51
+ }
52
+ return receipt;
53
+ }
54
+
55
+ /** Core to every site: it does not depend on the reader-steering plugin. */
56
+ export function ProposalsPanel() {
57
+ const [rows, setRows] = useState<ProposalRow[]>([]);
58
+ const [filter, setFilter] = useState<ProposalFilter>("open");
59
+ const [status, setStatus] = useState("Loading...");
60
+ const [busy, setBusy] = useState(false);
61
+ const [reason, setReason] = useState("");
62
+ const readers = useMemo(() => pluginSettings(amplifyOutputs as PluginOutputs, "reader-steering") !== null, []);
63
+
64
+ const refresh = useCallback(async (quiet = false) => {
65
+ setBusy(true);
66
+ try {
67
+ setRows(await loadProposals());
68
+ if (!quiet) setStatus("");
69
+ } catch (error) {
70
+ setStatus(`Error: ${error instanceof Error ? error.message : "Could not load proposals."}`);
71
+ } finally {
72
+ setBusy(false);
73
+ }
74
+ }, []);
75
+
76
+ useEffect(() => {
77
+ void refresh();
78
+ }, [refresh]);
79
+
80
+ const onMove = useCallback(
81
+ async (proposalId: string, move: ProposalMove) => {
82
+ setBusy(true);
83
+ setStatus("Working...");
84
+ try {
85
+ const receipt = await perform(proposalId, move, reason.trim());
86
+ setStatus(proposalReceiptMessage(receipt));
87
+ if (receipt.status === "ok") setReason("");
88
+ } catch (error) {
89
+ setStatus(`Error: ${error instanceof Error ? error.message : "The action failed."}`);
90
+ }
91
+ await refresh(true);
92
+ },
93
+ [reason, refresh],
94
+ );
95
+
96
+ return (
97
+ <ProposalsView
98
+ rows={rows}
99
+ readers={readers}
100
+ filter={filter}
101
+ busy={busy}
102
+ status={status}
103
+ reason={reason}
104
+ onFilter={setFilter}
105
+ onReason={setReason}
106
+ onMove={(proposalId, move) => void onMove(proposalId, move)}
107
+ onRefresh={() => void refresh()}
108
+ />
109
+ );
110
+ }