@anthusai/papyrus 2.0.0-next.44 → 2.0.0-next.45
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/plugins/newsletter/schema.js +22 -3
- package/amplify/plugins/newsletter/schema.ts +22 -3
- package/components/newsletter-panel.tsx +474 -0
- package/components/newsletter-sections.tsx +163 -0
- package/components/newsroom-organizer.css +14 -0
- package/components/topic-steering-workspace.tsx +24 -5
- package/lib/newsletter-client.ts +46 -0
- package/lib/newsletter-fixture.ts +184 -0
- package/lib/newsletter-model.ts +202 -0
- package/package.json +6 -1
- package/routes.manifest.json +1 -1
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import {
|
|
2
|
+
countsText,
|
|
3
|
+
formatInZone,
|
|
4
|
+
formatUtc,
|
|
5
|
+
issueEditorHref,
|
|
6
|
+
statusLabel,
|
|
7
|
+
statusMark,
|
|
8
|
+
type NewsletterDelivery,
|
|
9
|
+
type NewsletterEvent,
|
|
10
|
+
type NewsletterIssue,
|
|
11
|
+
type NewsletterOverview,
|
|
12
|
+
type NewsletterSubscriber,
|
|
13
|
+
} from "../lib/newsletter-model";
|
|
14
|
+
|
|
15
|
+
/** Presentation only: the panel fetches and holds state, these render what they are given, so every state is testable from fixtures. */
|
|
16
|
+
|
|
17
|
+
export function ListsCard({ overview }: { overview: NewsletterOverview }) {
|
|
18
|
+
const { totals } = overview;
|
|
19
|
+
return (
|
|
20
|
+
<article className="news-desk-doctrine-card" data-newsletter-section="lists">
|
|
21
|
+
<div className="news-desk-doctrine-card__header">
|
|
22
|
+
<div><h3>Lists</h3><span>Subscribers per list. A person on two lists counts on each.</span></div>
|
|
23
|
+
</div>
|
|
24
|
+
{overview.lists.length ? (
|
|
25
|
+
<dl className="news-desk-monitoring__usage">
|
|
26
|
+
{overview.lists.map((list) => (
|
|
27
|
+
<div key={list.id} data-newsletter-list={list.id}>
|
|
28
|
+
<dt>{list.name} ({list.kind === "roundup" ? "roundup" : list.kind === "alert" ? "alerts" : list.kind})</dt>
|
|
29
|
+
<dd>{list.confirmed}</dd>
|
|
30
|
+
<span className="news-desk-newsletter__quiet">confirmed · {list.pending} pending · {list.unsubscribed} unsubscribed</span>
|
|
31
|
+
</div>
|
|
32
|
+
))}
|
|
33
|
+
</dl>
|
|
34
|
+
) : <p>No list is configured.</p>}
|
|
35
|
+
<p className="news-desk-delegation-note" data-newsletter-totals>
|
|
36
|
+
Across the site: {totals.confirmed} confirmed, {totals.pending} waiting to confirm, {totals.unsubscribed} unsubscribed.
|
|
37
|
+
{" "}{overview.suppressed} {overview.suppressed === 1 ? "address is" : "addresses are"} suppressed after a bounce or complaint.
|
|
38
|
+
</p>
|
|
39
|
+
</article>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function SiteStatusCard({ paused, isAdmin, busy, onResume }: { paused: NewsletterOverview["paused"]; isAdmin: boolean; busy: boolean; onResume: () => void }) {
|
|
44
|
+
return (
|
|
45
|
+
<article className="news-desk-doctrine-card" data-newsletter-section="site-status">
|
|
46
|
+
<div className="news-desk-doctrine-card__header">
|
|
47
|
+
<div><h3>Site status</h3><span>Sending turns itself off when bounces or complaints run high</span></div>
|
|
48
|
+
</div>
|
|
49
|
+
{paused.paused ? (
|
|
50
|
+
<div className="category-steering-alert" role="alert" data-newsletter-paused>
|
|
51
|
+
<strong><span aria-hidden="true">■ </span>Sending is paused.</strong>{" "}
|
|
52
|
+
{paused.reason ? <>Reason: {paused.reason}. </> : null}
|
|
53
|
+
{paused.pausedAt ? <>Paused {formatUtc(paused.pausedAt)}. </> : null}
|
|
54
|
+
Issues due now are blocked until sending resumes; approve a blocked issue again afterwards.
|
|
55
|
+
<div className="news-desk-doctrine-card__actions">
|
|
56
|
+
{isAdmin ? (
|
|
57
|
+
<button type="button" disabled={busy} data-newsletter-resume onClick={onResume}>Resume sending</button>
|
|
58
|
+
) : <span>An admin can resume sending.</span>}
|
|
59
|
+
</div>
|
|
60
|
+
</div>
|
|
61
|
+
) : (
|
|
62
|
+
<p data-newsletter-running><span aria-hidden="true">✓ </span>Sending is on.{paused.resumedAt ? ` Resumed ${formatUtc(paused.resumedAt)}.` : ""}</p>
|
|
63
|
+
)}
|
|
64
|
+
</article>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** What an issue row says about the issue; the buttons beside it belong to the panel. */
|
|
69
|
+
export function IssueSummary({ issue, zone, demo }: { issue: NewsletterIssue; zone: string; demo: boolean }) {
|
|
70
|
+
return (
|
|
71
|
+
<>
|
|
72
|
+
<header>
|
|
73
|
+
<strong>{issue.subject || "(no subject)"}</strong>
|
|
74
|
+
<span data-newsletter-status={issue.status}><span aria-hidden="true">{statusMark(issue.status)} </span>{statusLabel(issue.status)}</span>
|
|
75
|
+
</header>
|
|
76
|
+
<p>{issue.listName ?? issue.listId}{issue.kind ? ` · ${issue.kind}` : ""}</p>
|
|
77
|
+
{issue.sendAt ? (
|
|
78
|
+
<p data-newsletter-send-at>
|
|
79
|
+
{issue.status === "sent" || issue.status === "sending" ? "Scheduled for " : "Send at "}
|
|
80
|
+
<time dateTime={issue.sendAt} title={formatUtc(issue.sendAt)}>{formatInZone(issue.sendAt, zone)}</time>
|
|
81
|
+
{issue.approvedBy ? ` · approved by ${issue.approvedBy}` : ""}
|
|
82
|
+
</p>
|
|
83
|
+
) : null}
|
|
84
|
+
<p data-newsletter-counts>{countsText(issue.counts ?? {})}</p>
|
|
85
|
+
{issue.status === "blocked" ? (
|
|
86
|
+
<p className="category-steering-alert" data-newsletter-blocked>
|
|
87
|
+
Blocked: {issue.note ?? "see the issue"}. Fix the cause, then approve it again.
|
|
88
|
+
</p>
|
|
89
|
+
) : issue.note ? <p data-newsletter-note>Note: {issue.note}</p> : null}
|
|
90
|
+
{issue.editedSinceApproval ? (
|
|
91
|
+
<p className="category-steering-alert" data-newsletter-edited>
|
|
92
|
+
Edited since approval. When it comes due it will go back to draft and send nothing. Approve it again to send this version.
|
|
93
|
+
</p>
|
|
94
|
+
) : null}
|
|
95
|
+
<p className="news-desk-newsletter__links">
|
|
96
|
+
{issue.itemId ? <a href={issueEditorHref(issue.itemId, demo)} data-newsletter-editor-link>Open in the editor</a> : <span>No item</span>}
|
|
97
|
+
{issue.archiveUrl ? <> · <a href={issue.archiveUrl} target="_blank" rel="noreferrer noopener" data-newsletter-archive-link>Archive page</a></> : null}
|
|
98
|
+
</p>
|
|
99
|
+
</>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function DeliveriesCard({
|
|
104
|
+
issue, rows, events, hasMore, loading, isAdmin, onMore,
|
|
105
|
+
}: {
|
|
106
|
+
issue: NewsletterIssue; rows: NewsletterDelivery[]; events: NewsletterEvent[]; hasMore: boolean; loading: boolean; isAdmin: boolean; onMore: () => void;
|
|
107
|
+
}) {
|
|
108
|
+
return (
|
|
109
|
+
<article className="news-desk-doctrine-card" data-newsletter-section="deliveries" data-newsletter-deliveries-for={issue.issueId}>
|
|
110
|
+
<div className="news-desk-doctrine-card__header">
|
|
111
|
+
<div><h3>Delivery history</h3><span>{issue.subject} · {countsText(issue.counts ?? {})}</span></div>
|
|
112
|
+
</div>
|
|
113
|
+
{!isAdmin ? <p className="news-desk-delegation-note">Addresses are masked for editors.</p> : null}
|
|
114
|
+
{rows.length ? (
|
|
115
|
+
<table className="news-desk-newsletter__table">
|
|
116
|
+
<thead><tr><th scope="col">Address</th><th scope="col">Result</th><th scope="col">Queued (UTC)</th><th scope="col">Last event (UTC)</th></tr></thead>
|
|
117
|
+
<tbody>
|
|
118
|
+
{rows.map((row, index) => (
|
|
119
|
+
<tr key={`${row.address}-${index}`} data-newsletter-delivery={row.status}>
|
|
120
|
+
<td>{row.address}</td>
|
|
121
|
+
<td>{row.status}{row.error ? ` (${row.error})` : ""}</td>
|
|
122
|
+
<td>{formatUtc(row.at)}</td>
|
|
123
|
+
<td>{row.eventAt ? formatUtc(row.eventAt) : "none yet"}</td>
|
|
124
|
+
</tr>
|
|
125
|
+
))}
|
|
126
|
+
</tbody>
|
|
127
|
+
</table>
|
|
128
|
+
) : <p>{loading ? "Loading deliveries..." : "Nothing has been sent for this issue."}</p>}
|
|
129
|
+
{hasMore ? <div className="news-desk-doctrine-card__actions"><button type="button" disabled={loading} onClick={onMore}>Load more deliveries</button></div> : null}
|
|
130
|
+
<h4>Recent events for this issue</h4>
|
|
131
|
+
{events.length ? (
|
|
132
|
+
<ul className="news-desk-monitoring__list" data-newsletter-events>
|
|
133
|
+
{events.map((event, index) => (
|
|
134
|
+
<li key={`${event.at}-${index}`}>
|
|
135
|
+
<strong>{event.type}{event.detail ? ` (${event.detail})` : ""}</strong>
|
|
136
|
+
<span>{event.list ?? ""}</span>
|
|
137
|
+
<span>{formatUtc(event.at)}</span>
|
|
138
|
+
</li>
|
|
139
|
+
))}
|
|
140
|
+
</ul>
|
|
141
|
+
) : <p>No recent events for this issue. Events are kept for 90 days and the panel shows the latest 100 for the whole site.</p>}
|
|
142
|
+
</article>
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function SubscribersTable({ rows, zone }: { rows: NewsletterSubscriber[]; zone: string }) {
|
|
147
|
+
return (
|
|
148
|
+
<table className="news-desk-newsletter__table" data-newsletter-subscribers>
|
|
149
|
+
<thead><tr><th scope="col">Address</th><th scope="col">Status</th><th scope="col">Lists</th><th scope="col">Signed up</th><th scope="col">Confirmed</th></tr></thead>
|
|
150
|
+
<tbody>
|
|
151
|
+
{rows.map((row) => (
|
|
152
|
+
<tr key={row.email} data-newsletter-subscriber={row.status}>
|
|
153
|
+
<td>{row.email}</td>
|
|
154
|
+
<td>{row.status}</td>
|
|
155
|
+
<td>{row.lists.length ? row.lists.map((entry) => `${entry.id} (${entry.status})`).join(", ") : "none"}</td>
|
|
156
|
+
<td>{formatInZone(row.createdAt, zone)}</td>
|
|
157
|
+
<td>{row.confirmedAt ? formatInZone(row.confirmedAt, zone) : "not yet"}</td>
|
|
158
|
+
</tr>
|
|
159
|
+
))}
|
|
160
|
+
</tbody>
|
|
161
|
+
</table>
|
|
162
|
+
);
|
|
163
|
+
}
|
|
@@ -330,3 +330,17 @@ body:has(.newsroom-organizer)>[data-staging-banner]{flex-shrink:0}
|
|
|
330
330
|
.news-desk-columns--administration .news-desk-monitoring__usage dt{font:600 11px/1.4 var(--kb-theme-ui-font);letter-spacing:.07em;text-transform:uppercase;color:var(--administration-quiet)}
|
|
331
331
|
.news-desk-columns--administration .news-desk-monitoring__usage dd{margin:0;font:700 26px/1.1 var(--kb-theme-title-font);font-variant-numeric:tabular-nums}
|
|
332
332
|
@container (max-width:640px){.news-desk-columns--administration .news-desk-monitoring__list li{grid-template-columns:minmax(0,1fr)}}
|
|
333
|
+
|
|
334
|
+
/* Newsletter panel (components/newsletter-panel.tsx): reuses the administration card, row and button styles above. */
|
|
335
|
+
.news-desk-columns--administration .news-desk-newsletter__quiet{font:400 12px/1.4 var(--kb-theme-ui-font);color:var(--administration-quiet)}
|
|
336
|
+
.news-desk-columns--administration .news-desk-monitoring__usage>div{display:grid;gap:4px;align-content:start}
|
|
337
|
+
.news-desk-columns--administration .news-desk-newsletter__links{display:flex;flex-wrap:wrap;gap:4px 8px}
|
|
338
|
+
.news-desk-columns--administration .news-desk-newsletter__table{width:100%;border-collapse:collapse;font:400 13px/1.4 var(--kb-theme-ui-font)}
|
|
339
|
+
.news-desk-columns--administration .news-desk-newsletter__table td:first-child{word-break:keep-all;overflow-wrap:break-word}
|
|
340
|
+
.news-desk-columns--administration .news-desk-newsletter__table th{text-align:left;padding:6px 8px;border-bottom:2px solid var(--administration-strong);font:600 11px/1.4 var(--kb-theme-ui-font);letter-spacing:.07em;text-transform:uppercase;color:var(--administration-quiet)}
|
|
341
|
+
.news-desk-columns--administration .news-desk-newsletter__table td{padding:8px;border-bottom:1px solid var(--org-line);vertical-align:top}
|
|
342
|
+
.news-desk-columns--administration .news-desk-newsletter__drawer{grid-column:1/-1;display:grid;gap:12px;padding:12px 0 0;border-top:1px dashed var(--org-line);min-width:0}
|
|
343
|
+
.news-desk-columns--administration .news-desk-newsletter__frame{width:100%;height:480px;border:1px solid var(--org-line);background:#fff}
|
|
344
|
+
.news-desk-columns--administration .news-desk-newsletter__text{margin:0;padding:12px;max-height:280px;overflow:auto;border:1px solid var(--org-line);font:400 12px/1.5 ui-monospace,monospace;white-space:pre-wrap;overflow-wrap:anywhere}
|
|
345
|
+
.news-desk-columns--administration .news-desk-newsletter__filters{display:flex;flex-wrap:wrap;gap:12px 24px}
|
|
346
|
+
.news-desk-columns--administration .news-desk-newsletter__filters .news-desk-doctrine-card__field{min-width:180px}
|
|
@@ -16,6 +16,7 @@ import amplifyOutputs from "papyrus-amplify-outputs";
|
|
|
16
16
|
import { EditorialChecksAdminPanel } from "./editorial-checks-admin-panel";
|
|
17
17
|
import { MonitoringPanel } from "./monitoring-panel";
|
|
18
18
|
import { ModerationPanel } from "./moderation-panel";
|
|
19
|
+
import { NewsletterPanel } from "./newsletter-panel";
|
|
19
20
|
import { SignupEntriesPanel } from "./signup-entries-panel";
|
|
20
21
|
import { pluginSettings, type PluginOutputs } from "../lib/plugin-settings";
|
|
21
22
|
import { generateClient } from "aws-amplify/data";
|
|
@@ -250,7 +251,7 @@ type AssignmentAction = "claim" | "release" | "complete" | "cancel" | "reopen" |
|
|
|
250
251
|
type ReportingPacketReviewDecision = "select" | "merge" | "brief" | "hold" | "kill";
|
|
251
252
|
type AssignmentDeskViewMode = "queue" | "budget";
|
|
252
253
|
type UserRoleAction = "grant" | "revoke";
|
|
253
|
-
type AdministrationPanel = "users" | "policies" | "sections" | "procedures" | "delegations" | "signups" | "moderation" | "editorial-checks" | "monitoring";
|
|
254
|
+
type AdministrationPanel = "users" | "policies" | "sections" | "procedures" | "delegations" | "signups" | "newsletter" | "moderation" | "editorial-checks" | "monitoring";
|
|
254
255
|
export type NewsDeskTab = "overview" | "desks" | "topics" | "concepts" | "references" | "messages" | "assignments" | "administration" | "search";
|
|
255
256
|
type LexicalRuleScope = "publication" | "corpus" | "classifier" | "category";
|
|
256
257
|
type AnalysisReindexMode = "online-update" | "classifier-retrain" | "scoped-topic-rebuild" | "entity-graph-rebuild" | "generated-analysis-rebuild";
|
|
@@ -882,7 +883,7 @@ function NewsDeskDashboard({
|
|
|
882
883
|
const [mergeSelection, setMergeSelection] = useState<MergeSelection | null>(null);
|
|
883
884
|
const [isPending, startTransition] = useTransition();
|
|
884
885
|
const controlsDisabled = isPending || !canEdit;
|
|
885
|
-
const initialAdministrationPanel = normalizeAdministrationPanel(initialSelection.panel);
|
|
886
|
+
const initialAdministrationPanel = normalizeAdministrationPanel(initialSelection.panel, Boolean(dashboard.isDemo));
|
|
886
887
|
const [administrationPanel, setAdministrationPanel] = useState<AdministrationPanel>(initialAdministrationPanel);
|
|
887
888
|
const [doctrineDrafts, setDoctrineDrafts] = useState<DoctrineEditorState>(() => buildDoctrineEditorState(dashboard.doctrineRecords));
|
|
888
889
|
const [loadedSections, setLoadedSections] = useState<Record<string, boolean>>({
|
|
@@ -1123,8 +1124,8 @@ function NewsDeskDashboard({
|
|
|
1123
1124
|
}, [dashboard.procedureDefinitions, dashboard.procedureRuns, dashboard.procedureVersions]);
|
|
1124
1125
|
|
|
1125
1126
|
useEffect(() => {
|
|
1126
|
-
setAdministrationPanel(normalizeAdministrationPanel(initialSelection.panel));
|
|
1127
|
-
}, [initialSelection.panel]);
|
|
1127
|
+
setAdministrationPanel(normalizeAdministrationPanel(initialSelection.panel, Boolean(dashboard.isDemo)));
|
|
1128
|
+
}, [initialSelection.panel, dashboard.isDemo]);
|
|
1128
1129
|
|
|
1129
1130
|
useEffect(() => {
|
|
1130
1131
|
if (activeTab !== "administration") return;
|
|
@@ -4352,6 +4353,17 @@ function AdministrationDeskView({
|
|
|
4352
4353
|
<span>Addresses from the site's signup forms</span>
|
|
4353
4354
|
</Link>
|
|
4354
4355
|
) : null}
|
|
4356
|
+
{newsletterPluginEnabled(isDemo) ? (
|
|
4357
|
+
<Link
|
|
4358
|
+
href={administrationPanelHref("newsletter", isDemo)}
|
|
4359
|
+
data-news-desk-admin-nav="newsletter"
|
|
4360
|
+
data-active={administrationPanel === "newsletter" || undefined}
|
|
4361
|
+
onClick={() => onPanelChange("newsletter")}
|
|
4362
|
+
>
|
|
4363
|
+
<strong>Newsletter</strong>
|
|
4364
|
+
<span>Subscribers, issues and delivery for the site's email</span>
|
|
4365
|
+
</Link>
|
|
4366
|
+
) : null}
|
|
4355
4367
|
{readerPluginEnabled() ? (
|
|
4356
4368
|
<Link
|
|
4357
4369
|
href={administrationPanelHref("moderation", isDemo)}
|
|
@@ -4366,6 +4378,7 @@ function AdministrationDeskView({
|
|
|
4366
4378
|
</nav>}
|
|
4367
4379
|
<div className="news-desk-settings-panel">
|
|
4368
4380
|
{administrationPanel === "signups" ? <SignupEntriesPanel /> : null}
|
|
4381
|
+
{administrationPanel === "newsletter" ? <NewsletterPanel isAdmin={canManageUsers} isDemo={isDemo} /> : null}
|
|
4369
4382
|
{administrationPanel === "moderation" ? <ModerationPanel /> : null}
|
|
4370
4383
|
{administrationPanel === "editorial-checks" ? <EditorialChecksAdminPanel /> : null}
|
|
4371
4384
|
{administrationPanel === "monitoring" ? <MonitoringPanel isAdmin={canManageUsers} isDemo={isDemo} /> : null}
|
|
@@ -12832,13 +12845,19 @@ function signupPluginEnabled(): boolean {
|
|
|
12832
12845
|
return pluginSettings(amplifyOutputs as PluginOutputs, "signup") !== null;
|
|
12833
12846
|
}
|
|
12834
12847
|
|
|
12848
|
+
/** The Newsletter panel exists when the site enabled the plugin; `?demo=1` shows it with fixture data so it can be seen and tested. */
|
|
12849
|
+
function newsletterPluginEnabled(demo = false): boolean {
|
|
12850
|
+
return demo || pluginSettings(amplifyOutputs as PluginOutputs, "newsletter") !== null;
|
|
12851
|
+
}
|
|
12852
|
+
|
|
12835
12853
|
function readerPluginEnabled(): boolean {
|
|
12836
12854
|
return pluginSettings(amplifyOutputs as PluginOutputs, "reader-steering") !== null;
|
|
12837
12855
|
}
|
|
12838
12856
|
|
|
12839
|
-
function normalizeAdministrationPanel(value: string | null | undefined): AdministrationPanel {
|
|
12857
|
+
function normalizeAdministrationPanel(value: string | null | undefined, demo = false): AdministrationPanel {
|
|
12840
12858
|
if (value === "policies" || value === "sections" || value === "procedures" || value === "delegations" || value === "editorial-checks" || value === "monitoring") return value;
|
|
12841
12859
|
if (value === "signups" && signupPluginEnabled()) return value;
|
|
12860
|
+
if (value === "newsletter" && newsletterPluginEnabled(demo)) return value;
|
|
12842
12861
|
if (value === "moderation" && readerPluginEnabled()) return value;
|
|
12843
12862
|
return "users";
|
|
12844
12863
|
}
|
|
@@ -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> · <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
|
+
}
|