@redeemid/emdash 0.0.30 → 0.0.32

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/plugin.ts CHANGED
@@ -7,9 +7,10 @@
7
7
  * })
8
8
  *
9
9
  * It adds Portable Text blocks to the editor, an Access panel on every entry,
10
- * admin pages (Members, Access, Webhooks, App events, Reports, Status, Access
11
- * test, Privacy), a daily webhook health check and a dashboard widget. Sign-in, webhooks and checkout stay on the
12
- * auth provider's routes.
10
+ * admin pages (Overview, Members, Access, Reports, Privacy, Webhooks, App
11
+ * events, Status, Setup), a daily webhook health check and a dashboard
12
+ * widget. The pages are Block Kit built here; admin.ts renders them in the
13
+ * browser. Sign-in, webhooks and checkout stay on the auth provider's routes.
13
14
  *
14
15
  * - "Paywall break" (`redeemid-paywall`): marks where the entry's rule starts.
15
16
  * Everything before it is for everyone; everything after it follows the
@@ -33,17 +34,17 @@ import { deleteAccess, entryKey, loadAccess, onLevels, withSaved } from "./setti
33
34
  import { env, ENV, shopOrigin, storeDomain, unknownEnvNames, type LevelConfig, type RedeemIDConfig } from "./config.js";
34
35
  import { discover } from "./shopify.js";
35
36
  import { stores, type MemberRecord, type Stores } from "./storage.js";
36
- import { EDITOR_PANELS, PAGES, WIDGETS } from "./descriptor.js";
37
+ import { CONNECTION_TABS, EDITOR_PANELS, EVENTS_TABS, PAGES, WIDGETS } from "./descriptor.js";
37
38
  import { VERSION } from "./version.js";
38
39
  import { AdminError, adminConfigured } from "./admin-api.js";
39
- import { cancelShopifyErasure, closeRequest, eraseMember, exportMember, exportRequest, requestShopifyErasure } from "./privacy.js";
40
- import { accessTest, bulkResync, BULK_BATCH, exportBlocks, memberDetail, privacyPage, registerWebhooks, resyncOne, staffLink, staffUnlink, tagEdit } from "./staff.js";
40
+ import { cancelShopifyErasure, closeRequest, eraseMember, exportMember, exportRequest, recentRequests, requestShopifyErasure } from "./privacy.js";
41
+ import { bulkResync, BULK_BATCH, memberDetail, privacyPage, registerWebhooks, resyncOne, staffLink, staffUnlink, tagEdit } from "./staff.js";
41
42
  import { textProblems } from "./text.js";
42
43
  import { cacheFindings, gatedFindings, templateFindings } from "./template-scan.js";
43
- import { setupFacts, setupPage, WEBHOOK } from "./setup.js";
44
+ import { nextStep, requiredSteps, setupFacts, setupPanels, setupSteps, STEP_TABS, WEBHOOK } from "./setup.js";
44
45
  import { record } from "./log.js";
45
46
  import { resend } from "./events.js";
46
- import { eventsPage, healthCheck, HEALTH_TASK, lastCheck, parseFilter, reportsPage, saveMonitor, WEBHOOK_PATH, WEBHOOK_TABS, webhooksPage, type MonitorEnv, type WebhookTab } from "./monitor.js";
47
+ import { eventRow, eventsPage, healthCheck, HEALTH_TASK, lastCheck, parseFilter, reportsPage, saveMonitor, WEBHOOK_PATH, webhookPanels, type MonitorEnv } from "./monitor.js";
47
48
  import { z } from "astro/zod";
48
49
  import { AgentError, explainAccess, findProducts, getCollection, getMember, getProduct, getRules, levelChanges, listLevels, siteHealth, type ExplainInput } from "./agents.js";
49
50
 
@@ -212,6 +213,9 @@ export function createPlugin(code: RedeemIDConfig = {}): ResolvedPlugin {
212
213
  // rule follows the entry's slug and goes with the entry.
213
214
  // email:send: the daily webhook check can warn staff by email.
214
215
  capabilities: ["content:read", "email:send"],
216
+ // No admin.entry here: EmDash turns off Block Kit editor panels for a
217
+ // plugin with one. The descriptor's adminEntry alone loads admin.ts,
218
+ // which renders the pages; the Access panel stays Block Kit.
215
219
  admin: {
216
220
  pages: PAGES,
217
221
  widgets: WIDGETS,
@@ -281,17 +285,19 @@ export function createPlugin(code: RedeemIDConfig = {}): ResolvedPlugin {
281
285
  if ((i.type === "block_action" || i.type === "form_submit") && isSetupAction(i.action_id)) return await setupAction(config, live, ctx, i);
282
286
  if (i.type === "block_action" || i.type === "form_submit") return await action(config, ctx, i);
283
287
  if (i.type !== "page_load") return { blocks: [] };
284
- if (i.page === "/") return await membersPage(config);
288
+ if (i.page === "/") return await overviewPage(config, ctx);
289
+ if (i.page === "/members") return await membersPage(config);
285
290
  if (i.page === "/access") return await accessPage(await accessEnv(config));
286
- if (i.page === "/webhooks") {
291
+ if (i.page === "/reports") return await reportsPage(config, await memberStores());
292
+ // Connection and Events, or one of their tabs by address (/connection/sign-in, /events/app).
293
+ if (i.page === "/connection" || i.page?.startsWith("/connection/")) {
287
294
  await scheduleHealth(ctx);
288
- return await webhooksPage(monitorEnv(config, await memberStores(), ctx));
295
+ return await connectionPage(config, ctx, tabOf(CONNECTION_TABS, i.page.split("/")[2]));
296
+ }
297
+ if (i.page === "/events" || i.page?.startsWith("/events/")) {
298
+ await scheduleHealth(ctx);
299
+ return await eventsAdminPage(config, ctx, tabOf(EVENTS_TABS, i.page.split("/")[2]));
289
300
  }
290
- if (i.page === "/events") return await eventsPage(await memberStores());
291
- if (i.page === "/reports") return await reportsPage(config, await memberStores());
292
- if (i.page === "/setup") return setupPage(await setupFacts(config, await memberStores(), siteOrigin(ctx)));
293
- if (i.page === "/status") return await statusPage(config, ctx.request);
294
- if (i.page === "/test") return await accessTest(config);
295
301
  if (i.page === "/privacy") return await privacyPage(await memberStores());
296
302
  if (i.page === "widget:status") return await statusWidget(config, ctx);
297
303
  } catch (e) {
@@ -340,6 +346,12 @@ async function accessEnv(config: RedeemIDConfig): Promise<AccessEnv> {
340
346
  saved: await loadAccess(store),
341
347
  collections: collections.map((c) => ({ slug: c.slug, label: c.label || c.slug })),
342
348
  taxonomies: taxonomies.map((t) => ({ name: t.name, label: t.label || t.name, collections: t.collections ?? [] })),
349
+ holders: async () => {
350
+ const levels = config.levels ?? [];
351
+ const out: Record<string, number> = Object.fromEntries(levels.map((l) => [l.slug, 0]));
352
+ for (const m of await allMembers()) if (m.status === "enabled") for (const slug of levelsFor(m.tags, levels)) out[slug] = (out[slug] ?? 0) + 1;
353
+ return out;
354
+ },
343
355
  terms: async (taxonomy) => {
344
356
  type Node = { slug: string; label?: string; name?: string; children?: Node[] };
345
357
  const out: Array<{ slug: string; label: string }> = [];
@@ -474,12 +486,12 @@ function siteOrigin(ctx: PluginContext): string {
474
486
  async function setupAction(
475
487
  config: RedeemIDConfig,
476
488
  live: () => Promise<RedeemIDConfig>,
477
- ctx: PluginContext,
478
- i: { action_id?: string; values?: Record<string, unknown> },
489
+ ctx: RouteContext,
490
+ i: { action_id?: string; value?: unknown; values?: Record<string, unknown> },
479
491
  ) {
480
- const s = await memberStores();
481
492
  const origin = siteOrigin(ctx);
482
- const page = async (toast?: { type: "success" | "error" | "info"; message: string }) => setupPage(await setupFacts(await live(), s, origin), toast);
493
+ const tab: ConnectionTab = i.action_id === "setup_register" ? "webhooks" : i.action_id === "setup_level" ? "level" : (tabOf(CONNECTION_TABS, i.value) ?? "status");
494
+ const page = async (toast?: { type: "success" | "error" | "info"; message: string }) => connectionPage(await live(), ctx, tab, toast);
483
495
  if (i.action_id === "setup_register") {
484
496
  try {
485
497
  const { added, failed } = await registerWebhooks(config, origin + WEBHOOK);
@@ -539,10 +551,10 @@ async function action(
539
551
  const { createKyselyAdapter } = await import("@emdash-cms/auth/adapters/kysely");
540
552
  const adapter = createKyselyAdapter((await getDb()) as never);
541
553
  const m = i.action_id === "member_link" ? await staffLink(adapter, s, id, i.values?.email) : await staffUnlink(adapter, s, id);
542
- return memberDetail(config, m, { type: "success", message: m.userId ? "Linked. Their next sign-in uses that site user." : "Unlinked." });
554
+ return memberDetail(config, m, { type: "success", message: m.userId ? "Linked. Their next sign-in uses that site user." : "Unlinked." }, "account");
543
555
  } catch (e) {
544
556
  const m = await s.members.get(id);
545
- return m ? memberDetail(config, m, fail(e)) : membersPage(config);
557
+ return m ? memberDetail(config, m, fail(e), "account") : membersPage(config);
546
558
  }
547
559
  }
548
560
  case "member_tag_add":
@@ -553,10 +565,10 @@ async function action(
553
565
  try {
554
566
  const m = await tagEdit(config, s, id, add ? "add" : "remove", add ? i.values?.tags : [v.tag]);
555
567
  const now = m ?? (await s.members.get(id));
556
- return now ? memberDetail(config, now, { type: "success", message: add ? "Tags added in Shopify." : "Tag removed in Shopify." }) : membersPage(config);
568
+ return now ? memberDetail(config, now, { type: "success", message: add ? "Tags added in Shopify." : "Tag removed in Shopify." }, "tags") : membersPage(config);
557
569
  } catch (e) {
558
570
  const m = await s.members.get(id);
559
- return m ? memberDetail(config, m, fail(e)) : membersPage(config);
571
+ return m ? memberDetail(config, m, fail(e), "tags") : membersPage(config);
560
572
  }
561
573
  }
562
574
  case "members_resync": {
@@ -576,49 +588,43 @@ async function action(
576
588
  const done = added.length ? `Registered ${added.length} webhooks.` : failed.length ? "" : "Nothing was missing.";
577
589
  const refused = failed.map((f) => `Shopify refused ${f.topic}: ${f.message}`).join(" ");
578
590
  await record({ event: "staff.webhooks", label: "register", status: failed.length ? `${failed.length} refused` : `${added.length} registered`, level: failed.length ? "warn" : "ok", origin: "staff" });
579
- return webhooksPage(monitor(), "setup", { type: failed.length ? "error" : "success", message: [done, refused].filter(Boolean).join(" ") });
591
+ return connectionPage(config, ctx, "webhooks", { type: failed.length ? "error" : "success", message: [done, refused].filter(Boolean).join(" ") });
580
592
  } catch (e) {
581
- return webhooksPage(monitor(), "setup", fail(e));
593
+ return connectionPage(config, ctx, "webhooks", fail(e));
582
594
  }
583
595
  }
584
- case "webhooks_tab": {
585
- const tab = WEBHOOK_TABS.includes(i.value as WebhookTab) ? (i.value as WebhookTab) : "received";
586
- return webhooksPage(monitor(), tab);
587
- }
588
596
  case "deliveries_page":
589
- return webhooksPage(monitor(), "received", undefined, cursorOf(i.value));
597
+ return eventsAdminPage(config, ctx, "webhook", { cursor: cursorOf(i.value) });
590
598
  case "health_check": {
591
599
  const r = await healthCheck({ ...monitor(), by: "staff" });
592
600
  const message = r.problems.length ? `Checked: ${r.problems.length === 1 ? "1 problem" : `${r.problems.length} problems`}.` : "Checked: subscriptions are in place and deliveries look healthy.";
593
- return webhooksPage(monitor(), "health", { type: r.problems.length ? "info" : "success", message });
601
+ return eventsAdminPage(config, ctx, "health", {}, { type: r.problems.length ? "info" : "success", message });
594
602
  }
595
603
  case "monitor_save":
596
- return webhooksPage(monitor(), "health", await saveMonitor(ctx.kv, i.values));
604
+ return eventsAdminPage(config, ctx, "health", {}, await saveMonitor(ctx.kv, i.values));
597
605
  case "events_filter":
598
- return eventsPage(s, { event: String(i.values?.event ?? "") || undefined, problems: i.values?.problems === true });
606
+ return eventsAdminPage(config, ctx, "app", { events: await eventsPage(s, { event: String(i.values?.event ?? "") || undefined, problems: i.values?.problems === true }) });
599
607
  case "events_show":
600
- return eventsPage(s, { event: String(i.value ?? "") || undefined });
608
+ return eventsAdminPage(config, ctx, "app", { events: await eventsPage(s, { event: String(i.value ?? "") || undefined }) });
601
609
  case "events_page":
602
- return eventsPage(s, parseFilter(i.block_id), cursorOf(i.value));
610
+ return eventsAdminPage(config, ctx, "app", { events: await eventsPage(s, parseFilter(i.block_id), cursorOf(i.value)) });
603
611
  case "failed_resend": {
604
612
  const sent = await resend(config, s, String(i.value ?? "")).catch(() => false);
605
- return eventsPage(s, {}, undefined, sent ? { type: "success", message: "Sent. It's off the failed list." } : { type: "error", message: "The receiver still didn't take it. It stays on the list." });
613
+ return eventsAdminPage(config, ctx, "app", {}, sent ? { type: "success", message: "Sent. It's off the failed list." } : { type: "error", message: "The receiver still didn't take it. It stays on the list." });
606
614
  }
607
615
  case "failed_clear":
608
616
  await s.failedEvents.delete(String(i.value ?? ""));
609
- return eventsPage(s, {}, undefined, { type: "success", message: "Cleared." });
617
+ return eventsAdminPage(config, ctx, "app", {}, { type: "success", message: "Cleared." });
610
618
  case "failed_clear_all": {
611
619
  const { items } = await s.failedEvents.query({ limit: 100 });
612
620
  await s.failedEvents.deleteMany(items.map((r) => r.id));
613
- return eventsPage(s, {}, undefined, { type: "success", message: "Failed events cleared." });
621
+ return eventsAdminPage(config, ctx, "app", {}, { type: "success", message: "Failed events cleared." });
614
622
  }
615
- case "access_test":
616
- return accessTest(config, i.values);
617
623
  case "member_export": {
618
624
  const id = String(i.value ?? "");
619
625
  const m = await s.members.get(id);
620
626
  if (!m) return membersPage(config);
621
- return exportBlocks(memberDetail(config, m, { type: "success", message: "Export below." }), await exportRequest(s, id, "staff"));
627
+ return memberDetail(config, m, { type: "success", message: "Export below." }, "privacy", await exportRequest(s, id, "staff"));
622
628
  }
623
629
  case "member_erase": {
624
630
  const id = String(i.value ?? "");
@@ -633,9 +639,9 @@ async function action(
633
639
  if (i.action_id === "member_shopify_erase") await requestShopifyErasure(config, s, id);
634
640
  else await cancelShopifyErasure(config, s, id);
635
641
  const message = i.action_id === "member_shopify_erase" ? "Shopify will erase the customer after its waiting period." : "The Shopify erasure is cancelled.";
636
- return m ? memberDetail(config, m, { type: "success", message }) : privacyPage(s, { type: "success", message });
642
+ return m ? memberDetail(config, m, { type: "success", message }, "privacy") : privacyPage(s, { type: "success", message });
637
643
  } catch (e) {
638
- return m ? memberDetail(config, m, fail(e)) : privacyPage(s, fail(e));
644
+ return m ? memberDetail(config, m, fail(e), "privacy") : privacyPage(s, fail(e));
639
645
  }
640
646
  }
641
647
  case "privacy_done": {
@@ -779,30 +785,119 @@ function textSummary(config: RedeemIDConfig): string {
779
785
  return ignored ? `${all - ignored} in use, ${ignored} ignored` : `${all} in use`;
780
786
  }
781
787
 
788
+ type ConnectionTab = (typeof CONNECTION_TABS)[number];
789
+ type EventsTab = (typeof EVENTS_TABS)[number];
790
+ type Toast = { type: "success" | "error" | "info"; message: string };
791
+
792
+ /** A tab name from an address or a button value, if it's one of these. */
793
+ function tabOf<T extends string>(tabs: readonly T[], v: unknown): T | undefined {
794
+ return tabs.includes(v as T) ? (v as T) : undefined;
795
+ }
796
+ const withoutHeader = (b: { blocks: unknown[] }) => b.blocks.filter((x, n) => !(n === 0 && (x as { type?: string }).type === "header"));
797
+
798
+ /**
799
+ * How this site talks to Shopify: Status (the values to copy into Shopify
800
+ * and what the site reads from its environment), then one tab per setup
801
+ * step, each with a check mark once done. Webhooks also holds the app's
802
+ * subscriptions. Opens on the first step not done, or on Status.
803
+ */
804
+ async function connectionPage(config: RedeemIDConfig, ctx: RouteContext, tab?: ConnectionTab, toast?: Toast) {
805
+ const s = await memberStores();
806
+ const [facts, status, hooks] = await Promise.all([setupFacts(config, s, siteOrigin(ctx)), statusPage(config, ctx.request), webhookPanels(monitorEnv(config, s, ctx))]);
807
+ const panels = setupPanels(facts);
808
+ const next = nextStep(facts);
809
+ const steps = requiredSteps(setupSteps(facts));
810
+ const open = tab ?? (next ? (STEP_TABS[next.key] as ConnectionTab) : "status");
811
+ const progress = next
812
+ ? [{ type: "banner", variant: "alert", title: `Setup: ${steps.filter((x) => x.state === "done").length} of ${steps.length} steps done`, description: `Work through the tabs. Each one checks the site as it is, so set a secret, redeploy or restart, and press Check again. Next: ${next.title}.` }]
813
+ : [];
814
+ // The Webhooks step has its own Register button while it's to do.
815
+ const ownButton = panels.some((p) => p.tab === "webhooks" && JSON.stringify(p.blocks).includes('"setup_register"'));
816
+ const subscriptions = ownButton ? hooks.subscriptions.filter((b) => !JSON.stringify(b).includes('"webhooks_register"')) : hooks.subscriptions;
817
+ return {
818
+ blocks: [
819
+ { type: "header", text: "Connection" },
820
+ { type: "context", text: "How this site talks to Shopify: the values to copy into the store, and each setup step." },
821
+ {
822
+ type: "tab",
823
+ default_tab: CONNECTION_TABS.indexOf(open),
824
+ panels: [
825
+ { label: "Status", blocks: [...progress, ...withoutHeader(status)] },
826
+ ...panels.map((p) => (p.tab === "webhooks" ? { label: p.label, blocks: [...p.blocks, { type: "divider" }, { type: "header", text: "Subscriptions" }, ...subscriptions] } : { label: p.label, blocks: p.blocks })),
827
+ ],
828
+ },
829
+ ],
830
+ ...(toast ? { toast } : {}),
831
+ };
832
+ }
833
+
834
+ /**
835
+ * What happened: webhook deliveries from Shopify, what RedeemID did (App),
836
+ * and whether deliveries are healthy. An action passes the App panel it
837
+ * rebuilt (a filter or a page) or a deliveries cursor.
838
+ */
839
+ async function eventsAdminPage(config: RedeemIDConfig, ctx: RouteContext, tab: EventsTab = "webhook", given: { events?: { blocks: unknown[] }; cursor?: string } = {}, toast?: Toast) {
840
+ const s = await memberStores();
841
+ const [hooks, events] = await Promise.all([webhookPanels(monitorEnv(config, s, ctx), given.cursor), given.events ?? eventsPage(s)]);
842
+ return {
843
+ blocks: [
844
+ { type: "header", text: "Events" },
845
+ { type: "context", text: "What Shopify sent, what RedeemID did with it, and whether deliveries are healthy." },
846
+ {
847
+ type: "tab",
848
+ default_tab: EVENTS_TABS.indexOf(tab),
849
+ panels: [
850
+ { label: "Webhook", blocks: hooks.received },
851
+ { label: "App", blocks: events.blocks },
852
+ { label: hooks.attention ? "Health (!)" : "Health", blocks: hooks.health },
853
+ ],
854
+ },
855
+ ],
856
+ ...(toast ? { toast } : {}),
857
+ };
858
+ }
859
+
782
860
  async function statusPage(config: RedeemIDConfig, request: Request) {
783
861
  const c = await checks(config);
784
862
  const origin = new URL(request.url).origin;
785
- const yes = (v: boolean) => (v ? "Set" : "Missing");
863
+ const set = (v: boolean) => (v ? "Set" : "Missing");
786
864
  const delivery = await lastDelivery().catch(() => null);
865
+ const unknown = unknownEnvNames();
866
+ const ready = c.discovery.startsWith("OK") && c.clientId;
787
867
  return {
788
868
  blocks: [
789
869
  { type: "header", text: "Status" },
790
- c.discovery.startsWith("OK") && c.clientId
791
- ? { type: "banner", variant: "default", title: "Sign-in is ready", description: "Members can sign in with their Shopify customer account." }
792
- : { type: "banner", variant: "alert", title: "Sign-in isn't ready", description: "Set the missing values below as environment secrets, then reload." },
870
+ ready
871
+ ? { type: "banner", variant: "default", title: "Sign-in is ready", description: "Members can sign in with their store customer account." }
872
+ : { type: "banner", variant: "alert", title: "Sign-in isn't ready", description: "Set the missing values below as environment secrets, then reload. The tabs after Status walk through each one." },
873
+ ...customDomainBlocks(config),
874
+ { type: "header", text: "Shopify settings" },
875
+ { type: "context", text: "Copy these into the store: the first three into the Customer Account API settings of the Headless storefront, the webhook URL into the app's webhook subscriptions." },
793
876
  {
794
- type: "fields",
877
+ type: "redeemid_copy",
795
878
  fields: [
796
- { label: "Store domain (REDEEMID_SHOPIFY_STORE_DOMAIN or shop)", value: c.domain || "Missing" },
797
- { label: "Store discovery", value: c.discovery },
798
- { label: "Customer Account API client ID", value: yes(c.clientId) },
799
- { label: "Webhook signing secret", value: yes(c.webhookSecret) },
800
- { label: "Admin API app client ID (staff tools)", value: yes(Boolean(env(ENV.adminClientId))) },
801
- { label: "Events", value: config.events?.url ? `${config.events.url}${env(ENV.eventsSecret) ? "" : " (REDEEMID_EVENTS_SECRET missing)"}` : "Off" },
802
- { label: "Storefront API access", value: c.storefront },
803
- { label: "Last webhook", value: delivery ?? "None received yet" },
804
- { label: "Replacement text", value: textSummary(config) },
805
- ...(unknownEnvNames().length ? [{ label: "Set but not used (check the name)", value: unknownEnvNames().join(", ") }] : []),
879
+ { label: "Callback URI", value: `${origin}/_emdash/api/auth/redeemid/callback` },
880
+ { label: "JavaScript origin", value: origin },
881
+ { label: "Logout URI", value: `${origin}/` },
882
+ { label: "Webhook URL", value: `${origin}/_emdash/api/auth/redeemid/webhook` },
883
+ ],
884
+ },
885
+ { type: "divider" },
886
+ { type: "header", text: "Environment" },
887
+ { type: "context", text: "What the site reads from its environment. Secrets show only whether they're set." },
888
+ {
889
+ type: "redeemid_copy",
890
+ fields: [
891
+ { label: "Store domain", value: c.domain || "Missing", hint: `${ENV.storeDomain} or shop`, copy: Boolean(c.domain) },
892
+ { label: "Store discovery", value: c.discovery, copy: false },
893
+ { label: "Customer Account API client ID", value: set(c.clientId), hint: ENV.clientId, copy: false },
894
+ { label: "Webhook signing secret", value: set(c.webhookSecret), hint: ENV.webhookSecret, copy: false },
895
+ { label: "Admin API app client ID (staff tools)", value: set(Boolean(env(ENV.adminClientId))), hint: ENV.adminClientId, copy: false },
896
+ { label: "Storefront API access", value: c.storefront, hint: `${ENV.storefrontPrivateToken} or ${ENV.storefrontPublicToken}`, copy: false },
897
+ { label: "Events", value: config.events?.url ? `${config.events.url}${env(ENV.eventsSecret) ? "" : " (secret missing)"}` : "Off", hint: config.events?.url ? ENV.eventsSecret : undefined, copy: Boolean(config.events?.url) },
898
+ { label: "Last webhook", value: delivery ?? "None received yet", copy: false },
899
+ { label: "Replacement text", value: textSummary(config), copy: false },
900
+ ...(unknown.length ? [{ label: "Set but not used (check the name)", value: unknown.join(", "), copy: false }] : []),
806
901
  ],
807
902
  },
808
903
  ...(textProblems(config).length
@@ -818,22 +913,9 @@ async function statusPage(config: RedeemIDConfig, request: Request) {
818
913
  },
819
914
  ]
820
915
  : []),
821
- ...customDomainBlocks(config),
822
- ...templateBlocks(config),
823
- { type: "context", text: "Setting up a store step by step is under Setup. Deliveries, their health and the webhook subscriptions are under Webhooks; everything RedeemID did is under App events." },
824
- { type: "divider" },
825
- { type: "header", text: "Shopify settings" },
826
- {
827
- type: "fields",
828
- fields: [
829
- { label: "Callback URI", value: `${origin}/_emdash/api/auth/redeemid/callback` },
830
- { label: "JavaScript origin", value: origin },
831
- { label: "Logout URI", value: `${origin}/` },
832
- { label: "Webhook URL", value: `${origin}/_emdash/api/auth/redeemid/webhook` },
833
- ],
834
- },
835
916
  { type: "divider" },
836
- { type: "context", text: "Levels, rules and the meter are under Access." },
917
+ { type: "header", text: "Templates" },
918
+ ...templateBlocks(config),
837
919
  ],
838
920
  };
839
921
  }
@@ -903,19 +985,183 @@ function templateBlocks(config: RedeemIDConfig): unknown[] {
903
985
  ];
904
986
  }
905
987
 
988
+ /** Counts the Overview and the dashboard card share. */
989
+ async function summary(config: RedeemIDConfig, ctx: PluginContext) {
990
+ const [c, members, check, delivery, requests] = await Promise.all([
991
+ checks(config),
992
+ allMembers().catch(() => [] as MemberRecord[]),
993
+ lastCheck(ctx.kv).catch(() => null),
994
+ memberStores()
995
+ .then((s) => s.deliveries.query({ orderBy: { receivedAt: "desc" }, limit: 1 }))
996
+ .then((r) => r.items[0]?.data ?? null)
997
+ .catch(() => null),
998
+ memberStores()
999
+ .then((s) => recentRequests(s))
1000
+ .catch(() => []),
1001
+ ]);
1002
+ const levels = config.levels ?? [];
1003
+ return {
1004
+ signIn: c.discovery.startsWith("OK") && c.clientId,
1005
+ members,
1006
+ active: members.filter((m) => m.status === "enabled" && levelsFor(m.tags, levels).length > 0).length,
1007
+ signedIn: members.filter((m) => m.userId).length,
1008
+ check,
1009
+ delivery,
1010
+ openRequests: requests.filter((r) => r.data.status === "open").length,
1011
+ levels,
1012
+ };
1013
+ }
1014
+
1015
+ type Summary = Awaited<ReturnType<typeof summary>>;
1016
+
1017
+ function webhookWord(s: Summary): { value: string; tone: "ok" | "warn" | "error" } {
1018
+ if (!s.check) return { value: "Unchecked", tone: "warn" };
1019
+ return s.check.problems.length ? { value: `${s.check.problems.length} to fix`, tone: "error" } : { value: "Healthy", tone: "ok" };
1020
+ }
1021
+
1022
+ /** "3 h ago" for a recent time. */
1023
+ function ago(iso: string | null | undefined, now = Date.now()): string {
1024
+ if (!iso) return "";
1025
+ const ms = now - Date.parse(iso);
1026
+ if (!Number.isFinite(ms)) return "";
1027
+ if (ms < 60_000) return "just now";
1028
+ if (ms < 3_600_000) return `${Math.floor(ms / 60_000)} min ago`;
1029
+ if (ms < 86_400_000) return `${Math.floor(ms / 3_600_000)} h ago`;
1030
+ const d = Math.floor(ms / 86_400_000);
1031
+ return d === 1 ? "yesterday" : `${d} days ago`;
1032
+ }
1033
+
1034
+ const page = (path: string) => ({ kind: "plugin-page" as const, path });
1035
+ const count = (n: number, word: string) => `${n} ${word}${n === 1 ? "" : "s"}`;
1036
+
1037
+ /** The dashboard card: what needs a look, the counts, and the way in. */
906
1038
  async function statusWidget(config: RedeemIDConfig, ctx: PluginContext) {
907
- const c = await checks(config);
908
- const members = await allMembers().catch(() => []);
909
- const check = await lastCheck(ctx.kv).catch(() => null);
1039
+ const s = await summary(config, ctx);
1040
+ const hooks = webhookWord(s);
1041
+ const alerts: unknown[] = [];
1042
+ if (!s.signIn) alerts.push({ type: "banner", variant: "error", title: "Sign-in isn't ready", description: "Members can't sign in until the store settings are complete. See Setup." });
1043
+ if (s.check?.problems.length) alerts.push({ type: "banner", variant: "alert", title: "Webhooks need attention", description: s.check.problems[0]!.text });
1044
+ if (s.openRequests) alerts.push({ type: "banner", variant: "alert", title: `${s.openRequests} privacy ${s.openRequests === 1 ? "request is" : "requests are"} open`, description: "Staff need to finish them under Privacy." });
910
1045
  return {
911
1046
  blocks: [
912
- ...(check?.problems.length ? [{ type: "banner", variant: "alert", title: "Webhooks need attention", description: check.problems[0]!.text }] : []),
1047
+ ...alerts,
1048
+ {
1049
+ type: "stats",
1050
+ items: [
1051
+ { label: "Members", value: String(s.members.length), description: `${s.active} with a level` },
1052
+ { label: "Sign-in", value: s.signIn ? "Ready" : "Not ready" },
1053
+ { label: "Webhooks", value: hooks.value, description: s.delivery ? `Last ${ago(s.delivery.receivedAt)}` : "None yet" },
1054
+ ],
1055
+ },
913
1056
  {
914
1057
  type: "fields",
915
1058
  fields: [
916
- { label: "Sign-in", value: c.discovery.startsWith("OK") && c.clientId ? "Ready" : "Not ready" },
917
- { label: "Members", value: String(members.length) },
918
- { label: "Webhooks", value: check ? (check.problems.length ? `${check.problems.length} to look at` : "Healthy") : "Not checked yet" },
1059
+ { label: "Signed in here", value: String(s.signedIn) },
1060
+ { label: "Levels", value: s.levels.map((l) => l.name).join(", ") || "None yet" },
1061
+ ],
1062
+ },
1063
+ {
1064
+ type: "actions",
1065
+ elements: [
1066
+ { type: "link", label: "Overview", target: page("/"), appearance: "primary" },
1067
+ { type: "link", label: "Members", target: page("/members"), appearance: "secondary" },
1068
+ { type: "link", label: "Access", target: page("/access"), appearance: "secondary" },
1069
+ { type: "link", label: s.signIn && !s.check?.problems.length ? "Events" : "Connection", target: page(s.signIn && !s.check?.problems.length ? "/events/webhook" : "/connection"), appearance: "secondary" },
1070
+ ],
1071
+ },
1072
+ ],
1073
+ };
1074
+ }
1075
+
1076
+ /** The first page: what needs doing, the numbers, recent activity and where to go. */
1077
+ async function overviewPage(config: RedeemIDConfig, ctx: PluginContext) {
1078
+ const st = await memberStores();
1079
+ const [s, facts, log] = await Promise.all([
1080
+ summary(config, ctx),
1081
+ setupFacts(config, st, siteOrigin(ctx)).catch(() => null),
1082
+ st.log.query({ orderBy: { at: "desc" }, limit: 8 }).catch(() => ({ items: [] as Array<{ data: Parameters<typeof eventRow>[0] }> })),
1083
+ ]);
1084
+ const required = facts ? requiredSteps(setupSteps(facts)) : [];
1085
+ const done = required.filter((x) => x.state === "done").length;
1086
+ const hooks = webhookWord(s);
1087
+ const rules = config.rules ?? [];
1088
+ const counts = s.levels.map((l) => ({
1089
+ level: l.name,
1090
+ members: s.members.filter((m) => m.status === "enabled" && levelsFor(m.tags, s.levels).includes(l.slug)).length,
1091
+ includes: (l.includes ?? []).map((x) => s.levels.find((o) => o.slug === x)?.name ?? x).join(", "),
1092
+ features: (config.features ?? []).filter((f) => f.levels?.includes(l.slug)).map((f) => f.name).join(", "),
1093
+ rules: rules.filter((r) => r.levels.includes(l.slug)).length,
1094
+ folders: (config.files ?? []).filter((f) => f.levels?.includes(l.slug)).length,
1095
+ }));
1096
+ const domain = storeDomain(config);
1097
+ const shop = domain && !domain.startsWith("http") ? `https://${domain.replace(/^https?:\/\//, "")}` : null;
1098
+ const alerts: unknown[] = [];
1099
+ if (required.length && done < required.length)
1100
+ alerts.push({ type: "banner", variant: s.signIn ? "default" : "alert", title: `Setup: ${done} of ${required.length} steps done`, description: `Next: ${required.find((x) => x.state !== "done")?.title ?? ""}.` });
1101
+ if (s.check?.problems.length) alerts.push({ type: "banner", variant: "alert", title: "Webhooks need attention", description: s.check.problems.map((p) => p.text).join(" ") });
1102
+ if (s.openRequests) alerts.push({ type: "banner", variant: "alert", title: `${s.openRequests} privacy ${s.openRequests === 1 ? "request is" : "requests are"} open`, description: "Finish them under Privacy." });
1103
+ return {
1104
+ blocks: [
1105
+ { type: "header", text: "RedeemID" },
1106
+ { type: "context", text: `Members sign in with their store customer account, and their Shopify customer tags decide what they read. Version ${VERSION}.` },
1107
+ ...alerts,
1108
+ {
1109
+ type: "redeemid_cards",
1110
+ items: [
1111
+ { title: "Sign-in", value: s.signIn ? "Ready" : "Not ready", tone: s.signIn ? "ok" : "error", text: domain ? `Store: ${domain.replace(/^https?:\/\//, "")}` : "No store set", link: page("/connection/status"), label: "Status" },
1112
+ { title: "Members", value: String(s.members.length), text: `${s.active} with a level, ${s.signedIn} signed in here`, link: page("/members"), label: "Members" },
1113
+ { title: "Webhooks", value: hooks.value, tone: hooks.tone, text: s.delivery ? `Last delivery ${ago(s.delivery.receivedAt)} (${s.delivery.topic})` : "None received yet", link: page("/events/webhook"), label: "Webhooks" },
1114
+ { title: "Access", value: count(s.levels.length, "level"), text: [count(rules.length, "rule"), count((config.features ?? []).length, "feature"), count((config.files ?? []).length, "protected folder")].join(", "), link: page("/access"), label: "Access" },
1115
+ { title: "Privacy", value: s.openRequests ? `${s.openRequests} open` : "Nothing open", tone: s.openRequests ? "warn" : undefined, text: "Exports and erasures from members, staff and Shopify", link: page("/privacy"), label: "Privacy" },
1116
+ { title: "Setup", value: required.length ? `${done} of ${required.length}` : "Unknown", tone: required.length && done === required.length ? "ok" : "warn", text: done === required.length ? "Every step is done" : `Next: ${required.find((x) => x.state !== "done")?.title ?? ""}`, link: page("/connection"), label: "Setup" },
1117
+ ],
1118
+ },
1119
+ { type: "header", text: "Levels" },
1120
+ counts.length
1121
+ ? {
1122
+ type: "table",
1123
+ page_action_id: "overview_levels",
1124
+ columns: [
1125
+ { key: "level", label: "Level" },
1126
+ { key: "members", label: "Members", format: "number" },
1127
+ { key: "includes", label: "Includes" },
1128
+ { key: "features", label: "Features" },
1129
+ { key: "rules", label: "Rules", format: "number" },
1130
+ { key: "folders", label: "Folders", format: "number" },
1131
+ ],
1132
+ rows: counts,
1133
+ }
1134
+ : { type: "empty", title: "No levels yet", description: "Levels name the readers who hold some Shopify tags.", size: "sm", actions: [{ type: "link", label: "Add a level", target: page("/access"), appearance: "primary" }] },
1135
+ ...(counts.length ? [{ type: "actions", elements: [{ type: "link", label: "Explore the access map", target: page("/access"), appearance: "secondary" }] }] : []),
1136
+ { type: "header", text: "Recent activity" },
1137
+ {
1138
+ type: "table",
1139
+ page_action_id: "overview_events",
1140
+ empty_text: "Nothing recorded yet.",
1141
+ columns: [
1142
+ { key: "event", label: "Event" },
1143
+ { key: "about", label: "About" },
1144
+ { key: "result", label: "Result", format: "badge" },
1145
+ { key: "at", label: "When", format: "relative_time" },
1146
+ ],
1147
+ rows: log.items.map((r) => {
1148
+ const { event, about, result, at } = eventRow(r.data);
1149
+ return { event, about, result, at };
1150
+ }),
1151
+ },
1152
+ { type: "actions", elements: [{ type: "link", label: "All app events", target: page("/events/app"), appearance: "secondary" }, { type: "link", label: "Reports", target: page("/reports"), appearance: "secondary" }] },
1153
+ { type: "header", text: "Resources" },
1154
+ {
1155
+ type: "redeemid_cards",
1156
+ items: [
1157
+ { title: "Documentation", text: "Setting up the store, levels, rules, blocks and templates.", link: { kind: "external", url: "https://www.redeemid.com/docs/emdash/" }, label: "Read the docs" },
1158
+ ...(shop
1159
+ ? [
1160
+ { title: "Customers in Shopify", text: "Tags, subscriptions and orders live on the Shopify customer.", link: { kind: "external", url: `${shop}/admin/customers` }, label: "Open Shopify" },
1161
+ { title: "Shopify Flow", text: "Add and remove customer tags when orders, subscriptions or dates change.", link: { kind: "external", url: `${shop}/admin/apps/flow` }, label: "Open Flow" },
1162
+ ]
1163
+ : []),
1164
+ { title: "Report a problem", text: "Bugs and questions about RedeemID for EmDash.", link: { kind: "external", url: "https://github.com/webmultipliers/redeemid-emdash/issues" }, label: "Open an issue" },
919
1165
  ],
920
1166
  },
921
1167
  ],