@redeemid/emdash 0.0.22 → 0.0.24
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 +38 -14
- package/docs/developer-api.md +5 -3
- package/package.json +1 -1
- package/src/access-admin.ts +84 -13
- package/src/access.ts +129 -29
- package/src/agents.ts +3 -3
- package/src/api-version.ts +1 -1
- package/src/blocks-names.ts +6 -8
- package/src/components/AccessBadge.astro +8 -9
- package/src/components/Account.astro +14 -3
- package/src/components/Content.astro +6 -6
- package/src/components/MemberCard.astro +4 -3
- package/src/components/Plan.astro +30 -10
- package/src/config.ts +54 -3
- package/src/contract.ts +1 -1
- package/src/files.ts +4 -5
- package/src/log.ts +1 -0
- package/src/plugin.ts +48 -17
- package/src/privacy.ts +10 -7
- package/src/routes/callback.ts +5 -2
- package/src/routes/webhook.ts +21 -1
- package/src/settings.ts +1 -1
- package/src/setup.ts +2 -2
- package/src/staff.ts +90 -3
- package/src/text.ts +1 -1
- package/src/version.ts +1 -1
- package/src/webhooks.ts +7 -3
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@ Memberships and paywalls for [EmDash](https://github.com/emdash-cms/emdash) site
|
|
|
7
7
|
|
|
8
8
|
Readers sign in with the store's own customer account. Their Shopify customer tags decide what they can read. Shopify keeps the store, checkout, orders and subscriptions, and EmDash keeps the content. Nothing on the site grants access by itself, so there is one source of truth for who paid for what.
|
|
9
9
|
|
|
10
|
-
**Status: 0.0.
|
|
10
|
+
**Status: 0.0.24, early.** Runs on Node and Cloudflare Workers. Unit tests and an end-to-end test that boots a real EmDash site against a fake Shopify store cover the features below. Expect changes before 1.0.
|
|
11
11
|
|
|
12
12
|
## Contents
|
|
13
13
|
|
|
@@ -26,7 +26,7 @@ Readers sign in with the store's own customer account. Their Shopify customer ta
|
|
|
26
26
|
|
|
27
27
|
1. **Sign in.** A reader clicks "Sign in", signs in on the store's customer account page, and comes back signed in as an EmDash Subscriber. That role only means "signed in". It never opens content.
|
|
28
28
|
2. **Mirror.** RedeemID keeps a copy of the customer: email, name, tags and whether the account is enabled. Signed Shopify webhooks keep it current.
|
|
29
|
-
3. **Levels.** You map tags to levels and name them whatever you like, such as `gold-member` to Gold. Tags come from Shopify Flow, a subscription app or staff, never from the site.
|
|
29
|
+
3. **Levels.** You map tags to levels and name them whatever you like, such as `gold-member` to Gold, or Gold for `gold` or for `silver` with `annual`. A level can include others, so Gold members read everything for Silver. Tags come from Shopify Flow, a subscription app or staff, never from the site.
|
|
30
30
|
4. **Rules.** You say which content types or terms need which levels, in the admin (Plugins → RedeemID → Access) or in code. Each entry can have its own rule in the editor's Access panel, and editors can drop a Paywall break or a Members only block into any body.
|
|
31
31
|
5. **Render.** Templates render bodies with `<Content>`. Readers who pass see everything. Everyone else gets a teaser and the wall, and the locked part never reaches the page.
|
|
32
32
|
|
|
@@ -161,7 +161,7 @@ Use `<Content>` in place of `<PortableText>` for every body that may be locked,
|
|
|
161
161
|
|
|
162
162
|
EmDash has no content filter hook, so templates decide what to render.
|
|
163
163
|
|
|
164
|
-
**`<Content>`** renders an entry body.
|
|
164
|
+
**`<Content>`** renders an entry body. Levels come from the entry's rule (its content type, its terms, or its own Access panel); a Paywall break only says where that rule starts. Everything before the first Paywall break block is shown to everyone, and what follows reaches the page only for readers the rule lets in. With no rule covering the entry, the break changes nothing. A body with no break follows the entry's rule as a whole, and readers it keeps out see the collection's `teaser` if one is set (whole paragraphs, never half of one).
|
|
165
165
|
|
|
166
166
|
```astro
|
|
167
167
|
<Content value={post.data.content} collection="posts" terms={post.data.terms} id={post.id} title={post.data.title}>
|
|
@@ -181,7 +181,7 @@ Pass `id` on the entry's own page. It lets the meter count the read, and a locke
|
|
|
181
181
|
<Gate levels={["pro"]}><a href="/reports/archive.zip">Download the archive</a></Gate>
|
|
182
182
|
```
|
|
183
183
|
|
|
184
|
-
**`<AccessBadge>`** shows what an entry needs, on cards and on the entry. It shows "Free" for open entries (`free=""` hides it), adds `is-locked` and a lock for readers it keeps out, and puts the level slugs in `data-redeemid-level` for colours:
|
|
184
|
+
**`<AccessBadge>`** shows what an entry needs, on cards and on the entry. It shows "Free" for open entries (`free=""` hides it), adds `is-locked` and a lock for readers it keeps out, and puts the level slugs in `data-redeemid-level` for colours (and the first level's `badge` colour in `--redeemid-level-color`):
|
|
185
185
|
|
|
186
186
|
```astro
|
|
187
187
|
<AccessBadge collection="posts" terms={post.data.terms} content={post.data.content} />
|
|
@@ -191,7 +191,7 @@ Pass `id` on the entry's own page. It lets the meter count the read, and a locke
|
|
|
191
191
|
|
|
192
192
|
**`<MemberCard />`** is for an account page: levels with start dates, the renewal or end date and any gift membership that Shopify keeps (see [membership dates](#membership-dates)), past levels, the meter, and links to the store account, Refresh membership and Sign out. Refresh membership sends a member to the store with no sign-in form; one still signed in there comes straight back with their tags read again. Add `<Preferences />` and `<YourData />` beside it.
|
|
193
193
|
|
|
194
|
-
**`<Plan>`** sells a membership: a Shopify product's title and price with a button that starts checkout (members check out with their email).
|
|
194
|
+
**`<Plan>`** sells a membership: a Shopify product's title and price with a button that starts checkout (members check out with their email). Its levels are `levels`, or else the levels whose `products` list the handle. Then the card fits the member: one who holds a level through its own tags sees "You're on this plan." in place of the button; one whose bigger level includes it sees "Included in your Gold membership."; one the plan would move up (its level includes theirs) gets **Upgrade** (`upgradeLabel`); and one who held it before (Shopify has since removed its tag) gets **Renew** (`renewLabel`) instead of `label`. `data-redeemid-state` is `held`, `included`, `upgrade`, `renew` or `join`. The wall does the same: a lapsed member reads "Your Pro membership has ended." with **Renew Pro** and **Already renewed? Refresh**, not the newcomer's join link.
|
|
195
195
|
|
|
196
196
|
```astro
|
|
197
197
|
<Plan handle="gold-membership" label="Join Gold" levels={["gold"]}>
|
|
@@ -247,13 +247,35 @@ Each rule names a collection and the levels, any of which may read. An empty `le
|
|
|
247
247
|
|
|
248
248
|
Term rules beat the collection's rule, which beats the site default. When several term rules cover one entry (a story in Investigations, which needs Gold, and Culture, which needs Silver), readers must pass every one of them, so only members with both read it. Set `combine: "any"` to let one be enough.
|
|
249
249
|
|
|
250
|
-
Levels
|
|
250
|
+
### Levels
|
|
251
|
+
|
|
252
|
+
A level is a name for customers who hold some Shopify tags. `tags` is one group: any one of them grants the level, or every one with `match: "all"`. `groups` adds more groups, and any one group is enough:
|
|
253
|
+
|
|
254
|
+
```js
|
|
255
|
+
levels: [
|
|
256
|
+
{ slug: "silver", name: "Silver", tags: ["silver"] },
|
|
257
|
+
// "gold", or "silver" with "annual".
|
|
258
|
+
{ slug: "gold", name: "Gold", tags: ["gold"], groups: [{ tags: ["silver", "annual"], match: "all" }], includes: ["silver"] },
|
|
259
|
+
],
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
`includes` lists levels this one also opens: Gold members read everything for Silver, and inclusion carries on (Gold includes Silver includes Members). So a rule names only the lowest level that may read, and an entry that needs Silver and Gold needs Gold once. Members hold the levels their tags grant and every level those include; the account box and member card show only the top one ("Gold", not "Gold, Silver"), and `member.levels_changed` lists them all.
|
|
263
|
+
|
|
264
|
+
The rest of a level is how it looks and where it leads:
|
|
265
|
+
|
|
266
|
+
| Key | What it does |
|
|
267
|
+
| --- | --- |
|
|
268
|
+
| `wall` | What the wall says to readers this level would let in |
|
|
269
|
+
| `joinUrl` | Where the wall's join link goes for this level (else `join.href`) |
|
|
270
|
+
| `products` | Handles of the Shopify products whose purchase leads to the level. Display only: plan cards use it (see `<Plan>`). It grants nothing. |
|
|
271
|
+
| `home` | Members' home: the same-site path a member lands on after signing in from the home page or a plain sign-in link (their top level's, in level order). Signing in from an entry returns them to it. |
|
|
272
|
+
| `badge` | A hex colour, set as `--redeemid-level-color` on badges, the account box and the member card. Badges always show the name too. |
|
|
251
273
|
|
|
252
274
|
### Access settings in the admin
|
|
253
275
|
|
|
254
276
|
Everything about who reads what can be set in the EmDash admin, so a new content type, taxonomy or level never needs a code change. Under **Plugins → RedeemID → Access**:
|
|
255
277
|
|
|
256
|
-
- **Levels.** Add, rename and remove levels and the Shopify tags that grant them. Name them anything; the slug is made from the name. A level that rules, features or folders still use can't be removed.
|
|
278
|
+
- **Levels.** Add, rename and remove levels and the Shopify tags that grant them: one group per line, commas between tags, and `all:` before a line that needs every tag on it. Tick the levels each one includes, and set its plan products, members' home and badge colour. Name them anything; the slug is made from the name. A level that rules, features or folders still use can't be removed; removing one drops it from other levels' includes.
|
|
257
279
|
- **Site rules.** The site default, how term rules combine, the meter and the wall's join link.
|
|
258
280
|
- **Content types.** Who can read each collection, with drip, the meter and the teaser. Every collection on the site is listed, including ones made after RedeemID was set up.
|
|
259
281
|
- **Taxonomies.** Rules for terms in every taxonomy, for one collection or every collection the taxonomy is on.
|
|
@@ -283,7 +305,7 @@ teaser: { posts: 40 },
|
|
|
283
305
|
|
|
284
306
|
The meter lets a signed-in reader open a locked entry anyway, `limit` entries per `period` (`"month"`, the default, or `"week"`, in UTC). Each entry counts once per period, when its own page renders through `<Content>` with an `id`. Lists, feeds, search and the content API never count. Visitors get the wall and a nudge to sign in. While it runs, `<Content>` says how many free reads are left, and when it runs out the wall says so. Rules with `meter: false` are never metered, and the meter never opens an entry a member is waiting on by drip.
|
|
285
307
|
|
|
286
|
-
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
|
|
308
|
+
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 a tag of its group (for a `match: "all"` group, the last of its tags); a level held through another's `includes` starts with that one. With several levels the earliest counts, and with none known, the first sign-in. RedeemID notes when each tag first arrives and forgets it when Shopify removes it. The wall shows the date the entry opens.
|
|
287
309
|
|
|
288
310
|
### Selling one entry on its own
|
|
289
311
|
|
|
@@ -410,7 +432,7 @@ What RedeemID keeps about a member: the synced record (Shopify customer ID, emai
|
|
|
410
432
|
| Staff, on a member's page | **Export data**, **Erase site data**, and **Also request erasure in Shopify** (orders included, after Shopify's wait; **Cancel the Shopify erasure** withdraws it). The Shopify request needs the `write_customer_data_erasure` scope. |
|
|
411
433
|
| Shopify `customers/data_request` | An open request on the **Privacy** page. Press **Export and mark done**, then send the export to the store owner. |
|
|
412
434
|
| Shopify `customers/redact` | The member's record and meter are erased. |
|
|
413
|
-
| Shopify `shop/redact` | Every member record and meter is erased. |
|
|
435
|
+
| Shopify `shop/redact` | Every member record and meter is erased, a hundred at a time, so even a large shop fits in one request on the Workers Free plan. |
|
|
414
436
|
|
|
415
437
|
The Privacy page shows each request's source and Shopify customer ID, never an email or a name. The daily check deletes finished requests after a year; open ones stay until staff mark them done. An erase sends the `member.erased` event so a CRM can delete its copy too. The site's own user account stays; delete it under Users if the person asks. The JSON route is `/_emdash/api/auth/redeemid/privacy`: `GET` downloads, `POST` with `confirm=yes` erases, only for the signed-in member and only from the site's own origin.
|
|
416
438
|
|
|
@@ -419,6 +441,7 @@ The Privacy page shows each request's source and Shopify customer ID, never an e
|
|
|
419
441
|
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 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**:
|
|
420
442
|
|
|
421
443
|
- **Member detail.** Levels, tags and features; resync from Shopify; add and remove tags. Tag edits are made in Shopify and read back. Nothing is granted on the site.
|
|
444
|
+
- **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.
|
|
422
445
|
- **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.
|
|
423
446
|
- **Webhook subscriptions.** Webhooks → Setup 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.
|
|
424
447
|
- **Access test.** A collection, an entry, terms and a reader's tags in; the verdict out.
|
|
@@ -457,7 +480,7 @@ Staff questions as data, for AI agents and scripts. Every one needs an administr
|
|
|
457
480
|
| MCP tool | Command | What it answers |
|
|
458
481
|
| --- | --- | --- |
|
|
459
482
|
| `explain_access` | `access <collection>` | Can this reader read this entry, and why. Give the entry by `id` or `terms`, and the reader by `tags`, `customerId` or `signedIn: false`. |
|
|
460
|
-
| `list_levels` | `levels` | Every level, its
|
|
483
|
+
| `list_levels` | `levels` | Every level, its tag groups, the levels it includes, wall text and join link |
|
|
461
484
|
| `get_rules` | `rules` | How rules combine, every rule, the meter, teasers, features and file rules |
|
|
462
485
|
| `get_member` | `member <id or email>` | One member: status, tags, levels with start dates, past levels, features, last sync |
|
|
463
486
|
| `site_health` | `health` | Each setting (set or not, never a value), the last webhook, open privacy requests, ignored text replacements |
|
|
@@ -536,9 +559,9 @@ Pass one object to both `redeemid(config)` and `redeemidPlugin(config)`. `levels
|
|
|
536
559
|
|
|
537
560
|
| Option | Type | What it does |
|
|
538
561
|
| --- | --- | --- |
|
|
539
|
-
| `shop` | `string` | The store's `*.myshopify.com` domain
|
|
562
|
+
| `shop` | `string` | The store's `*.myshopify.com` domain. A custom domain works for sign-in, but the Admin API (staff tools, webhook registration, tag reads) answers only on myshopify.com, and the Status page warns when the app is set up with a custom domain. Leave it out to read `REDEEMID_SHOPIFY_STORE_DOMAIN`. |
|
|
540
563
|
| `label` | `string` | The sign-in button's label. Default "Sign in". |
|
|
541
|
-
| `levels` | `{ slug, name, tags
|
|
564
|
+
| `levels` | `{ slug, name, tags?, match?, groups?, includes?, wall?, joinUrl?, products?, home?, badge? }[]` | Levels from customer tags. See [levels](#levels). |
|
|
542
565
|
| `rules` | `{ collection, taxonomy?, term?, levels, open?, drip?, meter? }[]` | What needs which levels. See [rules](#rules). |
|
|
543
566
|
| `combine` | `"all"` \| `"any"` | How several term rules on one entry combine. Default `"all"`. |
|
|
544
567
|
| `join` | `{ href, label? }` | The wall's join link. |
|
|
@@ -590,7 +613,7 @@ Import each from `@redeemid/emdash/components/<Name>.astro`.
|
|
|
590
613
|
| `YourData` | `title`, `heading` | Download or delete my data |
|
|
591
614
|
| `Feature` | `slug`; slot `else` | Shows its content to members holding the feature |
|
|
592
615
|
| `Notices` | `messages` | The message for `?redeemid_notice=` |
|
|
593
|
-
| `Plan` | `handle`, `label`, `title`, `levels`, `heldLabel`, `renewLabel` | A product with a buy button |
|
|
616
|
+
| `Plan` | `handle`, `label`, `title`, `levels`, `heldLabel`, `renewLabel`, `upgradeLabel` | A product with a buy button that fits the member |
|
|
594
617
|
| `Product` | `handle`, `heading`, `compact`, `html`, `schema`, `addLabel`, `buyLabel` | A product page |
|
|
595
618
|
| `Collection` | `handle`, `limit`, `productHref`, `showTitle`, `browse`, `quickAdd` | A grid of products, with filters and pages |
|
|
596
619
|
| `ProductSearch` | `productHref`, `limit`, `boxOnly`, `action`, `label`, `quickAdd` | Product search |
|
|
@@ -609,7 +632,7 @@ Import each from `@redeemid/emdash/components/<Name>.astro`.
|
|
|
609
632
|
|
|
610
633
|
| Block | Type | Does |
|
|
611
634
|
| --- | --- | --- |
|
|
612
|
-
| Paywall break | `redeemid-paywall` |
|
|
635
|
+
| Paywall break | `redeemid-paywall` | Everyone reads up to here; the rest follows the entry's rule |
|
|
613
636
|
| Members only / Members only: end | `redeemid-members`, `redeemid-members-end` | A members-only part in the middle of a body |
|
|
614
637
|
| Sold on its own | `redeemid-item` | Sells the entry by itself to holders of a tag |
|
|
615
638
|
| Plan | `redeemid-plan` | A product with a buy button |
|
|
@@ -666,6 +689,7 @@ Reword one with `messages={{ cart_empty: "…" }}`, or for every page with `text
|
|
|
666
689
|
- Staff accounts (any EmDash role above Subscriber) can't sign in through the store, even with a matching email. When that happens, the store session ends too, so the next sign-in attempt starts fresh.
|
|
667
690
|
- Customers can't create the first (admin) account on a new site.
|
|
668
691
|
- Webhooks are checked against the app's signature before anything changes.
|
|
692
|
+
- `customers/merge` moves the sign-in link to the customer Shopify kept. With the Admin API app set up, the kept customer's tags are read straight away; without it they arrive with its next webhook or sign-in.
|
|
669
693
|
- Access fails closed: when the saved access settings can't be read, a server keeps the ones it last read, and with none it locks every entry.
|
|
670
694
|
|
|
671
695
|
## Troubleshooting
|
package/docs/developer-api.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Developer API
|
|
2
2
|
|
|
3
|
-
What site code, other plugins and AI agents can rely on in `@redeemid/emdash`. API version **1.
|
|
3
|
+
What site code, other plugins and AI agents can rely on in `@redeemid/emdash`. API version **1.3.0** (`API_VERSION` in `@redeemid/emdash/contract`, also in `api/state` and `window.redeemid.apiVersion`).
|
|
4
4
|
|
|
5
5
|
Everything on this page is stable: it changes only with a changelog entry and an API version bump (minor when something is added, major when something changes or goes). Anything not listed is internal, even when a module exports it. `src/contract.ts` is the same list as code, and a test fails when the code, that list and this page disagree.
|
|
6
6
|
|
|
@@ -17,7 +17,7 @@ Import these in Astro pages, components and endpoints.
|
|
|
17
17
|
| Import | Stable exports |
|
|
18
18
|
| --- | --- |
|
|
19
19
|
| `@redeemid/emdash` | `redeemid`, `redeemidGuard`, `redeemidPlugin` |
|
|
20
|
-
| `@redeemid/emdash/access` | `siteConfig`, `viewer`, `canRead`, `readEntry`, `entryRequirement`, `meterUsage`, `membership`, `levelsFor`, `signInUrl`, `signOutUrl`, `accountUrl`, `cartUrl`, `refreshUrl`, `safeReturn` |
|
|
20
|
+
| `@redeemid/emdash/access` | `siteConfig`, `viewer`, `canRead`, `readEntry`, `entryRequirement`, `meterUsage`, `membership`, `levelsFor`, `topLevels`, `closure`, `simplify`, `signInUrl`, `signOutUrl`, `accountUrl`, `cartUrl`, `refreshUrl`, `safeReturn` |
|
|
21
21
|
| `@redeemid/emdash/features` | `hasFeature`, `viewerFeatures`, `featuresFor` |
|
|
22
22
|
| `@redeemid/emdash/preferences` | `readPreferences`, `savePreferences`, `preferenceFields` |
|
|
23
23
|
| `@redeemid/emdash/storefront` | `plan`, `product`, `collection`, `money`, `realOptions`, `variantFor`, `storeContext`, `cartDetails`, `addToCart`, `updateLines`, `removeLines`, `setDiscountCodes` |
|
|
@@ -26,6 +26,8 @@ Import these in Astro pages, components and endpoints.
|
|
|
26
26
|
| `@redeemid/emdash/text` | `STRINGS`, `text`, `textParts` |
|
|
27
27
|
| `@redeemid/emdash/search` | `readableResults` |
|
|
28
28
|
|
|
29
|
+
Levels: `levelsFor(tags, levels)` answers every level the tags hold, including the ones a held level `includes` (so `levels` in `api/state`, `Viewer.levels` and `member.levels_changed` list Silver for a Gold member whose Gold includes Silver). `topLevels(held, levels)` keeps the ones no other held level includes, for showing ("Gold"). `closure(slug, levels)` is every level holding `slug` opens, itself first. `simplify(required, levels)` drops what inclusion already covers from a requirement (`[["silver"], ["gold"]]` becomes `[["gold"]]`); `entryRequirement` already does it. `membership()` marks a level held only through another with `via` (that level's slug).
|
|
30
|
+
|
|
29
31
|
Functions that take a `config` want the site's config for the request: `await siteConfig(Astro.locals)`, which is the code's config with the admin's saved settings laid over it. Passing the object from `astro.config.mjs` instead skips what staff saved on the Access page. A few examples:
|
|
30
32
|
|
|
31
33
|
```astro
|
|
@@ -51,7 +53,7 @@ Under `/_emdash/api/auth/redeemid/`. Same-origin only: EmDash checks `Origin` on
|
|
|
51
53
|
- `api/state` (GET): the visitor's own state.
|
|
52
54
|
|
|
53
55
|
```json
|
|
54
|
-
{ "apiVersion": "1.
|
|
56
|
+
{ "apiVersion": "1.3.0", "version": "0.0.24", "signedIn": true, "linked": true,
|
|
55
57
|
"levels": ["gold"], "features": ["ad-free"], "cart": { "count": 2 },
|
|
56
58
|
"meter": { "used": 1, "limit": 3, "period": "month" } }
|
|
57
59
|
```
|
package/package.json
CHANGED
package/src/access-admin.ts
CHANGED
|
@@ -15,8 +15,8 @@
|
|
|
15
15
|
|
|
16
16
|
import type { StorageCollection } from "emdash";
|
|
17
17
|
|
|
18
|
-
import { applyEntryRule, describe, requirement, ruleSource, SIGNED_IN, termTree, type Requirement, type Terms } from "./access.js";
|
|
19
|
-
import type
|
|
18
|
+
import { applyEntryRule, badgeColor, describe, requirement, ruleSource, SIGNED_IN, termTree, type Requirement, type Terms } from "./access.js";
|
|
19
|
+
import { groupsOf, type FeatureConfig, type FileRule, type LevelConfig, type MeterConfig, type RedeemIDConfig, type RuleConfig, type TagGroup } from "./config.js";
|
|
20
20
|
import {
|
|
21
21
|
days,
|
|
22
22
|
deleteAccess,
|
|
@@ -153,6 +153,40 @@ function ruleText(rule: RuleConfig | undefined, levels: LevelConfig[]): string {
|
|
|
153
153
|
return extra ? `${who} (${extra})` : who;
|
|
154
154
|
}
|
|
155
155
|
|
|
156
|
+
/** A level's tag groups as the form shows them: one per line, "all:" before a line that needs every tag. */
|
|
157
|
+
function groupsText(level: LevelConfig | undefined): string {
|
|
158
|
+
if (!level) return "";
|
|
159
|
+
return groupsOf(level)
|
|
160
|
+
.map((g) => `${g.match === "all" && g.tags.length > 1 ? "all: " : ""}${g.tags.join(", ")}`)
|
|
161
|
+
.join("\n");
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** The level's tags in the table: "gold or silver + annual". */
|
|
165
|
+
function groupsWords(level: LevelConfig): string {
|
|
166
|
+
return groupsOf(level)
|
|
167
|
+
.map((g) => g.tags.join(g.match === "all" ? " + " : " or "))
|
|
168
|
+
.join(" or ");
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Tag groups from the form: one per line, tags separated by commas; a line
|
|
173
|
+
* starting "all:" needs every tag. `firstMatch` ("all") applies to a first
|
|
174
|
+
* line with no prefix, for callers that still send the old Customer needs field.
|
|
175
|
+
*/
|
|
176
|
+
export function parseGroups(raw: unknown, firstMatch?: unknown): TagGroup[] {
|
|
177
|
+
const lines = Array.isArray(raw) ? [raw.join(",")] : String(raw ?? "").split(/\r?\n/);
|
|
178
|
+
const out: TagGroup[] = [];
|
|
179
|
+
lines.forEach((line, i) => {
|
|
180
|
+
const all = /^\s*all\s*:/i.test(line);
|
|
181
|
+
const tags = list(line.replace(/^\s*(all|any)\s*:/i, "").split(","));
|
|
182
|
+
if (tags.length === 0) return;
|
|
183
|
+
const match = all || (i === 0 && firstMatch === "all") ? "all" : "any";
|
|
184
|
+
if (out.some((g) => (g.match ?? "any") === match && g.tags.join("|").toLowerCase() === tags.join("|").toLowerCase())) return;
|
|
185
|
+
out.push({ tags, ...(match === "all" && tags.length > 1 ? { match: "all" as const } : {}) });
|
|
186
|
+
});
|
|
187
|
+
return out;
|
|
188
|
+
}
|
|
189
|
+
|
|
156
190
|
// ---------------------------------------------------------------- tabs
|
|
157
191
|
|
|
158
192
|
function levelsTab(env: AccessEnv, editing?: string): unknown[] {
|
|
@@ -161,8 +195,9 @@ function levelsTab(env: AccessEnv, editing?: string): unknown[] {
|
|
|
161
195
|
const rules = config.rules ?? [];
|
|
162
196
|
const used = (slug: string) => rules.filter((r) => r.levels.includes(slug)).length;
|
|
163
197
|
const current = editing ? levels.find((l) => l.slug === editing) : undefined;
|
|
198
|
+
const others = levels.filter((l) => l.slug !== current?.slug);
|
|
164
199
|
return [
|
|
165
|
-
{ type: "context", text: "A level is a name for readers who hold some Shopify customer tags. Name them anything. Tags come from Shopify (Flow, a subscription app or staff), never from this site." },
|
|
200
|
+
{ type: "context", text: "A level is a name for readers who hold some Shopify customer tags. Name them anything. Tags come from Shopify (Flow, a subscription app or staff), never from this site. A level that includes another reads everything for it too, so a rule names only the lowest level that may read." },
|
|
166
201
|
...sourceLine(env, "levels", "Levels"),
|
|
167
202
|
levels.length
|
|
168
203
|
? {
|
|
@@ -173,31 +208,44 @@ function levelsTab(env: AccessEnv, editing?: string): unknown[] {
|
|
|
173
208
|
{ key: "name", label: "Level" },
|
|
174
209
|
{ key: "slug", label: "Slug", format: "code" },
|
|
175
210
|
{ key: "tags", label: "Shopify tags" },
|
|
176
|
-
{ key: "
|
|
211
|
+
{ key: "includes", label: "Includes" },
|
|
177
212
|
{ key: "rules", label: "Rules", format: "number" },
|
|
178
213
|
{ key: "act", label: "", format: "element" },
|
|
179
214
|
],
|
|
180
215
|
rows: levels.map((l) => ({
|
|
181
216
|
name: l.name,
|
|
182
217
|
slug: l.slug,
|
|
183
|
-
tags: l
|
|
184
|
-
|
|
218
|
+
tags: groupsWords(l),
|
|
219
|
+
includes: (l.includes ?? []).map((s) => levels.find((x) => x.slug === s)?.name ?? s).join(", "),
|
|
185
220
|
rules: used(l.slug),
|
|
186
221
|
act: { type: "menu", action_id: "level_menu", label: "Edit", items: [{ label: "Edit", value: `edit:${l.slug}` }, { label: "Remove", value: `remove:${l.slug}` }] },
|
|
187
222
|
})),
|
|
188
223
|
}
|
|
189
224
|
: { type: "empty", title: "No levels yet", description: "Add one below, such as Members for the tag member.", size: "sm" },
|
|
190
225
|
{ type: "header", text: current ? `Edit ${current.name}` : "Add a level" },
|
|
226
|
+
{ type: "context", text: "Shopify tags: a line passes when the customer has any one of its tags; start a line with all: to need every tag on it. Any one line grants the level, so gold on one line and all: silver, annual on the next means Gold, or Silver with Annual." },
|
|
191
227
|
{
|
|
192
228
|
type: "form",
|
|
193
229
|
block_id: current ? `level:${current.slug}` : "level:new",
|
|
194
230
|
fields: [
|
|
195
231
|
{ type: "text_input", action_id: "name", label: "Name", placeholder: "Members", initial_value: current?.name ?? "" },
|
|
196
232
|
...(current ? [] : [{ type: "text_input", action_id: "slug", label: "Slug (optional, fixed once saved)", placeholder: "members" }]),
|
|
197
|
-
{
|
|
198
|
-
|
|
233
|
+
{
|
|
234
|
+
type: "text_input",
|
|
235
|
+
action_id: "tags",
|
|
236
|
+
label: "Shopify tags: commas between tags, one group per line",
|
|
237
|
+
placeholder: "gold\nall: silver, annual",
|
|
238
|
+
initial_value: groupsText(current),
|
|
239
|
+
multiline: true,
|
|
240
|
+
},
|
|
241
|
+
...(others.length
|
|
242
|
+
? [{ type: "checkbox", action_id: "includes", label: "Also opens what these levels read", options: others.map((l) => ({ label: l.name, value: l.slug })), initial_value: current?.includes ?? [] }]
|
|
243
|
+
: []),
|
|
199
244
|
{ type: "text_input", action_id: "wall", label: "Wall text (optional)", initial_value: current?.wall ?? "", multiline: true },
|
|
200
245
|
{ type: "text_input", action_id: "join_url", label: "Join link (optional)", placeholder: "/join/", initial_value: current?.joinUrl ?? "" },
|
|
246
|
+
{ type: "text_input", action_id: "products", label: "Shopify product handles that lead to it (optional, for plan cards)", placeholder: "gold-membership", initial_value: current?.products?.join(", ") ?? "" },
|
|
247
|
+
{ type: "text_input", action_id: "home", label: "Members' home after sign-in (optional)", placeholder: "/members/gold/", initial_value: current?.home ?? "" },
|
|
248
|
+
{ type: "text_input", action_id: "badge", label: "Badge colour (optional)", placeholder: "#c9a227", initial_value: current?.badge ?? "" },
|
|
201
249
|
],
|
|
202
250
|
submit: { label: current ? "Save level" : "Add level", action_id: "level_save" },
|
|
203
251
|
},
|
|
@@ -497,16 +545,31 @@ export async function accessAction(env: AccessEnv, i: Interaction): Promise<Bloc
|
|
|
497
545
|
const now = effective(env);
|
|
498
546
|
const used = (now.rules ?? []).filter((r) => r.levels.includes(slug!)).length + (now.files ?? []).filter((f) => f.levels?.includes(slug!)).length + (now.features ?? []).filter((f) => f.levels?.includes(slug!)).length;
|
|
499
547
|
if (used) return bad("levels", `Rules, features or folders use this level (${used}). Change them first.`);
|
|
500
|
-
await saveSite(env, (site, n) => ({
|
|
548
|
+
await saveSite(env, (site, n) => ({
|
|
549
|
+
...site,
|
|
550
|
+
levels: start(site, n, "levels")
|
|
551
|
+
.filter((l) => l.slug !== slug)
|
|
552
|
+
.map((l) => {
|
|
553
|
+
if (!l.includes?.includes(slug!)) return l;
|
|
554
|
+
const includes = l.includes.filter((s) => s !== slug);
|
|
555
|
+
const { includes: _gone, ...rest } = l;
|
|
556
|
+
return includes.length ? { ...rest, includes } : rest;
|
|
557
|
+
}),
|
|
558
|
+
}));
|
|
501
559
|
return ok("levels", "Level removed.");
|
|
502
560
|
}
|
|
503
561
|
|
|
504
562
|
case "level_save": {
|
|
505
563
|
const name = String(values.name ?? "").trim();
|
|
506
|
-
const
|
|
564
|
+
const groups = parseGroups(values.tags, values.match);
|
|
507
565
|
const editing = target !== "new" ? target : null;
|
|
508
566
|
if (!name) return bad("levels", "Give the level a name.", { level: editing ?? undefined });
|
|
509
|
-
if (
|
|
567
|
+
if (groups.length === 0) return bad("levels", "Give it at least one Shopify tag.", { level: editing ?? undefined });
|
|
568
|
+
const home = String(values.home ?? "").trim();
|
|
569
|
+
if (home && (!home.startsWith("/") || home.startsWith("//"))) return bad("levels", "The members' home must be a path on this site, such as /members/.", { level: editing ?? undefined });
|
|
570
|
+
const badge = String(values.badge ?? "").trim();
|
|
571
|
+
if (badge && !badgeColor({ slug: "x", name: "x", badge })) return bad("levels", "The badge colour must be a hex colour, such as #c9a227.", { level: editing ?? undefined });
|
|
572
|
+
const products = list(values.products).map((h) => h.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "")).filter(Boolean);
|
|
510
573
|
const fail = await saveSite(env, (site, n) => {
|
|
511
574
|
const levels = start(site, n, "levels");
|
|
512
575
|
let slug = editing;
|
|
@@ -515,11 +578,19 @@ export async function accessAction(env: AccessEnv, i: Interaction): Promise<Bloc
|
|
|
515
578
|
if (typeof made !== "string") return made.error;
|
|
516
579
|
slug = made;
|
|
517
580
|
}
|
|
518
|
-
const
|
|
581
|
+
const [first, ...more] = groups;
|
|
582
|
+
const level: LevelConfig = { slug, name, tags: first!.tags, ...(first!.match === "all" ? { match: "all" as const } : {}) };
|
|
583
|
+
if (more.length) level.groups = more;
|
|
584
|
+
const known = new Set(levels.map((l) => l.slug));
|
|
585
|
+
const includes = list(values.includes).filter((s) => s !== slug && known.has(s));
|
|
586
|
+
if (includes.length) level.includes = includes;
|
|
519
587
|
const wall = String(values.wall ?? "").trim();
|
|
520
588
|
const joinUrl = String(values.join_url ?? "").trim();
|
|
521
589
|
if (wall) level.wall = wall;
|
|
522
590
|
if (joinUrl) level.joinUrl = joinUrl;
|
|
591
|
+
if (products.length) level.products = [...new Set(products)];
|
|
592
|
+
if (home) level.home = home;
|
|
593
|
+
if (badge) level.badge = badge;
|
|
523
594
|
const at = levels.findIndex((l) => l.slug === slug);
|
|
524
595
|
if (at >= 0) levels[at] = level;
|
|
525
596
|
else levels.push(level);
|
|
@@ -709,7 +780,7 @@ export async function entryPanel(env: AccessEnv, entry: PanelEntry, toast?: Toas
|
|
|
709
780
|
],
|
|
710
781
|
submit: { label: "Save access", action_id: "entry_save" },
|
|
711
782
|
},
|
|
712
|
-
{ type: "context", text: "Paywall break and Members only blocks can
|
|
783
|
+
{ type: "context", text: "With a Paywall break in the body, everyone reads up to it and this rule covers the rest. Members only blocks can lock parts to their own levels." },
|
|
713
784
|
],
|
|
714
785
|
};
|
|
715
786
|
}
|
package/src/access.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* "entitled".
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import type
|
|
13
|
+
import { groupsOf, levelTags, type LevelConfig, type RedeemIDConfig, type RuleConfig, type TagGroup } from "./config.js";
|
|
14
14
|
import { UNAVAILABLE, type EntryRule } from "./settings.js";
|
|
15
15
|
import { siteConfig } from "./site-config.js";
|
|
16
16
|
import { memberForUser, stores, type MemberRecord, type MeterRecord } from "./storage.js";
|
|
@@ -72,15 +72,78 @@ export type Terms = Record<string, TermRef[] | undefined>;
|
|
|
72
72
|
/** Parent slug by term slug, per taxonomy, for walking up a hierarchy. */
|
|
73
73
|
export type TermTree = Record<string, Record<string, string | null>>;
|
|
74
74
|
|
|
75
|
-
export
|
|
75
|
+
export { groupsOf, levelTags, type TagGroup };
|
|
76
|
+
|
|
77
|
+
function groupPasses(g: TagGroup, held: Set<string>): boolean {
|
|
78
|
+
const has = (t: string) => held.has(t.toLowerCase());
|
|
79
|
+
return g.match === "all" ? g.tags.every(has) : g.tags.some(has);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Levels the tags grant directly, through one of their own groups, in config order. */
|
|
83
|
+
export function directLevels(tags: string[], levels: LevelConfig[] = []): string[] {
|
|
76
84
|
const held = new Set(tags.map((t) => t.toLowerCase()));
|
|
77
|
-
return levels
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
85
|
+
return levels.filter((l) => groupsOf(l).some((g) => groupPasses(g, held))).map((l) => l.slug);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Every level holding `slug` opens, itself first: the levels it includes, and
|
|
90
|
+
* theirs (Gold includes Silver includes Members). Unknown slugs are skipped and
|
|
91
|
+
* loops stop, so a bad config can't hang.
|
|
92
|
+
*/
|
|
93
|
+
export function closure(slug: string, levels: LevelConfig[] = []): string[] {
|
|
94
|
+
const bySlug = new Map(levels.map((l) => [l.slug, l]));
|
|
95
|
+
const seen: string[] = [];
|
|
96
|
+
const queue = [slug];
|
|
97
|
+
while (queue.length) {
|
|
98
|
+
const next = queue.shift()!;
|
|
99
|
+
if (seen.includes(next) || !bySlug.has(next)) continue;
|
|
100
|
+
seen.push(next);
|
|
101
|
+
queue.push(...(bySlug.get(next)!.includes ?? []));
|
|
102
|
+
}
|
|
103
|
+
return seen;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Does holding `holder` open what needs `needed`? */
|
|
107
|
+
export function covers(holder: string, needed: string, levels: LevelConfig[] = []): boolean {
|
|
108
|
+
return holder === needed || closure(holder, levels).includes(needed);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The levels a member with these tags holds: the ones their tags grant, and
|
|
113
|
+
* every level those include. In config order.
|
|
114
|
+
*/
|
|
115
|
+
export function levelsFor(tags: string[], levels: LevelConfig[] = []): string[] {
|
|
116
|
+
const held = new Set(directLevels(tags, levels).flatMap((s) => closure(s, levels)));
|
|
117
|
+
return levels.filter((l) => held.has(l.slug)).map((l) => l.slug);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The held levels no other held level includes, for showing a member's
|
|
122
|
+
* levels: "Gold", not "Gold, Silver", when Gold includes Silver.
|
|
123
|
+
*/
|
|
124
|
+
export function topLevels(held: string[], levels: LevelConfig[] = []): string[] {
|
|
125
|
+
return held.filter((slug) => !held.some((o) => o !== slug && covers(o, slug, levels) && !covers(slug, o, levels)));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Where a member lands after signing in from the home page or a plain
|
|
130
|
+
* sign-in link: the `home` of their first top level (in level order) that has
|
|
131
|
+
* one, or null. Only same-site paths count.
|
|
132
|
+
*/
|
|
133
|
+
export function memberHome(tags: string[], levels: LevelConfig[] = []): string | null {
|
|
134
|
+
const top = topLevels(levelsFor(tags, levels), levels);
|
|
135
|
+
for (const level of levels) {
|
|
136
|
+
if (!top.includes(level.slug) || !level.home) continue;
|
|
137
|
+
const home = safeReturn(level.home.trim());
|
|
138
|
+
if (home !== "/") return home;
|
|
139
|
+
}
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** A level's badge colour when it is a hex colour, else null. */
|
|
144
|
+
export function badgeColor(level: LevelConfig | undefined): string | null {
|
|
145
|
+
const c = level?.badge?.trim() ?? "";
|
|
146
|
+
return /^#(?:[0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i.test(c) ? c : null;
|
|
84
147
|
}
|
|
85
148
|
|
|
86
149
|
const slugOf = (t: TermRef) => (typeof t === "string" ? t : t.slug);
|
|
@@ -89,12 +152,16 @@ const slugOf = (t: TermRef) => (typeof t === "string" ? t : t.slug);
|
|
|
89
152
|
* The clauses, simplified: duplicates go, a clause that contains another
|
|
90
153
|
* clause goes (needing Pro already passes "Member or Pro"), and an empty
|
|
91
154
|
* clause goes when a level clause is there (any level implies signed in).
|
|
155
|
+
*
|
|
156
|
+
* With `levels`, inclusion counts too: in "Silver or Gold" Gold goes when it
|
|
157
|
+
* includes Silver, and "Silver + Gold" needs only Gold.
|
|
92
158
|
*/
|
|
93
|
-
function reduce(clauses: string[][]): string[][] {
|
|
159
|
+
function reduce(clauses: string[][], levels?: LevelConfig[]): string[][] {
|
|
94
160
|
const seen = new Set<string>();
|
|
95
161
|
const out: string[][] = [];
|
|
96
162
|
for (const c of clauses) {
|
|
97
|
-
|
|
163
|
+
let sorted = c.includes(SIGNED_IN) ? [] : [...new Set(c)].sort();
|
|
164
|
+
if (levels?.length) sorted = sorted.filter((l) => !sorted.some((o) => o !== l && covers(l, o, levels) && !covers(o, l, levels)));
|
|
98
165
|
const key = sorted.join("|");
|
|
99
166
|
if (seen.has(key)) continue;
|
|
100
167
|
seen.add(key);
|
|
@@ -102,7 +169,14 @@ function reduce(clauses: string[][]): string[][] {
|
|
|
102
169
|
}
|
|
103
170
|
const levelled = out.filter((c) => c.length > 0);
|
|
104
171
|
if (levelled.length === 0) return out;
|
|
105
|
-
|
|
172
|
+
// `o` implies `c` when every level in `o` opens some level in `c`.
|
|
173
|
+
const implies = (o: string[], c: string[]) => o.every((l) => c.some((x) => (levels?.length ? covers(l, x, levels) : l === x)));
|
|
174
|
+
return levelled.filter((c) => !levelled.some((o) => o !== c && implies(o, c) && !(implies(c, o) && levelled.indexOf(c) < levelled.indexOf(o))));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** A requirement simplified with the levels' includes (see reduce). */
|
|
178
|
+
export function simplify(required: Requirement, levels: LevelConfig[] = []): Requirement {
|
|
179
|
+
return required === null ? null : reduce(required, levels);
|
|
106
180
|
}
|
|
107
181
|
|
|
108
182
|
function combined(rules: RuleConfig[], mode: "all" | "any"): string[][] {
|
|
@@ -229,29 +303,52 @@ export function meterable(rules: RuleConfig[] = [], collection: string, terms: T
|
|
|
229
303
|
}
|
|
230
304
|
|
|
231
305
|
/**
|
|
232
|
-
* When
|
|
233
|
-
*
|
|
234
|
-
*
|
|
306
|
+
* When each held level started, by slug (ISO, or null when unknown). A group
|
|
307
|
+
* starts when its first tag arrived ("any") or its last one ("all"); a level
|
|
308
|
+
* starts with its earliest passing group, and a level held only because
|
|
309
|
+
* another includes it starts with the earliest of those.
|
|
235
310
|
*/
|
|
236
|
-
export function
|
|
311
|
+
export function levelStarts(member: MemberRecord, levels: LevelConfig[] = []): Map<string, string | null> {
|
|
237
312
|
const since = member.tagsSince ?? {};
|
|
238
|
-
const
|
|
313
|
+
const heldTags = new Set(member.tags.map((t) => t.toLowerCase()));
|
|
314
|
+
const direct = new Map<string, string | null>();
|
|
315
|
+
for (const level of levels) {
|
|
316
|
+
const starts: string[] = [];
|
|
317
|
+
let passing = false;
|
|
318
|
+
for (const g of groupsOf(level)) {
|
|
319
|
+
if (!groupPasses(g, heldTags)) continue;
|
|
320
|
+
passing = true;
|
|
321
|
+
const dates = g.tags.filter((t) => heldTags.has(t.toLowerCase())).map((t) => since[t.toLowerCase()]).filter((d): d is string => Boolean(d)).sort();
|
|
322
|
+
const pick = g.match === "all" ? dates.at(-1) : dates[0];
|
|
323
|
+
if (pick) starts.push(pick);
|
|
324
|
+
}
|
|
325
|
+
if (passing) direct.set(level.slug, starts.sort()[0] ?? null);
|
|
326
|
+
}
|
|
327
|
+
const out = new Map<string, string | null>();
|
|
239
328
|
for (const slug of levelsFor(member.tags, levels)) {
|
|
240
|
-
const
|
|
241
|
-
|
|
242
|
-
if (dates.length === 0) continue;
|
|
243
|
-
const pick = level?.match === "all" ? dates[dates.length - 1] : dates[0];
|
|
244
|
-
if (pick) starts.push(pick);
|
|
329
|
+
const dates = [...direct.entries()].filter(([d]) => closure(d, levels).includes(slug)).map(([, at]) => at).filter((d): d is string => Boolean(d)).sort();
|
|
330
|
+
out.set(slug, dates[0] ?? null);
|
|
245
331
|
}
|
|
246
|
-
|
|
332
|
+
return out;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* When a member's membership started, for drip: the earliest start among the
|
|
337
|
+
* levels they hold now (see levelStarts), else when they first signed in.
|
|
338
|
+
* Null when neither is known.
|
|
339
|
+
*/
|
|
340
|
+
export function memberSince(member: MemberRecord, levels: LevelConfig[] = []): string | null {
|
|
341
|
+
const starts = [...levelStarts(member, levels).values()].filter((d): d is string => Boolean(d)).sort();
|
|
247
342
|
return starts[0] ?? member.linkedAt ?? null;
|
|
248
343
|
}
|
|
249
344
|
|
|
250
345
|
export interface HeldLevel {
|
|
251
346
|
slug: string;
|
|
252
347
|
name: string;
|
|
253
|
-
/** When the level started (see
|
|
348
|
+
/** When the level started (see levelStarts), ISO, or null when unknown. */
|
|
254
349
|
since: string | null;
|
|
350
|
+
/** Set when the member holds it only because a level they hold includes it: that level's slug. */
|
|
351
|
+
via?: string;
|
|
255
352
|
}
|
|
256
353
|
|
|
257
354
|
export interface PastLevel {
|
|
@@ -267,15 +364,18 @@ export interface PastLevel {
|
|
|
267
364
|
*/
|
|
268
365
|
export function membership(member: MemberRecord, levels: LevelConfig[] = []): { current: HeldLevel[]; past: PastLevel[] } {
|
|
269
366
|
const held = levelsFor(member.tags, levels);
|
|
367
|
+
const direct = directLevels(member.tags, levels);
|
|
368
|
+
const starts = levelStarts(member, levels);
|
|
270
369
|
const current = held.map((slug) => {
|
|
271
370
|
const level = levels.find((l) => l.slug === slug)!;
|
|
272
|
-
|
|
371
|
+
const via = direct.includes(slug) ? undefined : direct.find((d) => closure(d, levels).includes(slug));
|
|
372
|
+
return { slug, name: level.name, since: starts.get(slug) ?? null, ...(via ? { via } : {}) };
|
|
273
373
|
});
|
|
274
374
|
const pastTags = member.pastTags ?? [];
|
|
275
375
|
const past: PastLevel[] = [];
|
|
276
376
|
for (const level of levels) {
|
|
277
377
|
if (held.includes(level.slug)) continue;
|
|
278
|
-
const wanted = new Set(level
|
|
378
|
+
const wanted = new Set(levelTags(level));
|
|
279
379
|
const spans = pastTags.filter((p) => wanted.has(p.tag));
|
|
280
380
|
if (spans.length === 0) continue;
|
|
281
381
|
const froms = spans.map((p) => p.from).filter((d): d is string => Boolean(d)).sort();
|
|
@@ -340,7 +440,7 @@ export function passes(held: string[], required: string[][]): boolean {
|
|
|
340
440
|
export function describe(required: Requirement, levels: LevelConfig[] = [], open = "Free", signedIn = "Signed in"): string {
|
|
341
441
|
if (required === null) return open;
|
|
342
442
|
const name = (slug: string) => (slug === UNAVAILABLE ? "Members" : (levels.find((l) => l.slug === slug)?.name ?? slug));
|
|
343
|
-
const parts = reduce(required).filter((c) => c.length > 0).map((c) => c.map(name).join(" or "));
|
|
443
|
+
const parts = reduce(required, levels).filter((c) => c.length > 0).map((c) => c.map(name).join(" or "));
|
|
344
444
|
return parts.length ? parts.join(" + ") : signedIn;
|
|
345
445
|
}
|
|
346
446
|
|
|
@@ -392,7 +492,7 @@ export async function entryRequirement(
|
|
|
392
492
|
const own = entryRuleFor(config, collection, id);
|
|
393
493
|
if (own && (own.mode === "open" || own.mode === "levels") && !own.openUntil && !own.lockedUntil) return applyEntryRule(null, own);
|
|
394
494
|
const tree = await termTree(config.rules, collection, terms);
|
|
395
|
-
return applyEntryRule(requirement(config.rules, collection, terms, { combine: config.combine, tree }), own);
|
|
495
|
+
return simplify(applyEntryRule(requirement(config.rules, collection, terms, { combine: config.combine, tree }), own), config.levels);
|
|
396
496
|
}
|
|
397
497
|
|
|
398
498
|
const viewers = new WeakMap<object, Promise<Viewer>>();
|
|
@@ -433,7 +533,7 @@ async function readViewer(astro: AstroLike): Promise<Viewer> {
|
|
|
433
533
|
|
|
434
534
|
/**
|
|
435
535
|
* May this visitor read the entry? `override` replaces the entry's rule, for a
|
|
436
|
-
* part of a page locked to its own levels (a
|
|
536
|
+
* part of a page locked to its own levels (a Members only part with levels).
|
|
437
537
|
*/
|
|
438
538
|
export async function canRead(
|
|
439
539
|
astro: AstroLike,
|