@redeemid/emdash 0.0.31 → 0.0.32
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 +3 -3
- package/docs/developer-api.md +2 -2
- package/docs/getting-started.md +3 -3
- package/docs/production.md +1 -1
- package/docs/reference.md +1 -1
- package/docs/shopify-flow.md +1 -1
- package/docs/shopify-setup.md +4 -4
- package/docs/staff.md +11 -9
- package/package.json +1 -1
- package/src/descriptor.ts +8 -5
- package/src/monitor.ts +19 -32
- package/src/plugin.ts +81 -61
- package/src/setup.ts +33 -38
- package/src/version.ts +1 -1
package/README.md
CHANGED
|
@@ -27,7 +27,7 @@ Selling a membership is ordinary Shopify: a product, a checkout, and a Flow work
|
|
|
27
27
|
- **For shoppers:** product pages with options, subscriptions and Product structured data, a product sitemap, collections with Shopify's filters and sorting, product search, recommendations, a cart page and cart drawer, discount codes, local prices by market, and a checkout that opens signed in for members. It all works without JavaScript.
|
|
28
28
|
- **For members:** sign in, Account and Sign out without ever seeing the word "Shopify"; a member card with levels, renewal and end dates; preferences saved on their customer record; download or delete what the site keeps about them.
|
|
29
29
|
- **For editors:** Paywall break, Members only, Sold on its own, Plan, Product, Collection, Product search, Contribution and Preferences blocks; an Access panel on every entry; teasers, a monthly or weekly meter, drip, badges and protected file downloads.
|
|
30
|
-
- **For staff:** an Overview, then Members, Access (with Map and Test tabs), Reports, Privacy and Connection (Status,
|
|
30
|
+
- **For staff:** an Overview, then Members, Access (with Map and Test tabs), Reports, Events (Webhook, App and Health tabs), Privacy and Connection (Status, then a tab per setup step) in the EmDash admin, and a dashboard card. New content types and taxonomies appear on their own, so access never needs a code change.
|
|
31
31
|
- **For developers:** Astro components for every piece, `canRead()` and friends for your own code, a storefront from the Storefront API with a cart and a signed-in checkout, a browser client with analytics events, signed outbound events, MCP tools for AI agents and a `redeemid-emdash` command.
|
|
32
32
|
|
|
33
33
|
## Requirements
|
|
@@ -75,14 +75,14 @@ import Content from "@redeemid/emdash/components/Content.astro";
|
|
|
75
75
|
<Content value={post.data.content} collection="posts" terms={post.data.terms} id={post.id} title={post.data.title} />
|
|
76
76
|
```
|
|
77
77
|
|
|
78
|
-
Set the secrets, open **Plugins → RedeemID → Connection
|
|
78
|
+
Set the secrets, open **Plugins → RedeemID → Connection** in the admin, and work through its tabs. [Getting started](docs/getting-started.md) walks through all of it.
|
|
79
79
|
|
|
80
80
|
## Documentation
|
|
81
81
|
|
|
82
82
|
| Guide | What's in it |
|
|
83
83
|
| --- | --- |
|
|
84
84
|
| [Getting started](docs/getting-started.md) | Requirements, install, config, secrets, templates and a first check |
|
|
85
|
-
| [Connect a store](docs/shopify-setup.md) | The Shopify side: sign-in client, Storefront tokens and the
|
|
85
|
+
| [Connect a store](docs/shopify-setup.md) | The Shopify side: sign-in client, Storefront tokens and the Admin API app |
|
|
86
86
|
| [Shopify Flow recipes](docs/shopify-flow.md) | Tag on a paid order, remove on refund or cancellation, passes that end |
|
|
87
87
|
| [Content and templates](docs/content.md) | `<Content>`, the Paywall break, Members only, the wall, `<Gate>`, badges, plan cards, site search and replacement text |
|
|
88
88
|
| [Access](docs/access.md) | Rules, levels, the Access page and the entry panel, meter and drip, selling one entry, features and protected files |
|
package/docs/developer-api.md
CHANGED
|
@@ -56,7 +56,7 @@ Under `/_emdash/api/auth/redeemid/`. Same-origin only: EmDash checks `Origin` on
|
|
|
56
56
|
- `api/state` (GET): the visitor's own state.
|
|
57
57
|
|
|
58
58
|
```json
|
|
59
|
-
{ "apiVersion": "1.5.0", "version": "0.0.
|
|
59
|
+
{ "apiVersion": "1.5.0", "version": "0.0.32", "signedIn": true, "linked": true,
|
|
60
60
|
"levels": ["gold"], "features": ["ad-free"], "cart": { "count": 2 },
|
|
61
61
|
"meter": { "used": 1, "limit": 3, "period": "month" } }
|
|
62
62
|
```
|
|
@@ -125,7 +125,7 @@ Signed outbound webhooks for a CRM or email tool. Verify `X-RedeemID-Signature`
|
|
|
125
125
|
events: { url: "https://hooks.example.com/redeemid", only: ["member.signed_in", "member.levels_changed"] },
|
|
126
126
|
```
|
|
127
127
|
|
|
128
|
-
With `REDEEMID_EVENTS_SECRET` set, RedeemID POSTs `{ id, event, at, data }` to the URL with the headers `X-RedeemID-Event`, `X-RedeemID-Delivery` (the `id`) and `X-RedeemID-Signature: sha256=<hex HMAC-SHA256 of the raw body>`. Check the signature before you trust a body. Events go out after the response (with `waitUntil` on Workers), so sign-in, webhooks and pages never wait for your receiver. Each try waits at most 3 seconds; a send that fails is tried again after 1 second and after 5 seconds. One that fails all three tries is kept under **App
|
|
128
|
+
With `REDEEMID_EVENTS_SECRET` set, RedeemID POSTs `{ id, event, at, data }` to the URL with the headers `X-RedeemID-Event`, `X-RedeemID-Delivery` (the `id`) and `X-RedeemID-Signature: sha256=<hex HMAC-SHA256 of the raw body>`. Check the signature before you trust a body. Events go out after the response (with `waitUntil` on Workers), so sign-in, webhooks and pages never wait for your receiver. Each try waits at most 3 seconds; a send that fails is tried again after 1 second and after 5 seconds. One that fails all three tries is kept under **Events → App → Failed events**, where staff can **Send again** (same `id`, so drop duplicates by it) or **Clear** it; failed events are deleted after 30 days.
|
|
129
129
|
|
|
130
130
|
| Event | `data` |
|
|
131
131
|
| --- | --- |
|
package/docs/getting-started.md
CHANGED
|
@@ -12,7 +12,7 @@ From an empty EmDash site to a locked post that opens for a tagged customer.
|
|
|
12
12
|
|
|
13
13
|
## 1. Set up Shopify
|
|
14
14
|
|
|
15
|
-
Follow [Connect a store](shopify-setup.md). It gives you a Customer Account API client, Storefront API tokens and
|
|
15
|
+
Follow [Connect a store](shopify-setup.md). It gives you a Customer Account API client, Storefront API tokens and an Admin API app (staff tools, resyncs and webhooks). Once the package is installed, **Plugins → RedeemID → Connection** walks through the same steps for your site, one tab each, with its own callback URI filled in, and marks the ones that are done.
|
|
16
16
|
|
|
17
17
|
## 2. Install
|
|
18
18
|
|
|
@@ -68,7 +68,7 @@ REDEEMID_SHOPIFY_ADMIN_APP_CLIENT_SECRET=… # verifies webhooks
|
|
|
68
68
|
REDEEMID_SHOPIFY_STOREFRONT_PRIVATE_TOKEN=… # only to sell on the site: plans, shop and checkout
|
|
69
69
|
```
|
|
70
70
|
|
|
71
|
-
The [secrets reference](reference.md#secrets) lists the rest. Connection
|
|
71
|
+
The [secrets reference](reference.md#secrets) lists the rest. Connection prints the command for each secret its steps need.
|
|
72
72
|
|
|
73
73
|
## 5. Render content through RedeemID
|
|
74
74
|
|
|
@@ -89,6 +89,6 @@ Use `<Content>` in place of `<PortableText>` for every body that may be locked,
|
|
|
89
89
|
## 6. Check it
|
|
90
90
|
|
|
91
91
|
1. Finish EmDash setup as staff first. Store customers can't create the first account.
|
|
92
|
-
2. Open **Plugins → RedeemID → Connection
|
|
92
|
+
2. Open **Plugins → RedeemID → Connection**. Work through the step tabs until each has a check mark (the Webhooks tab registers the webhooks for you). **Status** lists every setting as Set or Missing.
|
|
93
93
|
3. Tag a test customer `member` in Shopify, open a locked post, and sign in as that customer. The post opens.
|
|
94
94
|
4. Remove the tag in Shopify. The post locks again once the webhook arrives.
|
package/docs/production.md
CHANGED
|
@@ -48,5 +48,5 @@ Each action counts separately per visitor (by `CF-Connecting-IP`), and Cloudflar
|
|
|
48
48
|
- **A setting shows Missing on Connection → Status.** Check the name: Connection → Status lists any `REDEEMID_` variable it doesn't read. Set it as a runtime secret, not a build variable. On Workers, a deploy can remove plain-text dashboard variables, so use encrypted secrets.
|
|
49
49
|
- **Sign-in comes back with a notice.** The code says why; see [notices](reference.md#notices). `sign_in_unavailable` means store discovery failed, usually a wrong `shop`. `not_configured` means the client ID or `shop` is missing.
|
|
50
50
|
- **Register webhooks names a refused topic.** The app lacks a scope, such as `read_customer_merge` for `customers/merge`. Add it to the app, have the store accept the new version, and press Register again.
|
|
51
|
-
- **Tags changed in Shopify but the site didn't follow.** Check
|
|
51
|
+
- **Tags changed in Shopify but the site didn't follow.** Check Events → Webhook and Health, and Events → App with Problems only. Resync the member from their page, or ask them to press Refresh membership.
|
|
52
52
|
- **A member sees the visitor's page.** A cache is serving it. See [caching](#caching).
|
package/docs/reference.md
CHANGED
|
@@ -110,7 +110,7 @@ The full developer API (stable exports, JSON routes, the browser client and its
|
|
|
110
110
|
|
|
111
111
|
Under `/_emdash/api/auth/redeemid/`: `login`, `callback`, `logout`, `webhook`, `buy`, `cart`, `cart-edit`, `account`, `preferences` and `privacy`, and the JSON routes `api/state`, `api/cart` and `api/search`. Staff-only routes for the admin, agents and the command line are under `/_emdash/api/plugins/redeemid/`.
|
|
112
112
|
|
|
113
|
-
In the Customer Account API client, set the callback URI to `https://<site>/_emdash/api/auth/redeemid/callback`, the logout URI to `https://<site>/`, and (public clients) the JavaScript origin to `https://<site>`. Point webhooks at `https://<site>/_emdash/api/auth/redeemid/webhook`: `customers/create`, `customers/update`, `customers/delete`, `customers/disable`, `customers/enable`, `customers/merge`, `customer.tags_added` and `customer.tags_removed` (Connection →
|
|
113
|
+
In the Customer Account API client, set the callback URI to `https://<site>/_emdash/api/auth/redeemid/callback`, the logout URI to `https://<site>/`, and (public clients) the JavaScript origin to `https://<site>`. Point webhooks at `https://<site>/_emdash/api/auth/redeemid/webhook`: `customers/create`, `customers/update`, `customers/delete`, `customers/disable`, `customers/enable`, `customers/merge`, `customer.tags_added` and `customer.tags_removed` (Connection → Webhooks registers these for you). The compliance topics `customers/data_request`, `customers/redact` and `shop/redact` go to the same URL, set in the app's own configuration.
|
|
114
114
|
|
|
115
115
|
## Notices
|
|
116
116
|
|
package/docs/shopify-flow.md
CHANGED
|
@@ -63,7 +63,7 @@ After a purchase, **Refresh membership** on the member card reads their tags aga
|
|
|
63
63
|
|
|
64
64
|
## Check a recipe
|
|
65
65
|
|
|
66
|
-
1. Under **Plugins → RedeemID → Connection →
|
|
66
|
+
1. Under **Plugins → RedeemID → Connection → Webhooks**, make sure `customer.tags_added` and `customer.tags_removed` show as Registered. The tab registers them for you.
|
|
67
67
|
2. Buy the product with a test customer, using Shopify's Bogus Gateway or a 100% discount code.
|
|
68
68
|
3. In Flow, open the workflow's **Recent runs**. The run should show the tag added.
|
|
69
69
|
4. Open **Plugins → RedeemID → Members**, then the customer. The level should be on top and the tag under **Tags**.
|
package/docs/shopify-setup.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
What to set up in Shopify, and on the site, to run RedeemID on an EmDash site. One store can serve several sites: each site gets its own Headless storefront for sign-in, and each receives webhooks.
|
|
4
4
|
|
|
5
|
-
Once the package is installed, **Plugins → RedeemID → Connection
|
|
5
|
+
Once the package is installed, **Plugins → RedeemID → Connection** in the EmDash admin walks through these same steps, one tab each, with your site's own addresses filled in, checks each one, and registers the webhooks for you.
|
|
6
6
|
|
|
7
7
|
## 1. Sign-in: a Headless storefront
|
|
8
8
|
|
|
@@ -27,7 +27,7 @@ The store must use the new customer accounts (Settings → Customer accounts). C
|
|
|
27
27
|
|
|
28
28
|
RedeemID keeps tags and status current from webhooks sent by a Shopify app on the store (for example a Dev Dashboard app). Shopify signs every webhook with that app's **client secret**, which is the site's webhook secret.
|
|
29
29
|
|
|
30
|
-
Give the app the `read_customers` and `write_customers` Admin API scopes (`write_customers` alone is enough, since a write scope includes its read scope), plus `read_customer_merge` for `customers/merge`, and set its **client ID** on the site as well. Then Connection →
|
|
30
|
+
Give the app the `read_customers` and `write_customers` Admin API scopes (`write_customers` alone is enough, since a write scope includes its read scope), plus `read_customer_merge` for `customers/merge`, and set its **client ID** on the site as well. Then Connection → Webhooks registers these topics at `https://<site>/_emdash/api/auth/redeemid/webhook` with one button:
|
|
31
31
|
|
|
32
32
|
`customers/create`, `customers/update`, `customers/delete`, `customers/disable`, `customers/enable`, `customers/merge`, `customer.tags_added`, `customer.tags_removed`
|
|
33
33
|
|
|
@@ -47,8 +47,8 @@ Set the secrets. On Cloudflare Workers:
|
|
|
47
47
|
npx wrangler secret put REDEEMID_SHOPIFY_STORE_DOMAIN # e.g. my-store.myshopify.com, unless `shop` is in the config
|
|
48
48
|
npx wrangler secret put REDEEMID_SHOPIFY_CUSTOMER_ACCOUNT_CLIENT_ID # Customer Account API client ID
|
|
49
49
|
npx wrangler secret put REDEEMID_SHOPIFY_CUSTOMER_ACCOUNT_CLIENT_SECRET # confidential clients only
|
|
50
|
-
npx wrangler secret put REDEEMID_SHOPIFY_ADMIN_APP_CLIENT_SECRET # the
|
|
51
|
-
npx wrangler secret put REDEEMID_SHOPIFY_ADMIN_APP_CLIENT_ID # optional; the
|
|
50
|
+
npx wrangler secret put REDEEMID_SHOPIFY_ADMIN_APP_CLIENT_SECRET # the Admin API app's client secret (signs webhooks)
|
|
51
|
+
npx wrangler secret put REDEEMID_SHOPIFY_ADMIN_APP_CLIENT_ID # optional; the Admin API app's client ID, for the staff tools
|
|
52
52
|
npx wrangler secret put REDEEMID_EVENTS_SECRET # optional; signs outbound events (events.url)
|
|
53
53
|
npx wrangler secret put REDEEMID_SHOPIFY_STOREFRONT_PRIVATE_TOKEN # optional; the Storefront API private token, to sell on the site (plans, shop, checkout)
|
|
54
54
|
npx wrangler secret put REDEEMID_SHOPIFY_STOREFRONT_PUBLIC_TOKEN # optional; used only without the private token
|
package/docs/staff.md
CHANGED
|
@@ -2,12 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
## Staff tools
|
|
4
4
|
|
|
5
|
-
With `REDEEMID_SHOPIFY_ADMIN_APP_CLIENT_ID` set, RedeemID gets an Admin API token for the
|
|
5
|
+
With `REDEEMID_SHOPIFY_ADMIN_APP_CLIENT_ID` set, RedeemID gets an Admin API token for the Admin API app with the client credentials grant. The app must be in the same Shopify organization as the store, as Dev Dashboard apps are, and needs `read_customers` and `write_customers`. Then, under **Plugins → RedeemID**:
|
|
6
6
|
|
|
7
7
|
- **Member detail.** Account, levels and tag count on top, then tabs: Membership (levels, features, membership dates and level history), Tags (add in a dialog, remove), Account (email, consent, last sync and the site user) and Privacy. Resync from Shopify, and on a myshopify.com store, open the customer in Shopify. Tag edits are made in Shopify and read back. Nothing is granted on the site.
|
|
8
8
|
- **Site user.** Signing in links a customer to the site user with the same verified email. When the emails differ, staff link the customer to an existing Subscriber by that user's email on the member's page (never a staff account, never a user linked to another customer), or unlink them. This part needs no Admin API app.
|
|
9
9
|
- **Bulk resync.** Reads the 25 members synced longest ago, few enough for the Workers free limit of 50 database queries a request. Press it again for the next 25. It never runs on its own.
|
|
10
|
-
- **Webhook subscriptions.** Connection →
|
|
10
|
+
- **Webhook subscriptions.** Connection → Webhooks lists the store's subscriptions to this site and registers any that are missing. A topic Shopify refuses, such as `customers/merge` without `read_customer_merge`, is named with the reason and doesn't stop the others. `customers/redact` stays in the app's own settings.
|
|
11
11
|
- **Access → Test.** A collection, an entry, terms and a reader's tags in; the verdict out.
|
|
12
12
|
|
|
13
13
|
Resync updates email, name, tags, email marketing consent and the membership dates. Enabled or disabled status comes only from the enable and disable webhooks. With the app set up, two more things read the customer from the Admin API as it is now: every sign-in (and Refresh membership), and every `customer.tags_added` or `customer.tags_removed` webhook. Those topics carry only the change and can arrive out of order, so reading the current tags keeps the mirror right. Without the app, or when the read fails, the webhook's change is applied as sent.
|
|
@@ -27,11 +27,13 @@ RedeemID reads them with the Admin API (resync, sign-in, tag webhooks), so they
|
|
|
27
27
|
|
|
28
28
|
## Overview, dashboard card and Connection
|
|
29
29
|
|
|
30
|
-
**Plugins → RedeemID → Overview** is the first page: anything that needs doing (setup steps left, webhook problems, open privacy requests), cards for sign-in, members, webhooks, access, privacy and setup with links to each, members by level, recent app events and links to the docs, the store's customers and Flow. The **RedeemID** card on the EmDash dashboard shows the same alerts and counts with links to Overview, Members, Access and
|
|
30
|
+
**Plugins → RedeemID → Overview** is the first page: anything that needs doing (setup steps left, webhook problems, open privacy requests), cards for sign-in, members, webhooks, access, privacy and setup with links to each, members by level, recent app events and links to the docs, the store's customers and Flow. The **RedeemID** card on the EmDash dashboard shows the same alerts and counts with links to Overview, Members, Access and Events (or Connection while a setup step is left).
|
|
31
31
|
|
|
32
|
-
**Connection** holds
|
|
32
|
+
**Connection** holds the store link: **Status** (the values to copy into Shopify, and every setting as Set or Missing), then one tab per setup step: Store, Sign-in, Admin app, Webhooks, First level, Try it and Storefront. A finished step's tab shows a check mark. It opens on the first step not done, then on Status once they all are.
|
|
33
33
|
|
|
34
|
-
**
|
|
34
|
+
**Events** shows what happened, in three tabs: Webhook, App and Health. Each tab of both pages has its own address, such as `/_emdash/admin/plugins/redeemid/connection/sign-in` or `/_emdash/admin/plugins/redeemid/events/app`, which the dashboard card and Overview link to.
|
|
35
|
+
|
|
36
|
+
**Status** shows the addresses to paste into Shopify (callback URI, JavaScript origin, logout URI, webhook URL) and the store domain as read-only fields with copy buttons, every setting as Set or Missing with the environment variable it comes from, and the template checks.
|
|
35
37
|
|
|
36
38
|
The admin pages are built as Block Kit on the server. The package's admin entry (`@redeemid/emdash/admin`, loaded by EmDash from the plugin descriptor) renders them in the browser and adds what Block Kit has no block for: copy fields, dialogs and linked cards. It needs `react`, `@cloudflare/kumo` and `@emdash-cms/blocks`, which every EmDash site already has through `emdash`.
|
|
37
39
|
|
|
@@ -39,10 +41,10 @@ The admin pages are built as Block Kit on the server. The package's admin entry
|
|
|
39
41
|
|
|
40
42
|
Members gain and lose access only when Shopify sends tag changes, so RedeemID shows what arrived and warns when deliveries stop.
|
|
41
43
|
|
|
42
|
-
- **
|
|
43
|
-
- **
|
|
44
|
-
- **
|
|
45
|
-
- **
|
|
44
|
+
- **Events → Webhook.** Each delivery's topic, what became of it (processed, skipped, older than what's stored, not used) and the customer ID, with counts of deliveries turned away (bad signature) and failed (storage errors; Shopify sends those again).
|
|
45
|
+
- **Events → Health.** Once a day RedeemID registers again any subscription Shopify dropped (with the Admin API app), and warns when no webhook has come in for a number of days (3 by default, 0 turns it off), or when deliveries were turned away or failed in the last day. **Check now** runs it at once. Warnings show on the page and the dashboard widget. If you enter an address on the Health tab and the site has an EmDash email provider, they also go by email: once when problems start or change, then once a week while they last.
|
|
46
|
+
- **Subscriptions** (on Connection → Webhooks). The endpoint, whether signatures can be checked, and the subscriptions.
|
|
47
|
+
- **Events → App.** What RedeemID did and what came of it: webhooks received, turned away and failed, sign-ins and refusals (by reason), Admin API calls (by operation, with timings), resyncs, tag edits, webhook registrations, settings saves, outbound events that failed, metered reads and the health checks. Filter by event or show problems only. Entries hold ids, labels and outcomes, never bodies, tokens or emails. Failed outbound events also keep the event body (ids and levels, never an email or a name) so they can be sent again.
|
|
46
48
|
- **Reports.** Members on each level, who joined and left in the last 30 days and by week for 12 weeks (from each member's tag dates), sign-ins and metered reads.
|
|
47
49
|
|
|
48
50
|
App events and delivery records are kept 30 days. The daily check runs from EmDash's scheduler: on Node it runs by itself; on Cloudflare Workers, add a Cron Trigger to the site's Worker as EmDash's deployment guide describes. The plugin asks for `email:send` only to send these warnings.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@redeemid/emdash",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.32",
|
|
4
4
|
"description": "Shopify commerce for EmDash sites: storefront, cart, checkout, customer accounts, memberships and paywalls. Shopify runs commerce; EmDash runs content.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/src/descriptor.ts
CHANGED
|
@@ -12,8 +12,8 @@ import { VERSION } from "./version.js";
|
|
|
12
12
|
* Every page shares one sidebar folder, so RedeemID shows as a single
|
|
13
13
|
* RedeemID entry under Plugins (opening to these pages) instead of loose
|
|
14
14
|
* items mixed in with other plugins' pages. Overview comes first; then the
|
|
15
|
-
* day-to-day pages (people and
|
|
16
|
-
* Status
|
|
15
|
+
* day-to-day pages (people, access and what happened), then Connection,
|
|
16
|
+
* with Status and one tab per setup step.
|
|
17
17
|
*/
|
|
18
18
|
const GROUP = "RedeemID";
|
|
19
19
|
export const PAGES = [
|
|
@@ -21,12 +21,15 @@ export const PAGES = [
|
|
|
21
21
|
{ path: "/members", label: "Members", icon: "users", group: GROUP },
|
|
22
22
|
{ path: "/access", label: "Access", icon: "lock", group: GROUP },
|
|
23
23
|
{ path: "/reports", label: "Reports", icon: "chart", group: GROUP },
|
|
24
|
+
{ path: "/events", label: "Events", icon: "pulse", group: GROUP },
|
|
24
25
|
{ path: "/privacy", label: "Privacy", icon: "shield-check", group: GROUP },
|
|
25
26
|
{ path: "/connection", label: "Connection", icon: "plugs-connected", group: GROUP },
|
|
26
27
|
];
|
|
27
|
-
/** Connection's tabs by address (/connection/
|
|
28
|
-
export const CONNECTION_TABS = ["status", "
|
|
29
|
-
|
|
28
|
+
/** Connection's tabs by address (/connection/sign-in), so a link can open one: Status, then a tab per setup step. */
|
|
29
|
+
export const CONNECTION_TABS = ["status", "store", "sign-in", "app", "webhooks", "level", "try", "storefront"] as const;
|
|
30
|
+
/** Events' tabs by address (/events/app). */
|
|
31
|
+
export const EVENTS_TABS = ["webhook", "app", "health"] as const;
|
|
32
|
+
export const TAB_PATHS = [...CONNECTION_TABS.map((t) => `/connection/${t}`), ...EVENTS_TABS.map((t) => `/events/${t}`)];
|
|
30
33
|
/** The Access panel on every saved entry, in every collection (new ones too). */
|
|
31
34
|
export const EDITOR_PANELS = [{ id: "access", title: "Access", route: "access-panel", order: 10 }];
|
|
32
35
|
export const WIDGETS = [{ id: "status", title: "RedeemID", size: "half" as const }];
|
package/src/monitor.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Watching what RedeemID does, in the admin (Block Kit):
|
|
3
3
|
*
|
|
4
|
-
* - Webhooks: what Shopify sent and what became of it (
|
|
5
|
-
* deliveries are flowing (Health: the daily check, rejected
|
|
6
|
-
* deliveries, and when to warn staff by email), and where Shopify
|
|
7
|
-
* (
|
|
8
|
-
* - App events: the log in log.ts, newest first, by event or
|
|
4
|
+
* - Webhooks: what Shopify sent and what became of it (Events → Webhook),
|
|
5
|
+
* whether deliveries are flowing (Events → Health: the daily check, rejected
|
|
6
|
+
* and failed deliveries, and when to warn staff by email), and where Shopify
|
|
7
|
+
* sends them (Connection → Webhooks: endpoint, signatures, subscriptions).
|
|
8
|
+
* - App events (Events → App): the log in log.ts, newest first, by event or
|
|
9
|
+
* problems only.
|
|
9
10
|
* - Reports: members by level, who joined and left, sign-ins and metered reads.
|
|
10
11
|
*
|
|
11
12
|
* The daily check re-creates subscriptions Shopify dropped and never changes
|
|
@@ -129,7 +130,7 @@ export async function healthCheck(env_: MonitorEnv): Promise<HealthResult> {
|
|
|
129
130
|
|
|
130
131
|
const last = await lastDelivery(s);
|
|
131
132
|
if (settings.quietDays > 0) {
|
|
132
|
-
if (!last) problems.push({ level: "warn", text: "No webhooks received yet. Check the webhook subscriptions under Connection →
|
|
133
|
+
if (!last) problems.push({ level: "warn", text: "No webhooks received yet. Check the webhook subscriptions under Connection → Webhooks." });
|
|
133
134
|
else if (Date.parse(last.receivedAt) < now - settings.quietDays * DAY) {
|
|
134
135
|
const days = Math.floor((now - Date.parse(last.receivedAt)) / DAY);
|
|
135
136
|
problems.push({ level: "warn", text: `No webhooks for ${days} days. Quiet stores are normal; if customers changed in Shopify since, check Setup.` });
|
|
@@ -140,7 +141,7 @@ export async function healthCheck(env_: MonitorEnv): Promise<HealthResult> {
|
|
|
140
141
|
const rejected = await count(s, "webhook.rejected", day);
|
|
141
142
|
if (rejected) problems.push({ level: "warn", text: `${rejected} ${rejected === 1 ? "delivery was" : "deliveries were"} turned away in the last day (bad signature): check the webhook signing secret.` });
|
|
142
143
|
const failed = await count(s, "webhook.failed", day);
|
|
143
|
-
if (failed) problems.push({ level: "error", text: `${failed} ${failed === 1 ? "delivery" : "deliveries"} failed to save in the last day. Shopify sends them again; see App
|
|
144
|
+
if (failed) problems.push({ level: "error", text: `${failed} ${failed === 1 ? "delivery" : "deliveries"} failed to save in the last day. Shopify sends them again; see Events → App.` });
|
|
144
145
|
|
|
145
146
|
const result: HealthResult = { at: new Date(now).toISOString(), problems, registered, subscriptions };
|
|
146
147
|
result.emailed = await alert(env_, settings, result, now);
|
|
@@ -195,10 +196,7 @@ export async function tidy(s: Stores): Promise<void> {
|
|
|
195
196
|
|
|
196
197
|
export { topicName };
|
|
197
198
|
|
|
198
|
-
// ----------------------------------------------------------------
|
|
199
|
-
|
|
200
|
-
export const WEBHOOK_TABS = ["received", "health", "setup"] as const;
|
|
201
|
-
export type WebhookTab = (typeof WEBHOOK_TABS)[number];
|
|
199
|
+
// ---------------------------------------------------------------- Webhook panels
|
|
202
200
|
|
|
203
201
|
const OUTCOMES: Record<string, string> = {
|
|
204
202
|
processed: "Processed",
|
|
@@ -210,7 +208,11 @@ const OUTCOMES: Record<string, string> = {
|
|
|
210
208
|
|
|
211
209
|
const cap = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);
|
|
212
210
|
|
|
213
|
-
|
|
211
|
+
/**
|
|
212
|
+
* The webhook parts of the admin: deliveries and their health (Events →
|
|
213
|
+
* Webhook and Health) and the app's subscriptions (Connection → Webhooks).
|
|
214
|
+
*/
|
|
215
|
+
export async function webhookPanels(env_: MonitorEnv, cursor?: string): Promise<{ received: unknown[]; health: unknown[]; subscriptions: unknown[]; attention: boolean }> {
|
|
214
216
|
const { s, kv } = env_;
|
|
215
217
|
const now = env_.now?.() ?? Date.now();
|
|
216
218
|
const month = since(now, LOG_DAYS);
|
|
@@ -236,7 +238,7 @@ export async function webhooksPage(env_: MonitorEnv, tab: WebhookTab = "received
|
|
|
236
238
|
{ label: "Failed", value: String(failed), ...(failed ? { trend: "down" } : {}) },
|
|
237
239
|
],
|
|
238
240
|
},
|
|
239
|
-
...(cursor ? [{ type: "actions", elements: [{ type: "button", action_id: "
|
|
241
|
+
...(cursor ? [{ type: "actions", elements: [{ type: "button", action_id: "deliveries_page", value: "", label: "Back to the newest", style: "secondary" }] }] : []),
|
|
240
242
|
{
|
|
241
243
|
type: "table",
|
|
242
244
|
page_action_id: "deliveries_page",
|
|
@@ -303,7 +305,7 @@ export async function webhooksPage(env_: MonitorEnv, tab: WebhookTab = "received
|
|
|
303
305
|
},
|
|
304
306
|
];
|
|
305
307
|
|
|
306
|
-
const
|
|
308
|
+
const subscriptions = [
|
|
307
309
|
{ type: "context", text: "Where Shopify sends deliveries, how they're checked, and the app's webhook subscriptions." },
|
|
308
310
|
{ type: "section", text: `Endpoint: ${env_.uri}` },
|
|
309
311
|
{
|
|
@@ -316,21 +318,7 @@ export async function webhooksPage(env_: MonitorEnv, tab: WebhookTab = "received
|
|
|
316
318
|
{ type: "context", text: "customers/redact, customers/data_request and shop/redact are set in the app's compliance webhooks, not here." },
|
|
317
319
|
];
|
|
318
320
|
|
|
319
|
-
return {
|
|
320
|
-
blocks: [
|
|
321
|
-
{ type: "header", text: "Webhooks" },
|
|
322
|
-
{
|
|
323
|
-
type: "tab",
|
|
324
|
-
default_tab: WEBHOOK_TABS.indexOf(tab),
|
|
325
|
-
panels: [
|
|
326
|
-
{ label: "Received", blocks: receivedTab },
|
|
327
|
-
{ label: attention ? "Health (!)" : "Health", blocks: healthTab },
|
|
328
|
-
{ label: "Setup", blocks: setupTab },
|
|
329
|
-
],
|
|
330
|
-
},
|
|
331
|
-
],
|
|
332
|
-
...(toast ? { toast } : {}),
|
|
333
|
-
};
|
|
321
|
+
return { received: receivedTab, health: healthTab, subscriptions, attention };
|
|
334
322
|
}
|
|
335
323
|
|
|
336
324
|
const SUBS: Record<HealthResult["subscriptions"], string> = {
|
|
@@ -385,7 +373,6 @@ export async function eventsPage(s: Stores, filter: EventFilter = {}, cursor?: s
|
|
|
385
373
|
const failed = cursor ? null : await s.failedEvents.query({ orderBy: { at: "desc" }, limit: 50 }).catch(() => null);
|
|
386
374
|
return {
|
|
387
375
|
blocks: [
|
|
388
|
-
{ type: "header", text: "App events" },
|
|
389
376
|
{
|
|
390
377
|
type: "context",
|
|
391
378
|
text: `What RedeemID did and what came of it: webhooks, sign-ins, calls to Shopify, staff actions, settings saves and outbound events. Kept ${LOG_DAYS} days. Never bodies, tokens or emails.`,
|
|
@@ -467,7 +454,7 @@ function failedBlocks(list: FailedEvent[]): unknown[] {
|
|
|
467
454
|
action_id: "failed_clear_all",
|
|
468
455
|
label: "Clear all",
|
|
469
456
|
style: "danger",
|
|
470
|
-
confirm: { title: "Clear every failed event?", text: "They won't be sent. App
|
|
457
|
+
confirm: { title: "Clear every failed event?", text: "They won't be sent. Events → App keeps the record that they failed.", confirm: "Clear all", deny: "Cancel", style: "danger" },
|
|
471
458
|
},
|
|
472
459
|
],
|
|
473
460
|
},
|
|
@@ -559,7 +546,7 @@ export async function reportsPage(config: RedeemIDConfig, s: Stores, now = Date.
|
|
|
559
546
|
{ type: "header", text: "Reports" },
|
|
560
547
|
{
|
|
561
548
|
type: "context",
|
|
562
|
-
text: "A level is joined or left when Shopify adds or removes its tag. Counts come from each member's tag dates; sign-ins and metered reads from App
|
|
549
|
+
text: "A level is joined or left when Shopify adds or removes its tag. Counts come from each member's tag dates; sign-ins and metered reads from Events → App (kept 30 days).",
|
|
563
550
|
},
|
|
564
551
|
{
|
|
565
552
|
type: "stats",
|
package/src/plugin.ts
CHANGED
|
@@ -34,17 +34,17 @@ import { deleteAccess, entryKey, loadAccess, onLevels, withSaved } from "./setti
|
|
|
34
34
|
import { env, ENV, shopOrigin, storeDomain, unknownEnvNames, type LevelConfig, type RedeemIDConfig } from "./config.js";
|
|
35
35
|
import { discover } from "./shopify.js";
|
|
36
36
|
import { stores, type MemberRecord, type Stores } from "./storage.js";
|
|
37
|
-
import { CONNECTION_TABS, EDITOR_PANELS, PAGES, WIDGETS } from "./descriptor.js";
|
|
37
|
+
import { CONNECTION_TABS, EDITOR_PANELS, EVENTS_TABS, PAGES, WIDGETS } from "./descriptor.js";
|
|
38
38
|
import { VERSION } from "./version.js";
|
|
39
39
|
import { AdminError, adminConfigured } from "./admin-api.js";
|
|
40
40
|
import { cancelShopifyErasure, closeRequest, eraseMember, exportMember, exportRequest, recentRequests, requestShopifyErasure } from "./privacy.js";
|
|
41
41
|
import { bulkResync, BULK_BATCH, memberDetail, privacyPage, registerWebhooks, resyncOne, staffLink, staffUnlink, tagEdit } from "./staff.js";
|
|
42
42
|
import { textProblems } from "./text.js";
|
|
43
43
|
import { cacheFindings, gatedFindings, templateFindings } from "./template-scan.js";
|
|
44
|
-
import { setupFacts,
|
|
44
|
+
import { nextStep, requiredSteps, setupFacts, setupPanels, setupSteps, STEP_TABS, WEBHOOK } from "./setup.js";
|
|
45
45
|
import { record } from "./log.js";
|
|
46
46
|
import { resend } from "./events.js";
|
|
47
|
-
import { eventRow, eventsPage, healthCheck, HEALTH_TASK, lastCheck, parseFilter, reportsPage, saveMonitor, WEBHOOK_PATH,
|
|
47
|
+
import { eventRow, eventsPage, healthCheck, HEALTH_TASK, lastCheck, parseFilter, reportsPage, saveMonitor, WEBHOOK_PATH, webhookPanels, type MonitorEnv } from "./monitor.js";
|
|
48
48
|
import { z } from "astro/zod";
|
|
49
49
|
import { AgentError, explainAccess, findProducts, getCollection, getMember, getProduct, getRules, levelChanges, listLevels, siteHealth, type ExplainInput } from "./agents.js";
|
|
50
50
|
|
|
@@ -289,11 +289,14 @@ export function createPlugin(code: RedeemIDConfig = {}): ResolvedPlugin {
|
|
|
289
289
|
if (i.page === "/members") return await membersPage(config);
|
|
290
290
|
if (i.page === "/access") return await accessPage(await accessEnv(config));
|
|
291
291
|
if (i.page === "/reports") return await reportsPage(config, await memberStores());
|
|
292
|
-
// Connection, or one of
|
|
292
|
+
// Connection and Events, or one of their tabs by address (/connection/sign-in, /events/app).
|
|
293
293
|
if (i.page === "/connection" || i.page?.startsWith("/connection/")) {
|
|
294
294
|
await scheduleHealth(ctx);
|
|
295
|
-
|
|
296
|
-
|
|
295
|
+
return await connectionPage(config, ctx, tabOf(CONNECTION_TABS, i.page.split("/")[2]));
|
|
296
|
+
}
|
|
297
|
+
if (i.page === "/events" || i.page?.startsWith("/events/")) {
|
|
298
|
+
await scheduleHealth(ctx);
|
|
299
|
+
return await eventsAdminPage(config, ctx, tabOf(EVENTS_TABS, i.page.split("/")[2]));
|
|
297
300
|
}
|
|
298
301
|
if (i.page === "/privacy") return await privacyPage(await memberStores());
|
|
299
302
|
if (i.page === "widget:status") return await statusWidget(config, ctx);
|
|
@@ -484,14 +487,11 @@ async function setupAction(
|
|
|
484
487
|
config: RedeemIDConfig,
|
|
485
488
|
live: () => Promise<RedeemIDConfig>,
|
|
486
489
|
ctx: RouteContext,
|
|
487
|
-
i: { action_id?: string; values?: Record<string, unknown> },
|
|
490
|
+
i: { action_id?: string; value?: unknown; values?: Record<string, unknown> },
|
|
488
491
|
) {
|
|
489
|
-
const s = await memberStores();
|
|
490
492
|
const origin = siteOrigin(ctx);
|
|
491
|
-
const
|
|
492
|
-
|
|
493
|
-
return connectionPage(now, ctx, "setup", { setup: setupPage(await setupFacts(now, s, origin), toast) });
|
|
494
|
-
};
|
|
493
|
+
const tab: ConnectionTab = i.action_id === "setup_register" ? "webhooks" : i.action_id === "setup_level" ? "level" : (tabOf(CONNECTION_TABS, i.value) ?? "status");
|
|
494
|
+
const page = async (toast?: { type: "success" | "error" | "info"; message: string }) => connectionPage(await live(), ctx, tab, toast);
|
|
495
495
|
if (i.action_id === "setup_register") {
|
|
496
496
|
try {
|
|
497
497
|
const { added, failed } = await registerWebhooks(config, origin + WEBHOOK);
|
|
@@ -588,41 +588,37 @@ async function action(
|
|
|
588
588
|
const done = added.length ? `Registered ${added.length} webhooks.` : failed.length ? "" : "Nothing was missing.";
|
|
589
589
|
const refused = failed.map((f) => `Shopify refused ${f.topic}: ${f.message}`).join(" ");
|
|
590
590
|
await record({ event: "staff.webhooks", label: "register", status: failed.length ? `${failed.length} refused` : `${added.length} registered`, level: failed.length ? "warn" : "ok", origin: "staff" });
|
|
591
|
-
return connectionPage(config, ctx, "
|
|
591
|
+
return connectionPage(config, ctx, "webhooks", { type: failed.length ? "error" : "success", message: [done, refused].filter(Boolean).join(" ") });
|
|
592
592
|
} catch (e) {
|
|
593
|
-
return connectionPage(config, ctx, "
|
|
593
|
+
return connectionPage(config, ctx, "webhooks", fail(e));
|
|
594
594
|
}
|
|
595
595
|
}
|
|
596
|
-
case "webhooks_tab": {
|
|
597
|
-
const tab = WEBHOOK_TABS.includes(i.value as WebhookTab) ? (i.value as WebhookTab) : "received";
|
|
598
|
-
return connectionPage(config, ctx, tab === "setup" ? "status" : tab === "health" ? "health" : "webhooks");
|
|
599
|
-
}
|
|
600
596
|
case "deliveries_page":
|
|
601
|
-
return
|
|
597
|
+
return eventsAdminPage(config, ctx, "webhook", { cursor: cursorOf(i.value) });
|
|
602
598
|
case "health_check": {
|
|
603
599
|
const r = await healthCheck({ ...monitor(), by: "staff" });
|
|
604
600
|
const message = r.problems.length ? `Checked: ${r.problems.length === 1 ? "1 problem" : `${r.problems.length} problems`}.` : "Checked: subscriptions are in place and deliveries look healthy.";
|
|
605
|
-
return
|
|
601
|
+
return eventsAdminPage(config, ctx, "health", {}, { type: r.problems.length ? "info" : "success", message });
|
|
606
602
|
}
|
|
607
603
|
case "monitor_save":
|
|
608
|
-
return
|
|
604
|
+
return eventsAdminPage(config, ctx, "health", {}, await saveMonitor(ctx.kv, i.values));
|
|
609
605
|
case "events_filter":
|
|
610
|
-
return
|
|
606
|
+
return eventsAdminPage(config, ctx, "app", { events: await eventsPage(s, { event: String(i.values?.event ?? "") || undefined, problems: i.values?.problems === true }) });
|
|
611
607
|
case "events_show":
|
|
612
|
-
return
|
|
608
|
+
return eventsAdminPage(config, ctx, "app", { events: await eventsPage(s, { event: String(i.value ?? "") || undefined }) });
|
|
613
609
|
case "events_page":
|
|
614
|
-
return
|
|
610
|
+
return eventsAdminPage(config, ctx, "app", { events: await eventsPage(s, parseFilter(i.block_id), cursorOf(i.value)) });
|
|
615
611
|
case "failed_resend": {
|
|
616
612
|
const sent = await resend(config, s, String(i.value ?? "")).catch(() => false);
|
|
617
|
-
return
|
|
613
|
+
return eventsAdminPage(config, ctx, "app", {}, sent ? { type: "success", message: "Sent. It's off the failed list." } : { type: "error", message: "The receiver still didn't take it. It stays on the list." });
|
|
618
614
|
}
|
|
619
615
|
case "failed_clear":
|
|
620
616
|
await s.failedEvents.delete(String(i.value ?? ""));
|
|
621
|
-
return
|
|
617
|
+
return eventsAdminPage(config, ctx, "app", {}, { type: "success", message: "Cleared." });
|
|
622
618
|
case "failed_clear_all": {
|
|
623
619
|
const { items } = await s.failedEvents.query({ limit: 100 });
|
|
624
620
|
await s.failedEvents.deleteMany(items.map((r) => r.id));
|
|
625
|
-
return
|
|
621
|
+
return eventsAdminPage(config, ctx, "app", {}, { type: "success", message: "Failed events cleared." });
|
|
626
622
|
}
|
|
627
623
|
case "member_export": {
|
|
628
624
|
const id = String(i.value ?? "");
|
|
@@ -790,45 +786,70 @@ function textSummary(config: RedeemIDConfig): string {
|
|
|
790
786
|
}
|
|
791
787
|
|
|
792
788
|
type ConnectionTab = (typeof CONNECTION_TABS)[number];
|
|
789
|
+
type EventsTab = (typeof EVENTS_TABS)[number];
|
|
790
|
+
type Toast = { type: "success" | "error" | "info"; message: string };
|
|
793
791
|
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
792
|
+
/** A tab name from an address or a button value, if it's one of these. */
|
|
793
|
+
function tabOf<T extends string>(tabs: readonly T[], v: unknown): T | undefined {
|
|
794
|
+
return tabs.includes(v as T) ? (v as T) : undefined;
|
|
795
|
+
}
|
|
796
|
+
const withoutHeader = (b: { blocks: unknown[] }) => b.blocks.filter((x, n) => !(n === 0 && (x as { type?: string }).type === "header"));
|
|
797
797
|
|
|
798
798
|
/**
|
|
799
|
-
*
|
|
800
|
-
*
|
|
801
|
-
*
|
|
802
|
-
*
|
|
799
|
+
* How this site talks to Shopify: Status (the values to copy into Shopify
|
|
800
|
+
* and what the site reads from its environment), then one tab per setup
|
|
801
|
+
* step, each with a check mark once done. Webhooks also holds the app's
|
|
802
|
+
* subscriptions. Opens on the first step not done, or on Status.
|
|
803
803
|
*/
|
|
804
|
-
async function connectionPage(config: RedeemIDConfig, ctx: RouteContext, tab?: ConnectionTab,
|
|
804
|
+
async function connectionPage(config: RedeemIDConfig, ctx: RouteContext, tab?: ConnectionTab, toast?: Toast) {
|
|
805
805
|
const s = await memberStores();
|
|
806
|
-
const [
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
const
|
|
816
|
-
const
|
|
817
|
-
const open = tab ?? (ready ? "status" : "setup");
|
|
818
|
-
const toast = (given.hooks ?? given.events ?? given.setup)?.toast;
|
|
806
|
+
const [facts, status, hooks] = await Promise.all([setupFacts(config, s, siteOrigin(ctx)), statusPage(config, ctx.request), webhookPanels(monitorEnv(config, s, ctx))]);
|
|
807
|
+
const panels = setupPanels(facts);
|
|
808
|
+
const next = nextStep(facts);
|
|
809
|
+
const steps = requiredSteps(setupSteps(facts));
|
|
810
|
+
const open = tab ?? (next ? (STEP_TABS[next.key] as ConnectionTab) : "status");
|
|
811
|
+
const progress = next
|
|
812
|
+
? [{ type: "banner", variant: "alert", title: `Setup: ${steps.filter((x) => x.state === "done").length} of ${steps.length} steps done`, description: `Work through the tabs. Each one checks the site as it is, so set a secret, redeploy or restart, and press Check again. Next: ${next.title}.` }]
|
|
813
|
+
: [];
|
|
814
|
+
// The Webhooks step has its own Register button while it's to do.
|
|
815
|
+
const ownButton = panels.some((p) => p.tab === "webhooks" && JSON.stringify(p.blocks).includes('"setup_register"'));
|
|
816
|
+
const subscriptions = ownButton ? hooks.subscriptions.filter((b) => !JSON.stringify(b).includes('"webhooks_register"')) : hooks.subscriptions;
|
|
819
817
|
return {
|
|
820
818
|
blocks: [
|
|
821
819
|
{ type: "header", text: "Connection" },
|
|
822
|
-
{ type: "context", text: "How this site talks to Shopify: the values to copy into
|
|
820
|
+
{ type: "context", text: "How this site talks to Shopify: the values to copy into the store, and each setup step." },
|
|
823
821
|
{
|
|
824
822
|
type: "tab",
|
|
825
823
|
default_tab: CONNECTION_TABS.indexOf(open),
|
|
826
824
|
panels: [
|
|
827
|
-
{ label: "Status", blocks: [...withoutHeader(status)
|
|
828
|
-
{ label:
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
825
|
+
{ label: "Status", blocks: [...progress, ...withoutHeader(status)] },
|
|
826
|
+
...panels.map((p) => (p.tab === "webhooks" ? { label: p.label, blocks: [...p.blocks, { type: "divider" }, { type: "header", text: "Subscriptions" }, ...subscriptions] } : { label: p.label, blocks: p.blocks })),
|
|
827
|
+
],
|
|
828
|
+
},
|
|
829
|
+
],
|
|
830
|
+
...(toast ? { toast } : {}),
|
|
831
|
+
};
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
/**
|
|
835
|
+
* What happened: webhook deliveries from Shopify, what RedeemID did (App),
|
|
836
|
+
* and whether deliveries are healthy. An action passes the App panel it
|
|
837
|
+
* rebuilt (a filter or a page) or a deliveries cursor.
|
|
838
|
+
*/
|
|
839
|
+
async function eventsAdminPage(config: RedeemIDConfig, ctx: RouteContext, tab: EventsTab = "webhook", given: { events?: { blocks: unknown[] }; cursor?: string } = {}, toast?: Toast) {
|
|
840
|
+
const s = await memberStores();
|
|
841
|
+
const [hooks, events] = await Promise.all([webhookPanels(monitorEnv(config, s, ctx), given.cursor), given.events ?? eventsPage(s)]);
|
|
842
|
+
return {
|
|
843
|
+
blocks: [
|
|
844
|
+
{ type: "header", text: "Events" },
|
|
845
|
+
{ type: "context", text: "What Shopify sent, what RedeemID did with it, and whether deliveries are healthy." },
|
|
846
|
+
{
|
|
847
|
+
type: "tab",
|
|
848
|
+
default_tab: EVENTS_TABS.indexOf(tab),
|
|
849
|
+
panels: [
|
|
850
|
+
{ label: "Webhook", blocks: hooks.received },
|
|
851
|
+
{ label: "App", blocks: events.blocks },
|
|
852
|
+
{ label: hooks.attention ? "Health (!)" : "Health", blocks: hooks.health },
|
|
832
853
|
],
|
|
833
854
|
},
|
|
834
855
|
],
|
|
@@ -848,7 +869,7 @@ async function statusPage(config: RedeemIDConfig, request: Request) {
|
|
|
848
869
|
{ type: "header", text: "Status" },
|
|
849
870
|
ready
|
|
850
871
|
? { type: "banner", variant: "default", title: "Sign-in is ready", description: "Members can sign in with their store customer account." }
|
|
851
|
-
: { type: "banner", variant: "alert", title: "Sign-in isn't ready", description: "Set the missing values below as environment secrets, then reload.
|
|
872
|
+
: { type: "banner", variant: "alert", title: "Sign-in isn't ready", description: "Set the missing values below as environment secrets, then reload. The tabs after Status walk through each one." },
|
|
852
873
|
...customDomainBlocks(config),
|
|
853
874
|
{ type: "header", text: "Shopify settings" },
|
|
854
875
|
{ type: "context", text: "Copy these into the store: the first three into the Customer Account API settings of the Headless storefront, the webhook URL into the app's webhook subscriptions." },
|
|
@@ -1045,7 +1066,7 @@ async function statusWidget(config: RedeemIDConfig, ctx: PluginContext) {
|
|
|
1045
1066
|
{ type: "link", label: "Overview", target: page("/"), appearance: "primary" },
|
|
1046
1067
|
{ type: "link", label: "Members", target: page("/members"), appearance: "secondary" },
|
|
1047
1068
|
{ type: "link", label: "Access", target: page("/access"), appearance: "secondary" },
|
|
1048
|
-
{ type: "link", label: s.signIn && !s.check?.problems.length ? "
|
|
1069
|
+
{ type: "link", label: s.signIn && !s.check?.problems.length ? "Events" : "Connection", target: page(s.signIn && !s.check?.problems.length ? "/events/webhook" : "/connection"), appearance: "secondary" },
|
|
1049
1070
|
],
|
|
1050
1071
|
},
|
|
1051
1072
|
],
|
|
@@ -1060,8 +1081,7 @@ async function overviewPage(config: RedeemIDConfig, ctx: PluginContext) {
|
|
|
1060
1081
|
setupFacts(config, st, siteOrigin(ctx)).catch(() => null),
|
|
1061
1082
|
st.log.query({ orderBy: { at: "desc" }, limit: 8 }).catch(() => ({ items: [] as Array<{ data: Parameters<typeof eventRow>[0] }> })),
|
|
1062
1083
|
]);
|
|
1063
|
-
const
|
|
1064
|
-
const required = steps.filter((x) => x.state !== "optional");
|
|
1084
|
+
const required = facts ? requiredSteps(setupSteps(facts)) : [];
|
|
1065
1085
|
const done = required.filter((x) => x.state === "done").length;
|
|
1066
1086
|
const hooks = webhookWord(s);
|
|
1067
1087
|
const rules = config.rules ?? [];
|
|
@@ -1090,10 +1110,10 @@ async function overviewPage(config: RedeemIDConfig, ctx: PluginContext) {
|
|
|
1090
1110
|
items: [
|
|
1091
1111
|
{ title: "Sign-in", value: s.signIn ? "Ready" : "Not ready", tone: s.signIn ? "ok" : "error", text: domain ? `Store: ${domain.replace(/^https?:\/\//, "")}` : "No store set", link: page("/connection/status"), label: "Status" },
|
|
1092
1112
|
{ title: "Members", value: String(s.members.length), text: `${s.active} with a level, ${s.signedIn} signed in here`, link: page("/members"), label: "Members" },
|
|
1093
|
-
{ title: "Webhooks", value: hooks.value, tone: hooks.tone, text: s.delivery ? `Last delivery ${ago(s.delivery.receivedAt)} (${s.delivery.topic})` : "None received yet", link: page("/
|
|
1113
|
+
{ title: "Webhooks", value: hooks.value, tone: hooks.tone, text: s.delivery ? `Last delivery ${ago(s.delivery.receivedAt)} (${s.delivery.topic})` : "None received yet", link: page("/events/webhook"), label: "Webhooks" },
|
|
1094
1114
|
{ title: "Access", value: count(s.levels.length, "level"), text: [count(rules.length, "rule"), count((config.features ?? []).length, "feature"), count((config.files ?? []).length, "protected folder")].join(", "), link: page("/access"), label: "Access" },
|
|
1095
1115
|
{ title: "Privacy", value: s.openRequests ? `${s.openRequests} open` : "Nothing open", tone: s.openRequests ? "warn" : undefined, text: "Exports and erasures from members, staff and Shopify", link: page("/privacy"), label: "Privacy" },
|
|
1096
|
-
{ title: "Setup", value: required.length ? `${done} of ${required.length}` : "Unknown", tone: required.length && done === required.length ? "ok" : "warn", text: done === required.length ? "Every step is done" : `Next: ${required.find((x) => x.state !== "done")?.title ?? ""}`, link: page("/connection
|
|
1116
|
+
{ title: "Setup", value: required.length ? `${done} of ${required.length}` : "Unknown", tone: required.length && done === required.length ? "ok" : "warn", text: done === required.length ? "Every step is done" : `Next: ${required.find((x) => x.state !== "done")?.title ?? ""}`, link: page("/connection"), label: "Setup" },
|
|
1097
1117
|
],
|
|
1098
1118
|
},
|
|
1099
1119
|
{ type: "header", text: "Levels" },
|
|
@@ -1129,7 +1149,7 @@ async function overviewPage(config: RedeemIDConfig, ctx: PluginContext) {
|
|
|
1129
1149
|
return { event, about, result, at };
|
|
1130
1150
|
}),
|
|
1131
1151
|
},
|
|
1132
|
-
{ type: "actions", elements: [{ type: "link", label: "All app events", target: page("/
|
|
1152
|
+
{ type: "actions", elements: [{ type: "link", label: "All app events", target: page("/events/app"), appearance: "secondary" }, { type: "link", label: "Reports", target: page("/reports"), appearance: "secondary" }] },
|
|
1133
1153
|
{ type: "header", text: "Resources" },
|
|
1134
1154
|
{
|
|
1135
1155
|
type: "redeemid_cards",
|
package/src/setup.ts
CHANGED
|
@@ -1,19 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
2
|
+
* RedeemID → Connection: connecting a store, step by step, from the admin
|
|
3
|
+
* alone, one tab per step. Each step says what to do in Shopify (with the exact values
|
|
4
4
|
* to paste), what to set on the site, and has a button for whatever
|
|
5
5
|
* RedeemID can do itself: register the webhooks, add the first level.
|
|
6
6
|
*
|
|
7
7
|
* Nothing about progress is stored. Each step's state comes from the site
|
|
8
|
-
* as it is (setupFacts), so opening
|
|
9
|
-
* and it always agrees with the Status
|
|
8
|
+
* as it is (setupFacts), so opening Connection again picks up where things stand
|
|
9
|
+
* and it always agrees with the Status tab.
|
|
10
10
|
*
|
|
11
11
|
* Secrets stay where they are today, in the site's environment: the wizard
|
|
12
12
|
* checks them and gives the command to set each one, and never stores one.
|
|
13
13
|
* It writes only what staff can already write (a level, through Access) and
|
|
14
14
|
* never grants access: levels still come only from Shopify tags.
|
|
15
15
|
*
|
|
16
|
-
* setupSteps() and
|
|
16
|
+
* setupSteps() and setupPanels() are pure.
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
19
|
import { adminConfigured, adminScopes, grantedScopes, topicName, webhookSubscriptions, WEBHOOK_TOPICS } from "./admin-api.js";
|
|
@@ -24,7 +24,7 @@ import type { Stores } from "./storage.js";
|
|
|
24
24
|
type Toast = { message: string; type: "success" | "error" | "info" };
|
|
25
25
|
export type Blocks = { blocks: unknown[]; toast?: Toast };
|
|
26
26
|
|
|
27
|
-
/** Scopes the
|
|
27
|
+
/** Scopes the Admin API app needs for staff tools and webhook registration. */
|
|
28
28
|
export const SETUP_SCOPES = ["read_customers", "write_customers"] as const;
|
|
29
29
|
|
|
30
30
|
export const CALLBACK = "/_emdash/api/auth/redeemid/callback";
|
|
@@ -134,8 +134,8 @@ export function setupSteps(f: SetupFacts): Step[] {
|
|
|
134
134
|
return [
|
|
135
135
|
{ key: "store", title: "Connect the store", state: store ? "done" : "todo" },
|
|
136
136
|
{ key: "sign_in", title: "Create the sign-in client", state: signIn ? "done" : store ? "todo" : "waiting", waitingFor: "Connect the store first." },
|
|
137
|
-
{ key: "app", title: "Connect the
|
|
138
|
-
{ key: "webhooks", title: "Register the webhooks", state: hooks ? "done" : app ? "todo" : "waiting", waitingFor: "Connect the
|
|
137
|
+
{ key: "app", title: "Connect the Admin API app", state: app ? "done" : store ? "todo" : "waiting", waitingFor: "Connect the store first." },
|
|
138
|
+
{ key: "webhooks", title: "Register the webhooks", state: hooks ? "done" : app ? "todo" : "waiting", waitingFor: "Connect the Admin API app first: webhooks are registered through it." },
|
|
139
139
|
{ key: "level", title: "Create your first level", state: level ? "done" : "todo" },
|
|
140
140
|
{ key: "try", title: "Try a sign-in", state: f.signedIn > 0 ? "done" : ready ? "todo" : "waiting", waitingFor: "Finish the steps above first." },
|
|
141
141
|
{ key: "storefront", title: "Add Storefront tokens (for plans and the shop)", state: f.storefront ? "done" : "optional" },
|
|
@@ -182,7 +182,7 @@ function stepBlocks(step: Step, f: SetupFacts): unknown[] {
|
|
|
182
182
|
text: "In Shopify admin, open Sales channels, then Headless (add the channel if it isn't there), and Create storefront. Name it after this site. Open its Customer Account API settings, pick a client type (Public needs no secret; Confidential gives one) and, under Application setup, add these:",
|
|
183
183
|
},
|
|
184
184
|
{
|
|
185
|
-
type: "
|
|
185
|
+
type: "redeemid_copy",
|
|
186
186
|
fields: [
|
|
187
187
|
{ label: "Callback URI", value: `${f.origin}${CALLBACK}` },
|
|
188
188
|
{ label: "JavaScript origin (public clients)", value: f.origin },
|
|
@@ -200,7 +200,7 @@ function stepBlocks(step: Step, f: SetupFacts): unknown[] {
|
|
|
200
200
|
? `Shopify refused the app: ${f.admin} The app must be installed on this store and be in the same Shopify organization.`
|
|
201
201
|
: f.admin === "ok"
|
|
202
202
|
? `The app is connected but lacks ${missingScopes(f).join(" and ")}. Add the scopes, release a new version and let the store accept it.`
|
|
203
|
-
: "
|
|
203
|
+
: "RedeemID uses an Admin API app for the staff tools (resyncs, tag edits, member lookups) and to register webhooks, and Shopify signs webhooks with its client secret.";
|
|
204
204
|
return [
|
|
205
205
|
...out,
|
|
206
206
|
{ type: "section", text: why },
|
|
@@ -266,34 +266,29 @@ function stepBlocks(step: Step, f: SetupFacts): unknown[] {
|
|
|
266
266
|
return out;
|
|
267
267
|
}
|
|
268
268
|
|
|
269
|
-
/**
|
|
270
|
-
export
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
269
|
+
/** Connection's tab address for each step (/connection/sign-in). */
|
|
270
|
+
export const STEP_TABS: Record<Step["key"], string> = { store: "store", sign_in: "sign-in", app: "app", webhooks: "webhooks", level: "level", try: "try", storefront: "storefront" };
|
|
271
|
+
const SHORT: Record<Step["key"], string> = { store: "Store", sign_in: "Sign-in", app: "Admin app", webhooks: "Webhooks", level: "First level", try: "Try it", storefront: "Storefront" };
|
|
272
|
+
|
|
273
|
+
/** The steps that count toward "set up" (Storefront is optional). */
|
|
274
|
+
export const requiredSteps = (steps: Step[]) => steps.filter((s) => s.key !== "storefront");
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* One tab per step, for Connection: the step, with a Check again button
|
|
278
|
+
* until it's done. Labels carry a check mark once done. Pure.
|
|
279
|
+
*/
|
|
280
|
+
export function setupPanels(f: SetupFacts): Array<{ tab: string; label: string; blocks: unknown[] }> {
|
|
281
|
+
return setupSteps(f).map((step) => ({
|
|
282
|
+
tab: STEP_TABS[step.key],
|
|
283
|
+
label: step.state === "done" ? `${SHORT[step.key]} ✓` : SHORT[step.key],
|
|
275
284
|
blocks: [
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
? { type: "banner", variant: "default", title: "RedeemID is set up", description: "Members can sign in with their Shopify customer account. Levels, rules and the meter are under Access." }
|
|
279
|
-
: {
|
|
280
|
-
type: "banner",
|
|
281
|
-
variant: "alert",
|
|
282
|
-
title: `${done} of ${required.length} steps done`,
|
|
283
|
-
description: "Work down the steps. Each one checks the site as it is, so set a secret, redeploy or restart, and press Check again.",
|
|
284
|
-
},
|
|
285
|
-
{ type: "actions", elements: [{ type: "button", action_id: "setup_check", label: "Check again", style: "secondary" }] },
|
|
286
|
-
{
|
|
287
|
-
type: "table",
|
|
288
|
-
page_action_id: "setup_steps",
|
|
289
|
-
columns: [
|
|
290
|
-
{ key: "step", label: "Step" },
|
|
291
|
-
{ key: "state", label: "State", format: "badge" },
|
|
292
|
-
],
|
|
293
|
-
rows: steps.map((s) => ({ step: s.title, state: BADGE[s.state] })),
|
|
294
|
-
},
|
|
295
|
-
...steps.flatMap((s) => [{ type: "divider" }, ...stepBlocks(s, f)]),
|
|
285
|
+
...stepBlocks(step, f),
|
|
286
|
+
...(step.state === "done" ? [] : [{ type: "actions", elements: [{ type: "button", action_id: "setup_check", value: STEP_TABS[step.key], label: "Check again", style: "secondary" }] }]),
|
|
296
287
|
],
|
|
297
|
-
|
|
298
|
-
|
|
288
|
+
}));
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** Where Connection opens: the first step not done, or null once every required one is. Pure. */
|
|
292
|
+
export function nextStep(f: SetupFacts): Step | null {
|
|
293
|
+
return requiredSteps(setupSteps(f)).find((s) => s.state !== "done") ?? null;
|
|
299
294
|
}
|
package/src/version.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const VERSION = "0.0.
|
|
1
|
+
export const VERSION = "0.0.32";
|