@redeemid/emdash 0.0.9 → 0.0.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +138 -2
- package/bin/redeemid-emdash.mjs +144 -0
- package/docs/shopify-setup.md +4 -2
- package/package.json +8 -2
- package/src/access.ts +2 -2
- package/src/agents.ts +179 -0
- package/src/blocks/ContributionBlock.astro +1 -1
- package/src/blocks/PlanBlock.astro +1 -1
- package/src/blocks/PreferencesBlock.astro +12 -0
- package/src/blocks/index.ts +3 -0
- package/src/blocks-names.ts +27 -0
- package/src/components/AccessBadge.astro +10 -6
- package/src/components/Account.astro +5 -4
- package/src/components/Cart.astro +4 -1
- package/src/components/CartView.astro +14 -13
- package/src/components/Collection.astro +2 -1
- package/src/components/Content.astro +25 -10
- package/src/components/Contribution.astro +4 -3
- package/src/components/LockedPanel.astro +35 -9
- package/src/components/MemberCard.astro +15 -14
- package/src/components/Notices.astro +12 -2
- package/src/components/Plan.astro +3 -2
- package/src/components/Preferences.astro +102 -0
- package/src/components/Product.astro +6 -5
- package/src/components/YourData.astro +42 -0
- package/src/config.ts +38 -1
- package/src/descriptor.ts +1 -0
- package/src/events.ts +3 -2
- package/src/files.ts +196 -0
- package/src/guard.ts +43 -2
- package/src/index.ts +2 -0
- package/src/plugin.ts +169 -4
- package/src/preferences.ts +298 -0
- package/src/privacy.ts +148 -0
- package/src/routes/preferences.ts +82 -0
- package/src/routes/privacy.ts +59 -0
- package/src/routes/webhook.ts +1 -1
- package/src/staff.ts +92 -0
- package/src/storage.ts +17 -0
- package/src/text.ts +185 -0
- package/src/version.ts +1 -1
- package/src/webhooks.ts +17 -4
package/src/plugin.ts
CHANGED
|
@@ -30,23 +30,29 @@ import { definePlugin } from "emdash";
|
|
|
30
30
|
import { describe, levelsFor, SIGNED_IN } from "./access.js";
|
|
31
31
|
import { env, ENV, storeDomain, type RedeemIDConfig } from "./config.js";
|
|
32
32
|
import { discover } from "./shopify.js";
|
|
33
|
-
import { stores, type MemberRecord } from "./storage.js";
|
|
33
|
+
import { stores, type MemberRecord, type Stores } from "./storage.js";
|
|
34
34
|
import { PAGES, WIDGETS } from "./descriptor.js";
|
|
35
35
|
import { VERSION } from "./version.js";
|
|
36
|
-
import { adminConfigured } from "./admin-api.js";
|
|
37
|
-
import {
|
|
36
|
+
import { AdminError, adminConfigured } from "./admin-api.js";
|
|
37
|
+
import { cancelShopifyErasure, closeRequest, eraseMember, exportMember, exportRequest, requestShopifyErasure } from "./privacy.js";
|
|
38
|
+
import { accessTest, bulkResync, BULK_BATCH, deliveriesSection, exportBlocks, memberDetail, privacyPage, registerWebhooks, resyncOne, tagEdit, webhooksSection } from "./staff.js";
|
|
39
|
+
import { textProblems } from "./text.js";
|
|
40
|
+
import { z } from "astro/zod";
|
|
41
|
+
import { AgentError, explainAccess, getMember, getRules, levelChanges, listLevels, siteHealth, type ExplainInput } from "./agents.js";
|
|
38
42
|
|
|
39
43
|
import {
|
|
40
44
|
COLLECTION_BLOCK,
|
|
41
45
|
CONTRIBUTION_BLOCK,
|
|
46
|
+
ITEM_BLOCK,
|
|
42
47
|
MEMBERS_BLOCK,
|
|
43
48
|
MEMBERS_END_BLOCK,
|
|
44
49
|
PAYWALL_BLOCK,
|
|
45
50
|
PLAN_BLOCK,
|
|
51
|
+
PREFERENCES_BLOCK,
|
|
46
52
|
PRODUCT_BLOCK,
|
|
47
53
|
} from "./blocks-names.js";
|
|
48
54
|
|
|
49
|
-
export { COLLECTION_BLOCK, CONTRIBUTION_BLOCK, MEMBERS_BLOCK, MEMBERS_END_BLOCK, PAYWALL_BLOCK, PLAN_BLOCK, PRODUCT_BLOCK };
|
|
55
|
+
export { COLLECTION_BLOCK, CONTRIBUTION_BLOCK, ITEM_BLOCK, MEMBERS_BLOCK, MEMBERS_END_BLOCK, PAYWALL_BLOCK, PLAN_BLOCK, PREFERENCES_BLOCK, PRODUCT_BLOCK };
|
|
50
56
|
|
|
51
57
|
function blocks(config: RedeemIDConfig) {
|
|
52
58
|
const levelOptions = [
|
|
@@ -89,6 +95,16 @@ function blocks(config: RedeemIDConfig) {
|
|
|
89
95
|
description: "Ends the members-only part; everyone reads on from here",
|
|
90
96
|
fields: [],
|
|
91
97
|
},
|
|
98
|
+
{
|
|
99
|
+
type: ITEM_BLOCK,
|
|
100
|
+
label: "Sold on its own",
|
|
101
|
+
icon: "tag",
|
|
102
|
+
description: "Buyers of this entry read all of it, whatever its levels say",
|
|
103
|
+
fields: [
|
|
104
|
+
{ type: "text_input" as const, action_id: "tag", label: "Shopify customer tag buyers get", placeholder: "item-big-story" },
|
|
105
|
+
{ type: "text_input" as const, action_id: "product", label: "Shopify product that sells it (handle or link)", placeholder: "the-big-story" },
|
|
106
|
+
],
|
|
107
|
+
},
|
|
92
108
|
{
|
|
93
109
|
type: PLAN_BLOCK,
|
|
94
110
|
label: "Plan",
|
|
@@ -140,6 +156,17 @@ function blocks(config: RedeemIDConfig) {
|
|
|
140
156
|
{ type: "text_input" as const, action_id: "label", label: "Button label", placeholder: "Give" },
|
|
141
157
|
],
|
|
142
158
|
},
|
|
159
|
+
{
|
|
160
|
+
type: PREFERENCES_BLOCK,
|
|
161
|
+
label: "Preferences",
|
|
162
|
+
icon: "users",
|
|
163
|
+
description: "The signed-in member's preferences, saved on their Shopify customer",
|
|
164
|
+
placeholder: "Member preferences",
|
|
165
|
+
fields: [
|
|
166
|
+
{ type: "text_input" as const, action_id: "title", label: "Title", placeholder: "Your preferences" },
|
|
167
|
+
{ type: "text_input" as const, action_id: "label", label: "Button label", placeholder: "Save preferences" },
|
|
168
|
+
],
|
|
169
|
+
},
|
|
143
170
|
];
|
|
144
171
|
}
|
|
145
172
|
|
|
@@ -153,7 +180,9 @@ export function createPlugin(config: RedeemIDConfig = {}): ResolvedPlugin {
|
|
|
153
180
|
widgets: WIDGETS,
|
|
154
181
|
portableTextBlocks: blocks(config),
|
|
155
182
|
},
|
|
183
|
+
mcp: agentTools(config),
|
|
156
184
|
routes: {
|
|
185
|
+
...agentRoutes(config),
|
|
157
186
|
admin: {
|
|
158
187
|
handler: async (ctx: RouteContext) => {
|
|
159
188
|
const i = (ctx.input ?? {}) as { type?: string; page?: string; action_id?: string; value?: unknown; values?: Record<string, unknown>; block_id?: string };
|
|
@@ -163,6 +192,7 @@ export function createPlugin(config: RedeemIDConfig = {}): ResolvedPlugin {
|
|
|
163
192
|
if (i.page === "/") return await membersPage(config);
|
|
164
193
|
if (i.page === "/status") return await statusPage(config, ctx.request);
|
|
165
194
|
if (i.page === "/test") return await accessTest(config);
|
|
195
|
+
if (i.page === "/privacy") return await privacyPage(await memberStores());
|
|
166
196
|
if (i.page === "widget:status") return await statusWidget(config);
|
|
167
197
|
} catch (e) {
|
|
168
198
|
return { blocks: [{ type: "banner", variant: "error", title: "RedeemID couldn't load this page", description: (e as Error).message }] };
|
|
@@ -176,6 +206,85 @@ export function createPlugin(config: RedeemIDConfig = {}): ResolvedPlugin {
|
|
|
176
206
|
|
|
177
207
|
export default createPlugin;
|
|
178
208
|
|
|
209
|
+
// ---------------------------------------------------------------- agents and CLI
|
|
210
|
+
|
|
211
|
+
const STAFF = "users:read" as const;
|
|
212
|
+
|
|
213
|
+
/** Staff-only JSON routes, also offered as MCP tools. */
|
|
214
|
+
function agentRoutes(config: RedeemIDConfig) {
|
|
215
|
+
const run = (fn: (s: Stores, input: Record<string, unknown>) => Promise<unknown> | unknown) => async (ctx: RouteContext) => {
|
|
216
|
+
try {
|
|
217
|
+
return { ok: true, result: await fn(await memberStores(), (ctx.input ?? {}) as Record<string, unknown>) };
|
|
218
|
+
} catch (e) {
|
|
219
|
+
if (e instanceof AgentError || e instanceof AdminError) return { ok: false, error: e.message };
|
|
220
|
+
throw e;
|
|
221
|
+
}
|
|
222
|
+
};
|
|
223
|
+
const route = (input: z.ZodType, fn: Parameters<typeof run>[0]) => ({ permission: STAFF, input, request: { body: "json" as const }, handler: run(fn) });
|
|
224
|
+
const read = {
|
|
225
|
+
"explain-access": route(EXPLAIN, (s, i) => explainAccess(config, s, i as unknown as ExplainInput)),
|
|
226
|
+
"list-levels": route(EMPTY, () => listLevels(config)),
|
|
227
|
+
"get-rules": route(EMPTY, () => getRules(config)),
|
|
228
|
+
"get-member": route(MEMBER, (s, i) => getMember(config, s, i as { customerId?: string; email?: string })),
|
|
229
|
+
"site-health": route(EMPTY, (s) => siteHealth(config, s)),
|
|
230
|
+
"level-changes": route(CHANGES, (s, i) => levelChanges(config, s, i as { since: string; level?: string })),
|
|
231
|
+
};
|
|
232
|
+
if (!config.agents?.actions) return read;
|
|
233
|
+
return {
|
|
234
|
+
...read,
|
|
235
|
+
"resync-member": route(CUSTOMER, async (s, i) => {
|
|
236
|
+
const m = await resyncOne(config, s, String(i.customerId), "agent resync");
|
|
237
|
+
if (!m) throw new AgentError("Shopify no longer has this customer");
|
|
238
|
+
return getMember(config, s, { customerId: m.customerId });
|
|
239
|
+
}),
|
|
240
|
+
"resync-batch": route(EMPTY, (s) => bulkResync(config, s)),
|
|
241
|
+
"edit-member-tags": route(TAGS, async (s, i) => {
|
|
242
|
+
const id = String(i.customerId);
|
|
243
|
+
const add = (i.add as string[] | undefined) ?? [];
|
|
244
|
+
const remove = (i.remove as string[] | undefined) ?? [];
|
|
245
|
+
if (add.length) await tagEdit(config, s, id, "add", add);
|
|
246
|
+
if (remove.length) await tagEdit(config, s, id, "remove", remove);
|
|
247
|
+
return getMember(config, s, { customerId: id });
|
|
248
|
+
}),
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const EMPTY = z.object({});
|
|
253
|
+
const EXPLAIN = z.object({
|
|
254
|
+
collection: z.string().min(1).describe("Collection slug, e.g. posts"),
|
|
255
|
+
id: z.string().optional().describe("An entry's id or slug; its terms and body are read from the site"),
|
|
256
|
+
terms: z.record(z.string(), z.array(z.string())).optional().describe('Terms by taxonomy when no id is given, e.g. {"category":["investigations"]}'),
|
|
257
|
+
tags: z.array(z.string()).optional().describe("The reader's Shopify customer tags"),
|
|
258
|
+
customerId: z.string().optional().describe("A real member's Shopify customer id, in place of tags"),
|
|
259
|
+
signedIn: z.boolean().optional().describe("false for a signed-out visitor (default true)"),
|
|
260
|
+
});
|
|
261
|
+
const MEMBER = z.object({ customerId: z.string().optional(), email: z.string().optional() });
|
|
262
|
+
const CUSTOMER = z.object({ customerId: z.string().min(1).describe("Shopify customer id") });
|
|
263
|
+
const CHANGES = z.object({ since: z.string().describe("A date, e.g. 2026-11-01"), level: z.string().optional().describe("Only this level slug") });
|
|
264
|
+
const TAGS = z.object({
|
|
265
|
+
customerId: z.string().min(1).describe("Shopify customer id"),
|
|
266
|
+
add: z.array(z.string()).optional().describe("Tags to add in Shopify"),
|
|
267
|
+
remove: z.array(z.string()).optional().describe("Tags to remove in Shopify"),
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
/** MCP tools for the routes above. Staff turn them on for RedeemID under Plugins. */
|
|
271
|
+
function agentTools(config: RedeemIDConfig) {
|
|
272
|
+
const tools: Record<string, { description: string; route: string; input: z.ZodType; destructive?: boolean }> = {
|
|
273
|
+
explain_access: { route: "explain-access", input: EXPLAIN, description: "Can this reader read this entry, and why? Give the entry by collection and id (or terms), and the reader by Shopify tags, a member's customer id, or signedIn:false. Answers what the entry needs, the reader's levels, the result, the level a locked reader is offered, and each members-only part." },
|
|
274
|
+
list_levels: { route: "list-levels", input: EMPTY, description: "Every membership level: slug, name, the Shopify tags that grant it (any or all), wall text and join link." },
|
|
275
|
+
get_rules: { route: "get-rules", input: EMPTY, description: "The paywall rules: how they combine, each rule (collection, term, levels, drip, open), the meter, teasers, features and protected-file rules." },
|
|
276
|
+
get_member: { route: "get-member", input: MEMBER, description: "One synced member by Shopify customer id or email: status, tags, current levels with start dates, past levels, features and last sync." },
|
|
277
|
+
site_health: { route: "site-health", input: EMPTY, description: "Whether each setting is in place (never the values), the last webhook, open privacy requests and ignored text replacements." },
|
|
278
|
+
level_changes: { route: "level-changes", input: CHANGES, description: "Who joined or left a level since a date, from each member's tag dates." },
|
|
279
|
+
};
|
|
280
|
+
if (config.agents?.actions) {
|
|
281
|
+
tools.resync_member = { route: "resync-member", input: CUSTOMER, description: "Read one member from Shopify now and update the site. Changes nothing in Shopify." };
|
|
282
|
+
tools.resync_batch = { route: "resync-batch", input: EMPTY, description: "Read the 50 members synced longest ago from Shopify. Run again for the next 50." };
|
|
283
|
+
tools.edit_member_tags = { route: "edit-member-tags", input: TAGS, destructive: true, description: "Add or remove tags on a member's Shopify customer, then read back what Shopify holds. Access follows from Shopify." };
|
|
284
|
+
}
|
|
285
|
+
return { tools };
|
|
286
|
+
}
|
|
287
|
+
|
|
179
288
|
// ---------------------------------------------------------------- actions
|
|
180
289
|
|
|
181
290
|
const webhookUri = (request: Request) => `${new URL(request.url).origin}/_emdash/api/auth/redeemid/webhook`;
|
|
@@ -241,6 +350,41 @@ async function action(
|
|
|
241
350
|
}
|
|
242
351
|
case "access_test":
|
|
243
352
|
return accessTest(config, i.values);
|
|
353
|
+
case "member_export": {
|
|
354
|
+
const id = String(i.value ?? "");
|
|
355
|
+
const m = await s.members.get(id);
|
|
356
|
+
if (!m) return membersPage(config);
|
|
357
|
+
return exportBlocks(memberDetail(config, m, { type: "success", message: "Export below." }), await exportRequest(s, id, "staff"));
|
|
358
|
+
}
|
|
359
|
+
case "member_erase": {
|
|
360
|
+
const id = String(i.value ?? "");
|
|
361
|
+
await eraseMember(config, s, id, "staff");
|
|
362
|
+
return { ...(await membersPage(config)), toast: { type: "success", message: "Erased from the site." } };
|
|
363
|
+
}
|
|
364
|
+
case "member_shopify_erase":
|
|
365
|
+
case "member_shopify_cancel": {
|
|
366
|
+
const id = String(i.value ?? "");
|
|
367
|
+
const m = await s.members.get(id);
|
|
368
|
+
try {
|
|
369
|
+
if (i.action_id === "member_shopify_erase") await requestShopifyErasure(config, s, id);
|
|
370
|
+
else await cancelShopifyErasure(config, s, id);
|
|
371
|
+
const message = i.action_id === "member_shopify_erase" ? "Shopify will erase the customer after its waiting period." : "The Shopify erasure is cancelled.";
|
|
372
|
+
return m ? memberDetail(config, m, { type: "success", message }) : privacyPage(s, { type: "success", message });
|
|
373
|
+
} catch (e) {
|
|
374
|
+
return m ? memberDetail(config, m, fail(e)) : privacyPage(s, fail(e));
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
case "privacy_done": {
|
|
378
|
+
const r = await closeRequest(s, String(i.value ?? ""));
|
|
379
|
+
if (r?.kind === "export" && r.customerId) {
|
|
380
|
+
const data = await exportMember(s, r.customerId);
|
|
381
|
+
return privacyPage(s, { type: "success", message: "Marked done. Send the export below to the store owner." }, [
|
|
382
|
+
{ type: "header", text: `Export for customer ${r.customerId}` },
|
|
383
|
+
{ type: "code", language: "jsonc", code: JSON.stringify(data, null, 2) },
|
|
384
|
+
]);
|
|
385
|
+
}
|
|
386
|
+
return privacyPage(s, { type: "success", message: "Marked done." });
|
|
387
|
+
}
|
|
244
388
|
}
|
|
245
389
|
return { blocks: [] };
|
|
246
390
|
}
|
|
@@ -364,6 +508,13 @@ async function lastDelivery(): Promise<string | null> {
|
|
|
364
508
|
return d ? `${d.topic} at ${d.receivedAt}` : null;
|
|
365
509
|
}
|
|
366
510
|
|
|
511
|
+
function textSummary(config: RedeemIDConfig): string {
|
|
512
|
+
const all = Object.keys(config.text ?? {}).length;
|
|
513
|
+
const ignored = textProblems(config).length;
|
|
514
|
+
if (!all) return "None";
|
|
515
|
+
return ignored ? `${all - ignored} in use, ${ignored} ignored` : `${all} in use`;
|
|
516
|
+
}
|
|
517
|
+
|
|
367
518
|
async function statusPage(config: RedeemIDConfig, request: Request) {
|
|
368
519
|
const c = await checks(config);
|
|
369
520
|
const origin = new URL(request.url).origin;
|
|
@@ -392,8 +543,22 @@ async function statusPage(config: RedeemIDConfig, request: Request) {
|
|
|
392
543
|
{ label: "Events", value: config.events?.url ? `${config.events.url}${env(ENV.eventsSecret) ? "" : " (REDEEMID_EVENTS_SECRET missing)"}` : "Off" },
|
|
393
544
|
{ label: "Storefront API access", value: c.storefront },
|
|
394
545
|
{ label: "Last webhook", value: delivery ?? "None received yet" },
|
|
546
|
+
{ label: "Replacement text", value: textSummary(config) },
|
|
395
547
|
],
|
|
396
548
|
},
|
|
549
|
+
...(textProblems(config).length
|
|
550
|
+
? [
|
|
551
|
+
{
|
|
552
|
+
type: "table",
|
|
553
|
+
page_action_id: "text_problems",
|
|
554
|
+
columns: [
|
|
555
|
+
{ key: "original", label: "Ignored replacement for" },
|
|
556
|
+
{ key: "problem", label: "Why" },
|
|
557
|
+
],
|
|
558
|
+
rows: textProblems(config),
|
|
559
|
+
},
|
|
560
|
+
]
|
|
561
|
+
: []),
|
|
397
562
|
{ type: "divider" },
|
|
398
563
|
{ type: "header", text: "Webhooks" },
|
|
399
564
|
...webhooks,
|
|
@@ -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
|
+
}
|