@redeemid/emdash 0.0.16 → 0.0.18

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 (73) hide show
  1. package/README.md +447 -232
  2. package/bin/redeemid-emdash.mjs +13 -1
  3. package/docs/developer-api.md +126 -0
  4. package/docs/shopify-setup.md +1 -1
  5. package/package.json +3 -1
  6. package/src/access-admin.ts +754 -0
  7. package/src/access.ts +84 -18
  8. package/src/admin-api.ts +9 -2
  9. package/src/agents.ts +62 -3
  10. package/src/api-version.ts +6 -0
  11. package/src/blocks/CollectionBlock.astro +4 -1
  12. package/src/blocks/SearchBlock.astro +14 -0
  13. package/src/blocks/index.ts +2 -0
  14. package/src/blocks-names.ts +1 -0
  15. package/src/cart-ops.ts +164 -0
  16. package/src/catalog.ts +287 -0
  17. package/src/checkout.ts +73 -0
  18. package/src/client/redeemid.ts +563 -0
  19. package/src/components/AccessBadge.astro +7 -5
  20. package/src/components/Account.astro +1 -1
  21. package/src/components/BrowseControls.astro +87 -0
  22. package/src/components/Cart.astro +12 -6
  23. package/src/components/CartDrawer.astro +59 -0
  24. package/src/components/CartView.astro +2 -1
  25. package/src/components/Collection.astro +23 -18
  26. package/src/components/Content.astro +10 -5
  27. package/src/components/Contribution.astro +1 -1
  28. package/src/components/Gate.astro +4 -2
  29. package/src/components/LockedPanel.astro +5 -3
  30. package/src/components/MemberCard.astro +1 -1
  31. package/src/components/Notices.astro +3 -28
  32. package/src/components/Plan.astro +1 -1
  33. package/src/components/Preferences.astro +1 -1
  34. package/src/components/Product.astro +116 -25
  35. package/src/components/ProductGrid.astro +52 -0
  36. package/src/components/ProductSearch.astro +83 -0
  37. package/src/components/Recommendations.astro +45 -0
  38. package/src/components/RedeemIDClient.astro +45 -0
  39. package/src/components/YourData.astro +1 -1
  40. package/src/config.ts +31 -5
  41. package/src/contract.ts +50 -0
  42. package/src/descriptor.ts +7 -0
  43. package/src/events.ts +3 -0
  44. package/src/features.ts +1 -1
  45. package/src/files.ts +1 -1
  46. package/src/guard.ts +5 -3
  47. package/src/index.ts +3 -0
  48. package/src/log.ts +96 -0
  49. package/src/monitor.ts +560 -0
  50. package/src/notices.ts +29 -0
  51. package/src/plugin.ts +234 -88
  52. package/src/rate-limit.ts +7 -0
  53. package/src/routes/api/cart.ts +84 -0
  54. package/src/routes/api/search.ts +49 -0
  55. package/src/routes/api/shared.ts +4 -0
  56. package/src/routes/api/state.ts +69 -0
  57. package/src/routes/buy.ts +4 -3
  58. package/src/routes/callback.ts +23 -5
  59. package/src/routes/cart-edit.ts +15 -66
  60. package/src/routes/cart.ts +4 -2
  61. package/src/routes/preferences.ts +2 -2
  62. package/src/routes/privacy.ts +2 -2
  63. package/src/routes/shared.ts +11 -0
  64. package/src/routes/webhook.ts +24 -11
  65. package/src/settings.ts +195 -0
  66. package/src/shopify.ts +37 -1
  67. package/src/site-config.ts +38 -5
  68. package/src/staff.ts +12 -23
  69. package/src/storage.ts +12 -0
  70. package/src/storefront.ts +231 -59
  71. package/src/text.ts +43 -2
  72. package/src/version.ts +1 -1
  73. package/src/webhooks.ts +2 -1
package/src/index.ts CHANGED
@@ -60,6 +60,9 @@ export function redeemid(config: RedeemIDConfig): AuthProviderDescriptor {
60
60
  { pattern: `${BASE}/account`, entrypoint: "@redeemid/emdash/routes/account.ts" },
61
61
  { pattern: `${BASE}/preferences`, entrypoint: "@redeemid/emdash/routes/preferences.ts" },
62
62
  { pattern: `${BASE}/privacy`, entrypoint: "@redeemid/emdash/routes/privacy.ts" },
63
+ { pattern: `${BASE}/api/state`, entrypoint: "@redeemid/emdash/routes/api/state.ts" },
64
+ { pattern: `${BASE}/api/cart`, entrypoint: "@redeemid/emdash/routes/api/cart.ts" },
65
+ { pattern: `${BASE}/api/search`, entrypoint: "@redeemid/emdash/routes/api/search.ts" },
63
66
  ],
64
67
  publicRoutes: [`${BASE}/`],
65
68
  storage: STORAGE_CONFIG,
package/src/log.ts ADDED
@@ -0,0 +1,96 @@
1
+ /**
2
+ * App events: what RedeemID did and what came of it, for the admin's App
3
+ * events page. Webhooks received and rejected, sign-ins and refusals, calls to
4
+ * Shopify's Admin API, staff actions, settings saves and outbound events.
5
+ *
6
+ * Each entry holds ids, labels, outcomes and timings only, never request or
7
+ * response bodies, tokens or emails. Kept 30 days.
8
+ */
9
+
10
+ import type { StorageCollection } from "emdash";
11
+
12
+ export const LOG_DAYS = 30;
13
+
14
+ export type LogOrigin = "webhook" | "member" | "staff" | "agent" | "cron" | "site";
15
+
16
+ export interface LogEntry {
17
+ at: string;
18
+ /** Dotted name, e.g. "webhook.received", "shopify.admin". */
19
+ event: string;
20
+ /** What it was about, e.g. a webhook topic or an Admin API operation. */
21
+ label: string;
22
+ /** ok, or what went wrong, e.g. "processed", "rejected", "error". */
23
+ status: string;
24
+ /** "ok" | "warn" | "error", for colour and the Problems filter. */
25
+ level: "ok" | "warn" | "error";
26
+ origin?: LogOrigin;
27
+ customerId?: string | null;
28
+ ms?: number;
29
+ /** A short note, never a body or a secret. */
30
+ note?: string;
31
+ }
32
+
33
+ /** Event names, for the filter. */
34
+ export const LOG_EVENTS: Record<string, string> = {
35
+ "webhook.received": "Webhook received",
36
+ "webhook.rejected": "Webhook rejected",
37
+ "webhook.failed": "Webhook failed",
38
+ "webhook.quiet": "No webhooks lately",
39
+ "member.signed_in": "Sign-in",
40
+ "member.refused": "Sign-in refused",
41
+ "shopify.admin": "Admin API call",
42
+ "staff.resync": "Resync",
43
+ "staff.tags": "Tag edit",
44
+ "staff.webhooks": "Webhook registration",
45
+ "settings.saved": "Settings saved",
46
+ "events.failed": "Outbound event failed",
47
+ "health.check": "Health check",
48
+ "meter.used": "Metered read",
49
+ "meter.exhausted": "Metered read, meter used up",
50
+ };
51
+
52
+ type Sink = (entry: LogEntry) => Promise<void>;
53
+ const SINK = Symbol.for("redeemid:log-sink");
54
+
55
+ /** Where entries go (the plugin's storage, or a test's array). */
56
+ export function setLogSink(sink: Sink | null): void {
57
+ (globalThis as unknown as Record<symbol, Sink | null>)[SINK] = sink;
58
+ }
59
+
60
+ async function defaultSink(entry: LogEntry): Promise<void> {
61
+ const [{ getDb }, { stores }] = await Promise.all([import("emdash/runtime"), import("./storage.js")]);
62
+ const s = stores(await getDb());
63
+ await s.log.put(`${entry.at}:${crypto.randomUUID().slice(0, 8)}`, entry);
64
+ }
65
+
66
+ /** Record one entry. Never throws: a log that can't be written never breaks the work. */
67
+ export async function record(entry: Omit<LogEntry, "at" | "level"> & { level?: LogEntry["level"] }): Promise<void> {
68
+ const full: LogEntry = { at: new Date().toISOString(), level: entry.level ?? "ok", ...entry } as LogEntry;
69
+ try {
70
+ const sink = (globalThis as unknown as Record<symbol, Sink | null | undefined>)[SINK] ?? defaultSink;
71
+ await sink(full);
72
+ } catch {
73
+ // No storage here (e.g. outside a request): skip.
74
+ }
75
+ }
76
+
77
+ /** Time a call and record it. */
78
+ export async function timed<T>(event: string, label: string, run: () => Promise<T>, extra: Partial<LogEntry> = {}): Promise<T> {
79
+ const started = Date.now();
80
+ try {
81
+ const out = await run();
82
+ await record({ event, label, status: "ok", ms: Date.now() - started, ...extra });
83
+ return out;
84
+ } catch (e) {
85
+ await record({ event, label, status: "error", level: "error", ms: Date.now() - started, note: String((e as Error).message ?? e).slice(0, 200), ...extra });
86
+ throw e;
87
+ }
88
+ }
89
+
90
+ /** Delete entries (and other dated records) older than `days`, a page at a time. */
91
+ export async function prune<T>(store: StorageCollection<T>, field: string, days = LOG_DAYS): Promise<number> {
92
+ const before = new Date(Date.now() - days * 86_400_000).toISOString();
93
+ const { items } = await store.query({ where: { [field]: { lt: before } }, limit: 100 });
94
+ if (items.length === 0) return 0;
95
+ return store.deleteMany(items.map((i) => i.id));
96
+ }
package/src/monitor.ts ADDED
@@ -0,0 +1,560 @@
1
+ /**
2
+ * Watching what RedeemID does, in the admin (Block Kit):
3
+ *
4
+ * - Webhooks: what Shopify sent and what became of it (Received), whether
5
+ * deliveries are flowing (Health: the daily check, rejected and failed
6
+ * deliveries, and when to warn staff by email), and where Shopify sends them
7
+ * (Setup: endpoint, signatures, subscriptions).
8
+ * - App events: the log in log.ts, newest first, by event or problems only.
9
+ * - Reports: members by level, who joined and left, sign-ins and metered reads.
10
+ *
11
+ * The daily check re-creates subscriptions Shopify dropped and never changes
12
+ * anyone's access: members still gain and lose levels only from Shopify tags.
13
+ */
14
+
15
+ import { levelsFor, membership } from "./access.js";
16
+ import { adminConfigured, registerWebhooks } from "./admin-api.js";
17
+ import { env, ENV, type RedeemIDConfig } from "./config.js";
18
+ import { LOG_DAYS, LOG_EVENTS, prune, record, type LogEntry } from "./log.js";
19
+ import type { DeliveryRecord, MemberRecord, Stores } from "./storage.js";
20
+ import { webhooksSection } from "./staff.js";
21
+
22
+ export const WEBHOOK_PATH = "/_emdash/api/auth/redeemid/webhook";
23
+ /** The daily check's cron task name. */
24
+ export const HEALTH_TASK = "health";
25
+
26
+ const DAY = 86_400_000;
27
+
28
+ type Toast = { message: string; type: "success" | "error" | "info" };
29
+ type Blocks = { blocks: unknown[]; toast?: Toast };
30
+
31
+ /** The plugin's key-value store (ctx.kv), or a test's map. */
32
+ export interface Kv {
33
+ get<T>(key: string): Promise<T | null>;
34
+ set(key: string, value: unknown): Promise<void>;
35
+ }
36
+
37
+ /** When to warn staff, saved from the Health tab. */
38
+ export interface MonitorSettings {
39
+ /** Warn when no delivery has come in for this many days (0 = never). */
40
+ quietDays: number;
41
+ /** Where warnings go. Empty: shown in the admin only. */
42
+ alertEmail: string;
43
+ }
44
+
45
+ export const MONITOR_DEFAULTS: MonitorSettings = { quietDays: 3, alertEmail: "" };
46
+
47
+ export interface Problem {
48
+ level: "warn" | "error";
49
+ text: string;
50
+ }
51
+
52
+ export interface HealthResult {
53
+ at: string;
54
+ problems: Problem[];
55
+ /** Topics the check registered again. */
56
+ registered: string[];
57
+ /** Whether subscriptions could be checked (needs the Admin API app). */
58
+ subscriptions: "ok" | "fixed" | "failed" | "unchecked";
59
+ emailed?: boolean;
60
+ }
61
+
62
+ const KEY_SETTINGS = "monitor:settings";
63
+ const KEY_LAST = "monitor:last";
64
+ const KEY_ALERTED = "monitor:alerted";
65
+
66
+ export async function monitorSettings(kv: Kv): Promise<MonitorSettings> {
67
+ return { ...MONITOR_DEFAULTS, ...((await kv.get<MonitorSettings>(KEY_SETTINGS)) ?? {}) };
68
+ }
69
+
70
+ export async function lastCheck(kv: Kv): Promise<HealthResult | null> {
71
+ return kv.get<HealthResult>(KEY_LAST);
72
+ }
73
+
74
+ /** Sends one email (ctx.email), when the site has an email provider. */
75
+ export type Mailer = { send(message: { to: string; subject: string; text: string }): Promise<void> } | undefined;
76
+
77
+ export interface MonitorEnv {
78
+ config: RedeemIDConfig;
79
+ s: Stores;
80
+ kv: Kv;
81
+ /** This site's webhook address. */
82
+ uri: string;
83
+ email: Mailer;
84
+ now?: () => number;
85
+ /** Who ran the check: the daily task, or staff with Check now. */
86
+ by?: "cron" | "staff";
87
+ }
88
+
89
+ const since = (now: number, days: number) => new Date(now - days * DAY).toISOString();
90
+
91
+ async function lastDelivery(s: Stores): Promise<DeliveryRecord | null> {
92
+ const { items } = await s.deliveries.query({ orderBy: { receivedAt: "desc" }, limit: 1 });
93
+ return items[0]?.data ?? null;
94
+ }
95
+
96
+ function count(s: Stores, event: string, from: string): Promise<number> {
97
+ return s.log.count({ event, at: { gte: from } });
98
+ }
99
+
100
+ /**
101
+ * The daily check: subscriptions in place (re-creating any that are
102
+ * missing), deliveries arriving, none rejected or failing. Warns by email
103
+ * when problems change, or once a week while they last. Also drops app
104
+ * events and delivery records older than LOG_DAYS.
105
+ */
106
+ export async function healthCheck(env_: MonitorEnv): Promise<HealthResult> {
107
+ const { config, s, kv, uri } = env_;
108
+ const now = env_.now?.() ?? Date.now();
109
+ const settings = await monitorSettings(kv);
110
+ const problems: Problem[] = [];
111
+ let registered: string[] = [];
112
+ let subscriptions: HealthResult["subscriptions"] = "unchecked";
113
+
114
+ if (!env(ENV.webhookSecret)) problems.push({ level: "error", text: `The webhook signing secret (the Admin API app's client secret, ${ENV.webhookSecret}) is missing, so the site turns away every delivery.` });
115
+
116
+ if (adminConfigured()) {
117
+ try {
118
+ const { added, failed } = await registerWebhooks(config, uri);
119
+ registered = added;
120
+ subscriptions = failed.length ? "failed" : added.length ? "fixed" : "ok";
121
+ if (added.length) problems.push({ level: "warn", text: `Shopify had dropped ${added.length} webhook ${added.length === 1 ? "subscription" : "subscriptions"}; registered again: ${added.map(topicName).join(", ")}.` });
122
+ for (const f of failed) problems.push({ level: "error", text: `Shopify refused ${topicName(f.topic)}: ${f.message}` });
123
+ } catch (e) {
124
+ subscriptions = "failed";
125
+ problems.push({ level: "error", text: `Couldn't check webhook subscriptions with Shopify: ${(e as Error).message}` });
126
+ }
127
+ }
128
+
129
+ const last = await lastDelivery(s);
130
+ if (settings.quietDays > 0) {
131
+ if (!last) problems.push({ level: "warn", text: "No webhooks received yet. Check the subscriptions under Setup." });
132
+ else if (Date.parse(last.receivedAt) < now - settings.quietDays * DAY) {
133
+ const days = Math.floor((now - Date.parse(last.receivedAt)) / DAY);
134
+ problems.push({ level: "warn", text: `No webhooks for ${days} days. Quiet stores are normal; if customers changed in Shopify since, check Setup.` });
135
+ }
136
+ }
137
+
138
+ const day = since(now, 1);
139
+ const rejected = await count(s, "webhook.rejected", day);
140
+ if (rejected) problems.push({ level: "warn", text: `${rejected} ${rejected === 1 ? "delivery was" : "deliveries were"} turned away in the last day (bad signature): check the webhook signing secret.` });
141
+ const failed = await count(s, "webhook.failed", day);
142
+ if (failed) problems.push({ level: "error", text: `${failed} ${failed === 1 ? "delivery" : "deliveries"} failed to save in the last day. Shopify sends them again; see App events.` });
143
+
144
+ const result: HealthResult = { at: new Date(now).toISOString(), problems, registered, subscriptions };
145
+ result.emailed = await alert(env_, settings, result, now);
146
+ await kv.set(KEY_LAST, result);
147
+ await record({
148
+ event: "health.check",
149
+ label: env_.by === "staff" ? "check now" : "daily check",
150
+ status: problems.length ? `${problems.length} ${problems.length === 1 ? "problem" : "problems"}` : "healthy",
151
+ level: problems.some((p) => p.level === "error") ? "error" : problems.length ? "warn" : "ok",
152
+ origin: env_.by ?? "cron",
153
+ });
154
+ await tidy(s);
155
+ return result;
156
+ }
157
+
158
+ /** Email the problems when they're new, or a week after the last email. */
159
+ async function alert(env_: MonitorEnv, settings: MonitorSettings, result: HealthResult, now: number): Promise<boolean> {
160
+ if (!result.problems.length || !settings.alertEmail || !env_.email) return false;
161
+ const sig = result.problems.map((p) => p.text.replace(/\d+/g, "#")).join("\n");
162
+ const before = await env_.kv.get<{ sig: string; at: number }>(KEY_ALERTED);
163
+ if (before && before.sig === sig && now - before.at < 7 * DAY) return false;
164
+ try {
165
+ await env_.email.send({
166
+ to: settings.alertEmail,
167
+ subject: "RedeemID: webhooks need attention",
168
+ text: [
169
+ "The daily check of Shopify webhooks found:",
170
+ "",
171
+ ...result.problems.map((p) => `- ${p.text}`),
172
+ "",
173
+ "Members gain and lose access only when Shopify sends tag changes, so these are worth a look: Plugins → RedeemID → Webhooks.",
174
+ ].join("\n"),
175
+ });
176
+ await env_.kv.set(KEY_ALERTED, { sig, at: now });
177
+ return true;
178
+ } catch (e) {
179
+ await record({ event: "health.check", label: "alert email", status: "not sent", level: "warn", origin: "cron", note: String((e as Error).message).slice(0, 200) });
180
+ return false;
181
+ }
182
+ }
183
+
184
+ /** Drop app events and delivery records older than LOG_DAYS, a page of each. */
185
+ export async function tidy(s: Stores): Promise<void> {
186
+ await prune(s.log, "at").catch(() => 0);
187
+ await prune(s.deliveries, "receivedAt").catch(() => 0);
188
+ }
189
+
190
+ /** WEBHOOK_TOPICS name → the topic Shopify shows: CUSTOMER_TAGS_ADDED → customer.tags_added. */
191
+ export function topicName(t: string): string {
192
+ return t.toLowerCase().replace("_", "/").replace("customer/tags", "customer.tags");
193
+ }
194
+
195
+ // ---------------------------------------------------------------- Webhooks page
196
+
197
+ export const WEBHOOK_TABS = ["received", "health", "setup"] as const;
198
+ export type WebhookTab = (typeof WEBHOOK_TABS)[number];
199
+
200
+ const OUTCOMES: Record<string, string> = {
201
+ processed: "Processed",
202
+ skipped: "Skipped",
203
+ stale: "Out of date",
204
+ ignored: "Not used",
205
+ pending: "Running",
206
+ };
207
+
208
+ const cap = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);
209
+
210
+ export async function webhooksPage(env_: MonitorEnv, tab: WebhookTab = "received", toast?: Toast, cursor?: string): Promise<Blocks> {
211
+ const { s, kv } = env_;
212
+ const now = env_.now?.() ?? Date.now();
213
+ const month = since(now, LOG_DAYS);
214
+ const [received, rejected, failed, last, check, settings] = await Promise.all([
215
+ s.log.count({ event: "webhook.received", at: { gte: month } }),
216
+ count(s, "webhook.rejected", month),
217
+ count(s, "webhook.failed", month),
218
+ lastDelivery(s),
219
+ lastCheck(kv),
220
+ monitorSettings(kv),
221
+ ]);
222
+ const page = await s.deliveries.query({ orderBy: { receivedAt: "desc" }, limit: 25, ...(cursor ? { cursor } : {}) });
223
+ const attention = Boolean(check?.problems.length);
224
+
225
+ const receivedTab = [
226
+ { type: "context", text: `What Shopify sent, and what became of it. Kept ${LOG_DAYS} days. Bodies are never stored: only the topic, the result and the customer id. Skipped: nothing to do. Out of date: older than what's stored. Not used: a topic RedeemID ignores.` },
227
+ ...(attention ? [{ type: "banner", variant: "alert", title: "Webhook delivery needs attention", description: "See the Health tab." }] : []),
228
+ {
229
+ type: "stats",
230
+ items: [
231
+ { label: "Received", value: String(received), description: `${LOG_DAYS} days` },
232
+ { label: "Turned away", value: String(rejected), ...(rejected ? { trend: "down" } : {}) },
233
+ { label: "Failed", value: String(failed), ...(failed ? { trend: "down" } : {}) },
234
+ ],
235
+ },
236
+ ...(cursor ? [{ type: "actions", elements: [{ type: "button", action_id: "webhooks_tab", value: "received", label: "Back to the newest", style: "secondary" }] }] : []),
237
+ {
238
+ type: "table",
239
+ page_action_id: "deliveries_page",
240
+ empty_text: "No webhooks received yet.",
241
+ columns: [
242
+ { key: "topic", label: "Topic", format: "code" },
243
+ { key: "result", label: "Result", format: "badge" },
244
+ { key: "customer", label: "Customer" },
245
+ { key: "at", label: "Received", format: "relative_time" },
246
+ ],
247
+ rows: page.items.map(({ data: d }) => ({
248
+ topic: d.topic,
249
+ result: OUTCOMES[d.outcome ?? "processed"] ?? cap(d.outcome ?? ""),
250
+ customer: d.customerId ?? "",
251
+ at: d.receivedAt,
252
+ })),
253
+ ...(page.cursor ? { next_cursor: page.cursor } : {}),
254
+ },
255
+ ];
256
+
257
+ const healthTab = [
258
+ {
259
+ type: "context",
260
+ text: "Members gain and lose access only when Shopify sends tag changes. Once a day RedeemID checks that the subscriptions are still in place (registering again any Shopify dropped), that deliveries are coming in, and that none were turned away or failed.",
261
+ },
262
+ check === null
263
+ ? { type: "banner", variant: "default", title: "Not checked yet", description: "Check now, or wait for the daily check." }
264
+ : check.problems.length
265
+ ? { type: "banner", variant: check.problems.some((p) => p.level === "error") ? "error" : "alert", title: check.problems.length === 1 ? "1 problem" : `${check.problems.length} problems`, description: check.problems.map((p) => p.text).join(" ") }
266
+ : { type: "banner", variant: "default", title: "Healthy", description: "Subscriptions are in place and deliveries look fine." },
267
+ {
268
+ type: "fields",
269
+ fields: [
270
+ { label: "Last check", value: check ? when(check.at, now) : "Never" },
271
+ { label: "Last delivery", value: last ? when(last.receivedAt, now) : "None yet" },
272
+ { label: "Turned away", value: String(rejected) },
273
+ { label: "Failed", value: String(failed) },
274
+ ],
275
+ },
276
+ { type: "context", text: `Turned away and failed: the last ${LOG_DAYS} days. Subscriptions: ${SUBS[check?.subscriptions ?? "unchecked"]}.` },
277
+ {
278
+ type: "actions",
279
+ elements: [
280
+ { type: "button", action_id: "health_check", label: "Check now", style: "primary" },
281
+ ...(rejected ? [{ type: "button", action_id: "events_show", value: "webhook.rejected", label: "See turned away", style: "secondary" }] : []),
282
+ ...(failed ? [{ type: "button", action_id: "events_show", value: "webhook.failed", label: "See failed", style: "secondary" }] : []),
283
+ ],
284
+ },
285
+ { type: "header", text: "Warnings" },
286
+ {
287
+ type: "form",
288
+ block_id: "monitor",
289
+ fields: [
290
+ { type: "number_input", action_id: "quiet_days", label: "Warn after this many days without a webhook (0 = never)", min: 0, max: 60, initial_value: settings.quietDays },
291
+ { type: "text_input", action_id: "alert_email", label: "Email warnings to", placeholder: "you@example.com", initial_value: settings.alertEmail },
292
+ ],
293
+ submit: { label: "Save", action_id: "monitor_save" },
294
+ },
295
+ {
296
+ type: "context",
297
+ text: env_.email
298
+ ? "One email when problems start or change, and once a week while they last."
299
+ : "No email provider is set up in EmDash, so warnings show here and on the dashboard only. Add an email plugin to get them by email.",
300
+ },
301
+ ];
302
+
303
+ const setupTab = [
304
+ { type: "context", text: "Where Shopify sends deliveries, how they're checked, and the app's webhook subscriptions." },
305
+ { type: "section", text: `Endpoint: ${env_.uri}` },
306
+ {
307
+ type: "section",
308
+ text: env(ENV.webhookSecret)
309
+ ? "Signatures: checked with the Admin API app's client secret."
310
+ : `Signatures: the Admin API app's client secret (${ENV.webhookSecret}) is missing, so every delivery is turned away.`,
311
+ },
312
+ ...(await webhooksSection(env_.config, env_.uri)),
313
+ { type: "context", text: "customers/redact, customers/data_request and shop/redact are set in the app's compliance webhooks, not here." },
314
+ ];
315
+
316
+ return {
317
+ blocks: [
318
+ { type: "header", text: "Webhooks" },
319
+ {
320
+ type: "tab",
321
+ default_tab: WEBHOOK_TABS.indexOf(tab),
322
+ panels: [
323
+ { label: "Received", blocks: receivedTab },
324
+ { label: attention ? "Health (!)" : "Health", blocks: healthTab },
325
+ { label: "Setup", blocks: setupTab },
326
+ ],
327
+ },
328
+ ],
329
+ ...(toast ? { toast } : {}),
330
+ };
331
+ }
332
+
333
+ const SUBS: Record<HealthResult["subscriptions"], string> = {
334
+ ok: "all in place",
335
+ fixed: "some were missing and are registered again",
336
+ failed: "couldn't all be registered (see the problems above)",
337
+ unchecked: "not checked (set the Admin API app's client ID to check them)",
338
+ };
339
+
340
+ function when(iso: string, now: number): string {
341
+ const ms = now - Date.parse(iso);
342
+ if (ms < 60_000) return "just now";
343
+ if (ms < 3_600_000) return `${Math.floor(ms / 60_000)} min ago`;
344
+ if (ms < DAY) return `${Math.floor(ms / 3_600_000)} h ago`;
345
+ const days = Math.floor(ms / DAY);
346
+ return days === 1 ? "1 day ago" : `${days} days ago`;
347
+ }
348
+
349
+ /** Save the Health tab's warning settings. */
350
+ export async function saveMonitor(kv: Kv, values: Record<string, unknown> = {}): Promise<Toast> {
351
+ const days = Math.max(0, Math.min(60, Math.round(Number(values.quiet_days ?? MONITOR_DEFAULTS.quietDays)) || 0));
352
+ const email = String(values.alert_email ?? "").trim();
353
+ if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return { type: "error", message: "That email address doesn't look right." };
354
+ await kv.set(KEY_SETTINGS, { quietDays: days, alertEmail: email } satisfies MonitorSettings);
355
+ await record({ event: "settings.saved", label: "webhook warnings", status: "ok", origin: "staff" });
356
+ return { type: "success", message: "Saved." };
357
+ }
358
+
359
+ // ---------------------------------------------------------------- App events page
360
+
361
+ export interface EventFilter {
362
+ event?: string;
363
+ problems?: boolean;
364
+ }
365
+
366
+ /** A filter carried in the table's block id, so Load more keeps it. */
367
+ export function filterId(f: EventFilter): string {
368
+ return `events:${f.event ?? ""}:${f.problems ? 1 : 0}`;
369
+ }
370
+
371
+ export function parseFilter(raw: unknown): EventFilter {
372
+ const [, event = "", problems = "0"] = String(raw ?? "").split(":");
373
+ return { ...(event ? { event } : {}), problems: problems === "1" };
374
+ }
375
+
376
+ export async function eventsPage(s: Stores, filter: EventFilter = {}, cursor?: string, toast?: Toast): Promise<Blocks> {
377
+ if (!cursor) await tidy(s);
378
+ const where: Record<string, unknown> = {};
379
+ if (filter.event && filter.event in LOG_EVENTS) where.event = filter.event;
380
+ if (filter.problems) where.level = { in: ["warn", "error"] };
381
+ const page = await s.log.query({ where: where as never, orderBy: { at: "desc" }, limit: 50, ...(cursor ? { cursor } : {}) });
382
+ return {
383
+ blocks: [
384
+ { type: "header", text: "App events" },
385
+ {
386
+ type: "context",
387
+ text: `What RedeemID did and what came of it: webhooks, sign-ins, calls to Shopify, staff actions, settings saves and outbound events. Kept ${LOG_DAYS} days. Never bodies, tokens or emails.`,
388
+ },
389
+ {
390
+ type: "form",
391
+ block_id: "events_filter",
392
+ fields: [
393
+ {
394
+ type: "select",
395
+ action_id: "event",
396
+ label: "Event",
397
+ options: [{ label: "All events", value: "" }, ...Object.entries(LOG_EVENTS).map(([value, label]) => ({ label, value }))],
398
+ initial_value: filter.event ?? "",
399
+ },
400
+ { type: "toggle", action_id: "problems", label: "Problems only", initial_value: Boolean(filter.problems) },
401
+ ],
402
+ submit: { label: "Show", action_id: "events_filter" },
403
+ },
404
+ ...(cursor ? [{ type: "actions", elements: [{ type: "button", action_id: "events_show", value: filter.event ?? "", label: "Back to the newest", style: "secondary" }] }] : []),
405
+ {
406
+ type: "table",
407
+ block_id: filterId(filter),
408
+ page_action_id: "events_page",
409
+ empty_text: filter.event || filter.problems ? "Nothing matches." : "Nothing recorded yet.",
410
+ columns: [
411
+ { key: "event", label: "Event" },
412
+ { key: "about", label: "About" },
413
+ { key: "result", label: "Result", format: "badge" },
414
+ { key: "customer", label: "Customer" },
415
+ { key: "ms", label: "Took" },
416
+ { key: "at", label: "When", format: "relative_time" },
417
+ { key: "note", label: "Note" },
418
+ ],
419
+ rows: page.items.map(({ data }) => eventRow(data)),
420
+ ...(page.cursor ? { next_cursor: page.cursor } : {}),
421
+ },
422
+ ],
423
+ ...(toast ? { toast } : {}),
424
+ };
425
+ }
426
+
427
+ function eventRow(e: LogEntry) {
428
+ const status = e.status === "ok" ? "OK" : cap(e.status);
429
+ return {
430
+ event: LOG_EVENTS[e.event] ?? e.event,
431
+ about: e.label,
432
+ result: e.level === "error" && !/error/i.test(status) ? `Error: ${status}` : status,
433
+ customer: e.customerId ?? "",
434
+ ms: e.ms !== undefined ? `${e.ms} ms` : "",
435
+ at: e.at,
436
+ note: e.note ?? "",
437
+ };
438
+ }
439
+
440
+ // ---------------------------------------------------------------- Reports page
441
+
442
+ const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
443
+ const weekLabel = (ts: number) => `${MONTHS[new Date(ts).getUTCMonth()]} ${new Date(ts).getUTCDate()}`;
444
+
445
+ const MEMBER_CAP = 1000;
446
+ const WEEKS = 12;
447
+
448
+ export async function reportsPage(config: RedeemIDConfig, s: Stores, now = Date.now()): Promise<Blocks> {
449
+ const levels = config.levels ?? [];
450
+ const members: MemberRecord[] = [];
451
+ let cursor: string | undefined;
452
+ do {
453
+ const page = await s.members.query({ limit: 100, ...(cursor ? { cursor } : {}) });
454
+ members.push(...page.items.map((r) => r.data));
455
+ cursor = page.cursor;
456
+ } while (cursor && members.length < MEMBER_CAP);
457
+ const capped = Boolean(cursor);
458
+
459
+ const month = now - 30 * DAY;
460
+ const weekEnd = Date.UTC(new Date(now).getUTCFullYear(), new Date(now).getUTCMonth(), new Date(now).getUTCDate()) + DAY;
461
+ const joinsByWeek = new Array<number>(WEEKS).fill(0);
462
+ const leavesByWeek = new Array<number>(WEEKS).fill(0);
463
+ const byLevel = new Map(levels.map((l) => [l.slug, { members: 0, joined: 0, left: 0 }]));
464
+ const bucket = (iso: string | null | undefined, into: number[]) => {
465
+ if (!iso) return;
466
+ const i = Math.floor((weekEnd - Date.parse(iso)) / (7 * DAY));
467
+ if (i >= 0 && i < WEEKS) into[WEEKS - 1 - i]!++;
468
+ };
469
+ const recent = (iso: string | null | undefined) => Boolean(iso) && Date.parse(iso!) >= month;
470
+
471
+ for (const m of members) {
472
+ const { current, past } = membership(m, levels);
473
+ for (const l of current) {
474
+ const row = byLevel.get(l.slug)!;
475
+ if (m.status === "enabled") row.members++;
476
+ if (recent(l.since)) row.joined++;
477
+ bucket(l.since, joinsByWeek);
478
+ }
479
+ for (const l of past) {
480
+ const row = byLevel.get(l.slug)!;
481
+ if (recent(l.from)) row.joined++;
482
+ if (recent(l.to)) row.left++;
483
+ bucket(l.from, joinsByWeek);
484
+ bucket(l.to, leavesByWeek);
485
+ }
486
+ }
487
+ const joined = [...byLevel.values()].reduce((n, r) => n + r.joined, 0);
488
+ const left = [...byLevel.values()].reduce((n, r) => n + r.left, 0);
489
+ const from = since(now, 30);
490
+ const [signIns, metered, usedUp] = await Promise.all([
491
+ count(s, "member.signed_in", from),
492
+ count(s, "meter.used", from),
493
+ count(s, "meter.exhausted", from),
494
+ ]);
495
+ const holding = members.filter((m) => m.status === "enabled" && levelsFor(m.tags, levels).length > 0).length;
496
+ const weekTs = (i: number) => weekEnd - (WEEKS - 1 - i) * 7 * DAY - DAY;
497
+
498
+ return {
499
+ blocks: [
500
+ { type: "header", text: "Reports" },
501
+ {
502
+ type: "context",
503
+ text: "A level is joined or left when Shopify adds or removes its tag. Counts come from each member's tag dates; sign-ins and metered reads from App events (kept 30 days).",
504
+ },
505
+ {
506
+ type: "stats",
507
+ items: [
508
+ { label: "On a level", value: String(holding), description: "now" },
509
+ { label: "Joined", value: String(joined), description: "30 days", ...(joined ? { trend: "up" } : {}) },
510
+ { label: "Left", value: String(left), description: "30 days", ...(left ? { trend: "down" } : {}) },
511
+ ],
512
+ },
513
+ {
514
+ type: "stats",
515
+ items: [
516
+ { label: "Sign-ins", value: String(signIns), description: "30 days" },
517
+ { label: "Metered reads", value: String(metered + usedUp), description: usedUp ? `30 days; ${usedUp} used up the meter` : "30 days" },
518
+ ],
519
+ },
520
+ { type: "header", text: "Joined and left, by week" },
521
+ {
522
+ type: "chart",
523
+ config: {
524
+ chart_type: "custom",
525
+ height: 260,
526
+ options: {
527
+ tooltip: { trigger: "axis" },
528
+ legend: { data: ["Joined", "Left"], bottom: 0 },
529
+ grid: { left: 32, right: 8, top: 16, bottom: 56 },
530
+ xAxis: { type: "category", data: joinsByWeek.map((_, i) => weekLabel(weekTs(i))), axisLabel: { hideOverlap: true } },
531
+ yAxis: { type: "value", minInterval: 1 },
532
+ series: [
533
+ { name: "Joined", type: "bar", data: joinsByWeek },
534
+ { name: "Left", type: "bar", data: leavesByWeek },
535
+ ],
536
+ },
537
+ },
538
+ },
539
+ { type: "context", text: "Each bar is the week ending on the date shown." },
540
+ { type: "header", text: "By level" },
541
+ levels.length
542
+ ? {
543
+ type: "table",
544
+ page_action_id: "reports_levels",
545
+ columns: [
546
+ { key: "level", label: "Level" },
547
+ { key: "members", label: "Members", format: "number" },
548
+ { key: "joined", label: "Joined (30 days)", format: "number" },
549
+ { key: "left", label: "Left (30 days)", format: "number" },
550
+ ],
551
+ rows: levels.map((l) => {
552
+ const r = byLevel.get(l.slug)!;
553
+ return { level: l.name, members: r.members, joined: r.joined, left: r.left };
554
+ }),
555
+ }
556
+ : { type: "context", text: "No levels yet. Add them under Access." },
557
+ ...(capped ? [{ type: "context", text: `Counted from the first ${MEMBER_CAP} members.` }] : []),
558
+ ],
559
+ };
560
+ }