@redeemid/emdash 0.0.4 → 0.0.6

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 CHANGED
@@ -2,18 +2,21 @@
2
2
 
3
3
  Shopify customer accounts, member levels and paywalls for [EmDash](https://github.com/emdash-cms/emdash) sites. Shopify runs the store and the customer account; EmDash runs the content.
4
4
 
5
- **Status: 0.0.4, early.** Runs on Node and Cloudflare Workers. Sign-in, the member mirror, webhooks, levels, rules, teasers, badges, the page gate, the Shopify cart and checkout are covered by an end-to-end test against a fake store.
5
+ **Status: 0.0.6, early.** Runs on Node and Cloudflare Workers. Sign-in, the member mirror, webhooks, levels, rules, the meter and drip, teasers, badges, the page gate, the Shopify cart and checkout are covered by an end-to-end test against a fake store.
6
6
 
7
7
  ## What it does
8
8
 
9
9
  - **Sign in with the store's customer account.** Members click "Sign in" on your site, sign in on the store's account page and come back signed in. They become EmDash Subscribers. That role only means "signed in"; it never grants content.
10
10
  - **Mirrors the customer.** Email, name, tags and enabled/disabled status, kept current by signed Shopify webhooks.
11
11
  - **Levels from tags.** You map Shopify customer tags to levels. Set tags in Shopify Flow or any app; RedeemID only reads them.
12
- - **Rules like RedeemID's on WordPress.** Lock a collection, entries with a term (and its child terms), or set a site default; a term can also open its entries to everyone. When several term rules cover an entry, readers must pass all of them.
12
+ - **Rules.** Lock a collection, entries with a term (and its child terms), or set a site default; a term can also open its entries to everyone. When several term rules cover an entry, readers must pass all of them.
13
+ - **Meter and drip.** Signed-in readers may open a few locked entries free each month or week. A rule can also drip: members read its entries once they've held their level for so many days.
13
14
  - **Teasers and badges.** Locked entries can show their first paragraphs, up to a word count. `<AccessBadge>` names the level an entry needs and shows a lock to readers it keeps out.
14
15
  - **Plans, cart and checkout.** `<Plan>` shows a Shopify product's price and adds it to the visitor's Shopify cart; `<Cart>` shows the count and opens Shopify checkout.
15
16
  - **Account.** Members get an Account link to their Shopify customer account (orders, profile, addresses) and Sign out, which also ends the Shopify session.
16
17
  - **Paywall break block.** Editors write one body and drop a "Paywall break" block where members-only content starts, picking which levels may read on, or leaving it to the entry's rule. `<Content>` renders the body and leaves the rest off the page for everyone else.
18
+ - **Members only block.** For a members part in the middle of a body: "Members only" (pick the levels) and "Members only: end". Everyone else sees the wall in its place and reads on after it.
19
+ - **The wall.** Readers kept out see why and what to do next: sign in, or join, with each level's own wall text. `<Notices>` shows the messages sign-in, Buy, Cart and Account send readers back with.
17
20
  - **Plan block.** Editors add a Shopify product to any page with the "Plan" block: title, price and a buy button.
18
21
  - **In the admin.** RedeemID shows under Plugins with a Members page (Shopify customers, tags, levels), a Status page (secrets, store discovery, the URLs to give Shopify, levels and rules) and a dashboard widget.
19
22
  - **Keeps members out of EmDash's own APIs**, so the gate is the only way to read locked content.
@@ -34,7 +37,8 @@ const config = {
34
37
  shop: "my-store.myshopify.com",
35
38
  levels: [
36
39
  { slug: "member", name: "Member", tags: ["member"] },
37
- { slug: "pro", name: "Pro", tags: ["pro"] },
40
+ // wall: what the wall says to readers this level would let in; joinUrl overrides join.href.
41
+ { slug: "pro", name: "Pro", tags: ["pro"], wall: "Pro members read every report." },
38
42
  // match: "all" needs every tag, e.g. both memberships:
39
43
  { slug: "both", name: "Member + Pro", tags: ["member", "pro"], match: "all" },
40
44
  ],
@@ -42,8 +46,12 @@ const config = {
42
46
  { collection: "posts", taxonomy: "category", term: "members", levels: ["member", "pro"] },
43
47
  { collection: "posts", taxonomy: "category", term: "pro", levels: ["pro"] },
44
48
  ],
49
+ // Optional: where the wall's join link goes.
50
+ join: { href: "/join/", label: "Become a member" },
45
51
  // Optional: locked posts with no paywall break show the paragraphs within their first 40 words.
46
52
  teaser: { posts: 40 },
53
+ // Optional: signed-in readers may open 3 locked posts a month.
54
+ meter: { limit: 3, period: "month", collections: ["posts"] },
47
55
  };
48
56
 
49
57
  export default defineConfig({
@@ -90,8 +98,17 @@ Each rule names a collection and the levels, any of which may read. An empty `le
90
98
  | `{ collection: "posts", taxonomy: "tag", term: "free-to-read", levels: [], open: true }` | Opens entries with that term to everyone, whatever else covers them |
91
99
  | `{ collection: "*", levels: [] }` | The site default, for collections with no rule of their own |
92
100
 
101
+ | `{ collection: "posts", taxonomy: "category", term: "archive", levels: ["member"], drip: 30 }` | Drip: members read these entries 30 days after their level started |
102
+ | `{ collection: "posts", taxonomy: "category", term: "exclusive", levels: ["pro"], meter: false }` | The meter never opens these entries |
103
+
93
104
  Term rules beat the collection's rule, which beats the site default. When several term rules cover one entry (a story in Investigations, Gold, and Culture, Silver), readers must pass every one of them, so only Gold + Silver members read it. Set `combine: "any"` to let one of them be enough.
94
105
 
106
+ ### Meter and drip
107
+
108
+ The meter lets a signed-in reader who can't read an entry open it 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; 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.
109
+
110
+ Drip holds a rule's entries back from members who pass it until they've held their level for `drip` days. A level starts when the customer first had its tag (for a `match: "all"` level, the last of its tags); with several levels the earliest counts, and with none known, the first sign-in. RedeemID notes when each tag first arrives from sign-in and webhooks, and forgets a tag when Shopify removes it. The wall shows the date the entry opens.
111
+
95
112
  ## Use it in templates
96
113
 
97
114
  EmDash has no content filter hook, so templates decide what to render. Render the entry body with `<Content>` instead of `<PortableText>`:
@@ -100,12 +117,16 @@ EmDash has no content filter hook, so templates decide what to render. Render th
100
117
  ---
101
118
  import Content from "@redeemid/emdash/components/Content.astro";
102
119
  ---
103
- <Content value={post.data.content} collection="posts" terms={post.data.terms}>
120
+ <Content value={post.data.content} collection="posts" terms={post.data.terms} id={post.id}>
104
121
  <a slot="locked" href="/join/">Become a member</a>
105
122
  </Content>
106
123
  ```
107
124
 
108
- Everything before the first Paywall break block is shown to everyone. What follows reaches the page only for readers the break allows: the levels picked on it ("Signed in" for any member), or when none are picked the entry's rule. With none picked and no rule covering the entry, the break changes nothing, as on WordPress. A body with no break follows the entry's rule as a whole; readers it keeps out see the collection's `teaser` (the paragraphs within that many words, never half a paragraph), if one is set. Plain `<PortableText>` ignores the break and shows everything, so use `<Content>` for any body that may hold one.
125
+ Everything before the first Paywall break block is shown to everyone. What follows reaches the page only for readers the break allows: the levels picked on it ("Signed in" for any member), or when none are picked the entry's rule. With none picked and no rule covering the entry, the break changes nothing. A body with no break follows the entry's rule as a whole; readers it keeps out see the collection's `teaser` (the paragraphs within that many words, never half a paragraph), if one is set. Plain `<PortableText>` ignores the break and shows everything, so use `<Content>` for any body that may hold one.
126
+
127
+ A "Members only" block starts a part for the levels picked on it (none picked: the entry's rule, or any signed-in member) that runs to the next "Members only: end" block. Readers who can't read it see the wall there, and the body goes on after the end block.
128
+
129
+ The wall is `<LockedPanel>`: it says why the reader is kept out and offers sign-in or the join link, with the wall text of the first level they are missing. `<Content>` and `<Gate>` show it; its default slot (and `<Content>`'s "locked" slot) goes on top.
109
130
 
110
131
  To lock a part of a template that isn't the body, wrap it in `<Gate>` (`<Gate levels={["pro"]}>…</Gate>`).
111
132
 
@@ -118,7 +139,9 @@ import AccessBadge from "@redeemid/emdash/components/AccessBadge.astro";
118
139
  <AccessBadge collection="posts" terms={post.data.terms} content={post.data.content} />
119
140
  ```
120
141
 
121
- In your own code (`@redeemid/emdash/access`), `entryRequirement(config, collection, terms)` gives what an entry needs (a list of clauses, each passed by any of its levels, or `null` when open), and `canRead(Astro, entry)` gives the verdict for the visitor.
142
+ In your own code (`@redeemid/emdash/access`), `entryRequirement(config, collection, terms)` gives what an entry needs (a list of clauses, each passed by any of its levels, or `null` when open), `canRead(Astro, entry)` gives the verdict for the visitor, and `readEntry(Astro, { collection, terms, id })` gives it with drip and the meter applied (and counts a metered read).
143
+
144
+ Put `<Notices />` near the top of your layout. It shows the message for a `?redeemid_notice=` code (see [docs/shopify-setup.md](docs/shopify-setup.md)) and nothing otherwise; pass `messages={{ cart_empty: "…" }}` to reword one.
122
145
 
123
146
  Add the account box and cart to your header. Visitors see Sign in; members see their name, levels, Account and Sign out. The cart shows how many items are in the visitor's Shopify cart and goes to its checkout:
124
147
 
@@ -149,6 +172,7 @@ import { canRead } from "@redeemid/emdash/access";
149
172
 
150
173
  const verdict = await canRead(Astro, { collection: "posts", terms: post.data.terms });
151
174
  // verdict.allowed, verdict.reason: open | allowed | sign_in | upgrade | disabled
175
+ // readEntry() adds drip (verdict.opensAt) and metered (verdict.meterLeft)
152
176
  ```
153
177
 
154
178
  Pages rendered for a signed-in member are sent with `Cache-Control: private, no-store`; pages for visitors stay cacheable.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@redeemid/emdash",
3
- "version": "0.0.4",
3
+ "version": "0.0.6",
4
4
  "description": "Shopify customer accounts, member levels and paywalls for EmDash sites. Shopify runs commerce; EmDash runs content.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/access.ts CHANGED
@@ -32,7 +32,7 @@ export interface Viewer {
32
32
  levels: string[];
33
33
  }
34
34
 
35
- export type Reason = "open" | "allowed" | "sign_in" | "upgrade" | "disabled" | "unconfigured";
35
+ export type Reason = "open" | "allowed" | "sign_in" | "upgrade" | "disabled" | "unconfigured" | "drip" | "metered";
36
36
 
37
37
  /**
38
38
  * What an entry needs, in conjunctive form: every clause must pass, and a
@@ -42,7 +42,7 @@ export type Reason = "open" | "allowed" | "sign_in" | "upgrade" | "disabled" | "
42
42
  export type Requirement = string[][] | null;
43
43
 
44
44
  /**
45
- * The built-in level every signed-in member holds, as on WordPress. A clause
45
+ * The built-in level every signed-in member holds. A clause
46
46
  * with it passes for any signed-in member, like an empty clause.
47
47
  */
48
48
  export const SIGNED_IN = "signed-in";
@@ -53,6 +53,12 @@ export interface Verdict {
53
53
  /** What the entry needs (see Requirement). `[]` when it is open. */
54
54
  required: string[][];
55
55
  viewer: Viewer;
56
+ /** For "drip": when the entry opens for this member. */
57
+ opensAt?: Date;
58
+ /** For "metered", and for a locked "upgrade" when a meter runs: free entries left this period. */
59
+ meterLeft?: number;
60
+ /** The meter's size and period, when one ran. */
61
+ meter?: { limit: number; period: "month" | "week" };
56
62
  }
57
63
 
58
64
  /** A term as EmDash hydrates it onto an entry, or just its slug. */
@@ -102,26 +108,14 @@ function combined(rules: RuleConfig[], mode: "all" | "any"): string[][] {
102
108
  return reduce(rules.map((r) => r.levels));
103
109
  }
104
110
 
105
- /**
106
- * What an entry needs (see Requirement), from the site's rules.
107
- *
108
- * Each term the entry carries is walked up its taxonomy (with `tree`) to the
109
- * nearest term that has a rule, so a child term takes its parent's rule unless
110
- * it has its own. Any matching `open` rule opens the entry.
111
- */
112
- export function requirement(
113
- rules: RuleConfig[] = [],
114
- collection: string,
115
- terms: Terms = {},
116
- options: { combine?: "all" | "any"; tree?: TermTree } = {},
117
- ): Requirement {
118
- const mode = options.combine ?? "all";
111
+ /** The rules that decide an entry: matching term rules, else the collection's, else the site default. */
112
+ function deciding(rules: RuleConfig[], collection: string, terms: Terms, tree?: TermTree): RuleConfig[] | "open" {
119
113
  const forCollection = rules.filter((r) => r.collection === collection);
120
114
  const matched: RuleConfig[] = [];
121
115
  for (const [taxonomy, list] of Object.entries(terms)) {
122
116
  const taxRules = forCollection.filter((r) => r.taxonomy === taxonomy && r.term);
123
117
  if (taxRules.length === 0) continue;
124
- const parents = options.tree?.[taxonomy] ?? {};
118
+ const parents = tree?.[taxonomy] ?? {};
125
119
  for (const ref of list ?? []) {
126
120
  let slug: string | null = slugOf(ref);
127
121
  const seen = new Set<string>();
@@ -136,13 +130,87 @@ export function requirement(
136
130
  }
137
131
  }
138
132
  }
139
- if (matched.some((r) => r.open)) return null;
140
- if (matched.length > 0) return combined(matched, mode);
133
+ if (matched.some((r) => r.open)) return "open";
134
+ if (matched.length > 0) return matched;
141
135
  const whole = forCollection.filter((r) => !r.taxonomy && !r.term && !r.open);
142
- if (whole.length > 0) return combined(whole, mode);
143
- const site = rules.filter((r) => r.collection === "*" && !r.taxonomy && !r.term && !r.open);
144
- if (site.length > 0) return combined(site, mode);
145
- return null;
136
+ if (whole.length > 0) return whole;
137
+ return rules.filter((r) => r.collection === "*" && !r.taxonomy && !r.term && !r.open);
138
+ }
139
+
140
+ /**
141
+ * What an entry needs (see Requirement), from the site's rules.
142
+ *
143
+ * Each term the entry carries is walked up its taxonomy (with `tree`) to the
144
+ * nearest term that has a rule, so a child term takes its parent's rule unless
145
+ * it has its own. Any matching `open` rule opens the entry.
146
+ */
147
+ export function requirement(
148
+ rules: RuleConfig[] = [],
149
+ collection: string,
150
+ terms: Terms = {},
151
+ options: { combine?: "all" | "any"; tree?: TermTree } = {},
152
+ ): Requirement {
153
+ const found = deciding(rules, collection, terms, options.tree);
154
+ if (found === "open" || found.length === 0) return null;
155
+ return combined(found, options.combine ?? "all");
156
+ }
157
+
158
+ /** Days of membership the entry's deciding rules ask for (the largest drip), or 0. */
159
+ export function dripDays(rules: RuleConfig[] = [], collection: string, terms: Terms = {}, tree?: TermTree): number {
160
+ const found = deciding(rules, collection, terms, tree);
161
+ if (found === "open") return 0;
162
+ return Math.max(0, ...found.map((r) => (typeof r.drip === "number" && r.drip > 0 ? r.drip : 0)));
163
+ }
164
+
165
+ /** May the meter open the entry? Not when a deciding rule says `meter: false`. */
166
+ export function meterable(rules: RuleConfig[] = [], collection: string, terms: Terms = {}, tree?: TermTree): boolean {
167
+ const found = deciding(rules, collection, terms, tree);
168
+ return found === "open" || !found.some((r) => r.meter === false);
169
+ }
170
+
171
+ /**
172
+ * When a member's membership started, for drip: the earliest start among the
173
+ * levels they hold now (a level starts when its last needed tag arrived),
174
+ * else when they first signed in. Null when neither is known.
175
+ */
176
+ export function memberSince(member: MemberRecord, levels: LevelConfig[] = []): string | null {
177
+ const since = member.tagsSince ?? {};
178
+ const starts: string[] = [];
179
+ for (const slug of levelsFor(member.tags, levels)) {
180
+ const level = levels.find((l) => l.slug === slug);
181
+ const dates = (level?.tags ?? []).map((t) => since[t.toLowerCase()]).filter((d): d is string => Boolean(d)).sort();
182
+ if (dates.length === 0) continue;
183
+ const pick = level?.match === "all" ? dates[dates.length - 1] : dates[0];
184
+ if (pick) starts.push(pick);
185
+ }
186
+ starts.sort();
187
+ return starts[0] ?? member.linkedAt ?? null;
188
+ }
189
+
190
+ /** When drip opens an entry for this member: `since` plus `days`. */
191
+ export function dripOpensAt(since: string | null, days: number): Date | null {
192
+ if (!since || days <= 0) return null;
193
+ const t = Date.parse(since);
194
+ return Number.isNaN(t) ? null : new Date(t + days * 86_400_000);
195
+ }
196
+
197
+ /** The meter period an instant falls in: "2026-11", or ISO week "2026-W45". UTC. */
198
+ export function periodKey(at: Date, period: "month" | "week" = "month"): string {
199
+ if (period === "month") return `${at.getUTCFullYear()}-${String(at.getUTCMonth() + 1).padStart(2, "0")}`;
200
+ const d = new Date(Date.UTC(at.getUTCFullYear(), at.getUTCMonth(), at.getUTCDate()));
201
+ const day = d.getUTCDay() || 7;
202
+ d.setUTCDate(d.getUTCDate() + 4 - day);
203
+ const yearStart = Date.UTC(d.getUTCFullYear(), 0, 1);
204
+ const week = Math.ceil(((d.getTime() - yearStart) / 86_400_000 + 1) / 7);
205
+ return `${d.getUTCFullYear()}-W${String(week).padStart(2, "0")}`;
206
+ }
207
+
208
+ /** Count `id` against a meter: an item already counted stays free; a new one needs room. Pure. */
209
+ export function consume(items: string[], id: string, limit: number): { allowed: boolean; items: string[]; left: number } {
210
+ if (items.includes(id)) return { allowed: true, items, left: Math.max(0, limit - items.length) };
211
+ if (items.length >= limit) return { allowed: false, items, left: 0 };
212
+ const next = [...items, id];
213
+ return { allowed: true, items: next, left: limit - next.length };
146
214
  }
147
215
 
148
216
  /** Does someone holding `held` pass `required`? Signed-in membership is checked by the caller. */
@@ -290,3 +358,60 @@ export function accountUrl(returnTo = "/"): string {
290
358
  export function signOutUrl(returnTo = "/"): string {
291
359
  return `/_emdash/api/auth/redeemid/logout?return=${encodeURIComponent(safeReturn(returnTo))}`;
292
360
  }
361
+
362
+ /**
363
+ * The entry's verdict with drip and the meter applied, for the entry's own
364
+ * page. Drip narrows a granted verdict; the meter opens a locked one for a
365
+ * signed-in reader while the period has room, and never opens a drip entry.
366
+ */
367
+ export async function readEntry(
368
+ astro: AstroLike,
369
+ entry: { collection: string; terms?: Terms; id?: string },
370
+ override?: { required: string[][] },
371
+ ): Promise<Verdict> {
372
+ const verdict = await canRead(astro, entry, override);
373
+ const locals = astro.locals as Locals;
374
+ const config = siteConfig(locals.emdash?.config);
375
+ if (!config) return verdict;
376
+
377
+ if (verdict.allowed && verdict.reason === "allowed" && verdict.viewer.member) {
378
+ const tree = await termTree(config.rules, entry.collection, entry.terms);
379
+ const days = dripDays(config.rules, entry.collection, entry.terms, tree);
380
+ const opensAt = dripOpensAt(memberSince(verdict.viewer.member, config.levels), days);
381
+ if (days > 0 && (!opensAt || opensAt.getTime() > Date.now())) {
382
+ return { ...verdict, allowed: false, reason: "drip", opensAt: opensAt ?? undefined };
383
+ }
384
+ return verdict;
385
+ }
386
+
387
+ const meter = config.meter;
388
+ const user = locals.user;
389
+ const db = locals.emdash?.db;
390
+ if (
391
+ verdict.reason !== "upgrade" ||
392
+ !meter ||
393
+ !(meter.limit > 0) ||
394
+ !entry.id ||
395
+ !user?.id ||
396
+ !db ||
397
+ (meter.collections && !meter.collections.includes(entry.collection))
398
+ ) {
399
+ return verdict;
400
+ }
401
+ if (!meterable(config.rules, entry.collection, entry.terms, await termTree(config.rules, entry.collection, entry.terms))) {
402
+ return verdict;
403
+ }
404
+ const period = meter.period ?? "month";
405
+ const key = `${user.id}:${periodKey(new Date(), period)}`;
406
+ try {
407
+ const s = stores(db);
408
+ const record = (await s.meter.get(key)) ?? { userId: user.id, period: periodKey(new Date(), period), items: [] };
409
+ const used = consume(record.items, `${entry.collection}:${entry.id}`, meter.limit);
410
+ const info = { limit: meter.limit, period };
411
+ if (!used.allowed) return { ...verdict, meterLeft: 0, meter: info };
412
+ if (used.items !== record.items) await s.meter.put(key, { ...record, items: used.items });
413
+ return { ...verdict, allowed: true, reason: "metered", meterLeft: used.left, meter: info };
414
+ } catch {
415
+ return verdict;
416
+ }
417
+ }
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  /**
3
- * The paywall break itself renders nothing. <Content> splits the body here
4
- * and leaves out what follows for readers who can't read it.
3
+ * RedeemID's marker blocks (Paywall break, Members only and its end) render
4
+ * nothing themselves. <Content> splits the body at them and leaves out what
5
+ * readers can't read.
5
6
  */
6
7
  ---
@@ -2,10 +2,12 @@
2
2
  * Site-side renderers for RedeemID's Portable Text blocks, picked up by
3
3
  * EmDash from the plugin's `componentsEntry`.
4
4
  */
5
- import PaywallBreak from "./PaywallBreak.astro";
5
+ import Marker from "./PaywallBreak.astro";
6
6
  import PlanBlock from "./PlanBlock.astro";
7
7
 
8
8
  export const blockComponents = {
9
- "redeemid-paywall": PaywallBreak,
9
+ "redeemid-paywall": Marker,
10
+ "redeemid-members": Marker,
11
+ "redeemid-members-end": Marker,
10
12
  "redeemid-plan": PlanBlock,
11
13
  };
@@ -1,6 +1,9 @@
1
1
  /** Portable Text block types RedeemID adds to the editor. */
2
2
  export const PAYWALL_BLOCK = "redeemid-paywall";
3
3
  export const PLAN_BLOCK = "redeemid-plan";
4
+ /** Starts a members-only part of the body; it runs to the next end block. */
5
+ export const MEMBERS_BLOCK = "redeemid-members";
6
+ export const MEMBERS_END_BLOCK = "redeemid-members-end";
4
7
 
5
8
  type Node = { _type?: unknown; levels?: unknown };
6
9
 
@@ -26,7 +29,7 @@ export function splitAtPaywall<T>(value: T[] | null | undefined): {
26
29
  type TextNode = { _type?: unknown; children?: Array<{ text?: unknown }> };
27
30
 
28
31
  /**
29
- * The text blocks within the first `words` words of a body, as on WordPress:
32
+ * The text blocks within the first `words` words of a body:
30
33
  * a block is never cut in half, so a block that would pass the count ends the
31
34
  * teaser. Images and custom blocks are left out. 0 gives nothing.
32
35
  */
@@ -44,3 +47,39 @@ export function teaser<T>(value: T[] | null | undefined, words: number): T[] {
44
47
  }
45
48
  return out;
46
49
  }
50
+
51
+ export interface Section<T> {
52
+ /** True for a part between a "Members only" block and its end. */
53
+ members: boolean;
54
+ /** Levels picked on the "Members only" block (empty = none picked). */
55
+ levels: string[];
56
+ blocks: T[];
57
+ }
58
+
59
+ const levelsOf = (raw: unknown) =>
60
+ Array.isArray(raw) ? raw.filter((l): l is string => typeof l === "string" && l !== "") : [];
61
+
62
+ /**
63
+ * Split blocks into open parts and members-only parts. A "Members only" block
64
+ * starts a part that runs to the next "Members only: end" block (or the end of
65
+ * the blocks); the marker blocks themselves are dropped. A stray end block is
66
+ * dropped too.
67
+ */
68
+ export function sections<T>(value: T[] | null | undefined): Section<T>[] {
69
+ const out: Section<T>[] = [];
70
+ let current: Section<T> = { members: false, levels: [], blocks: [] };
71
+ for (const b of Array.isArray(value) ? value : []) {
72
+ const type = (b as Node)?._type;
73
+ if (type === MEMBERS_BLOCK || type === MEMBERS_END_BLOCK) {
74
+ if (current.blocks.length > 0 || current.members) out.push(current);
75
+ current =
76
+ type === MEMBERS_BLOCK
77
+ ? { members: true, levels: levelsOf((b as Node).levels), blocks: [] }
78
+ : { members: false, levels: [], blocks: [] };
79
+ continue;
80
+ }
81
+ current.blocks.push(b);
82
+ }
83
+ if (current.blocks.length > 0 || current.members) out.push(current);
84
+ return out;
85
+ }
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  /**
3
3
  * The level an entry needs, read from its rules (and from a paywall break
4
- * with levels), like the Access Badge block on WordPress:
4
+ * with levels):
5
5
  *
6
6
  * <AccessBadge collection="posts" terms={post.data.terms} content={post.data.content} />
7
7
  *
@@ -1,60 +1,108 @@
1
1
  ---
2
2
  /**
3
- * An entry's body, with RedeemID's paywall break applied.
3
+ * An entry's body, with RedeemID's blocks applied.
4
4
  *
5
- * <Content value={post.data.content} collection="posts" terms={post.data.terms} />
5
+ * <Content value={post.data.content} collection="posts" terms={post.data.terms} id={post.id} />
6
6
  *
7
- * Everything before the first "Paywall break" block is shown to everyone.
8
- * What follows reaches the page only for readers who may read it: the levels
9
- * picked on the break, or when none are picked the entry's rule (with no rule,
10
- * the break changes nothing, as on WordPress). Others get the "locked" slot (or a built-in notice).
11
- * A body with no break follows the entry's rule as a whole; readers who can't
12
- * read it see the collection's teaser (config `teaser`, in words), if any.
7
+ * - "Paywall break": everything before the first one is shown to everyone.
8
+ * What follows reaches the page only for readers who may read it: the
9
+ * levels picked on the break, or when none are picked the entry's rule
10
+ * (with no rule, the break changes nothing).
11
+ * - "Members only" … "Members only: end": the part between is for the levels
12
+ * picked on the start block, or the entry's rule, or any signed-in member.
13
+ * Readers who can't read it see the wall in its place, and the body goes on.
14
+ * - No break: the entry's rule covers the whole body; readers it keeps out see
15
+ * the collection's teaser (config `teaser`, in words), if any.
16
+ *
17
+ * Readers kept out get the wall (LockedPanel), with the "locked" slot on top.
18
+ *
19
+ * Pass `id` on the entry's own page so drip and the meter apply: a rule's
20
+ * `drip` keeps it shut until the member has held their level that many days,
21
+ * and config `meter` lets signed-in readers open a few locked entries each
22
+ * period (each entry counts once). Leave `id` off in lists.
13
23
  */
14
24
  import { PortableText } from "emdash/ui";
15
25
 
16
- import { canRead, entryRequirement, signInUrl, type Terms } from "../access.js";
17
- import { splitAtPaywall, teaser } from "../blocks-names.js";
26
+ import { canRead, entryRequirement, readEntry, type Terms, type Verdict } from "../access.js";
27
+ import { sections, splitAtPaywall, teaser } from "../blocks-names.js";
18
28
  import { siteConfig } from "../site-config.js";
29
+ import LockedPanel from "./LockedPanel.astro";
19
30
 
20
31
  interface Props {
21
32
  value?: unknown[] | null;
22
33
  collection?: string;
23
34
  terms?: Terms;
35
+ /** The entry's id, on its own page: turns on drip and the meter. */
36
+ id?: string;
24
37
  }
25
38
 
26
- const { value, collection = "", terms } = Astro.props;
39
+ const { value, collection = "", terms, id } = Astro.props;
27
40
  const split = splitAtPaywall(value);
28
41
  const config = siteConfig((Astro.locals as { emdash?: { config?: unknown } }).emdash?.config);
42
+ const entryRule = await entryRequirement(config, collection, terms);
29
43
  // A break with levels picked locks the rest to them; with none picked it
30
44
  // follows the entry's rule, and does nothing when no rule covers the entry.
31
- const required = split.paywall
32
- ? split.levels.length
33
- ? [split.levels]
34
- : await entryRequirement(config, collection, terms)
35
- : undefined;
36
- const verdict =
45
+ const required = split.paywall ? (split.levels.length ? [split.levels] : entryRule) : undefined;
46
+ const verdict: Verdict | { allowed: true; reason: "open" } =
37
47
  split.paywall && required === null
38
- ? { allowed: true, reason: "open" as const }
39
- : await canRead(Astro, { collection, terms }, required ? { required } : undefined);
48
+ ? { allowed: true, reason: "open" }
49
+ : id
50
+ ? await readEntry(Astro, { collection, terms, id }, required ? { required } : undefined)
51
+ : await canRead(Astro, { collection, terms }, required ? { required } : undefined);
40
52
  const words = config?.teaser?.[collection] ?? 0;
41
53
  const shown = split.paywall ? split.open : verdict.allowed ? split.open : teaser(split.open, words);
42
- const here = Astro.url.pathname + Astro.url.search;
54
+ const rest = verdict.allowed ? split.locked : [];
55
+
56
+ // Each "Members only" part gets its own verdict.
57
+ async function parts(blocks: unknown[]) {
58
+ const out: Array<{ blocks: unknown[]; verdict?: Verdict }> = [];
59
+ for (const section of sections(blocks)) {
60
+ if (!section.members) {
61
+ out.push({ blocks: section.blocks });
62
+ continue;
63
+ }
64
+ const needs = section.levels.length ? [section.levels] : (entryRule ?? [[]]);
65
+ out.push({ blocks: section.blocks, verdict: await canRead(Astro, { collection, terms }, { required: needs }) });
66
+ }
67
+ return out;
68
+ }
69
+ const before = await parts(shown);
70
+ const after = await parts(rest);
71
+ const metered = verdict.reason === "metered" ? (verdict as Verdict) : null;
72
+ const per = metered?.meter?.period === "week" ? "this week" : "this month";
43
73
  ---
44
74
 
45
- {shown.length > 0 && <PortableText value={shown} />}
75
+ {metered && (
76
+ <p class="redeemid-meter" role="status" data-redeemid-left={metered.meterLeft}>
77
+ {metered.meterLeft === 0
78
+ ? `This is your last free read ${per}.`
79
+ : `You have ${metered.meterLeft} free ${metered.meterLeft === 1 ? "read" : "reads"} left ${per}.`}
80
+ </p>
81
+ )}
82
+ {before.map((p) =>
83
+ !p.verdict || p.verdict.allowed ? (
84
+ p.blocks.length > 0 && <PortableText value={p.blocks} />
85
+ ) : (
86
+ <LockedPanel reason={p.verdict.reason} required={p.verdict.required} held={p.verdict.viewer.levels} />
87
+ ),
88
+ )}
46
89
  {verdict.allowed ? (
47
- split.locked.length > 0 && <PortableText value={split.locked} />
90
+ after.map((p) =>
91
+ !p.verdict || p.verdict.allowed ? (
92
+ p.blocks.length > 0 && <PortableText value={p.blocks} />
93
+ ) : (
94
+ <LockedPanel reason={p.verdict.reason} required={p.verdict.required} held={p.verdict.viewer.levels} />
95
+ ),
96
+ )
48
97
  ) : (
49
- <div class="redeemid-locked" data-redeemid-reason={verdict.reason}>
98
+ <LockedPanel
99
+ reason={verdict.reason}
100
+ required={verdict.required}
101
+ held={verdict.viewer.levels}
102
+ opensAt={verdict.opensAt}
103
+ meterLeft={verdict.meterLeft}
104
+ meterPeriod={verdict.meter?.period}
105
+ >
50
106
  <slot name="locked" />
51
- {verdict.reason === "sign_in" && (
52
- <p>
53
- This is for members. <a href={signInUrl(here)}>Sign in</a> to read on.
54
- </p>
55
- )}
56
- {verdict.reason === "upgrade" && <p>Your membership doesn't include the rest of this yet.</p>}
57
- {verdict.reason === "disabled" && <p>Your account is turned off. Contact the store for help.</p>}
58
- {verdict.reason === "unconfigured" && <p>This is for members.</p>}
59
- </div>
107
+ </LockedPanel>
60
108
  )}
@@ -10,7 +10,8 @@
10
10
  *
11
11
  * Pass `levels` to lock a part of a page to specific levels instead.
12
12
  */
13
- import { canRead, signInUrl, type Terms } from "../access.js";
13
+ import { canRead, type Terms } from "../access.js";
14
+ import LockedPanel from "./LockedPanel.astro";
14
15
 
15
16
  interface Props {
16
17
  collection?: string;
@@ -20,21 +21,12 @@ interface Props {
20
21
 
21
22
  const { collection = "", terms, levels } = Astro.props;
22
23
  const verdict = await canRead(Astro, { collection, terms }, levels ? { levels } : undefined);
23
- const here = Astro.url.pathname + Astro.url.search;
24
24
  ---
25
25
 
26
26
  {verdict.allowed ? (
27
27
  <slot />
28
28
  ) : (
29
- <div class="redeemid-locked" data-redeemid-reason={verdict.reason}>
29
+ <LockedPanel reason={verdict.reason} required={verdict.required} held={verdict.viewer.levels}>
30
30
  <slot name="locked" />
31
- {verdict.reason === "sign_in" && (
32
- <p>
33
- This is for members. <a href={signInUrl(here)}>Sign in</a> to read it.
34
- </p>
35
- )}
36
- {verdict.reason === "upgrade" && <p>Your membership doesn't include this yet.</p>}
37
- {verdict.reason === "disabled" && <p>Your account is turned off. Contact the store for help.</p>}
38
- {verdict.reason === "unconfigured" && <p>This is for members.</p>}
39
- </div>
31
+ </LockedPanel>
40
32
  )}
@@ -0,0 +1,64 @@
1
+ ---
2
+ /**
3
+ * The wall readers see in place of what they can't read: why, and what to do
4
+ * next (sign in, or join). <Content> and <Gate> show it; use it directly for
5
+ * your own locked parts.
6
+ *
7
+ * The level it offers is the first one the reader is missing. Its `wall` text
8
+ * and `joinUrl` come from the level's config, the join link from `join`.
9
+ * Anything in the default slot is shown above the built-in text.
10
+ */
11
+ import { passes, signInUrl, type Reason } from "../access.js";
12
+ import { siteConfig } from "../site-config.js";
13
+
14
+ interface Props {
15
+ reason: Reason;
16
+ /** What the locked part needs (Verdict.required). */
17
+ required?: string[][];
18
+ /** Levels the reader holds (Verdict.viewer.levels). */
19
+ held?: string[];
20
+ /** For "drip": when it opens (Verdict.opensAt). */
21
+ opensAt?: Date;
22
+ /** Free reads left when a meter ran (Verdict.meterLeft); 0 shows the meter-used text. */
23
+ meterLeft?: number;
24
+ meterPeriod?: "month" | "week";
25
+ }
26
+
27
+ const { reason, required = [], held = [], opensAt, meterLeft, meterPeriod } = Astro.props;
28
+ const opens = opensAt
29
+ ? new Intl.DateTimeFormat("en-US", { dateStyle: "long", timeZone: "UTC" }).format(opensAt)
30
+ : null;
31
+ const config = siteConfig((Astro.locals as { emdash?: { config?: unknown } }).emdash?.config);
32
+ const missing = required.find((clause) => clause.length > 0 && !passes(held, [clause]));
33
+ const level = missing ? config?.levels?.find((l) => l.slug === missing[0]) : undefined;
34
+ const joinHref = level?.joinUrl ?? config?.join?.href;
35
+ const joinLabel = config?.join?.label ?? "Become a member";
36
+ const here = Astro.url.pathname + Astro.url.search;
37
+ ---
38
+
39
+ <div class="redeemid-locked" data-redeemid-reason={reason} data-redeemid-level={level?.slug}>
40
+ <slot />
41
+ {level?.wall && (reason === "sign_in" || reason === "upgrade") && <p class="redeemid-locked__wall">{level.wall}</p>}
42
+ {reason === "sign_in" && (
43
+ <p>
44
+ This is for members. <a href={signInUrl(here)}>Sign in</a> to read on.
45
+ {joinHref && <> Not a member yet? <a href={joinHref}>{joinLabel}</a>.</>}
46
+ </p>
47
+ )}
48
+ {reason === "upgrade" && meterLeft === 0 && (
49
+ <p class="redeemid-locked__meter">You've used your free reads {meterPeriod === "week" ? "this week" : "this month"}.</p>
50
+ )}
51
+ {reason === "drip" && (
52
+ <p>
53
+ {opens ? <>This opens for you on <time datetime={opensAt?.toISOString()}>{opens}</time>.</> : "This opens for you after you've been a member a little longer."}
54
+ </p>
55
+ )}
56
+ {reason === "upgrade" && (
57
+ <p>
58
+ {level ? `This is for ${level.name} members.` : "Your membership doesn't include this yet."}
59
+ {joinHref && <> <a href={joinHref}>{joinLabel}</a></>}
60
+ </p>
61
+ )}
62
+ {reason === "disabled" && <p>Your account is turned off. Contact the store for help.</p>}
63
+ {reason === "unconfigured" && <p>This is for members.</p>}
64
+ </div>
@@ -0,0 +1,36 @@
1
+ ---
2
+ /**
3
+ * The message for a `?redeemid_notice=` code that sign-in, Buy, Cart and
4
+ * Account send readers back with. Renders nothing without one. Pass
5
+ * `messages` to reword any of them.
6
+ */
7
+ interface Props {
8
+ messages?: Record<string, string>;
9
+ }
10
+
11
+ const DEFAULTS: Record<string, string> = {
12
+ not_configured: "Sign-in isn't set up on this site yet.",
13
+ sign_in_unavailable: "Sign-in isn't available right now. Try again in a few minutes.",
14
+ sign_in_expired: "That sign-in took too long. Try again.",
15
+ staff_account: "This account can't sign in here. Use a different email.",
16
+ site_not_ready: "This site isn't ready for sign-in yet.",
17
+ email_required: "Your account needs an email address to sign in.",
18
+ token_failed: "Sign-in didn't finish. Try again.",
19
+ bad_id_token: "Sign-in didn't finish. Try again.",
20
+ identity_failed: "Sign-in didn't finish. Try again.",
21
+ plan_unavailable: "That plan isn't available right now.",
22
+ checkout_failed: "Checkout didn't open. Try again.",
23
+ cart_empty: "Your cart is empty.",
24
+ account_unavailable: "Your account page isn't available right now. Try again in a few minutes.",
25
+ };
26
+
27
+ const { messages = {} } = Astro.props;
28
+ const code = Astro.url.searchParams.get("redeemid_notice") ?? "";
29
+ const text = code ? (messages[code] ?? DEFAULTS[code]) : undefined;
30
+ ---
31
+
32
+ {text && (
33
+ <div class="redeemid-notice" role="status" data-redeemid-notice={code}>
34
+ <p>{text}</p>
35
+ </div>
36
+ )}
package/src/config.ts CHANGED
@@ -15,10 +15,14 @@ export interface LevelConfig {
15
15
  tags: string[];
16
16
  /** "any" (default): one of the tags is enough. "all": every tag is needed. */
17
17
  match?: "any" | "all";
18
+ /** What the wall says to readers this level would let in, e.g. "Gold members read every investigation." */
19
+ wall?: string;
20
+ /** Where the wall's join link goes for this level. Falls back to `join.href`. */
21
+ joinUrl?: string;
18
22
  }
19
23
 
20
24
  /**
21
- * Who may read entries, like RedeemID's content rules on WordPress:
25
+ * Who may read entries:
22
26
  *
23
27
  * - A rule with `taxonomy` and `term` covers entries that carry that term, or
24
28
  * a child of it in a hierarchical taxonomy (a child's own rule wins over its
@@ -41,9 +45,30 @@ export interface RuleConfig {
41
45
  levels: string[];
42
46
  /**
43
47
  * A term rule that opens its entries to everyone, whatever else covers them
44
- * ("Opens" on WordPress). `levels` is ignored.
48
+ * `levels` is ignored.
45
49
  */
46
50
  open?: boolean;
51
+ /**
52
+ * Drip: readers who pass the rule may read its entries only once they have
53
+ * been members this many days (from when they first held a level they
54
+ * hold now, or else when they first signed in).
55
+ */
56
+ drip?: number;
57
+ /** `false`: the meter never opens this rule's entries. */
58
+ meter?: false;
59
+ }
60
+
61
+ /**
62
+ * The meter: signed-in readers without access may open a few locked entries
63
+ * free each month (or week). Guests get the wall; an entry counts once per
64
+ * period, when its own page renders through <Content> with an `id`.
65
+ */
66
+ export interface MeterConfig {
67
+ /** Free entries per period. */
68
+ limit: number;
69
+ period?: "month" | "week";
70
+ /** Collections the meter covers. All of them when left out. */
71
+ collections?: string[];
47
72
  }
48
73
 
49
74
  export interface RedeemIDConfig {
@@ -57,6 +82,9 @@ export interface RedeemIDConfig {
57
82
  label?: string;
58
83
  levels?: LevelConfig[];
59
84
  rules?: RuleConfig[];
85
+ meter?: MeterConfig;
86
+ /** The wall's join link: where readers become members, e.g. { href: "/join/", label: "Become a member" }. */
87
+ join?: { href: string; label?: string };
60
88
  /**
61
89
  * How several term rules on one entry combine: "all" (default, the most
62
90
  * restrictive: pass every rule) or "any" (pass one of them).
package/src/plugin.ts CHANGED
@@ -6,14 +6,17 @@
6
6
  * plugins: [redeemidPlugin(config)],
7
7
  * })
8
8
  *
9
- * It adds two Portable Text blocks to the editor, a Members page, a Status
9
+ * It adds four Portable Text blocks to the editor, a Members page, a Status
10
10
  * page and a dashboard widget. Sign-in, webhooks and checkout stay on the
11
11
  * auth provider's routes.
12
12
  *
13
13
  * - "Paywall break" (`redeemid-paywall`): everything after it in the body is
14
14
  * for members. Pick the levels that may read on ("Signed in" for any
15
15
  * member); none picked means the entry's rule, and nothing is locked when
16
- * no rule covers the entry, as on WordPress.
16
+ * no rule covers the entry.
17
+ * - "Members only" (`redeemid-members`) and "Members only: end"
18
+ * (`redeemid-members-end`): the part between is for the levels picked on
19
+ * the start block; everyone else sees the wall there and reads on after.
17
20
  * - "Plan" (`redeemid-plan`): a Shopify product's title and price with a
18
21
  * button that adds it to the cart and opens checkout.
19
22
  *
@@ -31,9 +34,9 @@ import { stores, type MemberRecord } from "./storage.js";
31
34
  import { PAGES, WIDGETS } from "./descriptor.js";
32
35
  import { VERSION } from "./version.js";
33
36
 
34
- import { PAYWALL_BLOCK, PLAN_BLOCK } from "./blocks-names.js";
37
+ import { MEMBERS_BLOCK, MEMBERS_END_BLOCK, PAYWALL_BLOCK, PLAN_BLOCK } from "./blocks-names.js";
35
38
 
36
- export { PAYWALL_BLOCK, PLAN_BLOCK };
39
+ export { MEMBERS_BLOCK, MEMBERS_END_BLOCK, PAYWALL_BLOCK, PLAN_BLOCK };
37
40
 
38
41
  function blocks(config: RedeemIDConfig) {
39
42
  const levelOptions = [
@@ -55,6 +58,27 @@ function blocks(config: RedeemIDConfig) {
55
58
  },
56
59
  ],
57
60
  },
61
+ {
62
+ type: MEMBERS_BLOCK,
63
+ label: "Members only",
64
+ icon: "users",
65
+ description: "What follows, up to \"Members only: end\", is for members",
66
+ fields: [
67
+ {
68
+ type: "checkbox" as const,
69
+ action_id: "levels",
70
+ label: "Who can read it (none = the entry's rule, or any signed-in member)",
71
+ options: levelOptions,
72
+ },
73
+ ],
74
+ },
75
+ {
76
+ type: MEMBERS_END_BLOCK,
77
+ label: "Members only: end",
78
+ icon: "users",
79
+ description: "Ends the members-only part; everyone reads on from here",
80
+ fields: [],
81
+ },
58
82
  {
59
83
  type: PLAN_BLOCK,
60
84
  label: "Plan",
@@ -257,6 +281,7 @@ async function statusPage(config: RedeemIDConfig, request: Request) {
257
281
  columns: [
258
282
  { key: "where", label: "Covers" },
259
283
  { key: "levels", label: "Who can read" },
284
+ { key: "extra", label: "Drip and meter" },
260
285
  ],
261
286
  rows: rules.map((r) => ({
262
287
  where:
@@ -266,9 +291,16 @@ async function statusPage(config: RedeemIDConfig, request: Request) {
266
291
  ? "Site default (collections with no rule)"
267
292
  : `All ${r.collection}`,
268
293
  levels: r.open ? "Everyone (opens the entry)" : r.levels.length ? describe([r.levels], levels) : "Any signed-in member",
294
+ extra: [r.drip ? `Opens ${r.drip} days into membership` : "", r.meter === false ? "Never metered" : ""].filter(Boolean).join("; "),
269
295
  })),
270
296
  }
271
297
  : { type: "context", text: "No rules. Entries are open unless their body has a paywall break." },
298
+ {
299
+ type: "context",
300
+ text: config.meter?.limit
301
+ ? `Meter: signed-in readers may open ${config.meter.limit} locked ${config.meter.limit === 1 ? "entry" : "entries"} a ${config.meter.period ?? "month"}${config.meter.collections?.length ? ` (${config.meter.collections.join(", ")})` : ""}.`
302
+ : "Meter: off.",
303
+ },
272
304
  ],
273
305
  };
274
306
  }
@@ -17,7 +17,7 @@ import type { Kysely } from "kysely";
17
17
 
18
18
  import { env, ENV, PROVIDER_ID, readConfig, storeDomain } from "../config.js";
19
19
  import { discover, exchangeCode, identity, ShopifyError, verifyIdToken } from "../shopify.js";
20
- import { emptyMember, stores } from "../storage.js";
20
+ import { emptyMember, stampTags, stores } from "../storage.js";
21
21
  import { callbackUrl, fail, FLOW_TTL_MS, SESSION_FLOW, SESSION_ID_TOKEN, type Flow } from "./shared.js";
22
22
 
23
23
  export const prerender = false;
@@ -83,16 +83,24 @@ export const GET: APIRoute = async ({ request, locals, session, redirect }) => {
83
83
 
84
84
  const s = stores(emdash.db);
85
85
  const member = (await s.members.get(who.customerId)) ?? emptyMember(who.customerId);
86
- await s.members.put(who.customerId, {
87
- ...member,
88
- userId: user.id,
89
- email,
90
- firstName: who.firstName,
91
- lastName: who.lastName,
92
- tags: who.tags,
93
- syncedAt: new Date().toISOString(),
94
- source: "sign-in",
95
- });
86
+ const now = new Date().toISOString();
87
+ await s.members.put(
88
+ who.customerId,
89
+ stampTags(
90
+ {
91
+ ...member,
92
+ userId: user.id,
93
+ email,
94
+ firstName: who.firstName,
95
+ lastName: who.lastName,
96
+ tags: who.tags,
97
+ syncedAt: now,
98
+ source: "sign-in",
99
+ linkedAt: member.linkedAt ?? now,
100
+ },
101
+ now,
102
+ ),
103
+ );
96
104
 
97
105
  session.regenerate?.();
98
106
  session.set("user", { id: user.id });
package/src/storage.ts CHANGED
@@ -31,6 +31,27 @@ export interface MemberRecord {
31
31
  syncedAt: string;
32
32
  /** What wrote it last: sign-in or a webhook topic. */
33
33
  source: string;
34
+ /** When each tag (lowercased) was first seen on the customer, ISO. Kept for drip; a removed tag is forgotten. */
35
+ tagsSince?: Record<string, string>;
36
+ /** When the customer first signed in here, ISO. */
37
+ linkedAt?: string | null;
38
+ }
39
+
40
+ /** A signed-in reader's meter for one period: the locked items they opened free. */
41
+ export interface MeterRecord {
42
+ userId: string;
43
+ /** "2026-11" for a month, "2026-W45" for a week. */
44
+ period: string;
45
+ items: string[];
46
+ }
47
+
48
+ /** Update `tagsSince` to the record's current tags: new tags start now, removed tags are dropped. */
49
+ export function stampTags(m: MemberRecord, now = new Date().toISOString()): MemberRecord {
50
+ const before = m.tagsSince ?? {};
51
+ const since: Record<string, string> = {};
52
+ for (const t of m.tags.map((t) => t.toLowerCase())) since[t] = before[t] ?? now;
53
+ m.tagsSince = since;
54
+ return m;
34
55
  }
35
56
 
36
57
  export interface DeliveryRecord {
@@ -41,11 +62,13 @@ export interface DeliveryRecord {
41
62
  export const STORAGE_CONFIG = {
42
63
  members: { indexes: ["email", "userId"] },
43
64
  deliveries: { indexes: ["receivedAt"] },
65
+ meter: { indexes: ["userId"] },
44
66
  };
45
67
 
46
68
  export interface Stores {
47
69
  members: StorageCollection<MemberRecord>;
48
70
  deliveries: StorageCollection<DeliveryRecord>;
71
+ meter: StorageCollection<MeterRecord>;
49
72
  }
50
73
 
51
74
  export function stores(db: unknown): Stores {
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const VERSION = "0.0.4";
1
+ export const VERSION = "0.0.6";
package/src/webhooks.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  * only; a profile payload without `tags` never means "tags removed".
7
7
  */
8
8
 
9
- import { cleanTags, emptyMember, numericId, type MemberRecord, type Stores } from "./storage.js";
9
+ import { cleanTags, emptyMember, numericId, stampTags, type MemberRecord, type Stores } from "./storage.js";
10
10
 
11
11
  export type Kind = "profile" | "delete" | "status" | "tags" | "merge" | "compliance" | "ignored";
12
12
 
@@ -114,7 +114,7 @@ export async function apply(s: Stores, topic: string, payload: Record<string, un
114
114
  if (!survivor.userId) survivor.userId = old.userId;
115
115
  survivor.syncedAt = new Date().toISOString();
116
116
  survivor.source = topic;
117
- await s.members.put(kept, survivor);
117
+ await s.members.put(kept, stampTags(survivor));
118
118
  }
119
119
  await s.members.delete(gone);
120
120
  return "processed";
@@ -156,6 +156,6 @@ export async function apply(s: Stores, topic: string, payload: Record<string, un
156
156
 
157
157
  current.syncedAt = new Date().toISOString();
158
158
  current.source = topic;
159
- await s.members.put(id, current);
159
+ await s.members.put(id, stampTags(current));
160
160
  return "processed";
161
161
  }