@anthusai/papyrus 2.0.0-next.41 → 2.0.0-next.42

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 (52) hide show
  1. package/amplify/functions/assignment-action/data-access.js +2 -0
  2. package/amplify/functions/assignment-action/data-access.ts +2 -0
  3. package/amplify/functions/content-actions/handler.py +1 -1
  4. package/amplify/functions/shared/editorial-context.ts +59 -6
  5. package/amplify/plugins/newsletter/config.js +195 -0
  6. package/amplify/plugins/newsletter/config.ts +240 -0
  7. package/amplify/plugins/newsletter/function/handler.py +6 -0
  8. package/amplify/plugins/newsletter/function/requirements.txt +5 -0
  9. package/amplify/plugins/newsletter/ops/handler.py +12 -0
  10. package/amplify/plugins/newsletter/ops/resource.js +27 -0
  11. package/amplify/plugins/newsletter/ops/resource.ts +37 -0
  12. package/amplify/plugins/newsletter/papyrus.plugin.js +246 -0
  13. package/amplify/plugins/newsletter/papyrus.plugin.ts +291 -0
  14. package/amplify/plugins/newsletter/schema.js +37 -0
  15. package/amplify/plugins/newsletter/schema.ts +42 -0
  16. package/amplify/plugins/newsletter/secure-string-parameter.js +107 -0
  17. package/amplify/plugins/newsletter/secure-string-parameter.ts +120 -0
  18. package/amplify/plugins/newsletter/sending.js +139 -0
  19. package/amplify/plugins/newsletter/sending.ts +185 -0
  20. package/amplify/plugins/reader-steering/config.js +55 -3
  21. package/amplify/plugins/reader-steering/config.ts +98 -3
  22. package/amplify/plugins/reader-steering/function/ai_moderation.py +230 -0
  23. package/amplify/plugins/reader-steering/function/handler.py +829 -10
  24. package/amplify/plugins/reader-steering/function/moderation-prompt.md +27 -0
  25. package/amplify/plugins/reader-steering/function/moderation.py +316 -0
  26. package/amplify/plugins/reader-steering/function/resource.js +1 -1
  27. package/amplify/plugins/reader-steering/function/resource.ts +1 -1
  28. package/amplify/plugins/reader-steering/papyrus.plugin.js +78 -6
  29. package/amplify/plugins/reader-steering/papyrus.plugin.ts +85 -6
  30. package/amplify/plugins/reader-steering/schema.js +101 -1
  31. package/amplify/plugins/reader-steering/schema.ts +101 -1
  32. package/amplify/plugins/registry.js +2 -0
  33. package/amplify/plugins/registry.ts +2 -0
  34. package/amplify/plugins/types.ts +2 -0
  35. package/app/api/newsletter/confirm/route.ts +13 -0
  36. package/app/api/newsletter/preferences/route.ts +13 -0
  37. package/app/api/newsletter/subscribe/route.ts +9 -0
  38. package/app/api/newsletter/unsubscribe/route.ts +13 -0
  39. package/app/api/reader/comment/delete/route.ts +9 -0
  40. package/app/api/reader/comment/edit/route.ts +9 -0
  41. package/app/api/reader/comment/route.ts +9 -0
  42. package/app/api/reader/report/route.ts +9 -0
  43. package/app/api/reader/submit/route.ts +9 -0
  44. package/components/moderation-panel.tsx +248 -0
  45. package/components/topic-steering-workspace.tsx +19 -1
  46. package/lib/editorial-checks.ts +3 -1
  47. package/lib/newsletter-api.ts +391 -0
  48. package/lib/newsletter-pages.ts +264 -0
  49. package/lib/reader-api.ts +7 -5
  50. package/lib/reader-moderation.ts +220 -0
  51. package/package.json +14 -1
  52. package/routes.manifest.json +103 -1
@@ -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
+ }
@@ -15,6 +15,7 @@ import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useSta
15
15
  import amplifyOutputs from "papyrus-amplify-outputs";
16
16
  import { EditorialChecksAdminPanel } from "./editorial-checks-admin-panel";
17
17
  import { MonitoringPanel } from "./monitoring-panel";
18
+ import { ModerationPanel } from "./moderation-panel";
18
19
  import { SignupEntriesPanel } from "./signup-entries-panel";
19
20
  import { pluginSettings, type PluginOutputs } from "../lib/plugin-settings";
20
21
  import { generateClient } from "aws-amplify/data";
@@ -249,7 +250,7 @@ type AssignmentAction = "claim" | "release" | "complete" | "cancel" | "reopen" |
249
250
  type ReportingPacketReviewDecision = "select" | "merge" | "brief" | "hold" | "kill";
250
251
  type AssignmentDeskViewMode = "queue" | "budget";
251
252
  type UserRoleAction = "grant" | "revoke";
252
- type AdministrationPanel = "users" | "policies" | "sections" | "procedures" | "delegations" | "signups" | "editorial-checks" | "monitoring";
253
+ type AdministrationPanel = "users" | "policies" | "sections" | "procedures" | "delegations" | "signups" | "moderation" | "editorial-checks" | "monitoring";
253
254
  export type NewsDeskTab = "overview" | "desks" | "topics" | "concepts" | "references" | "messages" | "assignments" | "administration" | "search";
254
255
  type LexicalRuleScope = "publication" | "corpus" | "classifier" | "category";
255
256
  type AnalysisReindexMode = "online-update" | "classifier-retrain" | "scoped-topic-rebuild" | "entity-graph-rebuild" | "generated-analysis-rebuild";
@@ -4351,9 +4352,21 @@ function AdministrationDeskView({
4351
4352
  <span>Addresses from the site&apos;s signup forms</span>
4352
4353
  </Link>
4353
4354
  ) : null}
4355
+ {readerPluginEnabled() ? (
4356
+ <Link
4357
+ href={administrationPanelHref("moderation", isDemo)}
4358
+ data-news-desk-admin-nav="moderation"
4359
+ data-active={administrationPanel === "moderation" || undefined}
4360
+ onClick={() => onPanelChange("moderation")}
4361
+ >
4362
+ <strong>Moderation</strong>
4363
+ <span>Reader submissions and comments</span>
4364
+ </Link>
4365
+ ) : null}
4354
4366
  </nav>}
4355
4367
  <div className="news-desk-settings-panel">
4356
4368
  {administrationPanel === "signups" ? <SignupEntriesPanel /> : null}
4369
+ {administrationPanel === "moderation" ? <ModerationPanel /> : null}
4357
4370
  {administrationPanel === "editorial-checks" ? <EditorialChecksAdminPanel /> : null}
4358
4371
  {administrationPanel === "monitoring" ? <MonitoringPanel isAdmin={canManageUsers} isDemo={isDemo} /> : null}
4359
4372
  {administrationPanel === "delegations" ? <AdministrationDelegationsPanel disabled={disabled} /> : null}
@@ -12819,9 +12832,14 @@ function signupPluginEnabled(): boolean {
12819
12832
  return pluginSettings(amplifyOutputs as PluginOutputs, "signup") !== null;
12820
12833
  }
12821
12834
 
12835
+ function readerPluginEnabled(): boolean {
12836
+ return pluginSettings(amplifyOutputs as PluginOutputs, "reader-steering") !== null;
12837
+ }
12838
+
12822
12839
  function normalizeAdministrationPanel(value: string | null | undefined): AdministrationPanel {
12823
12840
  if (value === "policies" || value === "sections" || value === "procedures" || value === "delegations" || value === "editorial-checks" || value === "monitoring") return value;
12824
12841
  if (value === "signups" && signupPluginEnabled()) return value;
12842
+ if (value === "moderation" && readerPluginEnabled()) return value;
12825
12843
  return "users";
12826
12844
  }
12827
12845
 
@@ -50,7 +50,8 @@ export type EditorialCheckResult = {
50
50
  protected: { kind: string; start: number; end: number }[];
51
51
  findings: EditorialFinding[];
52
52
  coverage: unknown[];
53
- lanes: { rules: LaneStatus; classifier: LaneStatus };
53
+ /** lanes.section is "skipped" when the story's section has no NewsroomSection record to take a mission and policy from. */
54
+ lanes: { rules: LaneStatus; classifier: LaneStatus; section?: LaneStatus };
54
55
  usage?: Record<string, number>;
55
56
  };
56
57
 
@@ -169,5 +170,6 @@ export function laneNotice(result: EditorialCheckResult): string | null {
169
170
  if (classifier.status === "partial") return `Some sentences were not judged: ${classifier.reason ?? "the check ran out of time"}.`;
170
171
  if (classifier.status === "failed") return `The judgment checks failed: ${classifier.reason ?? "unknown error"}. Rule checks still ran.`;
171
172
  if (classifier.status === "unavailable" && (classifier.detectors ?? 0) === 0 && classifier.reason) return `Judgment checks are not configured: ${classifier.reason}.`;
173
+ if (result.lanes.section?.status === "skipped") return `Section-based checks skipped: ${result.lanes.section.reason ?? "no section configuration"}.`;
172
174
  return null;
173
175
  }