@redeemid/emdash 0.0.8 → 0.0.10

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 (41) hide show
  1. package/README.md +127 -2
  2. package/docs/shopify-setup.md +5 -1
  3. package/package.json +5 -1
  4. package/src/access.ts +9 -3
  5. package/src/admin-api.ts +191 -0
  6. package/src/blocks/ContributionBlock.astro +1 -1
  7. package/src/blocks/PlanBlock.astro +1 -1
  8. package/src/blocks/PreferencesBlock.astro +12 -0
  9. package/src/blocks/index.ts +2 -0
  10. package/src/blocks-names.ts +1 -0
  11. package/src/components/AccessBadge.astro +4 -3
  12. package/src/components/Account.astro +5 -4
  13. package/src/components/Cart.astro +4 -1
  14. package/src/components/CartView.astro +14 -13
  15. package/src/components/Collection.astro +2 -1
  16. package/src/components/Content.astro +10 -4
  17. package/src/components/Contribution.astro +4 -3
  18. package/src/components/Feature.astro +20 -0
  19. package/src/components/LockedPanel.astro +10 -8
  20. package/src/components/MemberCard.astro +22 -13
  21. package/src/components/Notices.astro +10 -2
  22. package/src/components/Plan.astro +3 -2
  23. package/src/components/Preferences.astro +102 -0
  24. package/src/components/Product.astro +6 -5
  25. package/src/config.ts +53 -0
  26. package/src/descriptor.ts +1 -0
  27. package/src/events.ts +75 -0
  28. package/src/features.ts +39 -0
  29. package/src/files.ts +191 -0
  30. package/src/guard.ts +43 -2
  31. package/src/index.ts +1 -0
  32. package/src/plugin.ts +142 -3
  33. package/src/preferences.ts +298 -0
  34. package/src/routes/callback.ts +8 -1
  35. package/src/routes/preferences.ts +82 -0
  36. package/src/routes/webhook.ts +5 -3
  37. package/src/staff.ts +220 -0
  38. package/src/storage.ts +3 -1
  39. package/src/text.ts +174 -0
  40. package/src/version.ts +1 -1
  41. package/src/webhooks.ts +11 -2
@@ -0,0 +1,298 @@
1
+ /**
2
+ * Member preferences: customer metafields members may see or change, such as
3
+ * the topics they follow. Shopify defines each field (Settings → Custom data
4
+ * → Customers); `preferences` in the config picks which ones members get, and
5
+ * whether they may change them. Answers live on the Shopify customer, where
6
+ * Flow, segments and email can use them. The site keeps no copy.
7
+ *
8
+ * Reading and saving use the Admin API app (the staff tools' client ID), and
9
+ * always on the signed-in member's own customer. Preferences never open
10
+ * content: access still comes only from tags.
11
+ */
12
+
13
+ import { admin, adminConfigured, AdminError } from "./admin-api.js";
14
+ import type { PreferenceConfig, RedeemIDConfig } from "./config.js";
15
+ import type { MemberRecord } from "./storage.js";
16
+
17
+ /** Metafield types members can use, and the value each takes. */
18
+ export const PREFERENCE_TYPES = [
19
+ "single_line_text_field",
20
+ "multi_line_text_field",
21
+ "list.single_line_text_field",
22
+ "boolean",
23
+ "number_integer",
24
+ "number_decimal",
25
+ "date",
26
+ ] as const;
27
+ export type PreferenceType = (typeof PREFERENCE_TYPES)[number];
28
+ export type PreferenceValue = string | string[] | boolean | number | null;
29
+
30
+ export interface PreferenceField {
31
+ /** "namespace.key" */
32
+ key: string;
33
+ name: string;
34
+ description: string | null;
35
+ type: PreferenceType;
36
+ choices: string[] | null;
37
+ /** Length for text, value for numbers and dates. */
38
+ min: string | null;
39
+ max: string | null;
40
+ editable: boolean;
41
+ value: PreferenceValue;
42
+ }
43
+
44
+ export type Refusal = "unknown_field" | "read_only" | "invalid_type" | "invalid_choice" | "invalid_length" | "invalid_range";
45
+
46
+ export class PreferenceError extends Error {
47
+ constructor(
48
+ message: string,
49
+ readonly fields: Record<string, Refusal> = {},
50
+ ) {
51
+ super(message);
52
+ }
53
+ }
54
+
55
+ /** Saves allowed per member per minute. */
56
+ export const SAVES_PER_MINUTE = 10;
57
+ const DEFINITIONS_TTL_MS = 5 * 60 * 1000;
58
+ /** RedeemID reads membership dates from this namespace, so members never get it. */
59
+ const RESERVED_NAMESPACE = "membership";
60
+
61
+ type Definition = Omit<PreferenceField, "editable" | "value">;
62
+ let cache: { at: number; shop: string; defs: Definition[] } | null = null;
63
+
64
+ export function resetPreferenceCache(): void {
65
+ cache = null;
66
+ }
67
+
68
+ /** Can preferences run at all? */
69
+ export function preferencesConfigured(config: RedeemIDConfig | null): boolean {
70
+ return Boolean(config?.preferences?.length && adminConfigured());
71
+ }
72
+
73
+ function wanted(config: RedeemIDConfig): PreferenceConfig[] {
74
+ return (config.preferences ?? []).filter((p) => /^[\w-]+\.[\w-]+$/.test(p.key) && !p.key.startsWith(`${RESERVED_NAMESPACE}.`));
75
+ }
76
+
77
+ /** The customer metafield definitions Shopify has for the configured keys, cached for five minutes. */
78
+ export async function definitions(config: RedeemIDConfig, fetcher: typeof fetch = fetch): Promise<Definition[]> {
79
+ const shop = config.shop ?? "";
80
+ if (cache && cache.shop === shop && Date.now() - cache.at < DEFINITIONS_TTL_MS) return cache.defs;
81
+ type Node = { namespace: string; key: string; name: string; description: string | null; type: { name: string }; validations: Array<{ name: string; value: string | null }> };
82
+ const data = await admin<{ metafieldDefinitions: { nodes: Node[] } }>(
83
+ config,
84
+ `query RedeemIDPreferenceDefinitions { metafieldDefinitions(ownerType: CUSTOMER, first: 250) { nodes { namespace key name description type { name } validations { name value } } } }`,
85
+ {},
86
+ fetcher,
87
+ );
88
+ const defs = data.metafieldDefinitions.nodes
89
+ .filter((n) => (PREFERENCE_TYPES as readonly string[]).includes(n.type.name))
90
+ .map((n) => {
91
+ const v = (name: string) => n.validations.find((x) => x.name === name)?.value ?? null;
92
+ let choices: string[] | null = null;
93
+ try {
94
+ const raw = v("choices");
95
+ const parsed = raw ? (JSON.parse(raw) as unknown) : null;
96
+ if (Array.isArray(parsed)) choices = parsed.map(String);
97
+ } catch {
98
+ choices = null;
99
+ }
100
+ return {
101
+ key: `${n.namespace}.${n.key}`,
102
+ name: n.name,
103
+ description: n.description || null,
104
+ type: n.type.name as PreferenceType,
105
+ choices,
106
+ // Length for text types, value for numbers and dates.
107
+ min: v("min") || null,
108
+ max: v("max") || null,
109
+ };
110
+ });
111
+ cache = { at: Date.now(), shop, defs };
112
+ return defs;
113
+ }
114
+
115
+ /** Turn a stored metafield value into what members see. */
116
+ export function decode(type: PreferenceType, raw: string | null | undefined): PreferenceValue {
117
+ if (raw == null || raw === "") return type.startsWith("list.") ? [] : null;
118
+ switch (type) {
119
+ case "list.single_line_text_field":
120
+ try {
121
+ const v = JSON.parse(raw) as unknown;
122
+ return Array.isArray(v) ? v.map(String) : [];
123
+ } catch {
124
+ return [];
125
+ }
126
+ case "boolean":
127
+ return raw === "true";
128
+ case "number_integer":
129
+ return Number.parseInt(raw, 10);
130
+ default:
131
+ return raw;
132
+ }
133
+ }
134
+
135
+ const isEmpty = (v: unknown) => v === null || v === undefined || v === "" || (Array.isArray(v) && v.length === 0);
136
+
137
+ /**
138
+ * Check one value against its definition. Returns the metafield value to
139
+ * save, null to clear the field, or a refusal.
140
+ */
141
+ export function encode(def: Definition, input: unknown): { value: string | null } | { refused: Refusal } {
142
+ if (isEmpty(input)) return { value: null };
143
+ const len = (s: string) => {
144
+ if (def.min !== null && s.length < Number(def.min)) return false;
145
+ if (def.max !== null && s.length > Number(def.max)) return false;
146
+ return true;
147
+ };
148
+ const choice = (s: string) => !def.choices || def.choices.includes(s);
149
+ switch (def.type) {
150
+ case "single_line_text_field":
151
+ case "multi_line_text_field": {
152
+ if (typeof input !== "string") return { refused: "invalid_type" };
153
+ const s = def.type === "single_line_text_field" ? input.trim() : input;
154
+ if (def.type === "single_line_text_field" && /[\r\n]/.test(s)) return { refused: "invalid_type" };
155
+ if (!choice(s)) return { refused: "invalid_choice" };
156
+ if (!len(s)) return { refused: "invalid_length" };
157
+ return { value: s };
158
+ }
159
+ case "list.single_line_text_field": {
160
+ const list = typeof input === "string" ? [input] : input;
161
+ if (!Array.isArray(list) || list.some((x) => typeof x !== "string")) return { refused: "invalid_type" };
162
+ const items = [...new Set((list as string[]).map((x) => x.trim()).filter(Boolean))];
163
+ if (items.some((x) => /[\r\n]/.test(x))) return { refused: "invalid_type" };
164
+ if (!items.every(choice)) return { refused: "invalid_choice" };
165
+ if (!items.every(len)) return { refused: "invalid_length" };
166
+ return { value: items.length ? JSON.stringify(items) : null };
167
+ }
168
+ case "boolean": {
169
+ const b = input === true || input === "true" || input === "on" ? true : input === false || input === "false" ? false : null;
170
+ if (b === null) return { refused: "invalid_type" };
171
+ return { value: String(b) };
172
+ }
173
+ case "number_integer":
174
+ case "number_decimal": {
175
+ const s = typeof input === "number" ? String(input) : typeof input === "string" ? input.trim() : "";
176
+ const ok = def.type === "number_integer" ? /^-?\d+$/.test(s) : /^-?\d+(\.\d+)?$/.test(s);
177
+ if (!ok) return { refused: "invalid_type" };
178
+ const n = Number(s);
179
+ if ((def.min !== null && n < Number(def.min)) || (def.max !== null && n > Number(def.max))) return { refused: "invalid_range" };
180
+ return { value: s };
181
+ }
182
+ case "date": {
183
+ const s = typeof input === "string" ? input.trim() : "";
184
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(s) || Number.isNaN(Date.parse(`${s}T00:00:00Z`))) return { refused: "invalid_type" };
185
+ if ((def.min !== null && s < def.min.slice(0, 10)) || (def.max !== null && s > def.max.slice(0, 10))) return { refused: "invalid_range" };
186
+ return { value: s };
187
+ }
188
+ }
189
+ }
190
+
191
+ /** The fields members can use, in config order, with their access. Without values. */
192
+ export async function preferenceFields(config: RedeemIDConfig, fetcher: typeof fetch = fetch): Promise<Array<Omit<PreferenceField, "value">>> {
193
+ const defs = await definitions(config, fetcher);
194
+ return wanted(config).flatMap((p) => {
195
+ const def = defs.find((d) => d.key === p.key);
196
+ return def ? [{ ...def, editable: p.access !== "view" }] : [];
197
+ });
198
+ }
199
+
200
+ const gid = (id: string) => `gid://shopify/Customer/${id}`;
201
+
202
+ /** The member's fields with their current values, read from Shopify. */
203
+ export async function readPreferences(config: RedeemIDConfig, customerId: string, fetcher: typeof fetch = fetch): Promise<PreferenceField[]> {
204
+ const fields = await preferenceFields(config, fetcher);
205
+ if (fields.length === 0) return [];
206
+ type Node = { namespace: string; key: string; value: string };
207
+ const data = await admin<{ customer: { metafields: { nodes: Node[] } } | null }>(
208
+ config,
209
+ `query RedeemIDPreferences($id: ID!, $keys: [String!]) { customer(id: $id) { metafields(first: 100, keys: $keys) { nodes { namespace key value } } } }`,
210
+ { id: gid(customerId), keys: fields.map((f) => f.key) },
211
+ fetcher,
212
+ );
213
+ if (!data.customer) throw new AdminError("Shopify has no such customer.");
214
+ const values = new Map(data.customer.metafields.nodes.map((n) => [`${n.namespace}.${n.key}`, n.value]));
215
+ return fields.map((f) => ({ ...f, value: decode(f.type, values.get(f.key)) }));
216
+ }
217
+
218
+ /** Room for one more save this minute? Records it when there is. */
219
+ export function allowSave(member: Pick<MemberRecord, "prefSaves">, now = Date.now()): number[] | null {
220
+ const recent = (member.prefSaves ?? []).filter((t) => now - t < 60_000);
221
+ if (recent.length >= SAVES_PER_MINUTE) return null;
222
+ return [...recent, now];
223
+ }
224
+
225
+ /**
226
+ * Save some of the member's fields to their Shopify customer, then read them
227
+ * back. Every value is checked first; one refusal saves nothing. Keys left
228
+ * out are left alone; an empty value clears the field.
229
+ */
230
+ export async function savePreferences(
231
+ config: RedeemIDConfig,
232
+ customerId: string,
233
+ values: Record<string, unknown>,
234
+ fetcher: typeof fetch = fetch,
235
+ ): Promise<{ fields: PreferenceField[]; changed: string[] }> {
236
+ const fields = await preferenceFields(config, fetcher);
237
+ const refused: Record<string, Refusal> = {};
238
+ const set: Array<{ namespace: string; key: string; type: string; value: string }> = [];
239
+ const clear: Array<{ namespace: string; key: string }> = [];
240
+ for (const [key, input] of Object.entries(values)) {
241
+ const f = fields.find((x) => x.key === key);
242
+ if (!f) {
243
+ refused[key] = "unknown_field";
244
+ continue;
245
+ }
246
+ if (!f.editable) {
247
+ refused[key] = "read_only";
248
+ continue;
249
+ }
250
+ const out = encode(f, input);
251
+ if ("refused" in out) {
252
+ refused[key] = out.refused;
253
+ continue;
254
+ }
255
+ const [namespace = "", k = ""] = key.split(".");
256
+ if (out.value === null) clear.push({ namespace, key: k });
257
+ else set.push({ namespace, key: k, type: f.type, value: out.value });
258
+ }
259
+ if (Object.keys(refused).length) throw new PreferenceError("Some answers weren't saved.", refused);
260
+
261
+ const ownerId = gid(customerId);
262
+ if (set.length) {
263
+ const data = await admin<{ metafieldsSet: { userErrors: Array<{ field?: string[] | null; message: string }> } }>(
264
+ config,
265
+ `mutation RedeemIDSavePreferences($metafields: [MetafieldsSetInput!]!) { metafieldsSet(metafields: $metafields) { userErrors { field message } } }`,
266
+ { metafields: set.map((m) => ({ ownerId, ...m })) },
267
+ fetcher,
268
+ );
269
+ const err = data.metafieldsSet.userErrors[0];
270
+ if (err) throw new AdminError(err.message);
271
+ }
272
+ if (clear.length) {
273
+ const data = await admin<{ metafieldsDelete: { userErrors: Array<{ message: string }> } }>(
274
+ config,
275
+ `mutation RedeemIDClearPreferences($metafields: [MetafieldIdentifierInput!]!) { metafieldsDelete(metafields: $metafields) { userErrors { field message } } }`,
276
+ { metafields: clear.map((m) => ({ ownerId, ...m })) },
277
+ fetcher,
278
+ );
279
+ const err = data.metafieldsDelete.userErrors[0];
280
+ if (err) throw new AdminError(err.message);
281
+ }
282
+ const changed = [...set, ...clear].map((m) => `${m.namespace}.${m.key}`);
283
+ return { fields: await readPreferences(config, customerId, fetcher), changed };
284
+ }
285
+
286
+ /** Read a posted form into values: `pref:<key>` fields, with `present:<key>` marking checkboxes and lists that may be empty. */
287
+ export function formValues(form: FormData, fields: Array<Pick<PreferenceField, "key" | "type" | "editable">>): Record<string, unknown> {
288
+ const out: Record<string, unknown> = {};
289
+ for (const f of fields) {
290
+ if (!f.editable || !form.has(`present:${f.key}`)) continue;
291
+ const all = form.getAll(`pref:${f.key}`).map(String);
292
+ // A list without choices comes from a textarea: one answer per line.
293
+ if (f.type === "list.single_line_text_field") out[f.key] = all.flatMap((v) => v.split(/\r?\n/));
294
+ else if (f.type === "boolean") out[f.key] = all.includes("true");
295
+ else out[f.key] = all[0] ?? "";
296
+ }
297
+ return out;
298
+ }
@@ -16,6 +16,8 @@ import { OptionsRepository } from "emdash/api/route-utils";
16
16
  import type { Kysely } from "kysely";
17
17
 
18
18
  import { env, ENV, PROVIDER_ID, readConfig, storeDomain } from "../config.js";
19
+ import { emit, levelChange } from "../events.js";
20
+ import { levelsFor } from "../access.js";
19
21
  import { discover, exchangeCode, identity, ShopifyError, verifyIdToken } from "../shopify.js";
20
22
  import { emptyMember, stampTags, stores } from "../storage.js";
21
23
  import { callbackUrl, fail, FLOW_TTL_MS, SESSION_FLOW, SESSION_ID_TOKEN, type Flow } from "./shared.js";
@@ -86,7 +88,8 @@ export const GET: APIRoute = async ({ request, locals, session, redirect }) => {
86
88
  if (user.role > Role.SUBSCRIBER) return fail(redirect, returnTo, "staff_account");
87
89
 
88
90
  const s = stores(emdash.db);
89
- const member = (await s.members.get(who.customerId)) ?? emptyMember(who.customerId);
91
+ const found = await s.members.get(who.customerId);
92
+ const member = found ?? emptyMember(who.customerId);
90
93
  const now = new Date().toISOString();
91
94
  await s.members.put(
92
95
  who.customerId,
@@ -106,6 +109,10 @@ export const GET: APIRoute = async ({ request, locals, session, redirect }) => {
106
109
  ),
107
110
  );
108
111
 
112
+ const levels = levelsFor(who.tags, config.levels);
113
+ await emit(config, "member.signed_in", { customerId: who.customerId, levels, first: !member.linkedAt, refresh: Boolean(flow.refresh) });
114
+ await levelChange(config, found ? found.tags : null, { customerId: who.customerId, tags: who.tags, status: member.status }, "sign-in");
115
+
109
116
  session.regenerate?.();
110
117
  session.set("user", { id: user.id });
111
118
  session.set(SESSION_ID_TOKEN, tokens.idToken);
@@ -0,0 +1,82 @@
1
+ /**
2
+ * /_emdash/api/auth/redeemid/preferences: the signed-in member's preferences.
3
+ *
4
+ * GET answers JSON: { fields } with values, for the member's own customer.
5
+ * POST takes JSON { values: { "namespace.key": value } } and answers JSON,
6
+ * or a form (from <Preferences>) and goes back to `return` with the notice
7
+ * preferences_saved, preferences_refused or preferences_unavailable.
8
+ * The customer always comes from the session, never from the request.
9
+ * Refused JSON saves answer 400 with { fields: { key: reason } }; more than
10
+ * 10 saves a minute answer 429.
11
+ */
12
+
13
+ import type { APIRoute } from "astro";
14
+
15
+ import { safeReturn, signInUrl } from "../access.js";
16
+ import { readConfig } from "../config.js";
17
+ import { emit } from "../events.js";
18
+ import { allowSave, formValues, PreferenceError, preferenceFields, preferencesConfigured, readPreferences, savePreferences } from "../preferences.js";
19
+ import { memberForUser, stores } from "../storage.js";
20
+ import { fail } from "./shared.js";
21
+
22
+ export const prerender = false;
23
+
24
+ const json = (status: number, body: unknown) =>
25
+ new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json", "Cache-Control": "private, no-store" } });
26
+
27
+ async function context(locals: unknown, session: { get(k: string): Promise<unknown> } | undefined) {
28
+ const emdash = (locals as { emdash?: { config?: unknown; db?: unknown } }).emdash;
29
+ const config = readConfig(emdash?.config);
30
+ if (!config || !preferencesConfigured(config) || !emdash?.db) return { error: "unavailable" as const };
31
+ const user = (await session?.get("user")) as { id?: string } | undefined;
32
+ if (!user?.id) return { error: "sign_in" as const, config };
33
+ const s = stores(emdash.db);
34
+ const member = await memberForUser(s, user.id);
35
+ if (!member) return { error: "sign_in" as const, config };
36
+ if (member.status === "disabled") return { error: "disabled" as const, config };
37
+ return { config, s, member };
38
+ }
39
+
40
+ export const GET: APIRoute = async ({ locals, session }) => {
41
+ const c = await context(locals, session);
42
+ if ("error" in c) return json(c.error === "unavailable" ? 404 : 401, { error: c.error });
43
+ try {
44
+ return json(200, { fields: await readPreferences(c.config, c.member.customerId) });
45
+ } catch (e) {
46
+ console.error("[redeemid] preferences not read:", (e as Error).message);
47
+ return json(502, { error: "unavailable" });
48
+ }
49
+ };
50
+
51
+ export const POST: APIRoute = async ({ request, locals, session, redirect }) => {
52
+ const isJson = (request.headers.get("content-type") ?? "").includes("application/json");
53
+ const form = isJson ? null : await request.formData().catch(() => null);
54
+ const returnTo = safeReturn(String(form?.get("return") ?? "/"));
55
+ const c = await context(locals, session);
56
+ if ("error" in c) {
57
+ if (isJson) return json(c.error === "unavailable" ? 404 : 401, { error: c.error });
58
+ return c.error === "sign_in" ? redirect(signInUrl(returnTo)) : fail(redirect, returnTo, "preferences_unavailable");
59
+ }
60
+
61
+ const slots = allowSave(c.member);
62
+ if (!slots) return isJson ? json(429, { error: "too_many_saves" }) : fail(redirect, returnTo, "preferences_unavailable");
63
+
64
+ let values: Record<string, unknown>;
65
+ try {
66
+ if (isJson) {
67
+ const body = (await request.json()) as { values?: unknown };
68
+ values = body.values && typeof body.values === "object" && !Array.isArray(body.values) ? (body.values as Record<string, unknown>) : {};
69
+ } else {
70
+ values = formValues(form ?? new FormData(), await preferenceFields(c.config));
71
+ }
72
+ await c.s.members.put(c.member.customerId, { ...c.member, prefSaves: slots });
73
+ const { fields, changed } = await savePreferences(c.config, c.member.customerId, values);
74
+ // Field names only, never the answers.
75
+ if (changed.length) await emit(c.config, "preferences.saved", { customerId: c.member.customerId, fields: changed });
76
+ return isJson ? json(200, { fields }) : fail(redirect, returnTo, "preferences_saved");
77
+ } catch (e) {
78
+ if (e instanceof PreferenceError) return isJson ? json(400, { error: "refused", fields: e.fields }) : fail(redirect, returnTo, "preferences_refused");
79
+ console.error("[redeemid] preferences not saved:", (e as Error).message);
80
+ return isJson ? json(502, { error: "unavailable" }) : fail(redirect, returnTo, "preferences_unavailable");
81
+ }
82
+ };
@@ -8,7 +8,8 @@
8
8
 
9
9
  import type { APIRoute } from "astro";
10
10
 
11
- import { env, ENV } from "../config.js";
11
+ import { env, ENV, readConfig } from "../config.js";
12
+ import { levelChange } from "../events.js";
12
13
  import { stores } from "../storage.js";
13
14
  import { apply, kindOf, verifyHmac } from "../webhooks.js";
14
15
 
@@ -17,7 +18,7 @@ export const prerender = false;
17
18
  const MAX_BYTES = 1024 * 1024;
18
19
 
19
20
  export const POST: APIRoute = async ({ request, locals }) => {
20
- const emdash = (locals as { emdash?: { db?: unknown } }).emdash;
21
+ const emdash = (locals as { emdash?: { db?: unknown; config?: unknown } }).emdash;
21
22
  const secret = env(ENV.webhookSecret);
22
23
  if (!emdash?.db || !secret) return new Response("Not configured", { status: 503 });
23
24
 
@@ -48,7 +49,8 @@ export const POST: APIRoute = async ({ request, locals }) => {
48
49
  }
49
50
 
50
51
  try {
51
- const outcome = await apply(s, topic, payload);
52
+ const config = readConfig(emdash.config);
53
+ const outcome = await apply(s, topic, payload, (before, after) => levelChange(config, before, after, topic));
52
54
  return new Response(outcome, { status: 200 });
53
55
  } catch (e) {
54
56
  console.error("[redeemid] webhook failed:", topic, (e as Error).message);
package/src/staff.ts ADDED
@@ -0,0 +1,220 @@
1
+ /**
2
+ * Staff tools in the admin (Block Kit): member detail with Resync and tag
3
+ * edits written to Shopify, a paced manual bulk resync, webhook
4
+ * registration and health, recent deliveries and the access test.
5
+ */
6
+
7
+ import { adminConfigured, adminScopes, editTags, registerWebhooks, resync, webhookSubscriptions, WEBHOOK_TOPICS } from "./admin-api.js";
8
+ import { describe, levelsFor, membership, passes, requirement, termTree, type Terms } from "./access.js";
9
+ import type { RedeemIDConfig } from "./config.js";
10
+ import { levelChange } from "./events.js";
11
+ import { featuresFor } from "./features.js";
12
+ import { cleanTags, numericId, type MemberRecord, type Stores } from "./storage.js";
13
+
14
+ /** Members resynced per click; well inside Shopify's query cost limits. */
15
+ export const BULK_BATCH = 50;
16
+
17
+ type Blocks = { blocks: unknown[]; toast?: { message: string; type: "success" | "error" | "info" } };
18
+
19
+ const date = (iso: string | null | undefined) => (iso ? iso.slice(0, 10) : "—");
20
+
21
+ export function memberDetail(config: RedeemIDConfig, m: MemberRecord, toast?: Blocks["toast"]): Blocks {
22
+ const levels = config.levels ?? [];
23
+ const { current, past } = membership(m, levels);
24
+ const features = featuresFor(m, config.features, levels);
25
+ const featureNames = (config.features ?? []).filter((f) => features.includes(f.slug)).map((f) => f.name);
26
+ const name = [m.firstName, m.lastName].filter(Boolean).join(" ") || m.email || `Customer ${m.customerId}`;
27
+ return {
28
+ toast,
29
+ blocks: [
30
+ { type: "actions", elements: [{ type: "button", action_id: "members_back", label: "← All members", style: "secondary" }] },
31
+ { type: "header", text: name },
32
+ {
33
+ type: "fields",
34
+ fields: [
35
+ { label: "Email", value: m.email ?? "—" },
36
+ { label: "Shopify customer", value: m.customerId },
37
+ { label: "Account", value: m.status === "enabled" ? "On" : "Turned off in Shopify" },
38
+ { label: "Signed in here", value: m.userId ? `Yes, first on ${date(m.linkedAt)}` : "Not yet" },
39
+ { label: "Levels", value: current.map((l) => `${l.name} (since ${date(l.since)})`).join(", ") || "—" },
40
+ { label: "Levels before", value: past.map((l) => `${l.name} (${date(l.from)} to ${date(l.to)})`).join(", ") || "—" },
41
+ { label: "Features", value: featureNames.join(", ") || "—" },
42
+ { label: "Last synced", value: `${m.syncedAt} (${m.source || "unknown"})` },
43
+ ],
44
+ },
45
+ { type: "header", text: "Shopify tags" },
46
+ m.tags.length
47
+ ? {
48
+ type: "table",
49
+ page_action_id: "member_tags",
50
+ columns: [
51
+ { key: "tag", label: "Tag" },
52
+ { key: "since", label: "Since" },
53
+ { key: "remove", label: "", format: "element" },
54
+ ],
55
+ rows: m.tags.map((t) => ({
56
+ tag: t,
57
+ since: date(m.tagsSince?.[t.toLowerCase()]),
58
+ remove: adminConfigured()
59
+ ? {
60
+ type: "button",
61
+ action_id: "member_tag_remove",
62
+ label: "Remove in Shopify",
63
+ style: "danger",
64
+ value: { customerId: m.customerId, tag: t },
65
+ confirm: { title: "Remove this tag in Shopify?", text: `Removes "${t}" from the customer in Shopify. Access follows.`, confirm: "Remove", deny: "Cancel", style: "danger" },
66
+ }
67
+ : null,
68
+ })),
69
+ }
70
+ : { type: "context", text: "No tags." },
71
+ adminConfigured()
72
+ ? {
73
+ type: "form",
74
+ block_id: `tags:${m.customerId}`,
75
+ fields: [{ type: "text_input", action_id: "tags", label: "Add tags in Shopify (comma separated)", placeholder: "gold-member" }],
76
+ submit: { label: "Add in Shopify", action_id: "member_tag_add" },
77
+ }
78
+ : { type: "context", text: "Set the Admin API app's client ID to edit tags and resync from here." },
79
+ adminConfigured()
80
+ ? { type: "actions", elements: [{ type: "button", action_id: "member_resync", label: "Resync from Shopify", style: "primary", value: m.customerId }] }
81
+ : { type: "divider" },
82
+ { type: "context", text: "Tags are written to Shopify, then read back. Shopify stays the only source of access." },
83
+ ],
84
+ };
85
+ }
86
+
87
+ /** Resync the members synced longest ago, one batch per click. */
88
+ export async function bulkResync(config: RedeemIDConfig, s: Stores): Promise<{ done: number; missing: number; changed: number }> {
89
+ const page = await s.members.query({ orderBy: { syncedAt: "asc" }, limit: BULK_BATCH });
90
+ const ids = page.items.map((r) => r.data.customerId);
91
+ const before = new Map(page.items.map((r) => [r.data.customerId, [...r.data.tags]]));
92
+ const { updated, missing } = await resync(config, s, ids, "bulk resync");
93
+ let changed = 0;
94
+ for (const m of updated) {
95
+ const was = before.get(m.customerId) ?? [];
96
+ if (was.join("\n") !== m.tags.join("\n")) changed++;
97
+ await levelChange(config, was, m, "bulk resync");
98
+ }
99
+ return { done: updated.length, missing: missing.length, changed };
100
+ }
101
+
102
+ export async function resyncOne(config: RedeemIDConfig, s: Stores, customerId: string, source: string): Promise<MemberRecord | null> {
103
+ const before = await s.members.get(customerId);
104
+ const { updated } = await resync(config, s, [customerId], source);
105
+ const m = updated[0] ?? null;
106
+ if (m) await levelChange(config, before?.tags ?? null, m, source);
107
+ return m;
108
+ }
109
+
110
+ export async function tagEdit(config: RedeemIDConfig, s: Stores, customerId: string, change: "add" | "remove", raw: unknown): Promise<MemberRecord | null> {
111
+ const id = numericId(customerId);
112
+ if (!id) return null;
113
+ await editTags(config, id, change, cleanTags(raw));
114
+ return resyncOne(config, s, id, change === "add" ? "staff added tags" : "staff removed tags");
115
+ }
116
+
117
+ /** Webhooks: what Shopify sends to this site, and what's missing. */
118
+ export async function webhooksSection(config: RedeemIDConfig, uri: string): Promise<unknown[]> {
119
+ if (!adminConfigured()) {
120
+ return [{ type: "context", text: "Set the Admin API app's client ID to check and register webhooks from here. Until then, add them in the app's settings." }];
121
+ }
122
+ try {
123
+ const [subs, scope] = await Promise.all([webhookSubscriptions(config), adminScopes(config)]);
124
+ const rows = WEBHOOK_TOPICS.map((t) => {
125
+ const here = subs.find((s) => s.topic === t && s.uri === uri);
126
+ const elsewhere = subs.find((s) => s.topic === t && s.uri !== uri);
127
+ return { topic: t.toLowerCase().replace("_", "/").replace("customer/tags", "customer.tags"), state: here ? "Registered" : elsewhere ? `Goes to ${elsewhere.uri}` : "Missing" };
128
+ });
129
+ const missing = rows.filter((r) => r.state !== "Registered").length;
130
+ return [
131
+ { type: "fields", fields: [{ label: "Admin API", value: `Connected (${scope || "no scopes reported"})` }, { label: "Webhook address", value: uri }] },
132
+ { type: "table", page_action_id: "webhooks", columns: [{ key: "topic", label: "Topic" }, { key: "state", label: "Sends here", format: "badge" }], rows },
133
+ missing
134
+ ? { type: "actions", elements: [{ type: "button", action_id: "webhooks_register", label: `Register ${missing} missing`, style: "primary", value: uri }] }
135
+ : { type: "context", text: "Every topic RedeemID needs is sent here. customers/redact is set in the app's compliance settings." },
136
+ ];
137
+ } catch (e) {
138
+ return [{ type: "banner", variant: "alert", title: "Couldn't read webhooks from Shopify", description: (e as Error).message }];
139
+ }
140
+ }
141
+
142
+ export async function deliveriesSection(s: Stores): Promise<unknown[]> {
143
+ const { items } = await s.deliveries.query({ orderBy: { receivedAt: "desc" }, limit: 20 });
144
+ return items.length
145
+ ? [
146
+ {
147
+ type: "table",
148
+ page_action_id: "deliveries",
149
+ columns: [
150
+ { key: "topic", label: "Topic", format: "code" },
151
+ { key: "at", label: "Received", format: "relative_time" },
152
+ { key: "id", label: "Delivery" },
153
+ ],
154
+ rows: items.map((r) => ({ topic: r.data.topic, at: r.data.receivedAt, id: r.id })),
155
+ },
156
+ ]
157
+ : [{ type: "context", text: "No webhooks received yet." }];
158
+ }
159
+
160
+ export { registerWebhooks };
161
+
162
+ /** "category:pro, tag:weekend" → { category: ["pro"], tag: ["weekend"] } */
163
+ export function parseTerms(raw: string): Terms {
164
+ const terms: Record<string, string[]> = {};
165
+ for (const part of raw.split(",")) {
166
+ const [tax, term] = part.split(":").map((x) => x.trim());
167
+ if (tax && term) (terms[tax] ??= []).push(term);
168
+ }
169
+ return terms;
170
+ }
171
+
172
+ /** The access test: what an entry needs, and whether a reader with these tags passes. */
173
+ export async function accessTest(config: RedeemIDConfig, values: Record<string, unknown> = {}): Promise<Blocks> {
174
+ const collection = String(values.collection ?? "").trim();
175
+ const termsRaw = String(values.terms ?? "").trim();
176
+ const tags = cleanTags(String(values.tags ?? ""));
177
+ const signedIn = values.signed_in !== false;
178
+ const levels = config.levels ?? [];
179
+ const form = {
180
+ type: "form",
181
+ block_id: "access_test",
182
+ fields: [
183
+ { type: "text_input", action_id: "collection", label: "Collection", placeholder: "posts", initial_value: collection },
184
+ { type: "text_input", action_id: "terms", label: "Terms (taxonomy:slug, comma separated)", placeholder: "category:investigations, tag:free-to-read", initial_value: termsRaw },
185
+ { type: "text_input", action_id: "tags", label: "Reader's Shopify tags (comma separated)", placeholder: "gold-member", initial_value: tags.join(", ") },
186
+ { type: "toggle", action_id: "signed_in", label: "Signed in", initial_value: signedIn },
187
+ ],
188
+ submit: { label: "Test", action_id: "access_test" },
189
+ };
190
+ const out: unknown[] = [
191
+ { type: "header", text: "Access test" },
192
+ { type: "context", text: "Check what an entry needs under the rules, and whether a reader with some Shopify tags may read it. Nothing is saved." },
193
+ form,
194
+ ];
195
+ if (collection) {
196
+ const terms = parseTerms(termsRaw);
197
+ const tree = await termTree(config.rules, collection, terms);
198
+ const required = requirement(config.rules, collection, terms, { combine: config.combine, tree });
199
+ const held = levelsFor(tags, levels);
200
+ const ok = required === null || (signedIn && passes(held, required));
201
+ out.push(
202
+ { type: "divider" },
203
+ {
204
+ type: "fields",
205
+ fields: [
206
+ { label: "Needs", value: describe(required, levels) },
207
+ { label: "Reader's levels", value: levels.filter((l) => held.includes(l.slug)).map((l) => l.name).join(", ") || "None" },
208
+ { label: "Result", value: ok ? "Can read" : signedIn ? "Locked: asked to upgrade" : "Locked: asked to sign in" },
209
+ ],
210
+ },
211
+ {
212
+ type: "banner",
213
+ variant: ok ? "default" : "alert",
214
+ title: ok ? "This reader can read it" : "This reader is kept out",
215
+ description: "A Paywall break with levels picked, a Members only part, drip and the meter can still change what a page shows.",
216
+ },
217
+ );
218
+ }
219
+ return { blocks: out };
220
+ }