@redeemid/emdash 0.0.10 → 0.0.11
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 +65 -1
- package/bin/redeemid-emdash.mjs +144 -0
- package/docs/shopify-setup.md +3 -1
- package/package.json +6 -2
- package/src/agents.ts +179 -0
- package/src/blocks/index.ts +1 -0
- package/src/blocks-names.ts +26 -0
- package/src/components/AccessBadge.astro +6 -3
- package/src/components/Content.astro +15 -6
- package/src/components/LockedPanel.astro +25 -1
- package/src/components/Notices.astro +2 -0
- package/src/components/YourData.astro +42 -0
- package/src/config.ts +6 -1
- package/src/descriptor.ts +1 -0
- package/src/events.ts +1 -1
- package/src/files.ts +8 -3
- package/src/index.ts +1 -0
- package/src/plugin.ts +135 -4
- package/src/privacy.ts +148 -0
- package/src/routes/privacy.ts +59 -0
- package/src/routes/webhook.ts +1 -1
- package/src/staff.ts +92 -0
- package/src/storage.ts +15 -0
- package/src/text.ts +11 -0
- package/src/version.ts +1 -1
- package/src/webhooks.ts +17 -4
package/src/staff.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { describe, levelsFor, membership, passes, requirement, termTree, type Te
|
|
|
9
9
|
import type { RedeemIDConfig } from "./config.js";
|
|
10
10
|
import { levelChange } from "./events.js";
|
|
11
11
|
import { featuresFor } from "./features.js";
|
|
12
|
+
import { recentRequests } from "./privacy.js";
|
|
12
13
|
import { cleanTags, numericId, type MemberRecord, type Stores } from "./storage.js";
|
|
13
14
|
|
|
14
15
|
/** Members resynced per click; well inside Shopify's query cost limits. */
|
|
@@ -80,6 +81,97 @@ export function memberDetail(config: RedeemIDConfig, m: MemberRecord, toast?: Bl
|
|
|
80
81
|
? { type: "actions", elements: [{ type: "button", action_id: "member_resync", label: "Resync from Shopify", style: "primary", value: m.customerId }] }
|
|
81
82
|
: { type: "divider" },
|
|
82
83
|
{ type: "context", text: "Tags are written to Shopify, then read back. Shopify stays the only source of access." },
|
|
84
|
+
...privacySection(m.customerId),
|
|
85
|
+
],
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Export and erase for one member, on their detail page. */
|
|
90
|
+
function privacySection(customerId: string): unknown[] {
|
|
91
|
+
const erase = {
|
|
92
|
+
type: "button",
|
|
93
|
+
action_id: "member_erase",
|
|
94
|
+
label: "Erase site data",
|
|
95
|
+
style: "danger",
|
|
96
|
+
value: customerId,
|
|
97
|
+
confirm: {
|
|
98
|
+
title: "Erase this member's data on the site?",
|
|
99
|
+
text: "Removes their synced record and meter here. Their site user and everything in Shopify stay. If they sign in again they're linked again.",
|
|
100
|
+
confirm: "Erase",
|
|
101
|
+
deny: "Cancel",
|
|
102
|
+
style: "danger",
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
const shopify = adminConfigured()
|
|
106
|
+
? [
|
|
107
|
+
{
|
|
108
|
+
type: "button",
|
|
109
|
+
action_id: "member_shopify_erase",
|
|
110
|
+
label: "Also request erasure in Shopify",
|
|
111
|
+
style: "danger",
|
|
112
|
+
value: customerId,
|
|
113
|
+
confirm: {
|
|
114
|
+
title: "Ask Shopify to erase this customer?",
|
|
115
|
+
text: "Shopify erases the customer's personal data across the store, orders included, after a waiting period. Needs the write_customer_data_erasure scope.",
|
|
116
|
+
confirm: "Request erasure",
|
|
117
|
+
deny: "Cancel",
|
|
118
|
+
style: "danger",
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
{ type: "button", action_id: "member_shopify_cancel", label: "Cancel the Shopify erasure", style: "secondary", value: customerId },
|
|
122
|
+
]
|
|
123
|
+
: [];
|
|
124
|
+
return [
|
|
125
|
+
{ type: "header", text: "Privacy" },
|
|
126
|
+
{ type: "actions", elements: [{ type: "button", action_id: "member_export", label: "Export data", style: "secondary", value: customerId }, erase, ...shopify] },
|
|
127
|
+
{ type: "context", text: "Export shows everything RedeemID keeps about this member. Erasing removes it from the site; Shopify stays the record of the customer." },
|
|
128
|
+
];
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** The member's export, as JSON, under their detail page. */
|
|
132
|
+
export function exportBlocks(detail: Blocks, data: unknown): Blocks {
|
|
133
|
+
return { ...detail, blocks: [...detail.blocks, { type: "header", text: "Export" }, { type: "code", language: "jsonc", code: JSON.stringify(data, null, 2) }] };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Every privacy request: what came in, from where, and what's still open. */
|
|
137
|
+
export async function privacyPage(s: Stores, toast?: Blocks["toast"], extra: unknown[] = []): Promise<Blocks> {
|
|
138
|
+
const items = await recentRequests(s);
|
|
139
|
+
return {
|
|
140
|
+
toast,
|
|
141
|
+
blocks: [
|
|
142
|
+
{ type: "header", text: "Privacy requests" },
|
|
143
|
+
{ type: "context", text: "Exports and erasures of what RedeemID keeps, from members, staff and Shopify's compliance webhooks. Open requests need staff: send a Shopify data request's export to the store owner, then mark it done." },
|
|
144
|
+
items.length
|
|
145
|
+
? {
|
|
146
|
+
type: "table",
|
|
147
|
+
page_action_id: "privacy_requests",
|
|
148
|
+
columns: [
|
|
149
|
+
{ key: "at", label: "When", format: "relative_time" },
|
|
150
|
+
{ key: "kind", label: "Request" },
|
|
151
|
+
{ key: "source", label: "From" },
|
|
152
|
+
{ key: "customer", label: "Shopify customer" },
|
|
153
|
+
{ key: "status", label: "Status", format: "badge" },
|
|
154
|
+
{ key: "act", label: "", format: "element" },
|
|
155
|
+
],
|
|
156
|
+
rows: items.map(({ id, data: r }) => ({
|
|
157
|
+
at: r.at,
|
|
158
|
+
kind: r.kind === "export" ? "Export" : "Erase",
|
|
159
|
+
source: { member: "Member, on the site", staff: "Staff", shopify: "Shopify" }[r.source] + (r.shopifyRequestId ? ` (request ${r.shopifyRequestId})` : "") + (r.note ? `: ${r.note}` : ""),
|
|
160
|
+
customer: r.customerId ?? "All",
|
|
161
|
+
status: r.status === "open" ? "Open" : "Done",
|
|
162
|
+
act:
|
|
163
|
+
r.status === "open"
|
|
164
|
+
? {
|
|
165
|
+
type: "button",
|
|
166
|
+
action_id: "privacy_done",
|
|
167
|
+
label: r.kind === "export" && r.customerId ? "Export and mark done" : "Mark done",
|
|
168
|
+
value: id,
|
|
169
|
+
}
|
|
170
|
+
: null,
|
|
171
|
+
})),
|
|
172
|
+
}
|
|
173
|
+
: { type: "context", text: "No requests yet." },
|
|
174
|
+
...extra,
|
|
83
175
|
],
|
|
84
176
|
};
|
|
85
177
|
}
|
package/src/storage.ts
CHANGED
|
@@ -79,16 +79,31 @@ export interface DeliveryRecord {
|
|
|
79
79
|
receivedAt: string;
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
+
/** One privacy request: an export or erase, and where it came from. Names no one but the customer id. */
|
|
83
|
+
export interface PrivacyRequest {
|
|
84
|
+
kind: "export" | "erase";
|
|
85
|
+
/** member: on the site; staff: in the admin; shopify: a compliance webhook. */
|
|
86
|
+
source: "member" | "staff" | "shopify";
|
|
87
|
+
customerId: string | null;
|
|
88
|
+
at: string;
|
|
89
|
+
/** open: staff still need to act (a Shopify data request, or a Shopify erasure that's pending). */
|
|
90
|
+
status: "open" | "done";
|
|
91
|
+
shopifyRequestId?: string | null;
|
|
92
|
+
note?: string;
|
|
93
|
+
}
|
|
94
|
+
|
|
82
95
|
export const STORAGE_CONFIG = {
|
|
83
96
|
members: { indexes: ["email", "userId", "syncedAt"] },
|
|
84
97
|
deliveries: { indexes: ["receivedAt"] },
|
|
85
98
|
meter: { indexes: ["userId"] },
|
|
99
|
+
requests: { indexes: ["at", "customerId", "status"] },
|
|
86
100
|
};
|
|
87
101
|
|
|
88
102
|
export interface Stores {
|
|
89
103
|
members: StorageCollection<MemberRecord>;
|
|
90
104
|
deliveries: StorageCollection<DeliveryRecord>;
|
|
91
105
|
meter: StorageCollection<MeterRecord>;
|
|
106
|
+
requests: StorageCollection<PrivacyRequest>;
|
|
92
107
|
}
|
|
93
108
|
|
|
94
109
|
export function stores(db: unknown): Stores {
|
package/src/text.ts
CHANGED
|
@@ -57,6 +57,8 @@ export const STRINGS = {
|
|
|
57
57
|
"Your membership doesn't include this yet.",
|
|
58
58
|
"Your account is turned off. Contact the store for help.",
|
|
59
59
|
"This is for members.",
|
|
60
|
+
"Or buy just this for {price}.",
|
|
61
|
+
"Buy just this",
|
|
60
62
|
],
|
|
61
63
|
member: [
|
|
62
64
|
"Your membership",
|
|
@@ -96,8 +98,17 @@ export const STRINGS = {
|
|
|
96
98
|
"Your preferences are saved.",
|
|
97
99
|
"Some answers weren't saved. Check them and try again.",
|
|
98
100
|
"Your preferences can't be saved right now. Try again in a few minutes.",
|
|
101
|
+
"Your data on this site is deleted, and you're signed out.",
|
|
102
|
+
"Tick the box to confirm, then try again.",
|
|
99
103
|
"Your account page isn't available right now. Try again in a few minutes.",
|
|
100
104
|
],
|
|
105
|
+
privacy: [
|
|
106
|
+
"Your data",
|
|
107
|
+
"Download my data",
|
|
108
|
+
"Deleting removes what this site keeps about you and signs you out. Your store account and orders stay; ask the store to delete those.",
|
|
109
|
+
"I understand",
|
|
110
|
+
"Delete my data",
|
|
111
|
+
],
|
|
101
112
|
plan: ["Join", "You're on this plan.", "Not available right now."],
|
|
102
113
|
preferences: ["Your preferences", "Save preferences", "Your preferences can't be loaded right now. Try again in a few minutes.", "Not set", "Yes", "No", "One per line."],
|
|
103
114
|
product: ["Add to cart", "Buy now", "Option", "{option} ({price})", "{option} ({price}) sold out", "Quantity", "Sold out."],
|
package/src/version.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const VERSION = "0.0.
|
|
1
|
+
export const VERSION = "0.0.11";
|
package/src/webhooks.ts
CHANGED
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { cleanTags, emptyMember, numericId, stampTags, type MemberRecord, type Stores } from "./storage.js";
|
|
10
|
+
import type { RedeemIDConfig } from "./config.js";
|
|
11
|
+
import { eraseAll, eraseMember, shopifyDataRequest } from "./privacy.js";
|
|
10
12
|
|
|
11
13
|
export type Kind = "profile" | "delete" | "status" | "tags" | "merge" | "compliance" | "ignored";
|
|
12
14
|
|
|
@@ -94,16 +96,27 @@ export async function apply(
|
|
|
94
96
|
payload: Record<string, unknown>,
|
|
95
97
|
/** Called after a member is written, with their tags before (null when new). */
|
|
96
98
|
onWrite?: (beforeTags: string[] | null, after: MemberRecord) => Promise<void> | void,
|
|
99
|
+
/** For the member.erased event on customers/redact. */
|
|
100
|
+
config?: RedeemIDConfig | null,
|
|
97
101
|
): Promise<Outcome> {
|
|
98
102
|
const kind = kindOf(topic);
|
|
99
103
|
if (!kind || kind === "ignored") return "ignored";
|
|
100
104
|
|
|
101
105
|
if (kind === "compliance") {
|
|
102
|
-
//
|
|
103
|
-
|
|
106
|
+
// Every compliance topic is logged as a privacy request (see privacy.ts).
|
|
107
|
+
const id = customerOf(payload);
|
|
104
108
|
if (topic === "customers/redact") {
|
|
105
|
-
|
|
106
|
-
|
|
109
|
+
if (id) await eraseMember(config ?? null, s, id, "shopify");
|
|
110
|
+
return id ? "processed" : "skipped";
|
|
111
|
+
}
|
|
112
|
+
if (topic === "customers/data_request") {
|
|
113
|
+
if (!id) return "skipped";
|
|
114
|
+
const req = (payload.data_request as { id?: unknown } | undefined)?.id;
|
|
115
|
+
await shopifyDataRequest(s, id, req === undefined || req === null ? null : String(req));
|
|
116
|
+
return "processed";
|
|
117
|
+
}
|
|
118
|
+
if (topic === "shop/redact") {
|
|
119
|
+
await eraseAll(config ?? null, s);
|
|
107
120
|
return "processed";
|
|
108
121
|
}
|
|
109
122
|
return "skipped";
|