@anthusai/papyrus 2.0.0-next.11 → 2.0.0-next.110

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 (227) hide show
  1. package/amplify/auth/resource.js +16 -1
  2. package/amplify/auth/resource.ts +19 -1
  3. package/amplify/data/function-handler.js +20 -0
  4. package/amplify/data/function-handler.ts +40 -0
  5. package/amplify/data/resource.js +152 -62
  6. package/amplify/data/resource.ts +158 -62
  7. package/amplify/data/schema.js +2101 -1987
  8. package/amplify/data/schema.ts +197 -70
  9. package/amplify/functions/assignment-action/data-access.js +2 -0
  10. package/amplify/functions/assignment-action/data-access.ts +2 -0
  11. package/amplify/functions/assignment-action/handler.ts +1 -1
  12. package/amplify/functions/automation-view/handler.py +17 -0
  13. package/amplify/functions/automation-view/resource.js +24 -0
  14. package/amplify/functions/automation-view/resource.ts +34 -0
  15. package/amplify/functions/automation-view/schema.js +26 -0
  16. package/amplify/functions/automation-view/schema.ts +28 -0
  17. package/amplify/functions/automation-view/wire.js +56 -0
  18. package/amplify/functions/automation-view/wire.ts +76 -0
  19. package/amplify/functions/content-actions/handler.py +64 -3
  20. package/amplify/functions/content-actions/requirements.txt +1 -0
  21. package/amplify/functions/content-actions/resource.js +1 -1
  22. package/amplify/functions/content-actions/resource.ts +1 -1
  23. package/amplify/functions/editorial-checks/handler.py +2 -2
  24. package/amplify/functions/editorial-checks/requirements.txt +1 -1
  25. package/amplify/functions/editorial-checks/resource.js +3 -3
  26. package/amplify/functions/editorial-checks/resource.ts +3 -3
  27. package/amplify/functions/editorial-checks/schema.js +38 -8
  28. package/amplify/functions/editorial-checks/schema.ts +38 -8
  29. package/amplify/functions/editorial-checks/wire.js +4 -0
  30. package/amplify/functions/editorial-checks/wire.ts +4 -0
  31. package/amplify/functions/monitoring-view/handler.py +11 -0
  32. package/amplify/functions/monitoring-view/resource.js +24 -0
  33. package/amplify/functions/monitoring-view/resource.ts +34 -0
  34. package/amplify/functions/monitoring-view/schema.js +29 -0
  35. package/amplify/functions/monitoring-view/schema.ts +31 -0
  36. package/amplify/functions/monitoring-view/wire.js +64 -0
  37. package/amplify/functions/monitoring-view/wire.ts +81 -0
  38. package/amplify/functions/public-forms/core.js +247 -0
  39. package/amplify/functions/public-forms/core.ts +310 -0
  40. package/amplify/functions/public-forms/handler.ts +41 -0
  41. package/amplify/functions/public-forms/origins.js +35 -0
  42. package/amplify/functions/public-forms/origins.ts +43 -0
  43. package/amplify/functions/public-forms/resource.js +9 -0
  44. package/amplify/functions/public-forms/resource.ts +10 -0
  45. package/amplify/functions/public-forms/wire.js +25 -0
  46. package/amplify/functions/public-forms/wire.ts +32 -0
  47. package/amplify/functions/shared/editorial-context.ts +63 -4
  48. package/amplify/functions/shared/python-bundle.js +20 -14
  49. package/amplify/functions/shared/python-bundle.ts +25 -18
  50. package/amplify/plugins/newsletter/config.js +245 -0
  51. package/amplify/plugins/newsletter/config.ts +305 -0
  52. package/amplify/plugins/newsletter/function/handler.py +6 -0
  53. package/amplify/plugins/newsletter/function/requirements.txt +5 -0
  54. package/amplify/plugins/newsletter/ops/handler.py +12 -0
  55. package/amplify/plugins/newsletter/ops/resource.js +27 -0
  56. package/amplify/plugins/newsletter/ops/resource.ts +37 -0
  57. package/amplify/plugins/newsletter/papyrus.plugin.js +263 -0
  58. package/amplify/plugins/newsletter/papyrus.plugin.ts +311 -0
  59. package/amplify/plugins/newsletter/schema.js +57 -0
  60. package/amplify/plugins/newsletter/schema.ts +62 -0
  61. package/amplify/plugins/newsletter/secure-string-parameter.js +107 -0
  62. package/amplify/plugins/newsletter/secure-string-parameter.ts +120 -0
  63. package/amplify/plugins/newsletter/sending.js +140 -0
  64. package/amplify/plugins/newsletter/sending.ts +186 -0
  65. package/amplify/plugins/reader-steering/config.js +182 -0
  66. package/amplify/plugins/reader-steering/config.ts +290 -0
  67. package/amplify/plugins/reader-steering/function/ai_moderation.py +246 -0
  68. package/amplify/plugins/reader-steering/function/handler.py +1589 -0
  69. package/amplify/plugins/reader-steering/function/moderation-prompt.md +27 -0
  70. package/amplify/plugins/reader-steering/function/moderation.py +316 -0
  71. package/amplify/plugins/reader-steering/function/proposals.py +487 -0
  72. package/amplify/plugins/reader-steering/function/resource.js +27 -0
  73. package/amplify/plugins/reader-steering/function/resource.ts +35 -0
  74. package/amplify/plugins/reader-steering/papyrus.plugin.js +187 -0
  75. package/amplify/plugins/reader-steering/papyrus.plugin.ts +213 -0
  76. package/amplify/plugins/reader-steering/schema.js +252 -0
  77. package/amplify/plugins/reader-steering/schema.ts +254 -0
  78. package/amplify/plugins/registry.js +4 -0
  79. package/amplify/plugins/registry.ts +4 -0
  80. package/amplify/plugins/signup/schema.js +2 -1
  81. package/amplify/plugins/signup/schema.ts +2 -1
  82. package/amplify/plugins/types.ts +5 -0
  83. package/amplify/plugins/wire.js +1 -0
  84. package/amplify/plugins/wire.ts +1 -0
  85. package/amplify/publication/projection-lease.js +30 -0
  86. package/amplify/publication/projection-lease.ts +39 -0
  87. package/amplify/publication/redirect-and-index.js +105 -0
  88. package/amplify/publication/redirect-rules.js +73 -0
  89. package/amplify/publication/redirect-rules.ts +93 -0
  90. package/amplify/publication/s3-projection.js +7 -1
  91. package/amplify/publication/s3-projection.ts +9 -1
  92. package/amplify/site-backend-config.js +19 -0
  93. package/amplify/site-backend-config.ts +45 -1
  94. package/amplify/site-backend-names.js +4 -2
  95. package/amplify/site-backend-names.ts +4 -2
  96. package/amplify/site-backend.js +42 -4
  97. package/amplify/site-backend.ts +45 -4
  98. package/app/.well-known/papyrus-cli.json/route.ts +9 -0
  99. package/app/api/auth/prospectus/sign-in/route.ts +4 -0
  100. package/app/api/newsletter/confirm/route.ts +13 -0
  101. package/app/api/newsletter/preferences/route.ts +13 -0
  102. package/app/api/newsletter/state/route.ts +9 -0
  103. package/app/api/newsletter/subscribe/route.ts +9 -0
  104. package/app/api/newsletter/unsubscribe/route.ts +13 -0
  105. package/app/api/newsroom/chat/[...path]/route.ts +17 -0
  106. package/app/api/newsroom/curation/context/route.ts +9 -0
  107. package/app/api/newsroom/curation/priming/route.ts +9 -0
  108. package/app/api/newsroom/curation/publication/route.ts +9 -0
  109. package/app/api/reader/comment/delete/route.ts +9 -0
  110. package/app/api/reader/comment/edit/route.ts +9 -0
  111. package/app/api/reader/comment/route.ts +9 -0
  112. package/app/api/reader/flag/route.ts +9 -0
  113. package/app/api/reader/handle/route.ts +9 -0
  114. package/app/api/reader/me/route.ts +9 -0
  115. package/app/api/reader/rating/route.ts +9 -0
  116. package/app/api/reader/report/route.ts +9 -0
  117. package/app/api/reader/retired/route.ts +9 -0
  118. package/app/api/reader/submit/route.ts +9 -0
  119. package/app/api/reader/vote/route.ts +9 -0
  120. package/app/layout.tsx +5 -1
  121. package/bin/papyrus-infra.mjs +20 -1
  122. package/components/automation-panel.tsx +49 -0
  123. package/components/automation-run-now.tsx +97 -0
  124. package/components/automation-view.tsx +195 -0
  125. package/components/editorial-checks-admin-panel.tsx +32 -17
  126. package/components/moderation-panel.tsx +248 -0
  127. package/components/monitoring-overview-view.tsx +422 -0
  128. package/components/monitoring-panel.tsx +60 -0
  129. package/components/news-desk-client-provider.tsx +18 -18
  130. package/components/news-desk-taxonomy-client.ts +28 -8
  131. package/components/newsletter-panel.tsx +474 -0
  132. package/components/newsletter-sections.tsx +163 -0
  133. package/components/newsroom-access-gate.tsx +16 -2
  134. package/components/newsroom-article-editor.tsx +9 -7
  135. package/components/newsroom-articles-view.tsx +6 -6
  136. package/components/newsroom-assignment-detail.tsx +6 -6
  137. package/components/newsroom-board-view.tsx +8 -3
  138. package/components/newsroom-curation-bootstrap.tsx +45 -0
  139. package/components/newsroom-ops-overview.tsx +26 -96
  140. package/components/newsroom-ops-shell.tsx +2 -2
  141. package/components/newsroom-organizer.css +33 -0
  142. package/components/newsroom-overview-skeleton.tsx +25 -0
  143. package/components/newsroom-references-view.tsx +192 -49
  144. package/components/notification-center.tsx +281 -0
  145. package/components/papyrus-console-conversation.css +14 -0
  146. package/components/papyrus-console-conversation.tsx +55 -0
  147. package/components/papyrus-console-shell.tsx +62 -420
  148. package/components/papyrus-private-chat-panel.tsx +134 -0
  149. package/components/publication-cyclotron-panel.tsx +15 -10
  150. package/components/reader-auth-state.ts +1 -1
  151. package/components/reference-add-entry.tsx +75 -0
  152. package/components/reference-curation-controls.tsx +31 -22
  153. package/components/reference-cyclotron-priming.tsx +58 -19
  154. package/components/reference-cyclotron-suggestion.tsx +10 -6
  155. package/components/reference-priming.tsx +14 -10
  156. package/components/reference-relevance-review.tsx +3 -3
  157. package/components/reference-section-suggestion.tsx +1 -1
  158. package/components/topic-steering-workspace.tsx +230 -144
  159. package/infra/amplify-app-shell.js +96 -16
  160. package/infra/amplify-app-shell.ts +108 -18
  161. package/infra/cloud-email-config.js +32 -0
  162. package/infra/cloud-email-config.ts +39 -0
  163. package/infra/cloud-email.js +57 -0
  164. package/infra/cloud-email.test.js +69 -0
  165. package/infra/cloud-email.test.ts +66 -0
  166. package/infra/cloud-email.ts +60 -0
  167. package/infra/index.js +1 -0
  168. package/infra/secure-string-parameter-handler.js +41 -0
  169. package/infra/secure-string-parameter-handler.ts +41 -0
  170. package/infra/secure-string-parameter.js +79 -0
  171. package/infra/secure-string-parameter.ts +91 -0
  172. package/infra/site-config.js +92 -6
  173. package/infra/site-config.ts +138 -6
  174. package/lib/automation-client.ts +54 -0
  175. package/lib/automation-fixture.ts +93 -0
  176. package/lib/automation-overview.ts +252 -0
  177. package/lib/category-repository.ts +14 -12
  178. package/lib/cli-login-discovery.ts +78 -0
  179. package/lib/console-web-context.ts +1 -1
  180. package/lib/curation-routes.ts +345 -0
  181. package/lib/cyclotron-suggestions.ts +4 -4
  182. package/lib/editorial-checks-client.ts +18 -0
  183. package/lib/editorial-checks-demo.ts +1 -1
  184. package/lib/editorial-checks.ts +8 -4
  185. package/lib/editorial-preview.ts +5 -5
  186. package/lib/monitoring-client.ts +83 -0
  187. package/lib/monitoring-overview-fixture.ts +51 -0
  188. package/lib/monitoring-overview.ts +191 -0
  189. package/lib/news-desk-session.ts +12 -21
  190. package/lib/newsletter-api.ts +418 -0
  191. package/lib/newsletter-client.ts +46 -0
  192. package/lib/newsletter-fixture.ts +184 -0
  193. package/lib/newsletter-model.ts +202 -0
  194. package/lib/newsroom-demo-dashboard.ts +1 -1
  195. package/lib/newsroom-nav.ts +1 -1
  196. package/lib/newsroom-organizer-nav.ts +2 -1
  197. package/lib/papyrus-chat-admission.ts +65 -0
  198. package/lib/papyrus-hosted-chat.ts +217 -0
  199. package/lib/papyrus-private-chat-client.ts +81 -0
  200. package/lib/prospectus-session.ts +30 -14
  201. package/lib/prospectus-sign-in-routes.ts +106 -31
  202. package/lib/prospectus-site-identity.ts +46 -6
  203. package/lib/reader-api.ts +145 -0
  204. package/lib/reader-moderation.ts +220 -0
  205. package/lib/reader-retirement-route.ts +89 -0
  206. package/lib/reader-subject.md +29 -0
  207. package/lib/reader-subject.ts +53 -0
  208. package/lib/reference-review-details.ts +71 -0
  209. package/lib/reference-review-request.ts +14 -0
  210. package/lib/relevance-decisions.ts +42 -10
  211. package/lib/relevance-status.ts +2 -2
  212. package/lib/review-client.ts +120 -0
  213. package/lib/review-fixture.ts +45 -0
  214. package/lib/review-requests.ts +246 -0
  215. package/lib/semantic-graph.ts +2 -0
  216. package/lib/shared-chat-admission.ts +182 -0
  217. package/lib/site-brand-tenant.ts +11 -6
  218. package/lib/site-brand.ts +6 -3
  219. package/lib/site-origin-value.ts +50 -0
  220. package/lib/site-origin.ts +8 -0
  221. package/lib/use-reference-review-data.ts +44 -0
  222. package/middleware.ts +14 -3
  223. package/package.json +67 -3
  224. package/routes.manifest.json +238 -2
  225. package/src/sync.mjs +11 -2
  226. package/src/with-papyrus.d.mts +1 -0
  227. package/src/with-papyrus.mjs +17 -1
@@ -12,6 +12,7 @@ import {
12
12
  draftQuestions,
13
13
  importStarterPack,
14
14
  listPolicies,
15
+ listStarterPacks,
15
16
  loadSettings,
16
17
  recalibrate,
17
18
  savePolicy,
@@ -19,13 +20,9 @@ import {
19
20
  tryPolicy,
20
21
  type EditorialPolicyRecord,
21
22
  type EditorialSettings,
23
+ type StarterPackSummary,
22
24
  } from "../lib/editorial-checks-client";
23
25
 
24
- const STARTER_PACKS = [
25
- { name: "ai-writing-tells", label: "AI-writing tells" },
26
- { name: "plain-language", label: "Federal Plain Language Guidelines" },
27
- { name: "asd-ste100", label: "ASD-STE100 Simplified Technical English" },
28
- ] as const;
29
26
  const POLICY_KEY = /^[a-z0-9][a-z0-9._-]*$/;
30
27
 
31
28
  type Draft = {
@@ -67,8 +64,8 @@ function lines(text: string): string[] {
67
64
  return text.split("\n").map((line) => line.trim()).filter(Boolean);
68
65
  }
69
66
 
70
- /** The policy as Limatus sees it (its key is the policy id). Throws on invalid detector JSON. */
71
- function limatusPolicy(draft: Draft): Record<string, unknown> {
67
+ /** The policy as Humanus sees it (its key is the policy id). Throws on invalid detector JSON. */
68
+ function humanusPolicy(draft: Draft): Record<string, unknown> {
72
69
  const detectors = JSON.parse(draft.detectorsText || "[]") as unknown;
73
70
  if (!Array.isArray(detectors)) throw new Error("Detectors must be a JSON list.");
74
71
  const violates = lines(draft.violatesText);
@@ -91,7 +88,8 @@ export function EditorialChecksAdminPanel() {
91
88
  const [settings, setSettings] = useState<EditorialSettings>(DEFAULT_EDITORIAL_SETTINGS);
92
89
  const [settingsExist, setSettingsExist] = useState(false);
93
90
  const [draft, setDraft] = useState<Draft | null>(null);
94
- const [starterPack, setStarterPack] = useState<string>(STARTER_PACKS[0].name);
91
+ const [starterPacks, setStarterPacks] = useState<StarterPackSummary[]>([]);
92
+ const [starterPack, setStarterPack] = useState<string>("");
95
93
  const [message, setMessage] = useState<string | null>(null);
96
94
  const [working, setWorking] = useState<string | null>(null);
97
95
  const [tryText, setTryText] = useState("");
@@ -99,7 +97,9 @@ export function EditorialChecksAdminPanel() {
99
97
 
100
98
  const refresh = useCallback(async () => {
101
99
  try {
102
- const [loadedPolicies, loadedSettings] = await Promise.all([listPolicies(), loadSettings()]);
100
+ const [loadedPolicies, loadedSettings, catalog] = await Promise.all([listPolicies(), loadSettings(), listStarterPacks()]);
101
+ setStarterPacks(catalog.packs);
102
+ setStarterPack((current) => current || catalog.packs[0]?.name || "");
103
103
  setPolicies(loadedPolicies);
104
104
  setSettings(loadedSettings.settings);
105
105
  setSettingsExist(loadedSettings.exists);
@@ -133,7 +133,7 @@ export function EditorialChecksAdminPanel() {
133
133
  const key = draft.record.policyKey.trim();
134
134
  if (!POLICY_KEY.test(key)) throw new Error("The key must be lowercase letters, digits, dots, hyphens or underscores.");
135
135
  if (!draft.record.title.trim() || !draft.record.guidance.trim()) throw new Error("A policy needs a title and guidance.");
136
- const policy = limatusPolicy(draft);
136
+ const policy = humanusPolicy(draft);
137
137
  const record: EditorialPolicyRecord = {
138
138
  ...draft.record,
139
139
  id: draft.isNew ? `${draft.record.scope}#${key}` : draft.record.id,
@@ -163,14 +163,18 @@ export function EditorialChecksAdminPanel() {
163
163
  <div className="flex flex-wrap gap-2">
164
164
  <Button onClick={() => { setDraft(draftFrom(emptyPolicy(), true)); setTryResult(null); }} size="sm" type="button">New policy</Button>
165
165
  <select aria-label="Starter pack" onChange={(event) => setStarterPack(event.target.value)} value={starterPack}>
166
- {STARTER_PACKS.map((pack) => <option key={pack.name} value={pack.name}>{pack.label}</option>)}
166
+ {starterPacks.map((pack) => (
167
+ <option key={pack.name} value={pack.name}>
168
+ {pack.title} ({pack.importedCount} of {pack.policyCount} added)
169
+ </option>
170
+ ))}
167
171
  </select>
168
172
  <Button
169
- disabled={working !== null}
173
+ disabled={working !== null || !starterPack}
170
174
  onClick={() => run("import", async () => {
171
175
  const outcome = await importStarterPack(starterPack);
172
176
  await refresh();
173
- const label = STARTER_PACKS.find((pack) => pack.name === starterPack)?.label ?? starterPack;
177
+ const label = starterPacks.find((pack) => pack.name === starterPack)?.title ?? starterPack;
174
178
  return `${label}: ${outcome.created} policies added, ${outcome.skipped} already present.`;
175
179
  })}
176
180
  size="sm"
@@ -179,6 +183,17 @@ export function EditorialChecksAdminPanel() {
179
183
  >
180
184
  Add starter pack
181
185
  </Button>
186
+ </div>
187
+ {(() => {
188
+ const selected = starterPacks.find((pack) => pack.name === starterPack);
189
+ return selected ? (
190
+ <p className="text-sm text-muted-foreground">
191
+ {selected.title}{selected.version ? ` ${selected.version}` : ""}: {selected.policyCount} policies
192
+ {selected.isDefault ? " (added to every new site)" : ""}. {selected.guidance}
193
+ </p>
194
+ ) : null;
195
+ })()}
196
+ <div className="flex flex-wrap gap-2">
182
197
  <Button
183
198
  disabled={working !== null}
184
199
  onClick={() => run("recalibrate", async () => {
@@ -203,7 +218,7 @@ export function EditorialChecksAdminPanel() {
203
218
  {policies.map((policy) => (
204
219
  <tr data-editorial-policy-row={policy.id} key={policy.id}>
205
220
  <td>{policy.title}<br /><small>{policy.policyKey} · v{policy.version}</small></td>
206
- <td>{policy.scope === "publication" ? "Whole publication" : `Desk: ${policy.scope}`}</td>
221
+ <td>{policy.scope === "publication" ? "Whole publication" : `Section: ${policy.scope}`}</td>
207
222
  <td>{policy.severity}</td>
208
223
  <td>{policy.detectors.length === 0 ? "Guidance only" : policy.detectors.length}</td>
209
224
  <td>
@@ -236,7 +251,7 @@ export function EditorialChecksAdminPanel() {
236
251
  <label>Key <Input disabled={!draft.isNew} onChange={(event) => patchDraft({ policyKey: event.target.value })} value={draft.record.policyKey} /></label>
237
252
  <label>Title <Input onChange={(event) => patchDraft({ title: event.target.value })} value={draft.record.title} /></label>
238
253
  <label>
239
- Applies to (publication, or a desk key){" "}
254
+ Applies to (publication, or a section key){" "}
240
255
  <Input disabled={!draft.isNew} onChange={(event) => patchDraft({ scope: event.target.value.trim() || "publication" })} value={draft.record.scope} />
241
256
  </label>
242
257
  <label>
@@ -252,7 +267,7 @@ export function EditorialChecksAdminPanel() {
252
267
  <label>Examples that break it (one per line) <Textarea onChange={(event) => setDraft({ ...draft, violatesText: event.target.value })} rows={3} value={draft.violatesText} /></label>
253
268
  <label>Examples that follow it (one per line) <Textarea onChange={(event) => setDraft({ ...draft, compliesText: event.target.value })} rows={3} value={draft.compliesText} /></label>
254
269
  <label>
255
- Detectors (JSON; see <code>limatus schema policy-pack-v1</code>)
270
+ Detectors (JSON; see <code>humanus schema policy-pack-v1</code>)
256
271
  <Textarea className="font-mono text-xs" onChange={(event) => setDraft({ ...draft, detectorsText: event.target.value })} rows={10} value={draft.detectorsText} />
257
272
  </label>
258
273
  <div className="flex flex-wrap gap-2">
@@ -293,7 +308,7 @@ export function EditorialChecksAdminPanel() {
293
308
  <Button
294
309
  disabled={working !== null || !tryText.trim()}
295
310
  onClick={() => run("try", async () => {
296
- setTryResult(await tryPolicy(limatusPolicy(draft), tryText));
311
+ setTryResult(await tryPolicy(humanusPolicy(draft), tryText));
297
312
  })}
298
313
  size="sm"
299
314
  type="button"
@@ -0,0 +1,248 @@
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 { pluginSettings, type PluginOutputs } from "../lib/plugin-settings";
7
+ import {
8
+ buildActionLog,
9
+ buildHidden,
10
+ buildQueue,
11
+ moderationModeText,
12
+ receiptMessage,
13
+ shortReader,
14
+ type ActionEntry,
15
+ type ActionRow,
16
+ type CommentRow,
17
+ type ModerationActionName,
18
+ type ModerationRow,
19
+ type ModerationTarget,
20
+ type Receipt,
21
+ type ReportRow,
22
+ type SubmissionRow,
23
+ } from "../lib/reader-moderation";
24
+
25
+ const SUBMISSION_FIELDS = "id canonicalUrl domain title kind note readerId state moderation reportCount signals itemId createdAt";
26
+ const COMMENT_FIELDS = "id subjectKey readerId body parentId state moderation reportCount signals createdAt";
27
+
28
+ const QUEUE_SUBMISSIONS = `query Queue($nextToken: String) { listReaderSubmissionsByModerationAndCreatedAt(moderation: "new", sortDirection: DESC, limit: 100, nextToken: $nextToken) { items { ${SUBMISSION_FIELDS} } nextToken } }`;
29
+ const QUEUE_COMMENTS = `query Queue($nextToken: String) { listReaderCommentsByModerationAndCreatedAt(moderation: "new", sortDirection: DESC, limit: 100, nextToken: $nextToken) { items { ${COMMENT_FIELDS} } nextToken } }`;
30
+ const HIDDEN_SUBMISSIONS = `query Hidden($nextToken: String) { listReaderSubmissions(filter: { state: { eq: "hidden" } }, limit: 500, nextToken: $nextToken) { items { ${SUBMISSION_FIELDS} } nextToken } }`;
31
+ const HIDDEN_COMMENTS = `query Hidden($nextToken: String) { listReaderComments(filter: { state: { eq: "hidden" } }, limit: 500, nextToken: $nextToken) { items { ${COMMENT_FIELDS} } nextToken } }`;
32
+ const OPEN_REPORTS = `query Reports($nextToken: String) { listReaderReports(filter: { state: { eq: "open" } }, limit: 500, nextToken: $nextToken) { items { id targetType target reporterReaderId reason state createdAt } nextToken } }`;
33
+ const ACTIONS = `query Actions($nextToken: String) { listModerationActions(limit: 500, nextToken: $nextToken) { items { id by actorSub action targetType target reason fromState toState undoOf undoneBy at } nextToken } }`;
34
+ const HANDLES = `query Handles($nextToken: String) { listReaderHandles(limit: 1000, nextToken: $nextToken) { items { id readerId } nextToken } }`;
35
+ const MODERATE = `mutation Moderate($action: String!, $targetType: String!, $target: String!, $reason: String, $undoOf: String) { moderateReader(action: $action, targetType: $targetType, target: $target, reason: $reason, undoOf: $undoOf) { status id error message } }`;
36
+
37
+ type GraphResult = { data?: Record<string, { items: unknown[]; nextToken?: string | null } | Receipt | null>; errors?: { message?: string }[] };
38
+ const MAXIMUM_PAGES = 20;
39
+
40
+ async function loadAll<T>(query: string, field: string): Promise<T[]> {
41
+ const client = generateClient({ authMode: "userPool" });
42
+ const rows: T[] = [];
43
+ let nextToken: string | null | undefined;
44
+ for (let page = 0; page < MAXIMUM_PAGES; page += 1) {
45
+ const result = (await client.graphql({ query, variables: { nextToken } })) as GraphResult;
46
+ if (result.errors?.length) throw new Error(result.errors[0]?.message ?? "Could not load moderation data.");
47
+ const connection = result.data?.[field] as { items: T[]; nextToken?: string | null } | undefined;
48
+ rows.push(...(connection?.items ?? []).filter(Boolean));
49
+ nextToken = connection?.nextToken;
50
+ if (!nextToken) break;
51
+ }
52
+ return rows;
53
+ }
54
+
55
+ async function moderate(variables: Record<string, string | undefined>): Promise<Receipt> {
56
+ const client = generateClient({ authMode: "userPool" });
57
+ const result = (await client.graphql({ query: MODERATE, variables })) as GraphResult;
58
+ if (result.errors?.length) return { status: "refused", message: result.errors[0]?.message ?? "The action failed." };
59
+ return (result.data?.moderateReader as Receipt | null) ?? { status: "refused", message: "The action did not answer." };
60
+ }
61
+
62
+ export type ModerationViewProps = {
63
+ mode: unknown;
64
+ queue: ModerationRow[];
65
+ hidden: ModerationRow[];
66
+ log: ActionEntry[];
67
+ tab: "queue" | "hidden" | "log";
68
+ busy: boolean;
69
+ status: string;
70
+ reason: string;
71
+ onTab: (tab: "queue" | "hidden" | "log") => void;
72
+ onReason: (reason: string) => void;
73
+ onAction: (action: ModerationActionName, targetType: ModerationTarget, target: string, undoOf?: string) => void;
74
+ onRefresh: () => void;
75
+ };
76
+
77
+ function Row({ row, busy, onAction, listed }: { row: ModerationRow; busy: boolean; onAction: ModerationViewProps["onAction"]; listed: "queue" | "hidden" }) {
78
+ const kind = row.kind === "submission" ? "Submission" : "Comment";
79
+ return (
80
+ <li data-moderation-row={row.id}>
81
+ <article aria-label={`${kind} by ${shortReader(row.readerId, row.handle)}`}>
82
+ <p>
83
+ <strong>{kind}</strong> by {shortReader(row.readerId, row.handle)} <time dateTime={row.createdAt}>{row.createdAt}</time> ({row.state}
84
+ {row.reports.length > 0 ? `, ${row.reports.length} ${row.reports.length === 1 ? "report" : "reports"}` : ""})
85
+ </p>
86
+ {row.kind === "submission" ? (
87
+ <p>
88
+ {row.url ? (
89
+ <a href={row.url} target="_blank" rel="noopener noreferrer nofollow">{row.title}</a>
90
+ ) : (
91
+ <span>{row.title}</span>
92
+ )}
93
+ </p>
94
+ ) : null}
95
+ {row.text ? <p style={{ whiteSpace: "pre-wrap", overflowWrap: "anywhere" }}>{row.text}</p> : null}
96
+ {row.signals.length > 0 ? (
97
+ <ul aria-label="Automatic signals">
98
+ {row.signals.map((signal, index) => (
99
+ <li key={`${signal.detector}-${index}`}>Signal ({signal.severity}): {signal.policy} / {signal.detector}{signal.excerpt ? ` — “${signal.excerpt}”` : ""}</li>
100
+ ))}
101
+ </ul>
102
+ ) : null}
103
+ {row.reports.length > 0 ? (
104
+ <ul aria-label="Reports">
105
+ {row.reports.map((report) => (
106
+ <li key={report.id}>Reported: {report.reason}</li>
107
+ ))}
108
+ </ul>
109
+ ) : null}
110
+ <p>
111
+ {listed === "queue" ? (
112
+ <button type="button" disabled={busy} onClick={() => onAction("approve", row.kind, row.id)}>Approve</button>
113
+ ) : (
114
+ <button type="button" disabled={busy} onClick={() => onAction("unhide", row.kind, row.id)}>Show again</button>
115
+ )}{" "}
116
+ {row.state !== "hidden" ? <button type="button" disabled={busy} onClick={() => onAction("hide", row.kind, row.id)}>Hide</button> : null}{" "}
117
+ {row.canPromote ? <button type="button" disabled={busy} onClick={() => onAction("promote", row.kind, row.id)}>Promote to a draft</button> : null}{" "}
118
+ <button type="button" disabled={busy} onClick={() => onAction("ban", "reader", row.readerId)}>Ban reader</button>{" "}
119
+ <button type="button" disabled={busy} onClick={() => onAction("limit", "reader", row.readerId)}>Limit reader</button>
120
+ </p>
121
+ </article>
122
+ </li>
123
+ );
124
+ }
125
+
126
+ export function ModerationView(props: ModerationViewProps) {
127
+ const { queue, hidden, log, tab, busy } = props;
128
+ return (
129
+ <section aria-labelledby="newsroom-moderation-title" data-news-desk-admin-panel="moderation">
130
+ <h3 id="newsroom-moderation-title">Moderation</h3>
131
+ <p>{moderationModeText(props.mode)} Only editors and admins can see this.</p>
132
+ <p role="tablist" aria-label="Moderation views">
133
+ <button type="button" role="tab" aria-selected={tab === "queue"} onClick={() => props.onTab("queue")}>Queue ({queue.length})</button>{" "}
134
+ <button type="button" role="tab" aria-selected={tab === "hidden"} onClick={() => props.onTab("hidden")}>Hidden ({hidden.length})</button>{" "}
135
+ <button type="button" role="tab" aria-selected={tab === "log"} onClick={() => props.onTab("log")}>Recent actions</button>{" "}
136
+ <button type="button" onClick={props.onRefresh} disabled={busy}>Refresh</button>
137
+ </p>
138
+ <label>
139
+ Reason for the next action (optional){" "}
140
+ <input type="text" value={props.reason} maxLength={500} onChange={(event) => props.onReason(event.target.value)} />
141
+ </label>
142
+ <p role="status" aria-live="polite">{props.status}</p>
143
+ {tab === "queue" ? (
144
+ queue.length === 0 ? <p>Nothing is waiting for a decision.</p> : <ul>{queue.map((row) => <Row key={row.id} row={row} busy={busy} onAction={props.onAction} listed="queue" />)}</ul>
145
+ ) : null}
146
+ {tab === "hidden" ? (
147
+ hidden.length === 0 ? <p>Nothing is hidden.</p> : <ul>{hidden.map((row) => <Row key={row.id} row={row} busy={busy} onAction={props.onAction} listed="hidden" />)}</ul>
148
+ ) : null}
149
+ {tab === "log" ? (
150
+ log.length === 0 ? <p>No moderation actions yet.</p> : (
151
+ <table>
152
+ <thead>
153
+ <tr><th scope="col">When</th><th scope="col">Action</th><th scope="col">Target</th><th scope="col">By</th><th scope="col">Reason</th><th scope="col"><span>Undo</span></th></tr>
154
+ </thead>
155
+ <tbody>
156
+ {log.map((entry) => (
157
+ <tr key={entry.id}>
158
+ <td>{entry.at}</td>
159
+ <td>{entry.label} ({entry.fromState} to {entry.toState}){entry.undoneBy ? ", undone" : ""}</td>
160
+ <td>{entry.targetType} {entry.target.slice(0, 12)}</td>
161
+ <td>{entry.byLabel}</td>
162
+ <td>{entry.reason}</td>
163
+ <td>
164
+ {entry.undoable ? (
165
+ <button type="button" disabled={busy} onClick={() => props.onAction("undo", entry.targetType as ModerationTarget, entry.target, entry.id)}>Undo</button>
166
+ ) : null}
167
+ </td>
168
+ </tr>
169
+ ))}
170
+ </tbody>
171
+ </table>
172
+ )
173
+ ) : null}
174
+ </section>
175
+ );
176
+ }
177
+
178
+ export function ModerationPanel() {
179
+ const mode = useMemo(() => pluginSettings(amplifyOutputs as PluginOutputs, "reader-steering")?.moderation, []);
180
+ const [tab, setTab] = useState<"queue" | "hidden" | "log">("queue");
181
+ const [data, setData] = useState<{ queue: ModerationRow[]; hidden: ModerationRow[]; log: ActionEntry[] } | null>(null);
182
+ const [status, setStatus] = useState("Loading...");
183
+ const [busy, setBusy] = useState(false);
184
+ const [reason, setReason] = useState("");
185
+
186
+ const refresh = useCallback(async (quiet = false) => {
187
+ setBusy(true);
188
+ try {
189
+ const [newSubmissions, newComments, hiddenSubmissions, hiddenComments, reports, actions, handleRows] = await Promise.all([
190
+ loadAll<SubmissionRow>(QUEUE_SUBMISSIONS, "listReaderSubmissionsByModerationAndCreatedAt"),
191
+ loadAll<CommentRow>(QUEUE_COMMENTS, "listReaderCommentsByModerationAndCreatedAt"),
192
+ loadAll<SubmissionRow>(HIDDEN_SUBMISSIONS, "listReaderSubmissions"),
193
+ loadAll<CommentRow>(HIDDEN_COMMENTS, "listReaderComments"),
194
+ loadAll<ReportRow>(OPEN_REPORTS, "listReaderReports"),
195
+ loadAll<ActionRow>(ACTIONS, "listModerationActions"),
196
+ loadAll<{ id: string; readerId: string }>(HANDLES, "listReaderHandles"),
197
+ ]);
198
+ const handles = Object.fromEntries(handleRows.map((row) => [row.readerId, row.id]));
199
+ setData({
200
+ queue: buildQueue({ submissions: newSubmissions, comments: newComments, reports, handles }),
201
+ hidden: buildHidden({ submissions: hiddenSubmissions, comments: hiddenComments, reports, handles }),
202
+ log: buildActionLog(actions),
203
+ });
204
+ if (!quiet) setStatus("");
205
+ } catch (error) {
206
+ setStatus(`Error: ${error instanceof Error ? error.message : "Could not load moderation data."}`);
207
+ } finally {
208
+ setBusy(false);
209
+ }
210
+ }, []);
211
+
212
+ useEffect(() => {
213
+ void refresh();
214
+ }, [refresh]);
215
+
216
+ const onAction = useCallback(
217
+ async (action: ModerationActionName, targetType: ModerationTarget, target: string, undoOf?: string) => {
218
+ setBusy(true);
219
+ setStatus("Working...");
220
+ try {
221
+ const receipt = await moderate({ action, targetType, target, ...(reason.trim() ? { reason: reason.trim() } : {}), ...(undoOf ? { undoOf } : {}) });
222
+ setStatus(receiptMessage(receipt));
223
+ if (receipt.status === "ok") setReason("");
224
+ } catch (error) {
225
+ setStatus(`Error: ${error instanceof Error ? error.message : "The action failed."}`);
226
+ }
227
+ await refresh(true);
228
+ },
229
+ [reason, refresh],
230
+ );
231
+
232
+ return (
233
+ <ModerationView
234
+ mode={mode}
235
+ queue={data?.queue ?? []}
236
+ hidden={data?.hidden ?? []}
237
+ log={data?.log ?? []}
238
+ tab={tab}
239
+ busy={busy}
240
+ status={status}
241
+ reason={reason}
242
+ onTab={setTab}
243
+ onReason={setReason}
244
+ onAction={(action, targetType, target, undoOf) => void onAction(action, targetType, target, undoOf)}
245
+ onRefresh={() => void refresh()}
246
+ />
247
+ );
248
+ }