@anthusai/papyrus 2.0.0-next.6 → 2.0.0-next.61
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.
- package/amplify/data/resource.js +23 -3
- package/amplify/data/resource.ts +25 -3
- package/amplify/data/schema.js +2028 -1979
- package/amplify/data/schema.ts +72 -14
- package/amplify/functions/assignment-action/data-access.js +90 -0
- package/amplify/functions/assignment-action/data-access.ts +89 -0
- package/amplify/functions/assignment-action/handler.ts +1 -1
- package/amplify/functions/content-actions/handler.py +61 -2
- package/amplify/functions/content-actions/requirements.txt +1 -0
- package/amplify/functions/content-actions/resource.js +1 -1
- package/amplify/functions/content-actions/resource.ts +1 -1
- package/amplify/functions/editorial-checks/handler.py +14 -0
- package/amplify/functions/editorial-checks/requirements.txt +1 -0
- package/amplify/{plugins/editorial-checks/function → functions/editorial-checks}/resource.js +7 -7
- package/amplify/{plugins/editorial-checks/function → functions/editorial-checks}/resource.ts +7 -7
- package/amplify/functions/editorial-checks/schema.js +121 -0
- package/amplify/{plugins → functions}/editorial-checks/schema.ts +33 -6
- package/amplify/functions/editorial-checks/wire.js +53 -0
- package/amplify/functions/editorial-checks/wire.ts +61 -0
- package/amplify/functions/monitoring-view/handler.py +11 -0
- package/amplify/functions/monitoring-view/resource.js +24 -0
- package/amplify/functions/monitoring-view/resource.ts +34 -0
- package/amplify/functions/monitoring-view/schema.js +16 -0
- package/amplify/functions/monitoring-view/schema.ts +18 -0
- package/amplify/functions/monitoring-view/wire.js +37 -0
- package/amplify/functions/monitoring-view/wire.ts +47 -0
- package/amplify/functions/public-forms/core.js +247 -0
- package/amplify/functions/public-forms/core.ts +310 -0
- package/amplify/functions/public-forms/handler.ts +41 -0
- package/amplify/functions/public-forms/origins.js +35 -0
- package/amplify/functions/public-forms/origins.ts +43 -0
- package/amplify/functions/public-forms/resource.js +9 -0
- package/amplify/functions/public-forms/resource.ts +10 -0
- package/amplify/functions/public-forms/wire.js +25 -0
- package/amplify/functions/public-forms/wire.ts +32 -0
- package/amplify/functions/shared/editorial-context.ts +63 -4
- package/amplify/functions/shared/python-bundle.js +20 -14
- package/amplify/functions/shared/python-bundle.ts +25 -18
- package/amplify/plugins/newsletter/config.js +245 -0
- package/amplify/plugins/newsletter/config.ts +305 -0
- package/amplify/plugins/newsletter/function/handler.py +6 -0
- package/amplify/plugins/newsletter/function/requirements.txt +5 -0
- package/amplify/plugins/newsletter/ops/handler.py +12 -0
- package/amplify/plugins/newsletter/ops/resource.js +27 -0
- package/amplify/plugins/newsletter/ops/resource.ts +37 -0
- package/amplify/plugins/newsletter/papyrus.plugin.js +263 -0
- package/amplify/plugins/newsletter/papyrus.plugin.ts +311 -0
- package/amplify/plugins/newsletter/schema.js +56 -0
- package/amplify/plugins/newsletter/schema.ts +61 -0
- package/amplify/plugins/newsletter/secure-string-parameter.js +107 -0
- package/amplify/plugins/newsletter/secure-string-parameter.ts +120 -0
- package/amplify/plugins/newsletter/sending.js +140 -0
- package/amplify/plugins/newsletter/sending.ts +186 -0
- package/amplify/plugins/reader-steering/config.js +173 -0
- package/amplify/plugins/reader-steering/config.ts +268 -0
- package/amplify/plugins/reader-steering/function/ai_moderation.py +246 -0
- package/amplify/plugins/reader-steering/function/handler.py +1363 -0
- package/amplify/plugins/reader-steering/function/moderation-prompt.md +27 -0
- package/amplify/plugins/reader-steering/function/moderation.py +316 -0
- package/amplify/plugins/reader-steering/function/resource.js +27 -0
- package/amplify/plugins/reader-steering/function/resource.ts +35 -0
- package/amplify/plugins/reader-steering/papyrus.plugin.js +176 -0
- package/amplify/plugins/reader-steering/papyrus.plugin.ts +202 -0
- package/amplify/plugins/reader-steering/schema.js +140 -0
- package/amplify/plugins/reader-steering/schema.ts +142 -0
- package/amplify/plugins/registry.js +4 -2
- package/amplify/plugins/registry.ts +4 -2
- package/amplify/plugins/types.ts +5 -0
- package/amplify/plugins/wire.js +1 -0
- package/amplify/plugins/wire.ts +1 -0
- package/amplify/publication/projection-lease.js +30 -0
- package/amplify/publication/projection-lease.ts +39 -0
- package/amplify/publication/redirect-and-index.js +105 -0
- package/amplify/publication/redirect-rules.js +73 -0
- package/amplify/publication/redirect-rules.ts +93 -0
- package/amplify/publication/s3-projection.js +7 -1
- package/amplify/publication/s3-projection.ts +9 -1
- package/amplify/site-backend-config.ts +17 -0
- package/amplify/site-backend.js +38 -1
- package/amplify/site-backend.ts +46 -1
- package/app/api/auth/prospectus/sign-in/route.ts +4 -0
- package/app/api/newsletter/confirm/route.ts +13 -0
- package/app/api/newsletter/preferences/route.ts +13 -0
- package/app/api/newsletter/state/route.ts +9 -0
- package/app/api/newsletter/subscribe/route.ts +9 -0
- package/app/api/newsletter/unsubscribe/route.ts +13 -0
- package/app/api/newsroom/curation/context/route.ts +9 -0
- package/app/api/newsroom/curation/priming/route.ts +9 -0
- package/app/api/newsroom/curation/publication/route.ts +9 -0
- package/app/api/reader/comment/delete/route.ts +9 -0
- package/app/api/reader/comment/edit/route.ts +9 -0
- package/app/api/reader/comment/route.ts +9 -0
- package/app/api/reader/handle/route.ts +9 -0
- package/app/api/reader/me/route.ts +9 -0
- package/app/api/reader/rating/route.ts +9 -0
- package/app/api/reader/report/route.ts +9 -0
- package/app/api/reader/retired/route.ts +9 -0
- package/app/api/reader/submit/route.ts +9 -0
- package/app/api/reader/vote/route.ts +9 -0
- package/bin/papyrus-infra.mjs +20 -1
- package/components/editorial-checks-admin-panel.tsx +36 -11
- package/components/moderation-panel.tsx +248 -0
- package/components/monitoring-overview-view.tsx +241 -0
- package/components/monitoring-panel.tsx +40 -0
- package/components/newsletter-panel.tsx +474 -0
- package/components/newsletter-sections.tsx +163 -0
- package/components/newsroom-article-editor.tsx +2 -4
- package/components/newsroom-curation-bootstrap.tsx +45 -0
- package/components/newsroom-organizer.css +31 -0
- package/components/newsroom-references-view.tsx +14 -14
- package/components/publication-cyclotron-panel.tsx +15 -10
- package/components/reference-add-entry.tsx +75 -0
- package/components/reference-curation-controls.tsx +31 -22
- package/components/reference-cyclotron-priming.tsx +58 -19
- package/components/reference-cyclotron-suggestion.tsx +1 -1
- package/components/reference-priming.tsx +14 -10
- package/components/reference-section-suggestion.tsx +1 -1
- package/components/topic-steering-workspace.tsx +64 -20
- package/components/use-editorial-checks.ts +12 -10
- package/infra/amplify-app-shell.js +90 -16
- package/infra/amplify-app-shell.ts +102 -18
- package/infra/index.js +1 -0
- package/infra/secure-string-parameter-handler.js +41 -0
- package/infra/secure-string-parameter-handler.ts +41 -0
- package/infra/secure-string-parameter.js +79 -0
- package/infra/secure-string-parameter.ts +91 -0
- package/infra/site-config.js +86 -6
- package/infra/site-config.ts +129 -6
- package/lib/category-repository.ts +11 -9
- package/lib/curation-routes.ts +345 -0
- package/lib/editorial-checks-backend.ts +1 -1
- package/lib/editorial-checks-client.ts +19 -1
- package/lib/editorial-checks-demo.ts +2 -2
- package/lib/editorial-checks-settings.ts +3 -0
- package/lib/editorial-checks.ts +8 -11
- package/lib/editorial-preview.ts +5 -5
- package/lib/monitoring-client.ts +29 -0
- package/lib/monitoring-overview-fixture.ts +51 -0
- package/lib/monitoring-overview.ts +145 -0
- package/lib/newsletter-api.ts +418 -0
- package/lib/newsletter-client.ts +46 -0
- package/lib/newsletter-fixture.ts +184 -0
- package/lib/newsletter-model.ts +202 -0
- package/lib/prospectus-session.ts +15 -14
- package/lib/prospectus-sign-in-routes.ts +84 -19
- package/lib/prospectus-site-identity.ts +24 -1
- package/lib/reader-api.ts +145 -0
- package/lib/reader-moderation.ts +220 -0
- package/lib/reader-retirement-route.ts +89 -0
- package/lib/reader-subject.md +29 -0
- package/lib/reader-subject.ts +53 -0
- package/lib/relevance-status.ts +2 -2
- package/lib/semantic-graph.ts +2 -0
- package/lib/site-brand-tenant.ts +11 -6
- package/lib/site-brand.ts +6 -3
- package/lib/site-origin.ts +67 -0
- package/middleware.ts +14 -3
- package/package.json +41 -3
- package/routes.manifest.json +204 -2
- package/amplify/plugins/editorial-checks/function/handler.py +0 -11
- package/amplify/plugins/editorial-checks/function/requirements.txt +0 -1
- package/amplify/plugins/editorial-checks/papyrus.plugin.js +0 -50
- package/amplify/plugins/editorial-checks/papyrus.plugin.ts +0 -63
- package/amplify/plugins/editorial-checks/schema.js +0 -94
|
@@ -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> · <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
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createCipheriv, createDecipheriv, hkdfSync, randomBytes } from "node:crypto";
|
|
2
|
-
import {
|
|
2
|
+
import { GetParameterCommand, SSMClient } from "@aws-sdk/client-ssm";
|
|
3
3
|
import { readAmplifyOutputsFile } from "./amplify-outputs-path";
|
|
4
4
|
import {
|
|
5
5
|
ProspectusSiteAccounts,
|
|
@@ -21,7 +21,7 @@ import {
|
|
|
21
21
|
export const PROSPECTUS_PENDING_SIGN_IN_COOKIE = "__Host-papyrus-prospectus-pending";
|
|
22
22
|
export const PROSPECTUS_SESSION_COOKIE = "__Host-papyrus-prospectus-session";
|
|
23
23
|
export const PROSPECTUS_ACCOUNTS_ENDPOINT_ENV = "PAPYRUS_PROSPECTUS_ACCOUNTS_ENDPOINT";
|
|
24
|
-
export const
|
|
24
|
+
export const PROSPECTUS_SESSION_SECRET_PARAMETER_ENV = "PAPYRUS_PROSPECTUS_SESSION_SECRET_PARAMETER";
|
|
25
25
|
export const PROSPECTUS_SESSION_SECRET_CACHE_MILLISECONDS = 5 * 60 * 1000;
|
|
26
26
|
|
|
27
27
|
const SEALED_COOKIE_FORMAT_VERSION = "v1";
|
|
@@ -39,6 +39,7 @@ export interface ProspectusSignedInSession {
|
|
|
39
39
|
accessToken: string;
|
|
40
40
|
accessTokenExpiresAt: number;
|
|
41
41
|
selectedAccountId?: string;
|
|
42
|
+
siteSubject?: string;
|
|
42
43
|
}
|
|
43
44
|
|
|
44
45
|
function sealingKeyForCookie(sealingSecret: string, cookieName: string): Buffer {
|
|
@@ -101,37 +102,37 @@ export function sameSiteReturnPath(requestedReturnPath: string | null | undefine
|
|
|
101
102
|
return `${parsed.pathname}${parsed.search}${parsed.hash}`;
|
|
102
103
|
}
|
|
103
104
|
|
|
104
|
-
export type ProspectusSessionSecretReader = (
|
|
105
|
+
export type ProspectusSessionSecretReader = (parameterName: string) => Promise<string | null>;
|
|
105
106
|
|
|
106
|
-
type CachedSealingSecret = {
|
|
107
|
+
type CachedSealingSecret = { parameterName: string; value: string; expiresAt: number };
|
|
107
108
|
|
|
108
109
|
let cachedSealingSecret: CachedSealingSecret | null = null;
|
|
109
|
-
let
|
|
110
|
+
let defaultSsmClient: SSMClient | null = null;
|
|
110
111
|
|
|
111
112
|
export function resetProspectusSessionSecretCache(): void {
|
|
112
113
|
cachedSealingSecret = null;
|
|
113
114
|
}
|
|
114
115
|
|
|
115
|
-
async function
|
|
116
|
-
|
|
117
|
-
const response = await
|
|
118
|
-
return response.
|
|
116
|
+
export async function readSealingSecretFromSsm(parameterName: string, client?: Pick<SSMClient, "send">): Promise<string | null> {
|
|
117
|
+
defaultSsmClient ??= new SSMClient({});
|
|
118
|
+
const response = await (client ?? defaultSsmClient).send(new GetParameterCommand({ Name: parameterName, WithDecryption: true }));
|
|
119
|
+
return response.Parameter?.Value?.trim() || null;
|
|
119
120
|
}
|
|
120
121
|
|
|
121
122
|
export async function loadProspectusSessionSealingSecret(
|
|
122
123
|
options: { environment?: Record<string, string | undefined>; readSecret?: ProspectusSessionSecretReader; now?: number } = {},
|
|
123
124
|
): Promise<string | null> {
|
|
124
125
|
const environment = options.environment ?? process.env;
|
|
125
|
-
const
|
|
126
|
-
if (!
|
|
126
|
+
const parameterName = environment[PROSPECTUS_SESSION_SECRET_PARAMETER_ENV]?.trim();
|
|
127
|
+
if (!parameterName) return null;
|
|
127
128
|
const now = options.now ?? Date.now();
|
|
128
|
-
if (cachedSealingSecret && cachedSealingSecret.
|
|
129
|
+
if (cachedSealingSecret && cachedSealingSecret.parameterName === parameterName && cachedSealingSecret.expiresAt > now) {
|
|
129
130
|
return cachedSealingSecret.value;
|
|
130
131
|
}
|
|
131
132
|
try {
|
|
132
|
-
const value = (await (options.readSecret ??
|
|
133
|
+
const value = (await (options.readSecret ?? readSealingSecretFromSsm)(parameterName))?.trim();
|
|
133
134
|
if (!value || value.length < MINIMUM_SEALING_SECRET_LENGTH) return null;
|
|
134
|
-
cachedSealingSecret = {
|
|
135
|
+
cachedSealingSecret = { parameterName, value, expiresAt: now + PROSPECTUS_SESSION_SECRET_CACHE_MILLISECONDS };
|
|
135
136
|
return value;
|
|
136
137
|
} catch (error) {
|
|
137
138
|
console.error(`Prospectus session sealing secret could not be read (${error instanceof Error ? error.name : "error"}).`);
|