@redeemid/emdash 0.0.19 → 0.0.20

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.
@@ -1,7 +1,9 @@
1
1
  ---
2
2
  /**
3
3
  * The member's own account card, for an account page: their levels with
4
- * when each started, levels they held before, the meter, and links to the
4
+ * when each started, when the membership renews or ends and any gift
5
+ * membership (from the shop's membership.* customer metafields), levels
6
+ * they held before, the meter, and links to the
5
7
  * store account, Refresh membership and Sign out. Visitors get a sign-in link.
6
8
  *
7
9
  * <MemberCard />
@@ -35,6 +37,11 @@ const date = (iso: string | null) => {
35
37
  return Number.isNaN(t) ? null : new Intl.DateTimeFormat("en-US", { dateStyle: "long", timeZone: "UTC" }).format(new Date(t));
36
38
  };
37
39
  const name = [m?.firstName, m?.lastName].filter(Boolean).join(" ");
40
+ // Dates the shop keeps in Shopify (Flow or a subscription app). Display only.
41
+ const dates = m && !disabled ? (m.dates ?? {}) : {};
42
+ const today = new Date().toISOString().slice(0, 10);
43
+ const gift = dates.gift && dates.gift >= today ? dates.gift : null;
44
+ const day = (ymd: string | null | undefined) => (ymd ? date(`${ymd}T12:00:00Z`) : null);
38
45
  const join = config?.join;
39
46
  ---
40
47
 
@@ -55,7 +62,7 @@ const join = config?.join;
55
62
  {current.map((l) => (
56
63
  <li data-redeemid-level={l.slug}>
57
64
  <strong>{l.name}</strong>
58
- {date(l.since) && <> {textParts(config, "since {date}", { date: <time datetime={l.since ?? undefined}>{date(l.since)}</time> })}</>}
65
+ {(dates.since ? day(dates.since) : date(l.since)) && <> {textParts(config, "since {date}", { date: <time datetime={dates.since ?? l.since ?? undefined}>{dates.since ? day(dates.since) : date(l.since)}</time> })}</>}
59
66
  </li>
60
67
  ))}
61
68
  </ul>
@@ -64,6 +71,13 @@ const join = config?.join;
64
71
  {text(config, "You don't have a membership yet.")}{join && <> <a href={join.href}>{join.label ?? text(config, "Become a member")}</a></>}
65
72
  </p>
66
73
  )}
74
+ {current.length > 0 && (dates.renews || dates.ends || gift) && (
75
+ <ul class="redeemid-member__dates">
76
+ {dates.renews && <li data-redeemid-date="renews">{textParts(config, "Renews on {date}", { date: <time datetime={dates.renews}>{day(dates.renews)}</time> })}</li>}
77
+ {dates.ends && <li data-redeemid-date="ends">{textParts(config, "Ends on {date}", { date: <time datetime={dates.ends}>{day(dates.ends)}</time> })}</li>}
78
+ {gift && <li data-redeemid-date="gift">{textParts(config, "Gift membership until {date}", { date: <time datetime={gift}>{day(gift)}</time> })}</li>}
79
+ </ul>
80
+ )}
67
81
  {features.length > 0 && (
68
82
  <p class="redeemid-member__features">
69
83
  {text(config, "Includes: {features}", { features: features.map((f) => f.name).join(", ") })}
@@ -7,9 +7,10 @@
7
7
  * <Plan handle="gold-membership" levels={["gold"]} />
8
8
  *
9
9
  * With `levels`, members who hold one of them see "You're on this plan"
10
- * in place of the button.
10
+ * in place of the button, and a member who held one before (Shopify removed
11
+ * its tag) sees "Renew" instead of "Join".
11
12
  */
12
- import { viewer } from "../access.js";
13
+ import { membership, viewer } from "../access.js";
13
14
  import { siteConfig } from "../site-config.js";
14
15
  import { plan as loadPlan, type Plan } from "../storefront.js";
15
16
  import { text } from "../text.js";
@@ -23,10 +24,12 @@ interface Props {
23
24
  /** The levels this plan gives. Members holding one see `heldLabel`, not the button. */
24
25
  levels?: string[];
25
26
  heldLabel?: string;
27
+ /** Button text for a member whose plan lapsed. */
28
+ renewLabel?: string;
26
29
  }
27
30
 
28
31
  const config = await siteConfig(Astro.locals);
29
- const { handle, label = text(config, "Join"), title, levels = [], heldLabel = text(config, "You're on this plan.") } = Astro.props;
32
+ const { handle, label = text(config, "Join"), title, levels = [], heldLabel = text(config, "You're on this plan."), renewLabel = text(config, "Renew") } = Astro.props;
30
33
  let p: Plan | null = null;
31
34
  if (config) {
32
35
  try {
@@ -39,11 +42,14 @@ const price = p?.price
39
42
  ? new Intl.NumberFormat("en-US", { style: "currency", currency: p.price.currencyCode }).format(Number(p.price.amount))
40
43
  : null;
41
44
  const here = Astro.url.pathname + Astro.url.search;
42
- const held = levels.length > 0 && (await viewer(Astro)).levels.some((l) => levels.includes(l));
45
+ const v = levels.length > 0 ? await viewer(Astro) : null;
46
+ const held = Boolean(v?.levels.some((l) => levels.includes(l)));
47
+ // Lapsed: held one of these levels before. Offered their plan back, not "Join" as if new.
48
+ const lapsed = Boolean(!held && v?.member && v.member.status !== "disabled" && membership(v.member, config?.levels).past.some((p) => levels.includes(p.slug)));
43
49
  ---
44
50
 
45
51
  {p ? (
46
- <div class:list={["redeemid-plan", { "is-held": held }]} data-redeemid-plan={p.handle}>
52
+ <div class:list={["redeemid-plan", { "is-held": held, "is-lapsed": lapsed }]} data-redeemid-plan={p.handle} data-redeemid-state={held ? "held" : lapsed ? "renew" : "join"}>
47
53
  <h2 class="redeemid-plan__title">{title ?? p.title}</h2>
48
54
  {price && <p class="redeemid-plan__price">{price}</p>}
49
55
  {p.description && <p class="redeemid-plan__description">{p.description}</p>}
@@ -53,7 +59,7 @@ const held = levels.length > 0 && (await viewer(Astro)).levels.some((l) => level
53
59
  <form method="post" action="/_emdash/api/auth/redeemid/buy">
54
60
  <input type="hidden" name="plan" value={p.handle} />
55
61
  <input type="hidden" name="return" value={here} />
56
- <button type="submit">{label}</button>
62
+ <button type="submit">{lapsed ? renewLabel : label}</button>
57
63
  </form>
58
64
  ) : (
59
65
  <p class="redeemid-plan__unavailable">{text(config, "Not available right now.")}</p>
package/src/config.ts CHANGED
@@ -187,6 +187,12 @@ export interface RedeemIDConfig {
187
187
  * in the editor's Access panel rather than here.
188
188
  */
189
189
  entries?: Record<string, EntryRule>;
190
+ /**
191
+ * Set by RedeemID, never in astro.config.mjs: the settings saved in the
192
+ * admin couldn't be read and none were read before, so every entry is
193
+ * locked until they can be (see lockdown in settings.ts).
194
+ */
195
+ unavailable?: boolean;
190
196
  /**
191
197
  * Only for local tests: lets `shop` be a full http:// origin of a fake store.
192
198
  * Never set this on a live site.
package/src/descriptor.ts CHANGED
@@ -15,6 +15,7 @@ export const PAGES = [
15
15
  { path: "/events", label: "App events", icon: "list" },
16
16
  { path: "/reports", label: "Reports", icon: "chart" },
17
17
  { path: "/status", label: "Status", icon: "pulse" },
18
+ { path: "/setup", label: "Setup", icon: "settings" },
18
19
  { path: "/test", label: "Access test", icon: "search" },
19
20
  { path: "/privacy", label: "Privacy", icon: "lock" },
20
21
  ];
package/src/events.ts CHANGED
@@ -8,7 +8,10 @@
8
8
  * X-RedeemID-Signature: sha256=<hex HMAC of the raw body>. Events:
9
9
  * member.signed_in, member.levels_changed, meter.used, meter.exhausted,
10
10
  * preferences.saved.
11
- * Sending never blocks or breaks the page: failures are logged and dropped.
11
+ * Sending never blocks or breaks the page: events go out after the response
12
+ * (waitUntil on Workers), are tried three times, and one that fails every
13
+ * try is kept under App events → Failed events, where staff can send it
14
+ * again or clear it.
12
15
  * No event carries an email address or a name; match people by `customerId`.
13
16
  */
14
17
 
@@ -19,6 +22,8 @@ import { record } from "./log.js";
19
22
  export type EventName = "member.signed_in" | "member.levels_changed" | "meter.used" | "meter.exhausted" | "preferences.saved" | "member.erased";
20
23
 
21
24
  const TIMEOUT_MS = 3000;
25
+ /** Waits before the second and third tries. */
26
+ export const RETRY_DELAYS_MS = [1000, 5000];
22
27
 
23
28
  export async function signBody(body: string, secret: string): Promise<string> {
24
29
  const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
@@ -26,14 +31,20 @@ export async function signBody(body: string, secret: string): Promise<string> {
26
31
  return [...mac].map((b) => b.toString(16).padStart(2, "0")).join("");
27
32
  }
28
33
 
29
- /** Send one event. Returns whether the receiver answered 2xx; never throws. */
30
- export async function emit(config: RedeemIDConfig | null, name: EventName, data: Record<string, unknown>, fetcher: typeof fetch = fetch): Promise<boolean> {
31
- const url = config?.events?.url;
32
- const secret = env(ENV.eventsSecret);
33
- if (!url || !secret) return false;
34
- if (config.events?.only && !config.events.only.includes(name)) return false;
35
- const id = crypto.randomUUID();
36
- const body = JSON.stringify({ id, event: name, at: new Date().toISOString(), data });
34
+ /** An event that failed every try, kept so staff can send it again or clear it (App events). */
35
+ export interface FailedEvent {
36
+ /** The delivery id, also the record id: a resend keeps it, so receivers can drop duplicates. */
37
+ id: string;
38
+ event: EventName;
39
+ /** The JSON body as first sent (ids and levels, never an email or a name). */
40
+ body: string;
41
+ at: string;
42
+ attempts: number;
43
+ error: string;
44
+ }
45
+
46
+ /** One POST. Resolves to null when the receiver answered 2xx, else what went wrong. */
47
+ async function post(url: string, name: string, id: string, body: string, secret: string, fetcher: typeof fetch): Promise<string | null> {
37
48
  try {
38
49
  const res = await fetcher(url, {
39
50
  method: "POST",
@@ -47,22 +58,117 @@ export async function emit(config: RedeemIDConfig | null, name: EventName, data:
47
58
  body,
48
59
  signal: AbortSignal.timeout(TIMEOUT_MS),
49
60
  });
50
- if (!res.ok) await record({ event: "events.failed", label: name, status: `HTTP ${res.status}`, level: "warn", origin: "site" });
51
- return res.ok;
61
+ return res.ok ? null : `HTTP ${res.status}`;
52
62
  } catch (e) {
53
- console.error("[redeemid] event not sent:", name, (e as Error).message);
54
- await record({ event: "events.failed", label: name, status: "error", level: "error", origin: "site", note: String((e as Error).message).slice(0, 200) });
55
- return false;
63
+ return String((e as Error).message || "error").slice(0, 200);
56
64
  }
57
65
  }
58
66
 
67
+ type Sleep = (ms: number) => Promise<void>;
68
+ const sleep: Sleep = (ms) => new Promise((r) => setTimeout(r, ms));
69
+
70
+ /**
71
+ * Try a delivery up to three times (now, after 1 s, after 5 s). One that
72
+ * fails every try goes to the failed list (App events) for staff. True when
73
+ * the receiver took it.
74
+ */
75
+ export async function deliver(
76
+ url: string,
77
+ secret: string,
78
+ name: EventName,
79
+ id: string,
80
+ body: string,
81
+ opts: { fetcher?: typeof fetch; wait?: Sleep; failed?: (f: FailedEvent) => Promise<void> } = {},
82
+ ): Promise<boolean> {
83
+ const fetcher = opts.fetcher ?? fetch;
84
+ let error: string | null = null;
85
+ let attempts = 0;
86
+ for (const delay of [0, ...RETRY_DELAYS_MS]) {
87
+ if (delay) await (opts.wait ?? sleep)(delay);
88
+ attempts++;
89
+ error = await post(url, name, id, body, secret, fetcher);
90
+ if (error === null) return true;
91
+ }
92
+ console.error("[redeemid] event not sent:", name, error);
93
+ const failed: FailedEvent = { id, event: name, body, at: new Date().toISOString(), attempts, error: error ?? "error" };
94
+ await record({ event: "events.failed", label: name, status: error ?? "error", level: "error", origin: "site", note: `${attempts} tries` });
95
+ await (opts.failed ?? keepFailed)(failed).catch(() => undefined);
96
+ return false;
97
+ }
98
+
99
+ async function keepFailed(f: FailedEvent): Promise<void> {
100
+ const { getDb } = await import("emdash/runtime");
101
+ const { stores } = await import("./storage.js");
102
+ await stores(await getDb()).failedEvents.put(f.id, f);
103
+ }
104
+
105
+ /** Deliveries started and not finished yet: tests and the CLI can wait for them. */
106
+ const pending = new Set<Promise<boolean>>();
107
+
108
+ /** Wait until every event started so far has been delivered or given up on. */
109
+ export async function flushEvents(): Promise<void> {
110
+ while (pending.size) await Promise.allSettled([...pending]);
111
+ }
112
+
113
+ /** EmDash's after(), loaded once; null outside an EmDash site. */
114
+ let afterFn: Promise<((fn: () => Promise<void>) => void) | null> | null = null;
115
+
116
+ /** Keep the work alive after the response (waitUntil on Workers); plain Node needs nothing. Never waits. */
117
+ function keepAlive(work: Promise<unknown>): void {
118
+ afterFn ??= import("emdash").then(
119
+ (m) => m.after,
120
+ () => null,
121
+ );
122
+ void afterFn.then((after) =>
123
+ after?.(async () => {
124
+ await work;
125
+ }),
126
+ );
127
+ }
128
+
129
+ /**
130
+ * Send one event, after the response: the page, sign-in or webhook never
131
+ * waits for the receiver. Returns at once; false when events are off.
132
+ */
133
+ export async function emit(
134
+ config: RedeemIDConfig | null,
135
+ name: EventName,
136
+ data: Record<string, unknown>,
137
+ opts: { fetcher?: typeof fetch; wait?: Sleep; failed?: (f: FailedEvent) => Promise<void> } = {},
138
+ ): Promise<boolean> {
139
+ const url = config?.events?.url;
140
+ const secret = env(ENV.eventsSecret);
141
+ if (!url || !secret) return false;
142
+ if (config.events?.only && !config.events.only.includes(name)) return false;
143
+ const id = crypto.randomUUID();
144
+ const body = JSON.stringify({ id, event: name, at: new Date().toISOString(), data });
145
+ const work = deliver(url, secret, name, id, body, opts).catch(() => false);
146
+ pending.add(work);
147
+ void work.finally(() => pending.delete(work));
148
+ keepAlive(work);
149
+ return true;
150
+ }
151
+
152
+ /** Send a failed event again (staff). On success it leaves the failed list. */
153
+ export async function resend(config: RedeemIDConfig, s: { failedEvents: { get(id: string): Promise<FailedEvent | null>; delete(id: string): Promise<boolean> } }, id: string, fetcher: typeof fetch = fetch): Promise<boolean> {
154
+ const f = await s.failedEvents.get(id);
155
+ const url = config.events?.url;
156
+ const secret = env(ENV.eventsSecret);
157
+ if (!f || !url || !secret) return false;
158
+ const error = await post(url, f.event, f.id, f.body, secret, fetcher);
159
+ await record({ event: "events.resent", label: f.event, status: error ?? "ok", level: error ? "warn" : "ok", origin: "staff" });
160
+ if (error) return false;
161
+ await s.failedEvents.delete(id);
162
+ return true;
163
+ }
164
+
59
165
  /** Send member.levels_changed when a mirror write changed the member's levels. */
60
166
  export async function levelChange(
61
167
  config: RedeemIDConfig | null,
62
168
  beforeTags: string[] | null,
63
169
  after: { customerId: string; tags: string[]; status: string },
64
170
  source: string,
65
- fetcher: typeof fetch = fetch,
171
+ fetcher?: typeof fetch,
66
172
  ): Promise<void> {
67
173
  if (!config?.events?.url) return;
68
174
  const { levelsFor } = await import("./access.js");
@@ -73,6 +179,6 @@ export async function levelChange(
73
179
  config,
74
180
  "member.levels_changed",
75
181
  { customerId: after.customerId, levels: now, added: now.filter((l) => !was.includes(l)), removed: was.filter((l) => !now.includes(l)), source },
76
- fetcher,
182
+ fetcher ? { fetcher } : {},
77
183
  );
78
184
  }
package/src/files.ts CHANGED
@@ -115,16 +115,22 @@ export function ruleFor(config: RedeemIDConfig | null, folder: string | null): F
115
115
 
116
116
  /** Decide one download. `key` is the storage key from the file URL. */
117
117
  export async function fileAccess(astro: AstroLike, config: RedeemIDConfig | null, key: string): Promise<FileVerdict> {
118
- if (!config?.files?.length) return { protected: false };
118
+ if (!config?.files?.length && !config?.unavailable) return { protected: false };
119
119
  const locals = astro.locals as { user?: { role?: number } | null; emdash?: { db?: unknown } | null };
120
120
  const db = locals.emdash?.db;
121
121
  // No database to look the file up in: the caller fails closed.
122
122
  if (!db) throw new Error("no database for the file check");
123
123
  const media = await mediaByKey(db, key);
124
+ const role = locals.user?.role ?? 0;
125
+ // Saved settings can't be read: any file in a folder might be protected.
126
+ if (config.unavailable) {
127
+ if (!media?.folder) return { protected: false };
128
+ if (role > SUBSCRIBER) return { protected: true, allowed: true };
129
+ return { protected: true, allowed: false, reason: (await viewer(astro)).signedIn ? "denied" : "sign_in" };
130
+ }
124
131
  const rule = ruleFor(config, media?.folder ?? null);
125
132
  if (!media || !rule) return { protected: false };
126
133
 
127
- const role = locals.user?.role ?? 0;
128
134
  if (role > SUBSCRIBER) return { protected: true, allowed: true };
129
135
  const v = await viewer(astro);
130
136
  if (!v.signedIn) return { protected: true, allowed: false, reason: "sign_in" };
package/src/guard.ts CHANGED
@@ -87,7 +87,7 @@ export const onRequest = defineMiddleware(async (context, next) => {
87
87
  const config = await siteConfig(locals);
88
88
 
89
89
  // Protected files: checked for everyone, on every download.
90
- const keys = config?.files?.length ? keysFromUrl(url) : null;
90
+ const keys = config?.files?.length || config?.unavailable ? keysFromUrl(url) : null;
91
91
  if (keys) {
92
92
  let verdict;
93
93
  try {
package/src/index.ts CHANGED
@@ -28,11 +28,16 @@
28
28
  * (the client secret of the Shopify app whose webhooks the site receives).
29
29
  */
30
30
 
31
+ import { readdirSync, readFileSync, type Dirent } from "node:fs";
32
+ import { join, relative } from "node:path";
33
+ import { fileURLToPath } from "node:url";
34
+
31
35
  import type { AstroIntegration } from "astro";
32
36
  import type { AuthProviderDescriptor } from "emdash";
33
37
 
34
38
  import { PROVIDER_ID, type RedeemIDConfig } from "./config.js";
35
39
  import { STORAGE_CONFIG } from "./storage.js";
40
+ import { scanTemplate, type TemplateFinding } from "./template-scan.js";
36
41
 
37
42
  export type { LevelConfig, RedeemIDConfig, RuleConfig } from "./config.js";
38
43
  export type { MemberRecord } from "./storage.js";
@@ -77,7 +82,11 @@ export function redeemidGuard(options: { rateLimit?: boolean } = {}): AstroInteg
77
82
  return {
78
83
  name: "@redeemid/emdash/guard",
79
84
  hooks: {
80
- "astro:config:setup": ({ addMiddleware }) => {
85
+ "astro:config:setup": ({ addMiddleware, updateConfig, config }) => {
86
+ // Finds templates that render a body with EmDash's <PortableText>
87
+ // instead of <Content>, for the Status page.
88
+ updateConfig({ vite: { plugins: [templateScan(config.root, config.srcDir)] } });
89
+ addMiddleware({ entrypoint: "@redeemid/emdash/templates.ts", order: "pre" });
81
90
  // Cloudflare Workers only: limits Sign in, Buy and cart changes per
82
91
  // visitor with the REDEEMID_RATE_LIMIT Rate Limiting binding.
83
92
  if (options.rateLimit) addMiddleware({ entrypoint: "@redeemid/emdash/rate-limit-cloudflare.ts", order: "pre" });
@@ -87,4 +96,41 @@ export function redeemidGuard(options: { rateLimit?: boolean } = {}): AstroInteg
87
96
  };
88
97
  }
89
98
 
99
+ const TEMPLATES_MARK = "const FINDINGS: TemplateFinding[] | null = null; // redeemid:templates";
100
+
101
+ /** Vite plugin: fills templates.ts with the scan of the site's .astro files. */
102
+ function templateScan(root: URL, srcDir: URL) {
103
+ return {
104
+ name: "redeemid:templates",
105
+ // Before TypeScript is stripped, so the marker line is still there.
106
+ enforce: "pre" as const,
107
+ transform(code: string, id: string) {
108
+ if (!id.split("?")[0]!.endsWith("/templates.ts") || !code.includes(TEMPLATES_MARK)) return undefined;
109
+ const base = fileURLToPath(root);
110
+ const out: TemplateFinding[] = [];
111
+ // Static node: imports only: astro.config's module runner is closed by
112
+ // the time this runs, so a dynamic import() here fails.
113
+ const walk = (dir: string): void => {
114
+ let items: Dirent[] = [];
115
+ try {
116
+ items = readdirSync(dir, { withFileTypes: true });
117
+ } catch {
118
+ return;
119
+ }
120
+ for (const d of items) {
121
+ const path = join(dir, d.name);
122
+ if (d.isDirectory() && d.name !== "node_modules") walk(path);
123
+ else if (d.isFile() && d.name.endsWith(".astro")) {
124
+ const found = scanTemplate(relative(base, path).split("\\").join("/"), readFileSync(path, "utf8"));
125
+ if (found) out.push(found);
126
+ }
127
+ }
128
+ };
129
+ walk(fileURLToPath(srcDir));
130
+ out.sort((a, b) => a.file.localeCompare(b.file));
131
+ return { code: code.replace(TEMPLATES_MARK, `const FINDINGS = ${JSON.stringify(out)};`), map: null };
132
+ },
133
+ };
134
+ }
135
+
90
136
  export default redeemid;
package/src/log.ts CHANGED
@@ -45,6 +45,7 @@ export const LOG_EVENTS: Record<string, string> = {
45
45
  "staff.webhooks": "Webhook registration",
46
46
  "settings.saved": "Settings saved",
47
47
  "events.failed": "Outbound event failed",
48
+ "events.resent": "Outbound event sent again",
48
49
  "health.check": "Health check",
49
50
  "meter.used": "Metered read",
50
51
  "meter.exhausted": "Metered read, meter used up",
@@ -88,13 +89,15 @@ export async function timed<T>(event: string, label: string, run: () => Promise<
88
89
  }
89
90
  }
90
91
 
92
+ type Where<T> = NonNullable<NonNullable<Parameters<StorageCollection<T>["query"]>[0]>["where"]>;
93
+
91
94
  /** Delete entries (and other dated records) older than `days`, a page at a time. */
92
- export async function prune<T>(store: StorageCollection<T>, field: string, days = LOG_DAYS): Promise<number> {
95
+ export async function prune<T>(store: StorageCollection<T>, field: string, days = LOG_DAYS, where: Record<string, string | number | boolean> = {}): Promise<number> {
93
96
  const before = new Date(Date.now() - days * 86_400_000).toISOString();
94
97
  let gone = 0;
95
98
  // A few pages a run: enough to keep up with a busy site, few enough queries for one request.
96
99
  for (let page = 0; page < PRUNE_PAGES; page++) {
97
- const { items } = await store.query({ where: { [field]: { lt: before } }, limit: 100 });
100
+ const { items } = await store.query({ where: { ...where, [field]: { lt: before } } as Where<T>, limit: 100 });
98
101
  if (items.length === 0) break;
99
102
  gone += await store.deleteMany(items.map((i) => i.id));
100
103
  if (items.length < 100) break;
package/src/monitor.ts CHANGED
@@ -16,6 +16,7 @@ import { levelsFor, membership } from "./access.js";
16
16
  import { adminConfigured, registerWebhooks } from "./admin-api.js";
17
17
  import { env, ENV, type RedeemIDConfig } from "./config.js";
18
18
  import { LOG_DAYS, LOG_EVENTS, prune, record, type LogEntry } from "./log.js";
19
+ import { RETRY_DELAYS_MS, type FailedEvent } from "./events.js";
19
20
  import type { DeliveryRecord, MemberRecord, Stores } from "./storage.js";
20
21
  import { webhooksSection } from "./staff.js";
21
22
 
@@ -181,10 +182,15 @@ async function alert(env_: MonitorEnv, settings: MonitorSettings, result: Health
181
182
  }
182
183
  }
183
184
 
184
- /** Drop app events and delivery records older than LOG_DAYS, a page of each. */
185
+ /** Finished privacy requests are kept this long, as the record that they were answered. Open ones stay until staff close them. */
186
+ export const REQUEST_DAYS = 365;
187
+
188
+ /** Drop app events and delivery records older than LOG_DAYS, and finished privacy requests older than REQUEST_DAYS. */
185
189
  export async function tidy(s: Stores): Promise<void> {
186
190
  await prune(s.log, "at").catch(() => 0);
187
191
  await prune(s.deliveries, "receivedAt").catch(() => 0);
192
+ await prune(s.failedEvents, "at").catch(() => 0);
193
+ await prune(s.requests, "at", REQUEST_DAYS, { status: "done" }).catch(() => 0);
188
194
  }
189
195
 
190
196
  /** WEBHOOK_TOPICS name → the topic Shopify shows: CUSTOMER_TAGS_ADDED → customer.tags_added. */
@@ -379,6 +385,7 @@ export async function eventsPage(s: Stores, filter: EventFilter = {}, cursor?: s
379
385
  if (filter.event && filter.event in LOG_EVENTS) where.event = filter.event;
380
386
  if (filter.problems) where.level = { in: ["warn", "error"] };
381
387
  const page = await s.log.query({ where: where as never, orderBy: { at: "desc" }, limit: 50, ...(cursor ? { cursor } : {}) });
388
+ const failed = cursor ? null : await s.failedEvents.query({ orderBy: { at: "desc" }, limit: 50 }).catch(() => null);
382
389
  return {
383
390
  blocks: [
384
391
  { type: "header", text: "App events" },
@@ -386,6 +393,7 @@ export async function eventsPage(s: Stores, filter: EventFilter = {}, cursor?: s
386
393
  type: "context",
387
394
  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
395
  },
396
+ ...(failed?.items.length ? failedBlocks(failed.items.map((r) => r.data)) : []),
389
397
  {
390
398
  type: "form",
391
399
  block_id: "events_filter",
@@ -424,6 +432,60 @@ export async function eventsPage(s: Stores, filter: EventFilter = {}, cursor?: s
424
432
  };
425
433
  }
426
434
 
435
+ /** Outbound events that failed every try: send again or clear. */
436
+ function failedBlocks(list: FailedEvent[]): unknown[] {
437
+ return [
438
+ { type: "header", text: "Failed events" },
439
+ {
440
+ type: "context",
441
+ text: `Outbound events your receiver didn't take after ${RETRY_DELAYS_MS.length + 1} tries. Send again keeps each one's delivery id, so a receiver that got it after all can drop the copy. Cleared after ${LOG_DAYS} days.`,
442
+ },
443
+ {
444
+ type: "table",
445
+ page_action_id: "failed_page",
446
+ columns: [
447
+ { key: "event", label: "Event" },
448
+ { key: "customer", label: "Customer" },
449
+ { key: "error", label: "Last error" },
450
+ { key: "tries", label: "Tries" },
451
+ { key: "at", label: "When", format: "relative_time" },
452
+ { key: "resend", label: "", format: "element" },
453
+ { key: "clear", label: "", format: "element" },
454
+ ],
455
+ rows: list.map((f) => ({
456
+ event: f.event,
457
+ customer: customerOfBody(f.body),
458
+ error: f.error,
459
+ tries: String(f.attempts),
460
+ at: f.at,
461
+ resend: { type: "button", action_id: "failed_resend", label: "Send again", style: "primary", value: f.id },
462
+ clear: { type: "button", action_id: "failed_clear", label: "Clear", style: "secondary", value: f.id },
463
+ })),
464
+ },
465
+ {
466
+ type: "actions",
467
+ elements: [
468
+ {
469
+ type: "button",
470
+ action_id: "failed_clear_all",
471
+ label: "Clear all",
472
+ style: "danger",
473
+ confirm: { title: "Clear every failed event?", text: "They won't be sent. App events keeps the record that they failed.", confirm: "Clear all", deny: "Cancel", style: "danger" },
474
+ },
475
+ ],
476
+ },
477
+ ];
478
+ }
479
+
480
+ function customerOfBody(body: string): string {
481
+ try {
482
+ const id = (JSON.parse(body) as { data?: { customerId?: unknown } }).data?.customerId;
483
+ return typeof id === "string" ? id : "";
484
+ } catch {
485
+ return "";
486
+ }
487
+ }
488
+
427
489
  function eventRow(e: LogEntry) {
428
490
  const status = e.status === "ok" ? "OK" : cap(e.status);
429
491
  return {