@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
@@ -0,0 +1,46 @@
1
+ "use client";
2
+
3
+ import { generateClient } from "aws-amplify/data";
4
+ import type { NewsletterFailure } from "./newsletter-model";
5
+
6
+ /** The operations the newsroom Newsletter panel calls. All take and return AWSJSON; the function behind them checks the role again. */
7
+ export const NEWSLETTER_QUERY_OPERATIONS = [
8
+ "newsletterOverview", "newsletterIssues", "newsletterIssue", "newsletterPreview", "newsletterIssueDeliveries", "newsletterSubscribers", "newsletterExport",
9
+ ] as const;
10
+ export const NEWSLETTER_MUTATION_OPERATIONS = [
11
+ "newsletterApproveIssue", "newsletterCancelIssue", "newsletterStopIssue", "newsletterTestSend", "newsletterCreateDraft", "newsletterErase", "newsletterResume",
12
+ ] as const;
13
+ export type NewsletterOperation = (typeof NEWSLETTER_QUERY_OPERATIONS)[number] | (typeof NEWSLETTER_MUTATION_OPERATIONS)[number];
14
+
15
+ type GraphQLResult = { data?: Record<string, unknown>; errors?: { message?: string; errorType?: string }[] };
16
+
17
+ export type NewsletterApi = <T extends object = Record<string, unknown>>(operation: NewsletterOperation, input?: Record<string, unknown>) => Promise<({ ok: true } & T) | NewsletterFailure>;
18
+
19
+ export function newsletterOperationDocument(operation: NewsletterOperation): string {
20
+ const kind = (NEWSLETTER_MUTATION_OPERATIONS as readonly string[]).includes(operation) ? "mutation" : "query";
21
+ const title = operation.charAt(0).toUpperCase() + operation.slice(1);
22
+ return `${kind} ${title}($input: AWSJSON) { ${operation}(input: $input) }`;
23
+ }
24
+
25
+ /** Calls one operation through the site's authenticated API (user pool). The browser holds no table name, key or address list. */
26
+ export const callNewsletter: NewsletterApi = async (operation, input = {}) => {
27
+ const client = generateClient({ authMode: "userPool" });
28
+ let result: GraphQLResult;
29
+ try {
30
+ result = (await client.graphql({ query: newsletterOperationDocument(operation), variables: { input: JSON.stringify(input) } })) as GraphQLResult;
31
+ } catch (error) {
32
+ // Amplify throws the response when AppSync returns errors.
33
+ result = (error && typeof error === "object" && "errors" in error ? error : { errors: [{ message: "failed" }] }) as GraphQLResult;
34
+ }
35
+ if (result.errors?.some((entry) => /unauthorized|not authorized|forbidden/i.test(`${entry.errorType ?? ""} ${entry.message ?? ""}`))) {
36
+ return { ok: false, error: "forbidden" };
37
+ }
38
+ const raw = result.data?.[operation];
39
+ if (result.errors?.length || raw === undefined || raw === null) return { ok: false, error: "unavailable" };
40
+ try {
41
+ const parsed = (typeof raw === "string" ? JSON.parse(raw) : raw) as { ok?: boolean };
42
+ return parsed && typeof parsed === "object" && "ok" in parsed ? (parsed as never) : { ok: false, error: "unavailable" };
43
+ } catch {
44
+ return { ok: false, error: "unavailable" };
45
+ }
46
+ };
@@ -0,0 +1,184 @@
1
+ /**
2
+ * A pretend Newsletter service for `?demo=1` and the tests: the same operations and answers as the functions, held in memory.
3
+ * Placeholders only (reader addresses are @example.com); nothing here is a real subscriber, issue or postal address.
4
+ */
5
+ import type { NewsletterApi, NewsletterOperation } from "./newsletter-client";
6
+ import type {
7
+ NewsletterDelivery,
8
+ NewsletterEvent,
9
+ NewsletterExportRow,
10
+ NewsletterIssue,
11
+ NewsletterList,
12
+ NewsletterSubscriber,
13
+ } from "./newsletter-model";
14
+
15
+ export const FIXTURE_TIME_ZONE = "America/New_York";
16
+
17
+ const LISTS: NewsletterList[] = [
18
+ { id: "weekly", name: "Weekly roundup", kind: "roundup", confirmed: 412, pending: 17, unsubscribed: 38, timezone: FIXTURE_TIME_ZONE },
19
+ { id: "alerts-security", name: "Security alerts", kind: "alert", confirmed: 96, pending: 4, unsubscribed: 9 },
20
+ ];
21
+
22
+ export function fixtureIssues(): NewsletterIssue[] {
23
+ const counts = { queued: 0, sent: 0, failed: 0, suppressed: 0, skipped: 0, delivered: 0, bounced: 0, complained: 0, softBounced: 0 };
24
+ return [
25
+ {
26
+ issueId: "a1", listId: "weekly", listName: "Weekly roundup", kind: "roundup", itemId: "item-weekly-0122", status: "draft",
27
+ subject: "Weekly roundup: Jan 15 to Jan 22, 2027", counts,
28
+ },
29
+ {
30
+ issueId: "a2", listId: "alerts-security", listName: "Security alerts", kind: "alert", itemId: "item-alert-1", status: "awaiting_approval",
31
+ subject: "A critical patch is out for the build tool", counts,
32
+ },
33
+ {
34
+ issueId: "a3", listId: "weekly", listName: "Weekly roundup", kind: "roundup", itemId: "item-weekly-0115", status: "scheduled",
35
+ subject: "Weekly roundup: Jan 8 to Jan 15, 2027", sendAt: "2027-01-15T14:00:00Z", approvedBy: "admin-user", approvedAt: "2027-01-14T18:30:00Z",
36
+ counts, editedSinceApproval: true,
37
+ },
38
+ {
39
+ issueId: "a4", listId: "weekly", listName: "Weekly roundup", kind: "roundup", itemId: "item-weekly-0108", status: "blocked",
40
+ subject: "Weekly roundup: Jan 1 to Jan 8, 2027", sendAt: "2027-01-08T14:00:00Z", note: "postal address missing", counts,
41
+ },
42
+ {
43
+ issueId: "a5", listId: "weekly", listName: "Weekly roundup", kind: "roundup", itemId: "item-weekly-0101", status: "sending",
44
+ subject: "Weekly roundup: Dec 25 to Jan 1, 2027", sendAt: "2027-01-01T14:00:00Z", approvedBy: "admin-user",
45
+ counts: { ...counts, queued: 429, sent: 240, delivered: 188 },
46
+ },
47
+ {
48
+ issueId: "a6", listId: "weekly", listName: "Weekly roundup", kind: "roundup", itemId: "item-weekly-1225", status: "sent",
49
+ subject: "Weekly roundup: Dec 18 to Dec 25, 2026", sendAt: "2026-12-25T14:00:00Z", sentAt: "2026-12-25T14:09:40Z", approvedBy: "admin-user",
50
+ archiveUrl: "https://news.example.com/roundup/2026-12-25.html",
51
+ counts: { ...counts, queued: 421, sent: 418, delivered: 409, bounced: 3, complained: 1, failed: 1, suppressed: 2, softBounced: 2 },
52
+ },
53
+ {
54
+ issueId: "a7", listId: "alerts-security", listName: "Security alerts", kind: "alert", itemId: "item-alert-0", status: "quarantined",
55
+ subject: "An alert with a status this panel does not know", counts,
56
+ },
57
+ ];
58
+ }
59
+
60
+ function deliveriesFor(issueId: string, full: boolean): NewsletterDelivery[] {
61
+ const address = (index: number) => (full ? `reader${index}@example.com` : `r***@example.com`);
62
+ const rows: NewsletterDelivery[] = [
63
+ { address: address(1), status: "delivered", at: "2026-12-25T14:00:04Z", eventAt: "2026-12-25T14:00:09Z" },
64
+ { address: address(2), status: "delivered", at: "2026-12-25T14:00:05Z", eventAt: "2026-12-25T14:00:12Z" },
65
+ { address: address(3), status: "bounced", at: "2026-12-25T14:00:06Z", eventAt: "2026-12-25T14:00:20Z" },
66
+ { address: address(4), status: "complained", at: "2026-12-25T14:00:07Z", eventAt: "2026-12-25T14:02:31Z" },
67
+ { address: address(5), status: "failed", at: "2026-12-25T14:00:08Z", error: "MessageRejected" },
68
+ { address: "(erased)", status: "sent", at: "2026-12-25T14:00:09Z" },
69
+ ];
70
+ return issueId === "a6" || issueId === "a5" ? rows : [];
71
+ }
72
+
73
+ const SUBSCRIBERS: NewsletterSubscriber[] = [
74
+ { email: "reader1@example.com", status: "confirmed", lists: [{ id: "weekly", status: "subscribed" }, { id: "alerts-security", status: "subscribed" }], createdAt: "2026-11-02T10:11:00Z", confirmedAt: "2026-11-02T10:13:00Z" },
75
+ { email: "reader2@example.com", status: "confirmed", lists: [{ id: "weekly", status: "subscribed" }], createdAt: "2026-11-09T08:00:00Z", confirmedAt: "2026-11-09T08:02:00Z" },
76
+ { email: "reader3@example.com", status: "pending", lists: [{ id: "alerts-security", status: "pending" }], createdAt: "2026-12-30T21:45:00Z" },
77
+ { email: "=reader4@example.com", status: "unsubscribed", lists: [{ id: "weekly", status: "unsubscribed" }], createdAt: "2026-10-01T09:00:00Z", confirmedAt: "2026-10-01T09:05:00Z" },
78
+ ];
79
+
80
+ const EVENTS: NewsletterEvent[] = [
81
+ { type: "Complaint", at: "2026-12-25T14:02:31Z", issue: "a6", list: "weekly" },
82
+ { type: "Bounce", at: "2026-12-25T14:00:20Z", issue: "a6", list: "weekly", detail: "Permanent" },
83
+ { type: "Delivery", at: "2026-12-25T14:00:12Z", issue: "a6", list: "weekly" },
84
+ { type: "Delivery", at: "2026-12-25T14:00:09Z", issue: "a6", list: "weekly" },
85
+ { type: "Bounce", at: "2026-12-25T13:59:50Z", issue: "a5", list: "weekly", detail: "Transient" },
86
+ ];
87
+
88
+ const SAMPLE_HTML = `<!doctype html><html><body style="margin:0;font-family:Georgia,serif;color:#111"><div style="max-width:560px;margin:0 auto;padding:24px"><h1 style="font-size:24px;margin:0 0 12px">Weekly roundup</h1><p>A short introduction goes here.</p><h2 style="font-size:18px">Security</h2><ul><li><a href="#preview">A critical patch is out</a>: update today.</li></ul><hr><p style="font-size:12px;color:#555">You get this because you subscribed. <a href="#preview">Manage</a> &middot; <a href="#preview">Unsubscribe</a><br>1 Example Street, Example City</p></div></body></html>`;
89
+
90
+ export type FixtureOptions = { postalAddress?: boolean; admin?: boolean };
91
+
92
+ /** One fixture service per mounted panel, so a demo session keeps its changes until reload. */
93
+ export function createFixtureApi(options: FixtureOptions = {}): NewsletterApi {
94
+ const full = options.admin !== false;
95
+ const issues = fixtureIssues();
96
+ const subscribers = [...SUBSCRIBERS];
97
+ const paused = { paused: true, reason: "papyrus-newsletter-example-bounce-rate: bounce rate above 4%", pausedAt: "2026-12-26T09:12:00Z" } as { paused: boolean; reason?: string; pausedAt?: string };
98
+ const findIssue = (input: Record<string, unknown>) => issues.find((issue) => issue.issueId === input.issueId);
99
+ const failure = (error: string, message = "") => ({ ok: false as const, error, message });
100
+
101
+ const handlers: Record<NewsletterOperation, (input: Record<string, unknown>) => object> = {
102
+ newsletterOverview: () => ({
103
+ ok: true,
104
+ lists: LISTS,
105
+ totals: { confirmed: 488, pending: 20, unsubscribed: 45 },
106
+ suppressed: 12,
107
+ paused: { ...paused },
108
+ events: EVENTS,
109
+ }),
110
+ newsletterIssues: (input) => ({
111
+ ok: true,
112
+ issues: issues.filter((issue) => (!input.status || issue.status === input.status) && (!input.listId || issue.listId === input.listId)).map(({ editedSinceApproval: _edited, ...rest }) => rest),
113
+ }),
114
+ newsletterIssue: (input) => {
115
+ const issue = findIssue(input);
116
+ return issue ? { ok: true, issue } : failure("not_found");
117
+ },
118
+ newsletterPreview: (input) => {
119
+ const issue = findIssue(input);
120
+ if (!issue) return failure("not_found");
121
+ return { ok: true, subject: issue.subject, html: SAMPLE_HTML, text: `${issue.subject}\n\nA short introduction goes here.\n\nSecurity\n- A critical patch is out: update today.\n\nYou get this because you subscribed.`, bytes: SAMPLE_HTML.length, postalAddress: options.postalAddress !== false };
122
+ },
123
+ newsletterIssueDeliveries: (input) => (findIssue(input) ? { ok: true, deliveries: deliveriesFor(String(input.issueId), full) } : failure("not_found")),
124
+ newsletterSubscribers: (input) => ({
125
+ ok: true,
126
+ subscribers: subscribers.filter((row) => (!input.status || row.status === input.status) && (!input.listId || row.lists.some((entry) => entry.id === input.listId))),
127
+ }),
128
+ newsletterExport: () => ({
129
+ ok: true,
130
+ rows: subscribers.filter((row) => row.status !== "unsubscribed").map((row): NewsletterExportRow => ({
131
+ email: row.email, status: row.status, lists: row.lists.map((entry) => `${entry.id}:${entry.status}`).join(";"), createdAt: row.createdAt,
132
+ consentRequestedAt: row.createdAt, consentConfirmedAt: row.confirmedAt ?? "", consentFormId: "newsletter",
133
+ consentPageUrl: "https://news.example.com/roundup/", consentWording: "Send me the weekly email.",
134
+ })),
135
+ }),
136
+ newsletterApproveIssue: (input) => {
137
+ const issue = findIssue(input);
138
+ if (!issue) return failure("not_found");
139
+ if (!["draft", "awaiting_approval", "scheduled", "blocked"].includes(issue.status)) return failure("invalid_state");
140
+ issue.status = "scheduled";
141
+ issue.sendAt = String(input.sendAt);
142
+ issue.approvedBy = "admin-user";
143
+ delete issue.note;
144
+ delete issue.editedSinceApproval;
145
+ return { ok: true, issue, adjusted: false, warnings: options.postalAddress === false ? ["postal address missing: the send will be blocked until an operator sets it"] : [] };
146
+ },
147
+ newsletterCancelIssue: (input) => {
148
+ const issue = findIssue(input);
149
+ if (!issue) return failure("not_found");
150
+ if (issue.status !== "scheduled") return failure("invalid_state");
151
+ issue.status = "draft";
152
+ issue.note = "cancelled by admin-user";
153
+ delete issue.sendAt;
154
+ delete issue.editedSinceApproval;
155
+ return { ok: true, issue };
156
+ },
157
+ newsletterStopIssue: (input) => {
158
+ const issue = findIssue(input);
159
+ if (!issue) return failure("not_found");
160
+ if (issue.status !== "sending") return failure("invalid_state");
161
+ issue.status = "stopped";
162
+ issue.note = "stopped by admin-user";
163
+ return { ok: true, issue };
164
+ },
165
+ newsletterTestSend: (input) => (findIssue(input) ? { ok: true } : failure("not_found")),
166
+ newsletterCreateDraft: (input) => (input.listId === "weekly" ? { ok: true, status: "exists", issueId: "a1", itemId: "item-weekly-0122" } : failure("bad_request")),
167
+ newsletterErase: (input) => {
168
+ const at = subscribers.findIndex((row) => row.email === input.email);
169
+ if (at >= 0) subscribers.splice(at, 1);
170
+ return { ok: true, removed: at >= 0 ? 3 : 2 };
171
+ },
172
+ newsletterResume: () => {
173
+ paused.paused = false;
174
+ paused.reason = undefined;
175
+ return { ok: true, paused: false };
176
+ },
177
+ };
178
+
179
+ return (async (operation: NewsletterOperation, input: Record<string, unknown> = {}) => {
180
+ const admin = ["newsletterApproveIssue", "newsletterCancelIssue", "newsletterStopIssue", "newsletterSubscribers", "newsletterExport", "newsletterErase", "newsletterResume"];
181
+ if (options.admin === false && admin.includes(operation)) return { ok: false, error: "forbidden" };
182
+ return handlers[operation](input);
183
+ }) as NewsletterApi;
184
+ }
@@ -0,0 +1,202 @@
1
+ /** Shapes and wording for the newsroom Newsletter panel. `src/papyrus_content/newsletter/` (panel.py, issues.py) produces them. */
2
+ import { getNewsroomNavHref } from "./newsroom-nav";
3
+ import { csvCell } from "./signup-csv";
4
+
5
+ export type NewsletterCounts = Partial<Record<"queued" | "sent" | "failed" | "suppressed" | "skipped" | "delivered" | "bounced" | "complained" | "softBounced", number>>;
6
+
7
+ export type NewsletterList = { id: string; name: string; kind: string; confirmed: number; pending: number; unsubscribed: number; timezone?: string };
8
+
9
+ export type NewsletterEvent = { type: string; at: string; issue?: string; list?: string; detail?: string };
10
+
11
+ export type NewsletterOverview = {
12
+ lists: NewsletterList[];
13
+ totals: { confirmed: number; pending: number; unsubscribed: number };
14
+ suppressed: number;
15
+ paused: { paused: boolean; reason?: string; pausedAt?: string; resumedAt?: string };
16
+ events: NewsletterEvent[];
17
+ };
18
+
19
+ export type NewsletterIssue = {
20
+ issueId: string;
21
+ listId: string;
22
+ listName?: string;
23
+ kind: string;
24
+ itemId?: string;
25
+ subject: string;
26
+ status: string;
27
+ sendAt?: string;
28
+ approvedBy?: string;
29
+ approvedAt?: string;
30
+ sentAt?: string;
31
+ counts: NewsletterCounts;
32
+ note?: string;
33
+ archiveUrl?: string;
34
+ editedSinceApproval?: boolean;
35
+ };
36
+
37
+ export type NewsletterPreview = { subject: string; html: string; text: string; bytes: number; postalAddress: boolean };
38
+
39
+ export type NewsletterDelivery = { address: string; status: string; at: string; eventAt?: string; error?: string };
40
+
41
+ export type NewsletterSubscriber = { email: string; status: string; lists: { id: string; status: string }[]; createdAt: string; confirmedAt?: string };
42
+
43
+ export type NewsletterExportRow = {
44
+ email: string;
45
+ status: string;
46
+ lists: string;
47
+ createdAt: string;
48
+ consentRequestedAt: string;
49
+ consentConfirmedAt: string;
50
+ consentFormId: string;
51
+ consentPageUrl: string;
52
+ consentWording: string;
53
+ };
54
+
55
+ export type NewsletterFailure = { ok: false; error: string; message?: string };
56
+
57
+ /** The newsroom words for the statuses the functions write. A status this panel does not know is shown as written, never hidden. */
58
+ const STATUS_LABELS: Record<string, string> = {
59
+ draft: "Draft",
60
+ awaiting_approval: "Awaiting approval",
61
+ scheduled: "Scheduled",
62
+ sending: "Sending",
63
+ sent: "Sent",
64
+ stopped: "Stopped",
65
+ cancelled: "Cancelled",
66
+ blocked: "Blocked",
67
+ };
68
+ /** A mark beside the word, so no status relies on colour alone. */
69
+ const STATUS_MARKS: Record<string, string> = { draft: "✎", awaiting_approval: "?", scheduled: "◷", sending: "▶", sent: "✓", stopped: "■", cancelled: "–", blocked: "!" };
70
+
71
+ export function statusLabel(status: string): string {
72
+ if (STATUS_LABELS[status]) return STATUS_LABELS[status];
73
+ const words = status.replace(/_/g, " ").trim();
74
+ return words ? words.charAt(0).toUpperCase() + words.slice(1) : "Unknown";
75
+ }
76
+
77
+ export function statusMark(status: string): string {
78
+ return STATUS_MARKS[status] ?? "·";
79
+ }
80
+
81
+ export const ISSUE_STATUS_FILTERS = ["draft", "awaiting_approval", "scheduled", "sending", "sent", "blocked", "stopped", "cancelled"] as const;
82
+
83
+ /** Approval accepts a draft, an alert awaiting approval, a scheduled issue (a new time) and a blocked one (cause fixed). */
84
+ export function canApprove(status: string): boolean {
85
+ return status === "draft" || status === "awaiting_approval" || status === "scheduled" || status === "blocked";
86
+ }
87
+ export const canCancel = (status: string): boolean => status === "scheduled";
88
+ export const canStop = (status: string): boolean => status === "sending";
89
+
90
+ export function issueEditorHref(itemId: string, demo?: boolean): string {
91
+ return getNewsroomNavHref(`/newsroom/articles/${encodeURIComponent(itemId)}`, demo);
92
+ }
93
+
94
+ const ERROR_WORDS: Record<string, string> = {
95
+ forbidden: "Your role cannot do this.",
96
+ not_found: "That issue no longer exists.",
97
+ invalid_state: "The issue changed state. Refresh and try again.",
98
+ item_missing: "The issue's item no longer exists.",
99
+ render_failed: "The issue cannot be rendered. Open it in the editor and fix the body.",
100
+ bad_request: "The request was not understood.",
101
+ no_address: "Your account has no email address to send a test to.",
102
+ unavailable: "The newsletter service could not be reached. Try again in a minute.",
103
+ };
104
+
105
+ /** One sentence for a failed operation. A missing postal address is an operator task, so the panel says where it is set. */
106
+ export function failureText(failure: NewsletterFailure): string {
107
+ if (failure.error === "postal_address_missing") {
108
+ return "This site has no postal address for the email footer, and mail law requires one. An operator sets it in the site's AWS configuration (an SSM parameter); it cannot be set from the newsroom.";
109
+ }
110
+ return ERROR_WORDS[failure.error] ?? failure.message ?? "The request failed.";
111
+ }
112
+
113
+ // -- times ---------------------------------------------------------------------------------------------------------------
114
+
115
+ export function formatUtc(iso: string | null | undefined): string {
116
+ if (!iso) return "never";
117
+ const match = /^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2})/.exec(iso);
118
+ return match ? `${match[1]} ${match[2]} UTC` : iso;
119
+ }
120
+
121
+ export function validTimeZone(zone: string | null | undefined): string | null {
122
+ if (!zone) return null;
123
+ try {
124
+ new Intl.DateTimeFormat("en-US", { timeZone: zone });
125
+ return zone;
126
+ } catch {
127
+ return null;
128
+ }
129
+ }
130
+
131
+ function wallParts(instant: Date, zone: string): { year: number; month: number; day: number; hour: number; minute: number } {
132
+ const parts = new Intl.DateTimeFormat("en-US", {
133
+ timeZone: zone, hourCycle: "h23", year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit",
134
+ }).formatToParts(instant);
135
+ const pick = (type: string) => Number(parts.find((part) => part.type === type)?.value ?? "0");
136
+ return { year: pick("year"), month: pick("month"), day: pick("day"), hour: pick("hour") % 24, minute: pick("minute") };
137
+ }
138
+
139
+ /** Minutes the zone is ahead of UTC at that instant. */
140
+ function offsetMinutes(instant: Date, zone: string): number {
141
+ const wall = wallParts(instant, zone);
142
+ const asUtc = Date.UTC(wall.year, wall.month - 1, wall.day, wall.hour, wall.minute);
143
+ return Math.round((asUtc - Math.floor(instant.getTime() / 60000) * 60000) / 60000);
144
+ }
145
+
146
+ const pad = (value: number) => String(value).padStart(2, "0");
147
+
148
+ /** Local wall time `YYYY-MM-DDTHH:mm` (a datetime-local value) as an ISO instant, for the given zone. */
149
+ export function zonedInputToIso(local: string, zone: string): string | null {
150
+ const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})$/.exec(local);
151
+ if (!match) return null;
152
+ const [, year, month, day, hour, minute] = match.map(Number) as unknown as number[];
153
+ const wallAsUtc = Date.UTC(year, month - 1, day, hour, minute);
154
+ let guess = wallAsUtc - offsetMinutes(new Date(wallAsUtc), zone) * 60000;
155
+ guess = wallAsUtc - offsetMinutes(new Date(guess), zone) * 60000; // once more: the offset can differ across a clock change
156
+ const instant = new Date(guess);
157
+ return Number.isNaN(instant.getTime()) ? null : instant.toISOString().replace(".000Z", "Z");
158
+ }
159
+
160
+ /** The next whole hour on the zone's clock, as a datetime-local value. */
161
+ export function nextWholeHourInput(now: Date, zone: string): string {
162
+ const wall = wallParts(now, zone);
163
+ const next = new Date(Date.UTC(wall.year, wall.month - 1, wall.day, wall.hour + 1, 0));
164
+ return `${next.getUTCFullYear()}-${pad(next.getUTCMonth() + 1)}-${pad(next.getUTCDate())}T${pad(next.getUTCHours())}:00`;
165
+ }
166
+
167
+ /** An ISO instant as a datetime-local value in the zone. */
168
+ export function isoToZonedInput(iso: string, zone: string): string {
169
+ const wall = wallParts(new Date(iso), zone);
170
+ return `${wall.year}-${pad(wall.month)}-${pad(wall.day)}T${pad(wall.hour)}:${pad(wall.minute)}`;
171
+ }
172
+
173
+ /** `2027-01-15 09:00 EST` in the zone. */
174
+ export function formatInZone(iso: string | null | undefined, zone: string): string {
175
+ if (!iso) return "not set";
176
+ const instant = new Date(iso);
177
+ if (Number.isNaN(instant.getTime())) return iso;
178
+ const label = new Intl.DateTimeFormat("en-US", { timeZone: zone, timeZoneName: "short" }).formatToParts(instant).find((part) => part.type === "timeZoneName")?.value ?? zone;
179
+ return `${isoToZonedInput(iso, zone).replace("T", " ")} ${label}`;
180
+ }
181
+
182
+ // -- counts and CSV ------------------------------------------------------------------------------------------------------
183
+
184
+ /** "12 sent, 2 delivered, 1 bounced"; only counters above zero, in reading order. */
185
+ export function countsText(counts: NewsletterCounts): string {
186
+ const order: [keyof NewsletterCounts, string][] = [
187
+ ["queued", "queued"], ["sent", "sent"], ["delivered", "delivered"], ["bounced", "bounced"], ["complained", "complained"],
188
+ ["failed", "failed"], ["suppressed", "suppressed"], ["skipped", "skipped"], ["softBounced", "soft bounced"],
189
+ ];
190
+ const parts = order.filter(([name]) => (counts[name] ?? 0) > 0).map(([name, label]) => `${counts[name]} ${label}`);
191
+ return parts.length ? parts.join(", ") : "nothing sent yet";
192
+ }
193
+
194
+ export const NEWSLETTER_CSV_HEADER = [
195
+ "email", "status", "lists", "createdAt", "consentRequestedAt", "consentConfirmedAt", "consentFormId", "consentPageUrl", "consentWording",
196
+ ] as const;
197
+
198
+ /** Every cell goes through the formula neutraliser: addresses and consent wording are text people typed. */
199
+ export function subscribersToCsv(rows: NewsletterExportRow[]): string {
200
+ const lines = rows.map((row) => NEWSLETTER_CSV_HEADER.map((column) => csvCell(String(row[column] ?? ""))).join(","));
201
+ return `${[NEWSLETTER_CSV_HEADER.join(","), ...lines].join("\r\n")}\r\n`;
202
+ }
@@ -237,7 +237,7 @@ export function applyPilobolusDemoDashboardContent(
237
237
  ...record,
238
238
  title: "Pilobolus Editorial Policy",
239
239
  body: [
240
- "Publication doctrine is the global inclusion standard for Pilobol.us; desk doctrine sets local intake priorities for field notes and source intake.",
240
+ "Publication doctrine is the global inclusion standard for Pilobol.us; section doctrine sets local intake priorities for field notes and source intake.",
241
241
  "Field-notes reporting should prioritize accepted Pilobolus references and annotated intake artifacts—not the Papyrus platform knowledge base.",
242
242
  "High-risk claims about host takeover or network control require explicit uncertainty handling and accepted source linkage.",
243
243
  ],
@@ -21,7 +21,7 @@ export type NewsroomNavItem = {
21
21
  };
22
22
 
23
23
  export const NEWSROOM_OPS_NAV: NewsroomNavItem[] = [
24
- { id: "overview", label: "Overview", detail: "Desk home", href: newsroomHref() },
24
+ { id: "overview", label: "Overview", detail: "Newsroom home", href: newsroomHref() },
25
25
  { id: "articles", label: getContentItemName().plural, detail: "Drafts & published", href: newsroomHref("articles") },
26
26
  { id: "assignments", label: "Assignments", detail: "Work queue", href: newsroomHref("assignments") },
27
27
  { id: "references", label: "References", detail: "Knowledge base", href: newsroomHref("references") },
@@ -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,65 @@
1
+ import { postChatMessage, type ChatHttpTransport, type ChatMessageSubmission, type PostMessageResponse } from "chatticus/chat-client";
2
+
3
+ /** Stable author mapping supplied by the Papyrus host; never infer it from a display label or email. */
4
+ export interface PapyrusChatAuthor {
5
+ hostMappedAuthorId: string;
6
+ addressedToBotId: string | null;
7
+ }
8
+
9
+ /** One logical send. Reuse this exact value only for an explicit retry of the same message. */
10
+ export interface PapyrusChatAdmissionAttempt {
11
+ readonly submission: Readonly<ChatMessageSubmission>;
12
+ readonly idempotencyKey: string;
13
+ }
14
+
15
+ export interface PapyrusChatAdmissionSenderOptions {
16
+ /** Host-owned chat URL; no endpoint is configured or exposed by this module. */
17
+ url: string;
18
+ /** Must obtain current Papyrus auth headers on every call; token type stays a host decision. */
19
+ freshAuthorizationHeaders: () => Promise<Record<string, string>>;
20
+ fetch?: typeof globalThis.fetch;
21
+ /** Override only in hermetic tests. Production defaults to the platform CSPRNG. */
22
+ createIdempotencyKey?: () => string;
23
+ }
24
+
25
+ export interface PapyrusChatAdmissionSender {
26
+ createAttempt(text: string, author: PapyrusChatAuthor): PapyrusChatAdmissionAttempt;
27
+ send(attempt: PapyrusChatAdmissionAttempt): Promise<PostMessageResponse>;
28
+ }
29
+
30
+ /**
31
+ * Bind Papyrus's host-owned auth and URL to Chatticus's reusable admission
32
+ * transport. Each send makes one POST; there is no automatic retry or auth
33
+ * fallback. An explicit retry sends the same attempt and refreshes headers.
34
+ */
35
+ export function createPapyrusChatAdmissionSender(options: PapyrusChatAdmissionSenderOptions): PapyrusChatAdmissionSender {
36
+ const transport: ChatHttpTransport = {
37
+ url: options.url,
38
+ headers: async () => {
39
+ const headers = await options.freshAuthorizationHeaders();
40
+ const authorization = Object.entries(headers).find(([name]) => name.toLowerCase() === "authorization")?.[1];
41
+ if (!authorization?.trim()) throw new Error("Papyrus authorization headers are required");
42
+ return headers;
43
+ },
44
+ ...(options.fetch ? { fetch: options.fetch } : {}),
45
+ };
46
+ const createKey = options.createIdempotencyKey ?? (() => globalThis.crypto.randomUUID());
47
+
48
+ return {
49
+ createAttempt(text, author) {
50
+ if (!text.trim() || !author.hostMappedAuthorId.trim()) throw new Error("A message and host-mapped author are required");
51
+ const submission: ChatMessageSubmission = {
52
+ author_kind: "human",
53
+ author_id: author.hostMappedAuthorId,
54
+ body: text,
55
+ addressed_to_bot_id: author.addressedToBotId,
56
+ };
57
+ const idempotencyKey = createKey();
58
+ if (!idempotencyKey.trim()) throw new Error("A stable message admission key is required");
59
+ return Object.freeze({ submission: Object.freeze(submission), idempotencyKey });
60
+ },
61
+ send(attempt) {
62
+ return postChatMessage(transport, attempt.submission, attempt.idempotencyKey);
63
+ },
64
+ };
65
+ }