@redeemid/emdash 0.0.8 → 0.0.9
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 +55 -2
- package/docs/shopify-setup.md +4 -0
- package/package.json +3 -1
- package/src/access.ts +7 -1
- package/src/admin-api.ts +191 -0
- package/src/components/Feature.astro +20 -0
- package/src/components/MemberCard.astro +8 -0
- package/src/config.ts +21 -0
- package/src/descriptor.ts +1 -0
- package/src/events.ts +74 -0
- package/src/features.ts +39 -0
- package/src/plugin.ts +107 -2
- package/src/routes/callback.ts +8 -1
- package/src/routes/webhook.ts +5 -3
- package/src/staff.ts +220 -0
- package/src/storage.ts +1 -1
- package/src/version.ts +1 -1
- package/src/webhooks.ts +11 -2
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Shopify customer accounts, member levels and paywalls for [EmDash](https://github.com/emdash-cms/emdash) sites. Shopify runs the store and the customer account; EmDash runs the content.
|
|
4
4
|
|
|
5
|
-
**Status: 0.0.
|
|
5
|
+
**Status: 0.0.9, early.** Runs on Node and Cloudflare Workers. Sign-in, the member mirror, webhooks, levels, rules, the meter and drip, teasers, badges, the page gate, the Shopify cart and checkout, the staff tools and outbound events are covered by an end-to-end test against a fake store.
|
|
6
6
|
|
|
7
7
|
## What it does
|
|
8
8
|
|
|
@@ -20,7 +20,9 @@ Shopify customer accounts, member levels and paywalls for [EmDash](https://githu
|
|
|
20
20
|
- **The wall.** Readers kept out see why and what to do next: sign in, or join, with each level's own wall text. `<Notices>` shows the messages sign-in, Buy, Cart and Account send readers back with.
|
|
21
21
|
- **Plan block.** Editors add a Shopify product to any page with the "Plan" block: title, price and a buy button.
|
|
22
22
|
- **Shop.** `<Product>`, `<Collection>` and `<CartView>` (with the Product and Collection blocks) give the site product pages, collection grids and a cart page with quantities and discount codes. `<Contribution>` (and its block) takes pay-what-you-want amounts. Carts note the page each sale started on.
|
|
23
|
-
- **
|
|
23
|
+
- **Features.** Name perks members hold through levels or tags (ad-free, a supporter thank-you) and show them with `<Feature>` or `hasFeature()`.
|
|
24
|
+
- **In the admin.** RedeemID shows under Plugins with a Members page (Shopify customers, tags, levels; open a member to resync them or add and remove tags, which are written to Shopify), a bulk resync you start yourself, a Status page (secrets, store discovery, webhook subscriptions with a Register button, recent deliveries, levels and rules), an Access test and a dashboard widget.
|
|
25
|
+
- **Outbound events.** Signed JSON POSTs to a URL of yours when a member signs in, their levels change, or they use the meter, for a CRM, an email tool or your own code.
|
|
24
26
|
- **Keeps members out of EmDash's own APIs**, so the gate is the only way to read locked content.
|
|
25
27
|
|
|
26
28
|
## Install
|
|
@@ -76,6 +78,8 @@ Set these secrets in the environment (`.env` locally, `wrangler secret put` on W
|
|
|
76
78
|
| `REDEEMID_SHOPIFY_CUSTOMER_ACCOUNT_CLIENT_ID` | Customer Account API | Client ID of the Headless storefront's Customer Account API client |
|
|
77
79
|
| `REDEEMID_SHOPIFY_CUSTOMER_ACCOUNT_CLIENT_SECRET` | Customer Account API | Its client secret. Confidential clients only; leave unset for a public client. |
|
|
78
80
|
| `REDEEMID_SHOPIFY_ADMIN_APP_CLIENT_SECRET` | Webhooks (Admin API app) | Client secret of the Shopify app that sends the webhooks. Shopify signs each webhook with it. |
|
|
81
|
+
| `REDEEMID_SHOPIFY_ADMIN_APP_CLIENT_ID` | Admin API (staff tools) | Client ID of that same app. Optional; with it, staff can resync members, edit tags in Shopify and register webhooks. |
|
|
82
|
+
| `REDEEMID_EVENTS_SECRET` | None | Optional. Signs outbound events; set it with `events.url`. |
|
|
79
83
|
| `REDEEMID_SHOPIFY_STOREFRONT_PRIVATE_TOKEN` | Storefront API | Private access token, for the plan cards and checkout (server-side). |
|
|
80
84
|
| `REDEEMID_SHOPIFY_STOREFRONT_PUBLIC_TOKEN` | Storefront API | Public access token. Only used when the private token isn't set. |
|
|
81
85
|
|
|
@@ -111,6 +115,55 @@ The meter lets a signed-in reader who can't read an entry open it anyway, `limit
|
|
|
111
115
|
|
|
112
116
|
Drip holds a rule's entries back from members who pass it until they've held their level for `drip` days. A level starts when the customer first had its tag (for a `match: "all"` level, the last of its tags); with several levels the earliest counts, and with none known, the first sign-in. RedeemID notes when each tag first arrives from sign-in and webhooks, and forgets a tag when Shopify removes it. The wall shows the date the entry opens.
|
|
113
117
|
|
|
118
|
+
### Features
|
|
119
|
+
|
|
120
|
+
```ts
|
|
121
|
+
features: [
|
|
122
|
+
{ slug: "ad-free", name: "Ad-free", levels: ["gold", "premium"] },
|
|
123
|
+
{ slug: "supporter", name: "Supporter", tags: ["supporter"] },
|
|
124
|
+
],
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
A member holds a feature through any of its levels or tags; like levels, features come only from the mirrored tags. `<MemberCard>` lists them under the member's levels.
|
|
128
|
+
|
|
129
|
+
```astro
|
|
130
|
+
---
|
|
131
|
+
import Feature from "@redeemid/emdash/components/Feature.astro";
|
|
132
|
+
import { hasFeature } from "@redeemid/emdash/features";
|
|
133
|
+
---
|
|
134
|
+
<Feature slug="supporter"><p>Thanks for your support.</p><p slot="else">Become a supporter.</p></Feature>
|
|
135
|
+
{!(await hasFeature(Astro, "ad-free")) && <aside class="ad">…</aside>}
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
### Events
|
|
139
|
+
|
|
140
|
+
```ts
|
|
141
|
+
events: { url: "https://hooks.example.com/redeemid", only: ["member.signed_in", "member.levels_changed"] },
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
With `REDEEMID_EVENTS_SECRET` set, RedeemID POSTs `{ id, event, at, data }` to the URL. Headers: `X-RedeemID-Event`, `X-RedeemID-Delivery` (the `id`) and `X-RedeemID-Signature: sha256=<hex HMAC-SHA256 of the raw body with the secret>`. Check the signature before you trust a body. Sending waits at most 3 seconds and never breaks the page; a failed send is logged and dropped, not retried.
|
|
145
|
+
|
|
146
|
+
| Event | `data` |
|
|
147
|
+
| --- | --- |
|
|
148
|
+
| `member.signed_in` | `customerId`, `levels`, `first` (first sign-in), `refresh` |
|
|
149
|
+
| `member.levels_changed` | `customerId`, `levels`, `added`, `removed`, `source` (`sign-in`, the webhook topic, or a staff resync or tag edit) |
|
|
150
|
+
| `meter.used` | `userId` (EmDash), `entry` (`collection:id`), `used`, `limit`, `period` (`2026-11`, or `2026-W45` by week) |
|
|
151
|
+
| `meter.exhausted` | the same, sent with the last free read |
|
|
152
|
+
|
|
153
|
+
No event carries an email address or a name: match people by the Shopify `customerId`. `only` limits which events are sent; leave it out for all of them.
|
|
154
|
+
|
|
155
|
+
### Staff tools
|
|
156
|
+
|
|
157
|
+
With `REDEEMID_SHOPIFY_ADMIN_APP_CLIENT_ID` set, RedeemID gets an Admin API token for the webhook app with the client credentials grant (the app must belong to the same Shopify organization as the store, as Dev Dashboard apps do) and needs the `read_customers` and `write_customers` scopes. Then, in the admin:
|
|
158
|
+
|
|
159
|
+
- **Member detail.** Open a member from the Members page to see their levels, tags and features, resync them from Shopify, or add and remove tags. Tag edits are made in Shopify, then read back; nothing is granted on the EmDash side.
|
|
160
|
+
- **Bulk resync.** Reads the 50 members synced longest ago from Shopify in one request. Run it again for the next 50. It never runs on its own.
|
|
161
|
+
- **Webhooks.** The Status page lists the store's subscriptions to this site and registers any that are missing (`customers/redact` stays in the app's own settings).
|
|
162
|
+
- **Recent deliveries.** The last webhooks the site took, with their topics and times.
|
|
163
|
+
- **Access test.** Enter a collection, terms and a reader's tags to see what the entry needs and whether that reader gets in.
|
|
164
|
+
|
|
165
|
+
Resync updates email, name and tags. Status stays with the enable and disable webhooks.
|
|
166
|
+
|
|
114
167
|
## Use it in templates
|
|
115
168
|
|
|
116
169
|
EmDash has no content filter hook, so templates decide what to render. Render the entry body with `<Content>` instead of `<PortableText>`:
|
package/docs/shopify-setup.md
CHANGED
|
@@ -29,6 +29,8 @@ RedeemID keeps tags and status current from webhooks. Point these topics at `htt
|
|
|
29
29
|
|
|
30
30
|
Subscribe them from a Shopify app on the store (for example a Dev Dashboard app). If the app already sends webhooks to another site, add this URL as extra subscriptions. `customers/merge` needs the `read_customer_merge` scope. Shopify signs every webhook with that app's **client secret**, which is the site's webhook secret.
|
|
31
31
|
|
|
32
|
+
For the staff tools (member resync, tag edits written to Shopify, the Register webhooks button), give the same app the `read_customers` and `write_customers` Admin API scopes and set its **client ID** as well. RedeemID uses the client credentials grant, which works when the app and the store are in the same Shopify organization.
|
|
33
|
+
|
|
32
34
|
## 3. The site
|
|
33
35
|
|
|
34
36
|
Add the package and config as in the [README](../README.md#install), with `shop` set to the store's domain. A custom customer-account domain works as well as `*.myshopify.com`.
|
|
@@ -40,6 +42,8 @@ npx wrangler secret put REDEEMID_SHOPIFY_STORE_DOMAIN # e.g. m
|
|
|
40
42
|
npx wrangler secret put REDEEMID_SHOPIFY_CUSTOMER_ACCOUNT_CLIENT_ID # Customer Account API client ID
|
|
41
43
|
npx wrangler secret put REDEEMID_SHOPIFY_CUSTOMER_ACCOUNT_CLIENT_SECRET # confidential clients only
|
|
42
44
|
npx wrangler secret put REDEEMID_SHOPIFY_ADMIN_APP_CLIENT_SECRET # the webhook app's client secret
|
|
45
|
+
npx wrangler secret put REDEEMID_SHOPIFY_ADMIN_APP_CLIENT_ID # optional; the webhook app's client ID, for the staff tools
|
|
46
|
+
npx wrangler secret put REDEEMID_EVENTS_SECRET # optional; signs outbound events (events.url)
|
|
43
47
|
npx wrangler secret put REDEEMID_SHOPIFY_STOREFRONT_PRIVATE_TOKEN # Headless storefront's Storefront API private token (plans, checkout)
|
|
44
48
|
npx wrangler secret put REDEEMID_SHOPIFY_STOREFRONT_PUBLIC_TOKEN # optional; used only without the private token
|
|
45
49
|
```
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@redeemid/emdash",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.9",
|
|
4
4
|
"description": "Shopify customer accounts, member levels and paywalls for EmDash sites. Shopify runs commerce; EmDash runs content.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -15,6 +15,8 @@
|
|
|
15
15
|
"./access": "./src/access.ts",
|
|
16
16
|
"./guard.ts": "./src/guard.ts",
|
|
17
17
|
"./storefront": "./src/storefront.ts",
|
|
18
|
+
"./features": "./src/features.ts",
|
|
19
|
+
"./events": "./src/events.ts",
|
|
18
20
|
"./plugin": "./src/plugin.ts",
|
|
19
21
|
"./blocks": "./src/blocks/index.ts",
|
|
20
22
|
"./components/*": "./src/components/*",
|
package/src/access.ts
CHANGED
|
@@ -469,7 +469,13 @@ export async function readEntry(
|
|
|
469
469
|
const used = consume(record.items, `${entry.collection}:${entry.id}`, meter.limit);
|
|
470
470
|
const info = { limit: meter.limit, period };
|
|
471
471
|
if (!used.allowed) return { ...verdict, meterLeft: 0, meter: info };
|
|
472
|
-
if (used.items !== record.items)
|
|
472
|
+
if (used.items !== record.items) {
|
|
473
|
+
await s.meter.put(key, { ...record, items: used.items });
|
|
474
|
+
const { emit } = await import("./events.js");
|
|
475
|
+
const data = { userId: user.id, entry: `${entry.collection}:${entry.id}`, used: used.items.length, limit: meter.limit, period: record.period };
|
|
476
|
+
await emit(config, "meter.used", data);
|
|
477
|
+
if (used.left === 0) await emit(config, "meter.exhausted", data);
|
|
478
|
+
}
|
|
473
479
|
return { ...verdict, allowed: true, reason: "metered", meterLeft: used.left, meter: info };
|
|
474
480
|
} catch {
|
|
475
481
|
return verdict;
|
package/src/admin-api.ts
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shopify Admin API, for staff tools only: resync a member, write tags to
|
|
3
|
+
* Shopify, register webhooks. The token comes from the client credentials
|
|
4
|
+
* grant of the Shopify app whose webhooks the site receives
|
|
5
|
+
* (REDEEMID_SHOPIFY_ADMIN_APP_CLIENT_ID + _CLIENT_SECRET). Tokens last about
|
|
6
|
+
* a day; this keeps one per store in memory until shortly before expiry.
|
|
7
|
+
*
|
|
8
|
+
* Nothing here grants access: tag edits go to Shopify, and the mirror only
|
|
9
|
+
* changes from what Shopify answers.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { env, ENV, shopOrigin, type RedeemIDConfig } from "./config.js";
|
|
13
|
+
import { USER_AGENT } from "./shopify.js";
|
|
14
|
+
import { cleanTags, emptyMember, stampTags, type MemberRecord, type Stores } from "./storage.js";
|
|
15
|
+
|
|
16
|
+
export const ADMIN_API_VERSION = "2026-10";
|
|
17
|
+
|
|
18
|
+
/** Webhook topics RedeemID needs, as the Admin API names them. customers/redact is set in the app's config, not here. */
|
|
19
|
+
export const WEBHOOK_TOPICS = [
|
|
20
|
+
"CUSTOMERS_CREATE",
|
|
21
|
+
"CUSTOMERS_UPDATE",
|
|
22
|
+
"CUSTOMERS_DELETE",
|
|
23
|
+
"CUSTOMERS_DISABLE",
|
|
24
|
+
"CUSTOMERS_ENABLE",
|
|
25
|
+
"CUSTOMERS_MERGE",
|
|
26
|
+
"CUSTOMER_TAGS_ADDED",
|
|
27
|
+
"CUSTOMER_TAGS_REMOVED",
|
|
28
|
+
] as const;
|
|
29
|
+
|
|
30
|
+
const MARGIN_MS = 5 * 60 * 1000;
|
|
31
|
+
const tokens = new Map<string, { token: string; until: number; scope: string }>();
|
|
32
|
+
|
|
33
|
+
export function adminConfigured(): boolean {
|
|
34
|
+
return Boolean(env(ENV.adminClientId) && env(ENV.webhookSecret));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function resetAdminToken(): void {
|
|
38
|
+
tokens.clear();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export class AdminError extends Error {}
|
|
42
|
+
|
|
43
|
+
async function token(config: RedeemIDConfig, fetcher: typeof fetch): Promise<{ token: string; scope: string }> {
|
|
44
|
+
const origin = shopOrigin(config);
|
|
45
|
+
const hit = tokens.get(origin);
|
|
46
|
+
if (hit && hit.until > Date.now()) return hit;
|
|
47
|
+
const clientId = env(ENV.adminClientId);
|
|
48
|
+
const secret = env(ENV.webhookSecret);
|
|
49
|
+
if (!clientId || !secret) throw new AdminError(`Set ${ENV.adminClientId} and ${ENV.webhookSecret} to use the staff tools.`);
|
|
50
|
+
const res = await fetcher(`${origin}/admin/oauth/access_token`, {
|
|
51
|
+
method: "POST",
|
|
52
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json", "User-Agent": USER_AGENT },
|
|
53
|
+
body: new URLSearchParams({ grant_type: "client_credentials", client_id: clientId, client_secret: secret }).toString(),
|
|
54
|
+
});
|
|
55
|
+
const body = (await res.json().catch(() => ({}))) as { access_token?: string; expires_in?: number; scope?: string; error?: string };
|
|
56
|
+
if (!res.ok || !body.access_token) {
|
|
57
|
+
// shop_not_permitted: the app and the store are not in the same Shopify organization.
|
|
58
|
+
throw new AdminError(`Shopify refused the Admin API token (${body.error ?? res.status}).`);
|
|
59
|
+
}
|
|
60
|
+
const life = (body.expires_in && body.expires_in > 0 ? body.expires_in : 3600) * 1000;
|
|
61
|
+
const value = { token: body.access_token, until: Date.now() + Math.max(60_000, life - MARGIN_MS), scope: body.scope ?? "" };
|
|
62
|
+
tokens.set(origin, value);
|
|
63
|
+
return value;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The scopes the Admin API token carries, or an error message. */
|
|
67
|
+
export async function adminScopes(config: RedeemIDConfig, fetcher: typeof fetch = fetch): Promise<string> {
|
|
68
|
+
return (await token(config, fetcher)).scope;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function admin<T>(config: RedeemIDConfig, query: string, variables: Record<string, unknown> = {}, fetcher: typeof fetch = fetch, retry = true): Promise<T> {
|
|
72
|
+
const t = await token(config, fetcher);
|
|
73
|
+
const res = await fetcher(`${shopOrigin(config)}/admin/api/${ADMIN_API_VERSION}/graphql.json`, {
|
|
74
|
+
method: "POST",
|
|
75
|
+
headers: { "Content-Type": "application/json", Accept: "application/json", "User-Agent": USER_AGENT, "X-Shopify-Access-Token": t.token },
|
|
76
|
+
body: JSON.stringify({ query, variables }),
|
|
77
|
+
});
|
|
78
|
+
if (res.status === 401 && retry) {
|
|
79
|
+
tokens.delete(shopOrigin(config));
|
|
80
|
+
return admin(config, query, variables, fetcher, false);
|
|
81
|
+
}
|
|
82
|
+
const body = (await res.json().catch(() => ({}))) as { data?: T; errors?: Array<{ message?: string }> };
|
|
83
|
+
if (!res.ok || !body.data) throw new AdminError(body.errors?.[0]?.message ?? `Admin API request failed (${res.status}).`);
|
|
84
|
+
return body.data;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const gid = (id: string) => `gid://shopify/Customer/${id}`;
|
|
88
|
+
|
|
89
|
+
export interface AdminCustomer {
|
|
90
|
+
customerId: string;
|
|
91
|
+
email: string | null;
|
|
92
|
+
firstName: string | null;
|
|
93
|
+
lastName: string | null;
|
|
94
|
+
tags: string[];
|
|
95
|
+
updatedAt: number | null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Up to 100 customers by numeric id. Customers Shopify no longer has are left out. */
|
|
99
|
+
export async function customers(config: RedeemIDConfig, ids: string[], fetcher: typeof fetch = fetch): Promise<AdminCustomer[]> {
|
|
100
|
+
if (ids.length === 0) return [];
|
|
101
|
+
type Node = { id: string; firstName: string | null; lastName: string | null; tags: string[]; updatedAt: string; defaultEmailAddress: { emailAddress: string | null } | null } | null;
|
|
102
|
+
const data = await admin<{ nodes: Node[] }>(
|
|
103
|
+
config,
|
|
104
|
+
`query RedeemIDCustomers($ids: [ID!]!) { nodes(ids: $ids) { ... on Customer { id firstName lastName tags updatedAt defaultEmailAddress { emailAddress } } } }`,
|
|
105
|
+
{ ids: ids.slice(0, 100).map(gid) },
|
|
106
|
+
fetcher,
|
|
107
|
+
);
|
|
108
|
+
return data.nodes
|
|
109
|
+
.filter((n): n is NonNullable<Node> => Boolean(n?.id))
|
|
110
|
+
.map((n) => ({
|
|
111
|
+
customerId: n.id.replace(/^gid:\/\/shopify\/Customer\//, ""),
|
|
112
|
+
email: n.defaultEmailAddress?.emailAddress?.toLowerCase() ?? null,
|
|
113
|
+
firstName: n.firstName || null,
|
|
114
|
+
lastName: n.lastName || null,
|
|
115
|
+
tags: cleanTags(n.tags),
|
|
116
|
+
updatedAt: Number.isFinite(Date.parse(n.updatedAt)) ? Date.parse(n.updatedAt) : null,
|
|
117
|
+
}));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Read members from Shopify again and update the mirror: profile and tags.
|
|
122
|
+
* Status stays with the enable/disable webhooks. Members Shopify no longer
|
|
123
|
+
* has are reported in `missing` and left alone.
|
|
124
|
+
*/
|
|
125
|
+
export async function resync(config: RedeemIDConfig, s: Stores, ids: string[], source = "resync", fetcher: typeof fetch = fetch): Promise<{ updated: MemberRecord[]; missing: string[] }> {
|
|
126
|
+
const found = await customers(config, ids, fetcher);
|
|
127
|
+
const updated: MemberRecord[] = [];
|
|
128
|
+
for (const c of found) {
|
|
129
|
+
const m = (await s.members.get(c.customerId)) ?? emptyMember(c.customerId);
|
|
130
|
+
m.email = c.email ?? m.email;
|
|
131
|
+
m.firstName = c.firstName ?? m.firstName;
|
|
132
|
+
m.lastName = c.lastName ?? m.lastName;
|
|
133
|
+
m.tags = c.tags;
|
|
134
|
+
m.profileUpdatedAt = c.updatedAt ?? m.profileUpdatedAt;
|
|
135
|
+
m.syncedAt = new Date().toISOString();
|
|
136
|
+
m.source = source;
|
|
137
|
+
await s.members.put(c.customerId, stampTags(m));
|
|
138
|
+
updated.push(m);
|
|
139
|
+
}
|
|
140
|
+
const seen = new Set(found.map((c) => c.customerId));
|
|
141
|
+
return { updated, missing: ids.filter((id) => !seen.has(id)) };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Add or remove tags on the Shopify customer. Shopify then sends the tag webhook; callers resync to show it now. */
|
|
145
|
+
export async function editTags(config: RedeemIDConfig, customerId: string, change: "add" | "remove", tags: string[], fetcher: typeof fetch = fetch): Promise<void> {
|
|
146
|
+
const clean = cleanTags(tags);
|
|
147
|
+
if (clean.length === 0) return;
|
|
148
|
+
const m = change === "add" ? "tagsAdd" : "tagsRemove";
|
|
149
|
+
const data = await admin<Record<string, { userErrors: Array<{ message: string }> }>>(
|
|
150
|
+
config,
|
|
151
|
+
`mutation RedeemIDTags($id: ID!, $tags: [String!]!) { ${m}(id: $id, tags: $tags) { userErrors { field message } } }`,
|
|
152
|
+
{ id: gid(customerId), tags: clean },
|
|
153
|
+
fetcher,
|
|
154
|
+
);
|
|
155
|
+
const err = data[m]?.userErrors[0];
|
|
156
|
+
if (err) throw new AdminError(err.message);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export interface Subscription {
|
|
160
|
+
id: string;
|
|
161
|
+
topic: string;
|
|
162
|
+
uri: string;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export async function webhookSubscriptions(config: RedeemIDConfig, fetcher: typeof fetch = fetch): Promise<Subscription[]> {
|
|
166
|
+
type Node = { id: string; topic: string; uri?: string | null };
|
|
167
|
+
const data = await admin<{ webhookSubscriptions: { nodes: Node[] } }>(
|
|
168
|
+
config,
|
|
169
|
+
`query RedeemIDWebhooks { webhookSubscriptions(first: 100) { nodes { id topic uri } } }`,
|
|
170
|
+
{},
|
|
171
|
+
fetcher,
|
|
172
|
+
);
|
|
173
|
+
return data.webhookSubscriptions.nodes.map((n) => ({ id: n.id, topic: n.topic, uri: n.uri ?? "" }));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Register the topics RedeemID needs that no subscription sends to `uri` yet. Returns the topics it added. */
|
|
177
|
+
export async function registerWebhooks(config: RedeemIDConfig, uri: string, fetcher: typeof fetch = fetch): Promise<string[]> {
|
|
178
|
+
const have = await webhookSubscriptions(config, fetcher);
|
|
179
|
+
const missing = WEBHOOK_TOPICS.filter((t) => !have.some((h) => h.topic === t && h.uri === uri));
|
|
180
|
+
for (const topic of missing) {
|
|
181
|
+
const data = await admin<{ webhookSubscriptionCreate: { userErrors: Array<{ message: string }> } }>(
|
|
182
|
+
config,
|
|
183
|
+
`mutation RedeemIDWebhook($topic: WebhookSubscriptionTopic!, $uri: String!) { webhookSubscriptionCreate(topic: $topic, webhookSubscription: { uri: $uri, format: JSON }) { userErrors { field message } } }`,
|
|
184
|
+
{ topic, uri },
|
|
185
|
+
fetcher,
|
|
186
|
+
);
|
|
187
|
+
const err = data.webhookSubscriptionCreate.userErrors[0];
|
|
188
|
+
if (err) throw new AdminError(`${topic}: ${err.message}`);
|
|
189
|
+
}
|
|
190
|
+
return [...missing];
|
|
191
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* Shows its default slot only to members who hold the feature (see config
|
|
4
|
+
* `features`), and the "else" slot to everyone else.
|
|
5
|
+
*
|
|
6
|
+
* <Feature slug="supporter">
|
|
7
|
+
* <p>Thanks for supporting us.</p>
|
|
8
|
+
* <a slot="else" href="/support/">Support us</a>
|
|
9
|
+
* </Feature>
|
|
10
|
+
*/
|
|
11
|
+
import { hasFeature } from "../features.js";
|
|
12
|
+
|
|
13
|
+
interface Props {
|
|
14
|
+
slug: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const held = await hasFeature(Astro, Astro.props.slug);
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
{held ? <slot /> : <slot name="else" />}
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* membership reads it from the store again.
|
|
11
11
|
*/
|
|
12
12
|
import { accountUrl, membership, meterUsage, refreshUrl, signInUrl, signOutUrl, viewer } from "../access.js";
|
|
13
|
+
import { featuresFor } from "../features.js";
|
|
13
14
|
import { siteConfig } from "../site-config.js";
|
|
14
15
|
|
|
15
16
|
interface Props {
|
|
@@ -25,6 +26,8 @@ const m = v.member;
|
|
|
25
26
|
const disabled = m?.status === "disabled";
|
|
26
27
|
const { current, past } = m && !disabled ? membership(m, config?.levels) : { current: [], past: [] };
|
|
27
28
|
const meter = v.signedIn ? await meterUsage(Astro) : null;
|
|
29
|
+
const held = m ? featuresFor(m, config?.features, config?.levels) : [];
|
|
30
|
+
const features = (config?.features ?? []).filter((f) => held.includes(f.slug));
|
|
28
31
|
const date = (iso: string | null) => {
|
|
29
32
|
if (!iso) return null;
|
|
30
33
|
const t = Date.parse(iso);
|
|
@@ -60,6 +63,11 @@ const join = config?.join;
|
|
|
60
63
|
You don't have a membership yet.{join && <> <a href={join.href}>{join.label ?? "Become a member"}</a></>}
|
|
61
64
|
</p>
|
|
62
65
|
)}
|
|
66
|
+
{features.length > 0 && (
|
|
67
|
+
<p class="redeemid-member__features">
|
|
68
|
+
Includes: {features.map((f) => f.name).join(", ")}
|
|
69
|
+
</p>
|
|
70
|
+
)}
|
|
63
71
|
{past.length > 0 && (
|
|
64
72
|
<>
|
|
65
73
|
<h3 class="redeemid-member__subtitle">Before</h3>
|
package/src/config.ts
CHANGED
|
@@ -63,6 +63,21 @@ export interface RuleConfig {
|
|
|
63
63
|
* free each month (or week). Guests get the wall; an entry counts once per
|
|
64
64
|
* period, when its own page renders through <Content> with an `id`.
|
|
65
65
|
*/
|
|
66
|
+
/** A perk members hold through any of its levels or Shopify tags. */
|
|
67
|
+
export interface FeatureConfig {
|
|
68
|
+
slug: string;
|
|
69
|
+
name: string;
|
|
70
|
+
levels?: string[];
|
|
71
|
+
tags?: string[];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Where to send signed member events (see events.ts). The signing secret is REDEEMID_EVENTS_SECRET. */
|
|
75
|
+
export interface EventsConfig {
|
|
76
|
+
url: string;
|
|
77
|
+
/** Send only these events. All of them when left out. */
|
|
78
|
+
only?: Array<"member.signed_in" | "member.levels_changed" | "meter.used" | "meter.exhausted">;
|
|
79
|
+
}
|
|
80
|
+
|
|
66
81
|
export interface MeterConfig {
|
|
67
82
|
/** Free entries per period. */
|
|
68
83
|
limit: number;
|
|
@@ -83,6 +98,8 @@ export interface RedeemIDConfig {
|
|
|
83
98
|
levels?: LevelConfig[];
|
|
84
99
|
rules?: RuleConfig[];
|
|
85
100
|
meter?: MeterConfig;
|
|
101
|
+
features?: FeatureConfig[];
|
|
102
|
+
events?: EventsConfig;
|
|
86
103
|
/** The wall's join link: where readers become members, e.g. { href: "/join/", label: "Become a member" }. */
|
|
87
104
|
join?: { href: string; label?: string };
|
|
88
105
|
/**
|
|
@@ -123,6 +140,10 @@ export const ENV = {
|
|
|
123
140
|
clientSecret: "REDEEMID_SHOPIFY_CUSTOMER_ACCOUNT_CLIENT_SECRET",
|
|
124
141
|
/** Client secret of the Shopify (Admin API) app whose webhooks the site receives. Shopify signs webhooks with it. */
|
|
125
142
|
webhookSecret: "REDEEMID_SHOPIFY_ADMIN_APP_CLIENT_SECRET",
|
|
143
|
+
/** Client ID of that Shopify (Admin API) app. With the secret it lets staff tools call the Admin API (client credentials). */
|
|
144
|
+
adminClientId: "REDEEMID_SHOPIFY_ADMIN_APP_CLIENT_ID",
|
|
145
|
+
/** Secret for signing the outbound events RedeemID sends to `events.url`. */
|
|
146
|
+
eventsSecret: "REDEEMID_EVENTS_SECRET",
|
|
126
147
|
/** Storefront API private token, for server-side plan and checkout calls. */
|
|
127
148
|
storefrontPrivateToken: "REDEEMID_SHOPIFY_STOREFRONT_PRIVATE_TOKEN",
|
|
128
149
|
/** Storefront API public token, used only when the private token isn't set. */
|
package/src/descriptor.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { VERSION } from "./version.js";
|
|
|
11
11
|
export const PAGES = [
|
|
12
12
|
{ path: "/", label: "Members", icon: "users" },
|
|
13
13
|
{ path: "/status", label: "Status", icon: "pulse" },
|
|
14
|
+
{ path: "/test", label: "Access test", icon: "search" },
|
|
14
15
|
];
|
|
15
16
|
export const WIDGETS = [{ id: "status", title: "RedeemID", size: "half" as const }];
|
|
16
17
|
|
package/src/events.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Outbound events: RedeemID POSTs JSON to `events.url` when something
|
|
3
|
+
* happens to a member, signed so the receiver can trust it.
|
|
4
|
+
*
|
|
5
|
+
* events: { url: "https://hooks.example.com/redeemid" } // + REDEEMID_EVENTS_SECRET
|
|
6
|
+
*
|
|
7
|
+
* Headers: X-RedeemID-Event (the name), X-RedeemID-Delivery (a fresh id) and
|
|
8
|
+
* X-RedeemID-Signature: sha256=<hex HMAC of the raw body>. Events:
|
|
9
|
+
* member.signed_in, member.levels_changed, meter.used, meter.exhausted.
|
|
10
|
+
* Sending never blocks or breaks the page: failures are logged and dropped.
|
|
11
|
+
* No event carries an email address or a name; match people by `customerId`.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { env, ENV, type RedeemIDConfig } from "./config.js";
|
|
15
|
+
import { USER_AGENT } from "./shopify.js";
|
|
16
|
+
|
|
17
|
+
export type EventName = "member.signed_in" | "member.levels_changed" | "meter.used" | "meter.exhausted";
|
|
18
|
+
|
|
19
|
+
const TIMEOUT_MS = 3000;
|
|
20
|
+
|
|
21
|
+
export async function signBody(body: string, secret: string): Promise<string> {
|
|
22
|
+
const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
|
|
23
|
+
const mac = new Uint8Array(await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(body)));
|
|
24
|
+
return [...mac].map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Send one event. Returns whether the receiver answered 2xx; never throws. */
|
|
28
|
+
export async function emit(config: RedeemIDConfig | null, name: EventName, data: Record<string, unknown>, fetcher: typeof fetch = fetch): Promise<boolean> {
|
|
29
|
+
const url = config?.events?.url;
|
|
30
|
+
const secret = env(ENV.eventsSecret);
|
|
31
|
+
if (!url || !secret) return false;
|
|
32
|
+
if (config.events?.only && !config.events.only.includes(name)) return false;
|
|
33
|
+
const id = crypto.randomUUID();
|
|
34
|
+
const body = JSON.stringify({ id, event: name, at: new Date().toISOString(), data });
|
|
35
|
+
try {
|
|
36
|
+
const res = await fetcher(url, {
|
|
37
|
+
method: "POST",
|
|
38
|
+
headers: {
|
|
39
|
+
"Content-Type": "application/json",
|
|
40
|
+
"User-Agent": USER_AGENT,
|
|
41
|
+
"X-RedeemID-Event": name,
|
|
42
|
+
"X-RedeemID-Delivery": id,
|
|
43
|
+
"X-RedeemID-Signature": `sha256=${await signBody(body, secret)}`,
|
|
44
|
+
},
|
|
45
|
+
body,
|
|
46
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
47
|
+
});
|
|
48
|
+
return res.ok;
|
|
49
|
+
} catch (e) {
|
|
50
|
+
console.error("[redeemid] event not sent:", name, (e as Error).message);
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Send member.levels_changed when a mirror write changed the member's levels. */
|
|
56
|
+
export async function levelChange(
|
|
57
|
+
config: RedeemIDConfig | null,
|
|
58
|
+
beforeTags: string[] | null,
|
|
59
|
+
after: { customerId: string; tags: string[]; status: string },
|
|
60
|
+
source: string,
|
|
61
|
+
fetcher: typeof fetch = fetch,
|
|
62
|
+
): Promise<void> {
|
|
63
|
+
if (!config?.events?.url) return;
|
|
64
|
+
const { levelsFor } = await import("./access.js");
|
|
65
|
+
const was = beforeTags ? levelsFor(beforeTags, config.levels) : [];
|
|
66
|
+
const now = after.status === "disabled" ? [] : levelsFor(after.tags, config.levels);
|
|
67
|
+
if (was.length === now.length && was.every((l) => now.includes(l))) return;
|
|
68
|
+
await emit(
|
|
69
|
+
config,
|
|
70
|
+
"member.levels_changed",
|
|
71
|
+
{ customerId: after.customerId, levels: now, added: now.filter((l) => !was.includes(l)), removed: was.filter((l) => !now.includes(l)), source },
|
|
72
|
+
fetcher,
|
|
73
|
+
);
|
|
74
|
+
}
|
package/src/features.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Features: named perks a member holds through their levels or their
|
|
3
|
+
* Shopify tags (an ad-free site, a supporter thank-you, early access).
|
|
4
|
+
* Like levels they come only from the mirrored tags; nothing here grants
|
|
5
|
+
* them otherwise.
|
|
6
|
+
*
|
|
7
|
+
* features: [
|
|
8
|
+
* { slug: "ad-free", name: "Ad-free", levels: ["gold", "premium"] },
|
|
9
|
+
* { slug: "supporter", name: "Supporter", tags: ["supporter"] },
|
|
10
|
+
* ]
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { levelsFor, viewer, type AstroLike } from "./access.js";
|
|
14
|
+
import type { FeatureConfig, LevelConfig } from "./config.js";
|
|
15
|
+
import { siteConfig } from "./site-config.js";
|
|
16
|
+
import type { MemberRecord } from "./storage.js";
|
|
17
|
+
|
|
18
|
+
/** The feature slugs a member holds: from any of the feature's levels or tags. */
|
|
19
|
+
export function featuresFor(member: Pick<MemberRecord, "tags" | "status"> | null, features: FeatureConfig[] = [], levels: LevelConfig[] = []): string[] {
|
|
20
|
+
if (!member || member.status === "disabled") return [];
|
|
21
|
+
const held = levelsFor(member.tags, levels);
|
|
22
|
+
const tags = new Set(member.tags.map((t) => t.toLowerCase()));
|
|
23
|
+
return features
|
|
24
|
+
.filter((f) => (f.levels ?? []).some((l) => held.includes(l)) || (f.tags ?? []).some((t) => tags.has(t.toLowerCase())))
|
|
25
|
+
.map((f) => f.slug);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Does the visitor hold this feature? */
|
|
29
|
+
export async function hasFeature(astro: AstroLike, slug: string): Promise<boolean> {
|
|
30
|
+
return (await viewerFeatures(astro)).includes(slug);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The visitor's features. */
|
|
34
|
+
export async function viewerFeatures(astro: AstroLike): Promise<string[]> {
|
|
35
|
+
const config = siteConfig((astro.locals as { emdash?: { config?: unknown } }).emdash?.config);
|
|
36
|
+
if (!config?.features?.length) return [];
|
|
37
|
+
const v = await viewer(astro);
|
|
38
|
+
return featuresFor(v.member, config.features, config.levels);
|
|
39
|
+
}
|
package/src/plugin.ts
CHANGED
|
@@ -33,6 +33,8 @@ import { discover } from "./shopify.js";
|
|
|
33
33
|
import { stores, type MemberRecord } 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 { accessTest, bulkResync, BULK_BATCH, deliveriesSection, memberDetail, registerWebhooks, resyncOne, tagEdit, webhooksSection } from "./staff.js";
|
|
36
38
|
|
|
37
39
|
import {
|
|
38
40
|
COLLECTION_BLOCK,
|
|
@@ -154,11 +156,13 @@ export function createPlugin(config: RedeemIDConfig = {}): ResolvedPlugin {
|
|
|
154
156
|
routes: {
|
|
155
157
|
admin: {
|
|
156
158
|
handler: async (ctx: RouteContext) => {
|
|
157
|
-
const i = (ctx.input ?? {}) as { type?: string; page?: string };
|
|
158
|
-
if (i.type !== "page_load") return { blocks: [] };
|
|
159
|
+
const i = (ctx.input ?? {}) as { type?: string; page?: string; action_id?: string; value?: unknown; values?: Record<string, unknown>; block_id?: string };
|
|
159
160
|
try {
|
|
161
|
+
if (i.type === "block_action" || i.type === "form_submit") return await action(config, ctx.request, i);
|
|
162
|
+
if (i.type !== "page_load") return { blocks: [] };
|
|
160
163
|
if (i.page === "/") return await membersPage(config);
|
|
161
164
|
if (i.page === "/status") return await statusPage(config, ctx.request);
|
|
165
|
+
if (i.page === "/test") return await accessTest(config);
|
|
162
166
|
if (i.page === "widget:status") return await statusWidget(config);
|
|
163
167
|
} catch (e) {
|
|
164
168
|
return { blocks: [{ type: "banner", variant: "error", title: "RedeemID couldn't load this page", description: (e as Error).message }] };
|
|
@@ -172,6 +176,75 @@ export function createPlugin(config: RedeemIDConfig = {}): ResolvedPlugin {
|
|
|
172
176
|
|
|
173
177
|
export default createPlugin;
|
|
174
178
|
|
|
179
|
+
// ---------------------------------------------------------------- actions
|
|
180
|
+
|
|
181
|
+
const webhookUri = (request: Request) => `${new URL(request.url).origin}/_emdash/api/auth/redeemid/webhook`;
|
|
182
|
+
|
|
183
|
+
async function action(
|
|
184
|
+
config: RedeemIDConfig,
|
|
185
|
+
request: Request,
|
|
186
|
+
i: { action_id?: string; value?: unknown; values?: Record<string, unknown>; block_id?: string },
|
|
187
|
+
) {
|
|
188
|
+
const s = await memberStores();
|
|
189
|
+
const fail = (e: unknown) => ({ type: "error" as const, message: (e as Error).message });
|
|
190
|
+
switch (i.action_id) {
|
|
191
|
+
case "members_back":
|
|
192
|
+
return membersPage(config);
|
|
193
|
+
case "member_open": {
|
|
194
|
+
const m = await s.members.get(String(i.value ?? ""));
|
|
195
|
+
return m ? memberDetail(config, m) : { ...(await membersPage(config)), toast: { type: "error", message: "That member is gone." } };
|
|
196
|
+
}
|
|
197
|
+
case "member_resync": {
|
|
198
|
+
const id = String(i.value ?? "");
|
|
199
|
+
try {
|
|
200
|
+
const m = await resyncOne(config, s, id, "staff resync");
|
|
201
|
+
const now = m ?? (await s.members.get(id));
|
|
202
|
+
if (!now) return membersPage(config);
|
|
203
|
+
return memberDetail(config, now, m ? { type: "success", message: "Read again from Shopify." } : { type: "error", message: "Shopify no longer has this customer." });
|
|
204
|
+
} catch (e) {
|
|
205
|
+
const m = await s.members.get(id);
|
|
206
|
+
return m ? memberDetail(config, m, fail(e)) : membersPage(config);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
case "member_tag_add":
|
|
210
|
+
case "member_tag_remove": {
|
|
211
|
+
const add = i.action_id === "member_tag_add";
|
|
212
|
+
const v = (i.value ?? {}) as { customerId?: string; tag?: string };
|
|
213
|
+
const id = add ? String(i.block_id ?? "").replace(/^tags:/, "") : String(v.customerId ?? "");
|
|
214
|
+
try {
|
|
215
|
+
const m = await tagEdit(config, s, id, add ? "add" : "remove", add ? i.values?.tags : [v.tag]);
|
|
216
|
+
const now = m ?? (await s.members.get(id));
|
|
217
|
+
return now ? memberDetail(config, now, { type: "success", message: add ? "Tags added in Shopify." : "Tag removed in Shopify." }) : membersPage(config);
|
|
218
|
+
} catch (e) {
|
|
219
|
+
const m = await s.members.get(id);
|
|
220
|
+
return m ? memberDetail(config, m, fail(e)) : membersPage(config);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
case "members_resync": {
|
|
224
|
+
try {
|
|
225
|
+
const r = await bulkResync(config, s);
|
|
226
|
+
return {
|
|
227
|
+
...(await membersPage(config)),
|
|
228
|
+
toast: { type: "success", message: `Read ${r.done} members again (${r.changed} changed${r.missing ? `, ${r.missing} no longer in Shopify` : ""}).` },
|
|
229
|
+
};
|
|
230
|
+
} catch (e) {
|
|
231
|
+
return { ...(await membersPage(config)), toast: fail(e) };
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
case "webhooks_register": {
|
|
235
|
+
try {
|
|
236
|
+
const added = await registerWebhooks(config, webhookUri(request));
|
|
237
|
+
return { ...(await statusPage(config, request)), toast: { type: "success", message: added.length ? `Registered ${added.length} webhooks.` : "Nothing was missing." } };
|
|
238
|
+
} catch (e) {
|
|
239
|
+
return { ...(await statusPage(config, request)), toast: fail(e) };
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
case "access_test":
|
|
243
|
+
return accessTest(config, i.values);
|
|
244
|
+
}
|
|
245
|
+
return { blocks: [] };
|
|
246
|
+
}
|
|
247
|
+
|
|
175
248
|
// ---------------------------------------------------------------- pages
|
|
176
249
|
|
|
177
250
|
async function memberStores() {
|
|
@@ -210,6 +283,25 @@ async function membersPage(config: RedeemIDConfig) {
|
|
|
210
283
|
type: "context",
|
|
211
284
|
text: "Shopify customers who have signed in or that Shopify has sent. Levels come only from their Shopify tags; change tags in Shopify (or with Flow) and access follows.",
|
|
212
285
|
},
|
|
286
|
+
adminConfigured()
|
|
287
|
+
? {
|
|
288
|
+
type: "actions",
|
|
289
|
+
elements: [
|
|
290
|
+
{
|
|
291
|
+
type: "button",
|
|
292
|
+
action_id: "members_resync",
|
|
293
|
+
label: `Resync ${BULK_BATCH} from Shopify`,
|
|
294
|
+
style: "secondary",
|
|
295
|
+
confirm: {
|
|
296
|
+
title: "Read members again from Shopify?",
|
|
297
|
+
text: `Reads the ${BULK_BATCH} members synced longest ago from Shopify and updates their tags. Click again for the next ${BULK_BATCH}.`,
|
|
298
|
+
confirm: "Resync",
|
|
299
|
+
deny: "Cancel",
|
|
300
|
+
},
|
|
301
|
+
},
|
|
302
|
+
],
|
|
303
|
+
}
|
|
304
|
+
: { type: "context", text: "Set the Admin API app's client ID (REDEEMID_SHOPIFY_ADMIN_APP_CLIENT_ID) to resync members and edit their tags from here." },
|
|
213
305
|
{
|
|
214
306
|
type: "stats",
|
|
215
307
|
items: [
|
|
@@ -230,6 +322,7 @@ async function membersPage(config: RedeemIDConfig) {
|
|
|
230
322
|
{ key: "tags", label: "Shopify tags" },
|
|
231
323
|
{ key: "status", label: "Account" },
|
|
232
324
|
{ key: "synced", label: "Last synced", format: "relative_time" },
|
|
325
|
+
{ key: "open", label: "", format: "element" },
|
|
233
326
|
],
|
|
234
327
|
rows: members
|
|
235
328
|
.sort((a, b) => b.syncedAt.localeCompare(a.syncedAt))
|
|
@@ -240,6 +333,7 @@ async function membersPage(config: RedeemIDConfig) {
|
|
|
240
333
|
tags: m.tags.join(", ") || "—",
|
|
241
334
|
status: m.status === "enabled" ? "On" : "Off",
|
|
242
335
|
synced: m.syncedAt,
|
|
336
|
+
open: { type: "button", action_id: "member_open", label: "Open", value: m.customerId },
|
|
243
337
|
})),
|
|
244
338
|
},
|
|
245
339
|
],
|
|
@@ -277,6 +371,10 @@ async function statusPage(config: RedeemIDConfig, request: Request) {
|
|
|
277
371
|
const levels = config.levels ?? [];
|
|
278
372
|
const rules = config.rules ?? [];
|
|
279
373
|
const delivery = await lastDelivery().catch(() => null);
|
|
374
|
+
const webhooks = await webhooksSection(config, webhookUri(request));
|
|
375
|
+
const deliveries = await memberStores()
|
|
376
|
+
.then((s) => deliveriesSection(s))
|
|
377
|
+
.catch(() => [{ type: "context", text: "Couldn't read deliveries." }]);
|
|
280
378
|
return {
|
|
281
379
|
blocks: [
|
|
282
380
|
{ type: "header", text: "Status" },
|
|
@@ -290,11 +388,18 @@ async function statusPage(config: RedeemIDConfig, request: Request) {
|
|
|
290
388
|
{ label: "Store discovery", value: c.discovery },
|
|
291
389
|
{ label: "Customer Account API client ID", value: yes(c.clientId) },
|
|
292
390
|
{ label: "Webhook signing secret", value: yes(c.webhookSecret) },
|
|
391
|
+
{ label: "Admin API app client ID (staff tools)", value: yes(Boolean(env(ENV.adminClientId))) },
|
|
392
|
+
{ label: "Events", value: config.events?.url ? `${config.events.url}${env(ENV.eventsSecret) ? "" : " (REDEEMID_EVENTS_SECRET missing)"}` : "Off" },
|
|
293
393
|
{ label: "Storefront API access", value: c.storefront },
|
|
294
394
|
{ label: "Last webhook", value: delivery ?? "None received yet" },
|
|
295
395
|
],
|
|
296
396
|
},
|
|
297
397
|
{ type: "divider" },
|
|
398
|
+
{ type: "header", text: "Webhooks" },
|
|
399
|
+
...webhooks,
|
|
400
|
+
{ type: "header", text: "Recent deliveries" },
|
|
401
|
+
...deliveries,
|
|
402
|
+
{ type: "divider" },
|
|
298
403
|
{ type: "header", text: "Shopify settings" },
|
|
299
404
|
{
|
|
300
405
|
type: "fields",
|
package/src/routes/callback.ts
CHANGED
|
@@ -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
|
|
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);
|
package/src/routes/webhook.ts
CHANGED
|
@@ -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
|
|
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
|
+
}
|
package/src/storage.ts
CHANGED
|
@@ -78,7 +78,7 @@ export interface DeliveryRecord {
|
|
|
78
78
|
}
|
|
79
79
|
|
|
80
80
|
export const STORAGE_CONFIG = {
|
|
81
|
-
members: { indexes: ["email", "userId"] },
|
|
81
|
+
members: { indexes: ["email", "userId", "syncedAt"] },
|
|
82
82
|
deliveries: { indexes: ["receivedAt"] },
|
|
83
83
|
meter: { indexes: ["userId"] },
|
|
84
84
|
};
|
package/src/version.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const VERSION = "0.0.
|
|
1
|
+
export const VERSION = "0.0.9";
|
package/src/webhooks.ts
CHANGED
|
@@ -88,7 +88,13 @@ export type Outcome = "processed" | "skipped" | "stale" | "ignored";
|
|
|
88
88
|
* Apply one webhook to the mirror. Throws on storage errors so the caller can
|
|
89
89
|
* answer 500 and let Shopify retry.
|
|
90
90
|
*/
|
|
91
|
-
export async function apply(
|
|
91
|
+
export async function apply(
|
|
92
|
+
s: Stores,
|
|
93
|
+
topic: string,
|
|
94
|
+
payload: Record<string, unknown>,
|
|
95
|
+
/** Called after a member is written, with their tags before (null when new). */
|
|
96
|
+
onWrite?: (beforeTags: string[] | null, after: MemberRecord) => Promise<void> | void,
|
|
97
|
+
): Promise<Outcome> {
|
|
92
98
|
const kind = kindOf(topic);
|
|
93
99
|
if (!kind || kind === "ignored") return "ignored";
|
|
94
100
|
|
|
@@ -128,7 +134,9 @@ export async function apply(s: Stores, topic: string, payload: Record<string, un
|
|
|
128
134
|
return "processed";
|
|
129
135
|
}
|
|
130
136
|
|
|
131
|
-
const
|
|
137
|
+
const existing = await s.members.get(id);
|
|
138
|
+
const beforeTags = existing ? [...existing.tags] : null;
|
|
139
|
+
const current: MemberRecord = existing ?? emptyMember(id);
|
|
132
140
|
const updatedAt = time(payload.updated_at);
|
|
133
141
|
|
|
134
142
|
if (kind === "profile") {
|
|
@@ -157,5 +165,6 @@ export async function apply(s: Stores, topic: string, payload: Record<string, un
|
|
|
157
165
|
current.syncedAt = new Date().toISOString();
|
|
158
166
|
current.source = topic;
|
|
159
167
|
await s.members.put(id, stampTags(current));
|
|
168
|
+
await onWrite?.(beforeTags, current);
|
|
160
169
|
return "processed";
|
|
161
170
|
}
|