@redeemid/emdash 0.0.7 → 0.0.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,74 @@
1
+ ---
2
+ /**
3
+ * Pay what you want: the reader picks an amount and checks out. Set up a
4
+ * Shopify product priced at 1.00 (one unit of the currency); the amount
5
+ * becomes the line's quantity, so Shopify charges exactly what they chose.
6
+ * With another price the field asks for a quantity instead.
7
+ *
8
+ * <Contribution handle="support-the-newsroom" amounts={[5, 10, 25]} />
9
+ */
10
+ import { siteConfig } from "../site-config.js";
11
+ import { money, product as loadProduct, type Product } from "../storefront.js";
12
+
13
+ interface Props {
14
+ handle: string;
15
+ /** Suggested amounts, as buttons. */
16
+ amounts?: number[];
17
+ /** The amount filled in to start with. */
18
+ initial?: number;
19
+ label?: string;
20
+ title?: string;
21
+ }
22
+
23
+ const { handle, amounts = [5, 10, 25], initial = 10, label = "Give", title } = Astro.props;
24
+ const config = siteConfig((Astro.locals as { emdash?: { config?: unknown } }).emdash?.config);
25
+ let p: Product | null = null;
26
+ if (config) {
27
+ try {
28
+ p = await loadProduct(config, handle);
29
+ } catch {
30
+ p = null;
31
+ }
32
+ }
33
+ const variant = p?.variants.find((v) => v.availableForSale);
34
+ const unit = variant ? Number(variant.price.amount) === 1 : false;
35
+ const currency = variant?.price.currencyCode ?? "USD";
36
+ const symbol = new Intl.NumberFormat("en-US", { style: "currency", currency }).formatToParts(0).find((x) => x.type === "currency")?.value ?? "";
37
+ const here = Astro.url.pathname + Astro.url.search;
38
+ const id = `redeemid-give-${handle}`;
39
+ ---
40
+
41
+ {p && variant ? (
42
+ <form class="redeemid-contribution" method="post" action="/_emdash/api/auth/redeemid/cart-edit" data-redeemid-product={p.handle}>
43
+ <h2 class="redeemid-contribution__title">{title ?? p.title}</h2>
44
+ {p.description && <p>{p.description}</p>}
45
+ <input type="hidden" name="action" value="add" />
46
+ <input type="hidden" name="product" value={p.handle} />
47
+ <input type="hidden" name="variant" value={variant.id} />
48
+ <input type="hidden" name="return" value={here} />
49
+ <input type="hidden" name="then" value="checkout" />
50
+ {unit ? (
51
+ <>
52
+ {amounts.length > 0 && (
53
+ <p class="redeemid-contribution__amounts">
54
+ {amounts.map((a) => (
55
+ <button type="submit" name="quantity" value={String(a)}>{symbol}{a}</button>
56
+ ))}
57
+ </p>
58
+ )}
59
+ <p>
60
+ <label for={`${id}-amount`}>Another amount ({currency})</label>
61
+ <input id={`${id}-amount`} name="quantity" type="number" min="1" max="1000" step="1" value={initial} inputmode="numeric" />
62
+ </p>
63
+ </>
64
+ ) : (
65
+ <p>
66
+ <label for={`${id}-amount`}>Quantity ({money(variant.price)} each)</label>
67
+ <input id={`${id}-amount`} name="quantity" type="number" min="1" max="1000" value="1" inputmode="numeric" />
68
+ </p>
69
+ )}
70
+ <button type="submit">{label}</button>
71
+ </form>
72
+ ) : (
73
+ <slot />
74
+ )}
@@ -0,0 +1,20 @@
1
+ ---
2
+ /**
3
+ * Shows its default slot only to members who hold the feature (see config
4
+ * `features`), and the "else" slot to everyone else.
5
+ *
6
+ * <Feature slug="supporter">
7
+ * <p>Thanks for supporting us.</p>
8
+ * <a slot="else" href="/support/">Support us</a>
9
+ * </Feature>
10
+ */
11
+ import { hasFeature } from "../features.js";
12
+
13
+ interface Props {
14
+ slug: string;
15
+ }
16
+
17
+ const held = await hasFeature(Astro, Astro.props.slug);
18
+ ---
19
+
20
+ {held ? <slot /> : <slot name="else" />}
@@ -10,6 +10,7 @@
10
10
  * membership reads it from the store again.
11
11
  */
12
12
  import { accountUrl, membership, meterUsage, refreshUrl, signInUrl, signOutUrl, viewer } from "../access.js";
13
+ import { featuresFor } from "../features.js";
13
14
  import { siteConfig } from "../site-config.js";
14
15
 
15
16
  interface Props {
@@ -25,6 +26,8 @@ const m = v.member;
25
26
  const disabled = m?.status === "disabled";
26
27
  const { current, past } = m && !disabled ? membership(m, config?.levels) : { current: [], past: [] };
27
28
  const meter = v.signedIn ? await meterUsage(Astro) : null;
29
+ const held = m ? featuresFor(m, config?.features, config?.levels) : [];
30
+ const features = (config?.features ?? []).filter((f) => held.includes(f.slug));
28
31
  const date = (iso: string | null) => {
29
32
  if (!iso) return null;
30
33
  const t = Date.parse(iso);
@@ -60,6 +63,11 @@ const join = config?.join;
60
63
  You don't have a membership yet.{join && <> <a href={join.href}>{join.label ?? "Become a member"}</a></>}
61
64
  </p>
62
65
  )}
66
+ {features.length > 0 && (
67
+ <p class="redeemid-member__features">
68
+ Includes: {features.map((f) => f.name).join(", ")}
69
+ </p>
70
+ )}
63
71
  {past.length > 0 && (
64
72
  <>
65
73
  <h3 class="redeemid-member__subtitle">Before</h3>
@@ -21,6 +21,10 @@ const DEFAULTS: Record<string, string> = {
21
21
  plan_unavailable: "That plan isn't available right now.",
22
22
  checkout_failed: "Checkout didn't open. Try again.",
23
23
  cart_empty: "Your cart is empty.",
24
+ cart_added: "Added to your cart.",
25
+ cart_updated: "Your cart is updated.",
26
+ discount_applied: "Discount applied.",
27
+ discount_not_applicable: "That discount code doesn't apply to this cart.",
24
28
  refreshed: "Your membership is up to date.",
25
29
  account_unavailable: "Your account page isn't available right now. Try again in a few minutes.",
26
30
  };
@@ -0,0 +1,78 @@
1
+ ---
2
+ /**
3
+ * A Shopify product: image, title, price, description, a variant choice
4
+ * when it has several, a quantity, and "Add to cart" and "Buy now".
5
+ * Everything comes from the Storefront API; the default slot shows if the
6
+ * product can't be loaded.
7
+ *
8
+ * <Product handle="harbor-tote" />
9
+ */
10
+ import { siteConfig } from "../site-config.js";
11
+ import { money, product as loadProduct, type Product } from "../storefront.js";
12
+
13
+ interface Props {
14
+ handle: string;
15
+ /** Heading level for the title. */
16
+ heading?: "h1" | "h2" | "h3";
17
+ /** Leave out the description (for cards). */
18
+ compact?: boolean;
19
+ addLabel?: string;
20
+ buyLabel?: string;
21
+ }
22
+
23
+ const { handle, heading = "h2", compact = false, addLabel = "Add to cart", buyLabel = "Buy now" } = Astro.props;
24
+ const config = siteConfig((Astro.locals as { emdash?: { config?: unknown } }).emdash?.config);
25
+ let p: Product | null = null;
26
+ if (config) {
27
+ try {
28
+ p = await loadProduct(config, handle);
29
+ } catch {
30
+ p = null;
31
+ }
32
+ }
33
+ const available = p?.variants.filter((v) => v.availableForSale) ?? [];
34
+ const first = available[0] ?? p?.variants[0];
35
+ const here = Astro.url.pathname + Astro.url.search;
36
+ const Heading = heading;
37
+ const id = `redeemid-product-${handle}`;
38
+ ---
39
+
40
+ {p ? (
41
+ <div class="redeemid-product" data-redeemid-product={p.handle}>
42
+ {p.image && <img class="redeemid-product__image" src={p.image.url} alt={p.image.altText ?? ""} loading="lazy" />}
43
+ <Heading class="redeemid-product__title">{p.title}</Heading>
44
+ {first && <p class="redeemid-product__price">{money(first.price)}</p>}
45
+ {!compact && p.description && <p class="redeemid-product__description">{p.description}</p>}
46
+ {available.length > 0 ? (
47
+ <form method="post" action="/_emdash/api/auth/redeemid/cart-edit">
48
+ <input type="hidden" name="action" value="add" />
49
+ <input type="hidden" name="product" value={p.handle} />
50
+ <input type="hidden" name="return" value={here} />
51
+ {p.variants.length > 1 ? (
52
+ <p>
53
+ <label for={`${id}-variant`}>Option</label>
54
+ <select id={`${id}-variant`} name="variant">
55
+ {p.variants.map((v) => (
56
+ <option value={v.id} disabled={!v.availableForSale}>
57
+ {v.title} ({money(v.price)}){!v.availableForSale && " sold out"}
58
+ </option>
59
+ ))}
60
+ </select>
61
+ </p>
62
+ ) : (
63
+ <input type="hidden" name="variant" value={first?.id} />
64
+ )}
65
+ <p>
66
+ <label for={`${id}-quantity`}>Quantity</label>
67
+ <input id={`${id}-quantity`} name="quantity" type="number" min="1" max="1000" value="1" inputmode="numeric" />
68
+ </p>
69
+ <button type="submit" name="then" value="back">{addLabel}</button>
70
+ <button type="submit" name="then" value="checkout">{buyLabel}</button>
71
+ </form>
72
+ ) : (
73
+ <p class="redeemid-product__unavailable">Sold out.</p>
74
+ )}
75
+ </div>
76
+ ) : (
77
+ <slot />
78
+ )}
package/src/config.ts CHANGED
@@ -63,6 +63,21 @@ export interface RuleConfig {
63
63
  * free each month (or week). Guests get the wall; an entry counts once per
64
64
  * period, when its own page renders through <Content> with an `id`.
65
65
  */
66
+ /** A perk members hold through any of its levels or Shopify tags. */
67
+ export interface FeatureConfig {
68
+ slug: string;
69
+ name: string;
70
+ levels?: string[];
71
+ tags?: string[];
72
+ }
73
+
74
+ /** Where to send signed member events (see events.ts). The signing secret is REDEEMID_EVENTS_SECRET. */
75
+ export interface EventsConfig {
76
+ url: string;
77
+ /** Send only these events. All of them when left out. */
78
+ only?: Array<"member.signed_in" | "member.levels_changed" | "meter.used" | "meter.exhausted">;
79
+ }
80
+
66
81
  export interface MeterConfig {
67
82
  /** Free entries per period. */
68
83
  limit: number;
@@ -83,6 +98,8 @@ export interface RedeemIDConfig {
83
98
  levels?: LevelConfig[];
84
99
  rules?: RuleConfig[];
85
100
  meter?: MeterConfig;
101
+ features?: FeatureConfig[];
102
+ events?: EventsConfig;
86
103
  /** The wall's join link: where readers become members, e.g. { href: "/join/", label: "Become a member" }. */
87
104
  join?: { href: string; label?: string };
88
105
  /**
@@ -123,6 +140,10 @@ export const ENV = {
123
140
  clientSecret: "REDEEMID_SHOPIFY_CUSTOMER_ACCOUNT_CLIENT_SECRET",
124
141
  /** Client secret of the Shopify (Admin API) app whose webhooks the site receives. Shopify signs webhooks with it. */
125
142
  webhookSecret: "REDEEMID_SHOPIFY_ADMIN_APP_CLIENT_SECRET",
143
+ /** Client ID of that Shopify (Admin API) app. With the secret it lets staff tools call the Admin API (client credentials). */
144
+ adminClientId: "REDEEMID_SHOPIFY_ADMIN_APP_CLIENT_ID",
145
+ /** Secret for signing the outbound events RedeemID sends to `events.url`. */
146
+ eventsSecret: "REDEEMID_EVENTS_SECRET",
126
147
  /** Storefront API private token, for server-side plan and checkout calls. */
127
148
  storefrontPrivateToken: "REDEEMID_SHOPIFY_STOREFRONT_PRIVATE_TOKEN",
128
149
  /** Storefront API public token, used only when the private token isn't set. */
package/src/descriptor.ts CHANGED
@@ -11,6 +11,7 @@ import { VERSION } from "./version.js";
11
11
  export const PAGES = [
12
12
  { path: "/", label: "Members", icon: "users" },
13
13
  { path: "/status", label: "Status", icon: "pulse" },
14
+ { path: "/test", label: "Access test", icon: "search" },
14
15
  ];
15
16
  export const WIDGETS = [{ id: "status", title: "RedeemID", size: "half" as const }];
16
17
 
package/src/events.ts ADDED
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Outbound events: RedeemID POSTs JSON to `events.url` when something
3
+ * happens to a member, signed so the receiver can trust it.
4
+ *
5
+ * events: { url: "https://hooks.example.com/redeemid" } // + REDEEMID_EVENTS_SECRET
6
+ *
7
+ * Headers: X-RedeemID-Event (the name), X-RedeemID-Delivery (a fresh id) and
8
+ * X-RedeemID-Signature: sha256=<hex HMAC of the raw body>. Events:
9
+ * member.signed_in, member.levels_changed, meter.used, meter.exhausted.
10
+ * Sending never blocks or breaks the page: failures are logged and dropped.
11
+ * No event carries an email address or a name; match people by `customerId`.
12
+ */
13
+
14
+ import { env, ENV, type RedeemIDConfig } from "./config.js";
15
+ import { USER_AGENT } from "./shopify.js";
16
+
17
+ export type EventName = "member.signed_in" | "member.levels_changed" | "meter.used" | "meter.exhausted";
18
+
19
+ const TIMEOUT_MS = 3000;
20
+
21
+ export async function signBody(body: string, secret: string): Promise<string> {
22
+ const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
23
+ const mac = new Uint8Array(await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(body)));
24
+ return [...mac].map((b) => b.toString(16).padStart(2, "0")).join("");
25
+ }
26
+
27
+ /** Send one event. Returns whether the receiver answered 2xx; never throws. */
28
+ export async function emit(config: RedeemIDConfig | null, name: EventName, data: Record<string, unknown>, fetcher: typeof fetch = fetch): Promise<boolean> {
29
+ const url = config?.events?.url;
30
+ const secret = env(ENV.eventsSecret);
31
+ if (!url || !secret) return false;
32
+ if (config.events?.only && !config.events.only.includes(name)) return false;
33
+ const id = crypto.randomUUID();
34
+ const body = JSON.stringify({ id, event: name, at: new Date().toISOString(), data });
35
+ try {
36
+ const res = await fetcher(url, {
37
+ method: "POST",
38
+ headers: {
39
+ "Content-Type": "application/json",
40
+ "User-Agent": USER_AGENT,
41
+ "X-RedeemID-Event": name,
42
+ "X-RedeemID-Delivery": id,
43
+ "X-RedeemID-Signature": `sha256=${await signBody(body, secret)}`,
44
+ },
45
+ body,
46
+ signal: AbortSignal.timeout(TIMEOUT_MS),
47
+ });
48
+ return res.ok;
49
+ } catch (e) {
50
+ console.error("[redeemid] event not sent:", name, (e as Error).message);
51
+ return false;
52
+ }
53
+ }
54
+
55
+ /** Send member.levels_changed when a mirror write changed the member's levels. */
56
+ export async function levelChange(
57
+ config: RedeemIDConfig | null,
58
+ beforeTags: string[] | null,
59
+ after: { customerId: string; tags: string[]; status: string },
60
+ source: string,
61
+ fetcher: typeof fetch = fetch,
62
+ ): Promise<void> {
63
+ if (!config?.events?.url) return;
64
+ const { levelsFor } = await import("./access.js");
65
+ const was = beforeTags ? levelsFor(beforeTags, config.levels) : [];
66
+ const now = after.status === "disabled" ? [] : levelsFor(after.tags, config.levels);
67
+ if (was.length === now.length && was.every((l) => now.includes(l))) return;
68
+ await emit(
69
+ config,
70
+ "member.levels_changed",
71
+ { customerId: after.customerId, levels: now, added: now.filter((l) => !was.includes(l)), removed: was.filter((l) => !now.includes(l)), source },
72
+ fetcher,
73
+ );
74
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Features: named perks a member holds through their levels or their
3
+ * Shopify tags (an ad-free site, a supporter thank-you, early access).
4
+ * Like levels they come only from the mirrored tags; nothing here grants
5
+ * them otherwise.
6
+ *
7
+ * features: [
8
+ * { slug: "ad-free", name: "Ad-free", levels: ["gold", "premium"] },
9
+ * { slug: "supporter", name: "Supporter", tags: ["supporter"] },
10
+ * ]
11
+ */
12
+
13
+ import { levelsFor, viewer, type AstroLike } from "./access.js";
14
+ import type { FeatureConfig, LevelConfig } from "./config.js";
15
+ import { siteConfig } from "./site-config.js";
16
+ import type { MemberRecord } from "./storage.js";
17
+
18
+ /** The feature slugs a member holds: from any of the feature's levels or tags. */
19
+ export function featuresFor(member: Pick<MemberRecord, "tags" | "status"> | null, features: FeatureConfig[] = [], levels: LevelConfig[] = []): string[] {
20
+ if (!member || member.status === "disabled") return [];
21
+ const held = levelsFor(member.tags, levels);
22
+ const tags = new Set(member.tags.map((t) => t.toLowerCase()));
23
+ return features
24
+ .filter((f) => (f.levels ?? []).some((l) => held.includes(l)) || (f.tags ?? []).some((t) => tags.has(t.toLowerCase())))
25
+ .map((f) => f.slug);
26
+ }
27
+
28
+ /** Does the visitor hold this feature? */
29
+ export async function hasFeature(astro: AstroLike, slug: string): Promise<boolean> {
30
+ return (await viewerFeatures(astro)).includes(slug);
31
+ }
32
+
33
+ /** The visitor's features. */
34
+ export async function viewerFeatures(astro: AstroLike): Promise<string[]> {
35
+ const config = siteConfig((astro.locals as { emdash?: { config?: unknown } }).emdash?.config);
36
+ if (!config?.features?.length) return [];
37
+ const v = await viewer(astro);
38
+ return featuresFor(v.member, config.features, config.levels);
39
+ }
package/src/index.ts CHANGED
@@ -56,6 +56,7 @@ export function redeemid(config: RedeemIDConfig): AuthProviderDescriptor {
56
56
  { pattern: `${BASE}/webhook`, entrypoint: "@redeemid/emdash/routes/webhook.ts" },
57
57
  { pattern: `${BASE}/buy`, entrypoint: "@redeemid/emdash/routes/buy.ts" },
58
58
  { pattern: `${BASE}/cart`, entrypoint: "@redeemid/emdash/routes/cart.ts" },
59
+ { pattern: `${BASE}/cart-edit`, entrypoint: "@redeemid/emdash/routes/cart-edit.ts" },
59
60
  { pattern: `${BASE}/account`, entrypoint: "@redeemid/emdash/routes/account.ts" },
60
61
  ],
61
62
  publicRoutes: [`${BASE}/`],
package/src/plugin.ts CHANGED
@@ -33,10 +33,20 @@ import { discover } from "./shopify.js";
33
33
  import { stores, type MemberRecord } from "./storage.js";
34
34
  import { PAGES, WIDGETS } from "./descriptor.js";
35
35
  import { VERSION } from "./version.js";
36
+ import { adminConfigured } from "./admin-api.js";
37
+ import { accessTest, bulkResync, BULK_BATCH, deliveriesSection, memberDetail, registerWebhooks, resyncOne, tagEdit, webhooksSection } from "./staff.js";
36
38
 
37
- import { MEMBERS_BLOCK, MEMBERS_END_BLOCK, PAYWALL_BLOCK, PLAN_BLOCK } from "./blocks-names.js";
39
+ import {
40
+ COLLECTION_BLOCK,
41
+ CONTRIBUTION_BLOCK,
42
+ MEMBERS_BLOCK,
43
+ MEMBERS_END_BLOCK,
44
+ PAYWALL_BLOCK,
45
+ PLAN_BLOCK,
46
+ PRODUCT_BLOCK,
47
+ } from "./blocks-names.js";
38
48
 
39
- export { MEMBERS_BLOCK, MEMBERS_END_BLOCK, PAYWALL_BLOCK, PLAN_BLOCK };
49
+ export { COLLECTION_BLOCK, CONTRIBUTION_BLOCK, MEMBERS_BLOCK, MEMBERS_END_BLOCK, PAYWALL_BLOCK, PLAN_BLOCK, PRODUCT_BLOCK };
40
50
 
41
51
  function blocks(config: RedeemIDConfig) {
42
52
  const levelOptions = [
@@ -97,6 +107,39 @@ function blocks(config: RedeemIDConfig) {
97
107
  },
98
108
  ],
99
109
  },
110
+ {
111
+ type: PRODUCT_BLOCK,
112
+ label: "Product",
113
+ icon: "tag",
114
+ description: "A Shopify product with Add to cart and Buy now",
115
+ placeholder: "Shopify product handle",
116
+ fields: [{ type: "text_input" as const, action_id: "handle", label: "Shopify product handle", placeholder: "harbor-tote" }],
117
+ },
118
+ {
119
+ type: COLLECTION_BLOCK,
120
+ label: "Collection",
121
+ icon: "grid",
122
+ description: "A Shopify collection's products as cards",
123
+ placeholder: "Shopify collection handle",
124
+ fields: [
125
+ { type: "text_input" as const, action_id: "handle", label: "Shopify collection handle", placeholder: "shop" },
126
+ { type: "text_input" as const, action_id: "limit", label: "How many products (up to 50)", placeholder: "12" },
127
+ { type: "text_input" as const, action_id: "link", label: "Product page link, with {handle}", placeholder: "/shop/{handle}/" },
128
+ ],
129
+ },
130
+ {
131
+ type: CONTRIBUTION_BLOCK,
132
+ label: "Contribution",
133
+ icon: "star",
134
+ description: "Pay what you want, with a Shopify product priced at 1.00",
135
+ placeholder: "Shopify product handle",
136
+ fields: [
137
+ { type: "text_input" as const, action_id: "handle", label: "Shopify product handle (priced at 1.00)", placeholder: "support" },
138
+ { type: "text_input" as const, action_id: "title", label: "Title (optional, Shopify's title otherwise)" },
139
+ { type: "text_input" as const, action_id: "amounts", label: "Suggested amounts, comma separated", placeholder: "5, 10, 25" },
140
+ { type: "text_input" as const, action_id: "label", label: "Button label", placeholder: "Give" },
141
+ ],
142
+ },
100
143
  ];
101
144
  }
102
145
 
@@ -113,11 +156,13 @@ export function createPlugin(config: RedeemIDConfig = {}): ResolvedPlugin {
113
156
  routes: {
114
157
  admin: {
115
158
  handler: async (ctx: RouteContext) => {
116
- const i = (ctx.input ?? {}) as { type?: string; page?: string };
117
- if (i.type !== "page_load") return { blocks: [] };
159
+ const i = (ctx.input ?? {}) as { type?: string; page?: string; action_id?: string; value?: unknown; values?: Record<string, unknown>; block_id?: string };
118
160
  try {
161
+ if (i.type === "block_action" || i.type === "form_submit") return await action(config, ctx.request, i);
162
+ if (i.type !== "page_load") return { blocks: [] };
119
163
  if (i.page === "/") return await membersPage(config);
120
164
  if (i.page === "/status") return await statusPage(config, ctx.request);
165
+ if (i.page === "/test") return await accessTest(config);
121
166
  if (i.page === "widget:status") return await statusWidget(config);
122
167
  } catch (e) {
123
168
  return { blocks: [{ type: "banner", variant: "error", title: "RedeemID couldn't load this page", description: (e as Error).message }] };
@@ -131,6 +176,75 @@ export function createPlugin(config: RedeemIDConfig = {}): ResolvedPlugin {
131
176
 
132
177
  export default createPlugin;
133
178
 
179
+ // ---------------------------------------------------------------- actions
180
+
181
+ const webhookUri = (request: Request) => `${new URL(request.url).origin}/_emdash/api/auth/redeemid/webhook`;
182
+
183
+ async function action(
184
+ config: RedeemIDConfig,
185
+ request: Request,
186
+ i: { action_id?: string; value?: unknown; values?: Record<string, unknown>; block_id?: string },
187
+ ) {
188
+ const s = await memberStores();
189
+ const fail = (e: unknown) => ({ type: "error" as const, message: (e as Error).message });
190
+ switch (i.action_id) {
191
+ case "members_back":
192
+ return membersPage(config);
193
+ case "member_open": {
194
+ const m = await s.members.get(String(i.value ?? ""));
195
+ return m ? memberDetail(config, m) : { ...(await membersPage(config)), toast: { type: "error", message: "That member is gone." } };
196
+ }
197
+ case "member_resync": {
198
+ const id = String(i.value ?? "");
199
+ try {
200
+ const m = await resyncOne(config, s, id, "staff resync");
201
+ const now = m ?? (await s.members.get(id));
202
+ if (!now) return membersPage(config);
203
+ return memberDetail(config, now, m ? { type: "success", message: "Read again from Shopify." } : { type: "error", message: "Shopify no longer has this customer." });
204
+ } catch (e) {
205
+ const m = await s.members.get(id);
206
+ return m ? memberDetail(config, m, fail(e)) : membersPage(config);
207
+ }
208
+ }
209
+ case "member_tag_add":
210
+ case "member_tag_remove": {
211
+ const add = i.action_id === "member_tag_add";
212
+ const v = (i.value ?? {}) as { customerId?: string; tag?: string };
213
+ const id = add ? String(i.block_id ?? "").replace(/^tags:/, "") : String(v.customerId ?? "");
214
+ try {
215
+ const m = await tagEdit(config, s, id, add ? "add" : "remove", add ? i.values?.tags : [v.tag]);
216
+ const now = m ?? (await s.members.get(id));
217
+ return now ? memberDetail(config, now, { type: "success", message: add ? "Tags added in Shopify." : "Tag removed in Shopify." }) : membersPage(config);
218
+ } catch (e) {
219
+ const m = await s.members.get(id);
220
+ return m ? memberDetail(config, m, fail(e)) : membersPage(config);
221
+ }
222
+ }
223
+ case "members_resync": {
224
+ try {
225
+ const r = await bulkResync(config, s);
226
+ return {
227
+ ...(await membersPage(config)),
228
+ toast: { type: "success", message: `Read ${r.done} members again (${r.changed} changed${r.missing ? `, ${r.missing} no longer in Shopify` : ""}).` },
229
+ };
230
+ } catch (e) {
231
+ return { ...(await membersPage(config)), toast: fail(e) };
232
+ }
233
+ }
234
+ case "webhooks_register": {
235
+ try {
236
+ const added = await registerWebhooks(config, webhookUri(request));
237
+ return { ...(await statusPage(config, request)), toast: { type: "success", message: added.length ? `Registered ${added.length} webhooks.` : "Nothing was missing." } };
238
+ } catch (e) {
239
+ return { ...(await statusPage(config, request)), toast: fail(e) };
240
+ }
241
+ }
242
+ case "access_test":
243
+ return accessTest(config, i.values);
244
+ }
245
+ return { blocks: [] };
246
+ }
247
+
134
248
  // ---------------------------------------------------------------- pages
135
249
 
136
250
  async function memberStores() {
@@ -169,6 +283,25 @@ async function membersPage(config: RedeemIDConfig) {
169
283
  type: "context",
170
284
  text: "Shopify customers who have signed in or that Shopify has sent. Levels come only from their Shopify tags; change tags in Shopify (or with Flow) and access follows.",
171
285
  },
286
+ adminConfigured()
287
+ ? {
288
+ type: "actions",
289
+ elements: [
290
+ {
291
+ type: "button",
292
+ action_id: "members_resync",
293
+ label: `Resync ${BULK_BATCH} from Shopify`,
294
+ style: "secondary",
295
+ confirm: {
296
+ title: "Read members again from Shopify?",
297
+ text: `Reads the ${BULK_BATCH} members synced longest ago from Shopify and updates their tags. Click again for the next ${BULK_BATCH}.`,
298
+ confirm: "Resync",
299
+ deny: "Cancel",
300
+ },
301
+ },
302
+ ],
303
+ }
304
+ : { type: "context", text: "Set the Admin API app's client ID (REDEEMID_SHOPIFY_ADMIN_APP_CLIENT_ID) to resync members and edit their tags from here." },
172
305
  {
173
306
  type: "stats",
174
307
  items: [
@@ -189,6 +322,7 @@ async function membersPage(config: RedeemIDConfig) {
189
322
  { key: "tags", label: "Shopify tags" },
190
323
  { key: "status", label: "Account" },
191
324
  { key: "synced", label: "Last synced", format: "relative_time" },
325
+ { key: "open", label: "", format: "element" },
192
326
  ],
193
327
  rows: members
194
328
  .sort((a, b) => b.syncedAt.localeCompare(a.syncedAt))
@@ -199,6 +333,7 @@ async function membersPage(config: RedeemIDConfig) {
199
333
  tags: m.tags.join(", ") || "—",
200
334
  status: m.status === "enabled" ? "On" : "Off",
201
335
  synced: m.syncedAt,
336
+ open: { type: "button", action_id: "member_open", label: "Open", value: m.customerId },
202
337
  })),
203
338
  },
204
339
  ],
@@ -236,6 +371,10 @@ async function statusPage(config: RedeemIDConfig, request: Request) {
236
371
  const levels = config.levels ?? [];
237
372
  const rules = config.rules ?? [];
238
373
  const delivery = await lastDelivery().catch(() => null);
374
+ const webhooks = await webhooksSection(config, webhookUri(request));
375
+ const deliveries = await memberStores()
376
+ .then((s) => deliveriesSection(s))
377
+ .catch(() => [{ type: "context", text: "Couldn't read deliveries." }]);
239
378
  return {
240
379
  blocks: [
241
380
  { type: "header", text: "Status" },
@@ -249,11 +388,18 @@ async function statusPage(config: RedeemIDConfig, request: Request) {
249
388
  { label: "Store discovery", value: c.discovery },
250
389
  { label: "Customer Account API client ID", value: yes(c.clientId) },
251
390
  { label: "Webhook signing secret", value: yes(c.webhookSecret) },
391
+ { label: "Admin API app client ID (staff tools)", value: yes(Boolean(env(ENV.adminClientId))) },
392
+ { label: "Events", value: config.events?.url ? `${config.events.url}${env(ENV.eventsSecret) ? "" : " (REDEEMID_EVENTS_SECRET missing)"}` : "Off" },
252
393
  { label: "Storefront API access", value: c.storefront },
253
394
  { label: "Last webhook", value: delivery ?? "None received yet" },
254
395
  ],
255
396
  },
256
397
  { type: "divider" },
398
+ { type: "header", text: "Webhooks" },
399
+ ...webhooks,
400
+ { type: "header", text: "Recent deliveries" },
401
+ ...deliveries,
402
+ { type: "divider" },
257
403
  { type: "header", text: "Shopify settings" },
258
404
  {
259
405
  type: "fields",
package/src/routes/buy.ts CHANGED
@@ -39,7 +39,7 @@ export const POST: APIRoute = async ({ request, locals, session, redirect }) =>
39
39
  config,
40
40
  current?.id ?? null,
41
41
  { variantId: p.variantId, sellingPlanId: p.sellingPlanId },
42
- { email: member?.email ?? null, buyerIp: request.headers.get("cf-connecting-ip") },
42
+ { email: member?.email ?? null, attributes: { _redeemid_page: returnTo }, buyerIp: request.headers.get("cf-connecting-ip") },
43
43
  );
44
44
  saveCart(session, { id: cart.id, count: cart.totalQuantity });
45
45
  return redirect(cart.checkoutUrl, 303);
@@ -16,6 +16,8 @@ import { OptionsRepository } from "emdash/api/route-utils";
16
16
  import type { Kysely } from "kysely";
17
17
 
18
18
  import { env, ENV, PROVIDER_ID, readConfig, storeDomain } from "../config.js";
19
+ import { emit, levelChange } from "../events.js";
20
+ import { levelsFor } from "../access.js";
19
21
  import { discover, exchangeCode, identity, ShopifyError, verifyIdToken } from "../shopify.js";
20
22
  import { emptyMember, stampTags, stores } from "../storage.js";
21
23
  import { callbackUrl, fail, FLOW_TTL_MS, SESSION_FLOW, SESSION_ID_TOKEN, type Flow } from "./shared.js";
@@ -86,7 +88,8 @@ export const GET: APIRoute = async ({ request, locals, session, redirect }) => {
86
88
  if (user.role > Role.SUBSCRIBER) return fail(redirect, returnTo, "staff_account");
87
89
 
88
90
  const s = stores(emdash.db);
89
- const member = (await s.members.get(who.customerId)) ?? emptyMember(who.customerId);
91
+ const found = await s.members.get(who.customerId);
92
+ const member = found ?? emptyMember(who.customerId);
90
93
  const now = new Date().toISOString();
91
94
  await s.members.put(
92
95
  who.customerId,
@@ -106,6 +109,10 @@ export const GET: APIRoute = async ({ request, locals, session, redirect }) => {
106
109
  ),
107
110
  );
108
111
 
112
+ const levels = levelsFor(who.tags, config.levels);
113
+ await emit(config, "member.signed_in", { customerId: who.customerId, levels, first: !member.linkedAt, refresh: Boolean(flow.refresh) });
114
+ await levelChange(config, found ? found.tags : null, { customerId: who.customerId, tags: who.tags, status: member.status }, "sign-in");
115
+
109
116
  session.regenerate?.();
110
117
  session.set("user", { id: user.id });
111
118
  session.set(SESSION_ID_TOKEN, tokens.idToken);