@marktiderman/genesis-support 0.2.0 → 0.2.2

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
@@ -104,8 +104,10 @@ await client.submit({ type: "bug", message, context: await collector.collect(),
104
104
 
105
105
  - `createSupportClient(supabase)`: `submit` (the Supabase sink), `myReports`, `report`,
106
106
  `replies`, `reply`, `markRead`, `deleteReport`, `attachments` (signed URLs), `ideas`,
107
- `suggestIdea`, `vote`, `unvote`, `shippedForMe`. Errors are `SupportError` with the database's
108
- code (`rateLimited` for `GS429`).
107
+ `suggestIdea`, `vote`, `unvote`, `shippedForMe`, and for staff `isStaff`, `setStatus` and
108
+ (support-chat block) `assign`; `thread`, `conversationId` and `subscribe` for a thread on
109
+ genesis-chat (see below). Errors are `SupportError` with the database's code (`rateLimited` for
110
+ `GS429`).
109
111
  - `createIntakeClient` + `createIntakeSink`: the wire contract over HTTP, for code without
110
112
  supabase-js (it takes no screenshots, and says so in `attachmentsDropped`).
111
113
  - `contract.ts` is the wire contract (`CONTRACT_VERSION`, headers, `SubmitFeedbackInput`,
@@ -115,6 +117,58 @@ await client.submit({ type: "bug", message, context: await collector.collect(),
115
117
  A GitHub issue per report, or a signed webhook, is a server-side sink: the
116
118
  `genesis-support-notify` function template in `@marktiderman/genesis-supabase`.
117
119
 
120
+ ## Threads on genesis-chat (the `support-chat` block)
121
+
122
+ With `@marktiderman/genesis-chat` installed, a report's conversation can run on genesis-chat
123
+ instead of `support_replies`: live updates instead of a 30 second re-read, photos, files and
124
+ voice notes, push, an assignee (pushed every message) and internal notes staff write and the
125
+ person never sees. The report stays the record (type, status, message, screenshots, device).
126
+
127
+ 1. Apply this package's `migrations/0001_genesis_support_chat.sql` (and its test) after
128
+ `0006_genesis_support.sql` and genesis-chat's `0001` and `0002`. It registers the chat kind
129
+ `support` (the reporter its only member, staff observers who post, no threads), gives every
130
+ report its conversation, and copies the replies already in `support_replies` into it, in
131
+ order and with their times, sending no push.
132
+ 2. Give support genesis-chat's client:
133
+
134
+ ```ts
135
+ import { createChatClient } from "@marktiderman/genesis-chat";
136
+ const chat = createChatClient(supabase);
137
+ createSupport({ supabase, chat, … }); // native
138
+ const client = createSupportClient(supabase, { chat }); // web
139
+ ```
140
+
141
+ The client asks the database once (`support_chat_conversation`). With the block, `replies`,
142
+ `reply`, `markRead` and the new `subscribe` go through the report's conversation; without it
143
+ (or without `chat`) they use `support_replies` as before, and a `chat` client given to a
144
+ database without the block says so once (`onChatUnavailable`, default `console.warn`).
145
+ 3. Native: `useSupportThread(id)` needs no change. On chat it updates live (no polling), shows
146
+ photos in replies and names voice notes and files; it also returns `mode` and
147
+ `conversationId`. `useSupportConversation(id)` gives a screen of your own the conversation id,
148
+ for genesis-chat's native `ChatThreadScreen`.
149
+ 4. Web (`@marktiderman/genesis-support/web-chat`, under genesis-chat's `ChatProvider` in a
150
+ QueryClientProvider): `<SupportReportThread client={client} reportId={id} />` is the person's
151
+ view, the report above genesis-chat's `ChatThread`. `<SupportStaffThread client reportId
152
+ resolveUser staff />` adds the status and assignee pickers; the composer's note toggle writes
153
+ internal notes.
154
+
155
+ Kept in step by the migration: the report's status and the conversation's (open and submitted
156
+ are open, in_progress pending, addressed solved, wont_fix closed; a status set in chat moves the
157
+ report the same way), the report's reply times and read state from messages and
158
+ `genesis_chat.mark_read` (so `unread` and the Help badge keep working), assignees (staff only:
159
+ `client.assign` or `support_assign_report`), and deletion (a deleted report, or account, takes its
160
+ conversation; files follow each package's own cleanup). Staff set statuses with `client.setStatus`.
161
+
162
+ **`support_replies`, for one release.** App builds from before the block still read and write
163
+ `support_replies`, so the two are mirrored: a reply written there becomes a chat message, and a
164
+ chat message (never an internal note) becomes a reply there, edits and deletes included. While
165
+ the mirror runs, 0006's reply limit (60 an hour) counts every message. The next release drops the
166
+ mirror and moves the limit onto chat messages; then `support_replies` can go.
167
+
168
+ One difference: a chat message carries its sender's id, so the person can learn which staff
169
+ member answered (support_replies hid it). Name staff as "Support" in `resolveUser` (the web views
170
+ do by default for the person).
171
+
118
172
  ## Access, in the database
119
173
 
120
174
  A person sees, edits (while open) and deletes their own reports, replies on them and sees their
@@ -125,7 +179,10 @@ feature id, vote counts and read receipts are the server's. See the migration's
125
179
 
126
180
  ## Not yet
127
181
 
128
- - No staff (operator) screens: staff work through the database functions or a tool of yours.
129
- - No live updates (Realtime): the thread re-reads every 30 seconds while open.
182
+ - No staff inbox (operator queue) screen. With the support-chat block, `SupportStaffThread` is one
183
+ report for staff on the web; list reports with `genesis_chat.observed('support')` or a tool of
184
+ yours. Without it, staff work through the database functions.
185
+ - Without the support-chat block there are no live updates: the thread re-reads every 30 seconds
186
+ while open.
130
187
  - No ratings, no web idea board, no notification when an idea ships.
131
188
  - The CDN (script-tag) build of the web loader isn't published; `mountSupportWidget` is the loader.
@@ -1,6 +1,6 @@
1
1
  import * as _marktiderman_genesis_features_blocks from '@marktiderman/genesis-features/blocks';
2
2
  import { BlockContributions } from '@marktiderman/genesis-features/blocks';
3
- import { A as SupportCopyOverrides } from './copy-CuTArBFg.js';
3
+ import { O as SupportCopyOverrides } from './copy-D3rc0SCl.js';
4
4
 
5
5
  /** The FeatureId of the support block (features.ts). */
6
6
  declare const SUPPORT_BLOCK_ID = "support-feedback";
@@ -35,5 +35,13 @@ interface SupportBlockOptions {
35
35
  declare function supportContributions(options?: SupportBlockOptions): BlockContributions<never>;
36
36
  /** The platform-neutral declaration. */
37
37
  declare function defineSupportBlock(options?: SupportBlockOptions): _marktiderman_genesis_features_blocks.BlockDef<"support-feedback", never>;
38
+ /**
39
+ * The support-chat block: a report's conversation runs on genesis-chat. It adds no screens of
40
+ * its own (the support block's thread route renders the conversation); it brings migration
41
+ * `0001_genesis_support_chat.sql` from this package, after 0006 and genesis-chat's migrations.
42
+ */
43
+ declare function defineSupportChatBlock(options?: {
44
+ requires?: readonly string[];
45
+ }): _marktiderman_genesis_features_blocks.BlockDef<"support-chat", never>;
38
46
 
39
- export { SUPPORT_BLOCK_ID as S, type SupportBlockOptions as a, type SupportPaths as b, supportPaths as c, defineSupportBlock as d, supportContributions as s };
47
+ export { SUPPORT_BLOCK_ID as S, type SupportBlockOptions as a, type SupportPaths as b, defineSupportChatBlock as c, defineSupportBlock as d, supportPaths as e, supportContributions as s };
@@ -0,0 +1,89 @@
1
+ import { cleanRoute } from './chunk-OSB6G6Y3.js';
2
+
3
+ // src/record.ts
4
+ var FEEDBACK_TYPES = ["bug", "idea", "question", "praise"];
5
+ var DEVICE_MAX_BYTES = 4096;
6
+ var EXTRA_MAX_BYTES = 16384;
7
+ var jsonBytes = (value) => new TextEncoder().encode(JSON.stringify(value)).length;
8
+ var read = (value) => typeof value === "function" ? value() : value;
9
+ function createContextCollector(options) {
10
+ return {
11
+ collect() {
12
+ return sanitizeContext({
13
+ appVersion: read(options.appVersion),
14
+ route: read(options.route),
15
+ platform: read(options.platform),
16
+ device: read(options.device),
17
+ userId: read(options.userId),
18
+ extra: read(options.extra)
19
+ });
20
+ }
21
+ };
22
+ }
23
+ function composeCollectors(...collectors) {
24
+ return {
25
+ async collect() {
26
+ let out = {};
27
+ for (const c of collectors) {
28
+ const next = await c.collect();
29
+ out = {
30
+ ...out,
31
+ ...dropUndefined(next),
32
+ device: next.device || out.device ? { ...out.device, ...next.device } : void 0,
33
+ extra: next.extra || out.extra ? { ...out.extra, ...next.extra } : void 0
34
+ };
35
+ }
36
+ return sanitizeContext(out);
37
+ }
38
+ };
39
+ }
40
+ function dropUndefined(value) {
41
+ return Object.fromEntries(Object.entries(value).filter(([, v]) => v !== void 0));
42
+ }
43
+ function sanitizeContext(context) {
44
+ const out = {};
45
+ const route = cleanRoute(context.route);
46
+ if (route) out.route = route;
47
+ if (context.appVersion) out.appVersion = String(context.appVersion).slice(0, 200);
48
+ if (context.platform) out.platform = String(context.platform).slice(0, 40);
49
+ if (context.userId) out.userId = context.userId;
50
+ if (context.device && typeof context.device === "object" && jsonBytes(context.device) <= DEVICE_MAX_BYTES) out.device = context.device;
51
+ if (context.extra && typeof context.extra === "object" && jsonBytes(context.extra) <= EXTRA_MAX_BYTES) out.extra = context.extra;
52
+ return out;
53
+ }
54
+ var B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
55
+ var LOOKUP = (() => {
56
+ const table = new Int16Array(256).fill(-1);
57
+ for (let i = 0; i < B64.length; i++) table[B64.charCodeAt(i)] = i;
58
+ table["-".charCodeAt(0)] = 62;
59
+ table["_".charCodeAt(0)] = 63;
60
+ return table;
61
+ })();
62
+ function base64ToBytes(input) {
63
+ const comma = input.startsWith("data:") ? input.indexOf(",") : -1;
64
+ const clean = (comma >= 0 ? input.slice(comma + 1) : input).replace(/[\s=]/g, "");
65
+ const out = new Uint8Array(Math.floor(clean.length * 3 / 4));
66
+ let buffer = 0;
67
+ let bits = 0;
68
+ let o = 0;
69
+ for (let i = 0; i < clean.length; i++) {
70
+ const v = LOOKUP[clean.charCodeAt(i)] ?? -1;
71
+ if (v < 0) throw new Error("base64ToBytes: not base64");
72
+ buffer = buffer << 6 | v;
73
+ bits += 6;
74
+ if (bits >= 8) {
75
+ bits -= 8;
76
+ out[o++] = buffer >> bits & 255;
77
+ }
78
+ }
79
+ return out.subarray(0, o);
80
+ }
81
+ function attachmentBytes(attachment) {
82
+ if (attachment.bytes?.length) return attachment.bytes;
83
+ if (attachment.base64) return base64ToBytes(attachment.base64);
84
+ return null;
85
+ }
86
+
87
+ export { DEVICE_MAX_BYTES, EXTRA_MAX_BYTES, FEEDBACK_TYPES, attachmentBytes, base64ToBytes, composeCollectors, createContextCollector, sanitizeContext };
88
+ //# sourceMappingURL=chunk-MMBNG2NK.js.map
89
+ //# sourceMappingURL=chunk-MMBNG2NK.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/record.ts"],"names":[],"mappings":"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","file":"chunk-MMBNG2NK.js","sourcesContent":["/**\n * The feedback record and its two seams: a collector assembles the context\n * (everything except what the person typed), a sink delivers the finished\n * record. Platform-neutral; one default of each ships (`createContextCollector`,\n * `createSupabaseSink`), and an app can swap either.\n */\nimport { cleanRoute, type FeedbackReportType } from \"./contract\";\n\n/** What kind of feedback: a bug, an idea, a question or praise. */\nexport type FeedbackType = FeedbackReportType;\n\nexport const FEEDBACK_TYPES: readonly FeedbackType[] = [\"bug\", \"idea\", \"question\", \"praise\"];\n\n/** Where the person was and what they were running when they sent it. */\nexport interface FeedbackContext {\n /** The version signature, `1.4.0 (37) · preview · 3f2a9c1b`. */\n appVersion?: string;\n /** The screen, as a path. Query strings and fragments are always dropped. */\n route?: string;\n /** `ios`, `android`, `web`. */\n platform?: string;\n /** Model, OS version, locale, screen size, update id… At most 4 KB as JSON. */\n device?: Record<string, unknown>;\n /**\n * Who sent it, for sinks that need it. The Supabase sink ignores it: the\n * database takes the person from the session, never from the request.\n */\n userId?: string;\n /** Anything else small (breadcrumbs, a pin on the page). At most 16 KB as JSON. */\n extra?: Record<string, unknown>;\n}\n\nexport type ScreenshotContentType = \"image/png\" | \"image/jpeg\" | \"image/webp\";\n\n/**\n * A screenshot to attach. The record carries the bytes (or base64) and no\n * URL: the destination stores it and keeps its own key, so nothing in the\n * record expires.\n */\nexport interface FeedbackAttachment {\n kind: \"screenshot\";\n contentType: ScreenshotContentType;\n /** The image, base64 (no `data:` prefix; a data URI is accepted and stripped). */\n base64?: string;\n /** Or the bytes. */\n bytes?: Uint8Array;\n width?: number;\n height?: number;\n}\n\nexport interface FeedbackRecord {\n type: FeedbackType;\n /** What the person wrote. */\n message: string;\n title?: string;\n context: FeedbackContext;\n attachments?: FeedbackAttachment[];\n}\n\n/** Everything but what the person typed. */\nexport interface ContextCollector {\n collect(): Promise<FeedbackContext> | FeedbackContext;\n}\n\n/** What the destination kept. */\nexport interface FeedbackDelivery {\n /** The saved report's id. */\n id: string;\n /** How many attachments the destination saved the report without (an upload failed, or it takes none). */\n attachmentsDropped: number;\n}\n\n/** Takes a finished record to its destination. */\nexport interface FeedbackSink {\n submit(record: FeedbackRecord): Promise<FeedbackDelivery>;\n}\n\nexport const DEVICE_MAX_BYTES = 4096;\nexport const EXTRA_MAX_BYTES = 16384;\n\nconst jsonBytes = (value: unknown) => new TextEncoder().encode(JSON.stringify(value)).length;\n\n/** A value given as itself or as a function that reads it now. */\nexport type Lazy<T> = T | (() => T);\nconst read = <T,>(value: Lazy<T> | undefined): T | undefined => (typeof value === \"function\" ? (value as () => T)() : value);\n\nexport interface ContextCollectorOptions {\n appVersion?: Lazy<string | undefined>;\n route?: Lazy<string | undefined>;\n platform?: Lazy<string | undefined>;\n device?: Lazy<Record<string, unknown> | undefined>;\n userId?: Lazy<string | undefined>;\n extra?: Lazy<Record<string, unknown> | undefined>;\n}\n\n/**\n * The default collector: reads each value when feedback is sent, cleans the\n * route, and drops a device or extra bag over its size cap rather than\n * sending a report the database would refuse.\n */\nexport function createContextCollector(options: ContextCollectorOptions): ContextCollector {\n return {\n collect() {\n return sanitizeContext({\n appVersion: read(options.appVersion),\n route: read(options.route),\n platform: read(options.platform),\n device: read(options.device),\n userId: read(options.userId),\n extra: read(options.extra),\n });\n },\n };\n}\n\n/** Collectors merged left to right (a later one's values win; `device` and `extra` merge by key). */\nexport function composeCollectors(...collectors: ContextCollector[]): ContextCollector {\n return {\n async collect() {\n let out: FeedbackContext = {};\n for (const c of collectors) {\n const next = await c.collect();\n out = {\n ...out,\n ...dropUndefined(next),\n device: next.device || out.device ? { ...out.device, ...next.device } : undefined,\n extra: next.extra || out.extra ? { ...out.extra, ...next.extra } : undefined,\n };\n }\n return sanitizeContext(out);\n },\n };\n}\n\nfunction dropUndefined<T extends object>(value: T): Partial<T> {\n return Object.fromEntries(Object.entries(value).filter(([, v]) => v !== undefined)) as Partial<T>;\n}\n\n/** The context as the database takes it: a clean route, short strings, bags under their caps. */\nexport function sanitizeContext(context: FeedbackContext): FeedbackContext {\n const out: FeedbackContext = {};\n const route = cleanRoute(context.route);\n if (route) out.route = route;\n if (context.appVersion) out.appVersion = String(context.appVersion).slice(0, 200);\n if (context.platform) out.platform = String(context.platform).slice(0, 40);\n if (context.userId) out.userId = context.userId;\n if (context.device && typeof context.device === \"object\" && jsonBytes(context.device) <= DEVICE_MAX_BYTES) out.device = context.device;\n if (context.extra && typeof context.extra === \"object\" && jsonBytes(context.extra) <= EXTRA_MAX_BYTES) out.extra = context.extra;\n return out;\n}\n\nconst B64 = \"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/\";\nconst LOOKUP = (() => {\n const table = new Int16Array(256).fill(-1);\n for (let i = 0; i < B64.length; i++) table[B64.charCodeAt(i)] = i;\n table[\"-\".charCodeAt(0)] = 62;\n table[\"_\".charCodeAt(0)] = 63;\n return table;\n})();\n\n/** Base64 (or a `data:` URI) to bytes, without `atob` (Hermes, workers, Node all agree). */\nexport function base64ToBytes(input: string): Uint8Array {\n const comma = input.startsWith(\"data:\") ? input.indexOf(\",\") : -1;\n const clean = (comma >= 0 ? input.slice(comma + 1) : input).replace(/[\\s=]/g, \"\");\n const out = new Uint8Array(Math.floor((clean.length * 3) / 4));\n let buffer = 0;\n let bits = 0;\n let o = 0;\n for (let i = 0; i < clean.length; i++) {\n const v = LOOKUP[clean.charCodeAt(i)] ?? -1;\n if (v < 0) throw new Error(\"base64ToBytes: not base64\");\n buffer = (buffer << 6) | v;\n bits += 6;\n if (bits >= 8) {\n bits -= 8;\n out[o++] = (buffer >> bits) & 0xff;\n }\n }\n return out.subarray(0, o);\n}\n\n/** The bytes of an attachment, from `bytes` or `base64`. */\nexport function attachmentBytes(attachment: FeedbackAttachment): Uint8Array | null {\n if (attachment.bytes?.length) return attachment.bytes;\n if (attachment.base64) return base64ToBytes(attachment.base64);\n return null;\n}\n"]}
@@ -71,90 +71,6 @@ function validateSubmitInput(raw) {
71
71
  return { ok: true, value };
72
72
  }
73
73
 
74
- // src/record.ts
75
- var FEEDBACK_TYPES = ["bug", "idea", "question", "praise"];
76
- var DEVICE_MAX_BYTES = 4096;
77
- var EXTRA_MAX_BYTES = 16384;
78
- var jsonBytes = (value) => new TextEncoder().encode(JSON.stringify(value)).length;
79
- var read = (value) => typeof value === "function" ? value() : value;
80
- function createContextCollector(options) {
81
- return {
82
- collect() {
83
- return sanitizeContext({
84
- appVersion: read(options.appVersion),
85
- route: read(options.route),
86
- platform: read(options.platform),
87
- device: read(options.device),
88
- userId: read(options.userId),
89
- extra: read(options.extra)
90
- });
91
- }
92
- };
93
- }
94
- function composeCollectors(...collectors) {
95
- return {
96
- async collect() {
97
- let out = {};
98
- for (const c of collectors) {
99
- const next = await c.collect();
100
- out = {
101
- ...out,
102
- ...dropUndefined(next),
103
- device: next.device || out.device ? { ...out.device, ...next.device } : void 0,
104
- extra: next.extra || out.extra ? { ...out.extra, ...next.extra } : void 0
105
- };
106
- }
107
- return sanitizeContext(out);
108
- }
109
- };
110
- }
111
- function dropUndefined(value) {
112
- return Object.fromEntries(Object.entries(value).filter(([, v]) => v !== void 0));
113
- }
114
- function sanitizeContext(context) {
115
- const out = {};
116
- const route = cleanRoute(context.route);
117
- if (route) out.route = route;
118
- if (context.appVersion) out.appVersion = String(context.appVersion).slice(0, 200);
119
- if (context.platform) out.platform = String(context.platform).slice(0, 40);
120
- if (context.userId) out.userId = context.userId;
121
- if (context.device && typeof context.device === "object" && jsonBytes(context.device) <= DEVICE_MAX_BYTES) out.device = context.device;
122
- if (context.extra && typeof context.extra === "object" && jsonBytes(context.extra) <= EXTRA_MAX_BYTES) out.extra = context.extra;
123
- return out;
124
- }
125
- var B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
126
- var LOOKUP = (() => {
127
- const table = new Int16Array(256).fill(-1);
128
- for (let i = 0; i < B64.length; i++) table[B64.charCodeAt(i)] = i;
129
- table["-".charCodeAt(0)] = 62;
130
- table["_".charCodeAt(0)] = 63;
131
- return table;
132
- })();
133
- function base64ToBytes(input) {
134
- const comma = input.startsWith("data:") ? input.indexOf(",") : -1;
135
- const clean = (comma >= 0 ? input.slice(comma + 1) : input).replace(/[\s=]/g, "");
136
- const out = new Uint8Array(Math.floor(clean.length * 3 / 4));
137
- let buffer = 0;
138
- let bits = 0;
139
- let o = 0;
140
- for (let i = 0; i < clean.length; i++) {
141
- const v = LOOKUP[clean.charCodeAt(i)] ?? -1;
142
- if (v < 0) throw new Error("base64ToBytes: not base64");
143
- buffer = buffer << 6 | v;
144
- bits += 6;
145
- if (bits >= 8) {
146
- bits -= 8;
147
- out[o++] = buffer >> bits & 255;
148
- }
149
- }
150
- return out.subarray(0, o);
151
- }
152
- function attachmentBytes(attachment) {
153
- if (attachment.bytes?.length) return attachment.bytes;
154
- if (attachment.base64) return base64ToBytes(attachment.base64);
155
- return null;
156
- }
157
-
158
74
  // src/copy.ts
159
75
  var supportCopy = {
160
76
  settings: {
@@ -228,7 +144,21 @@ var supportCopy = {
228
144
  you: "You",
229
145
  sending: "Sending",
230
146
  notSent: "Not sent. Tap to retry.",
231
- retryHint: "Double tap to send again"
147
+ retryHint: "Double tap to send again",
148
+ /** A message's file, where the thread can't show it. */
149
+ voiceNote: "Voice message",
150
+ video: "Video",
151
+ file: "File",
152
+ internalNote: "Internal note"
153
+ },
154
+ staff: {
155
+ status: "Status",
156
+ assignee: "Assigned to",
157
+ unassigned: "Nobody",
158
+ reporter: "Reported by",
159
+ unavailableTitle: "This conversation isn't available",
160
+ unavailableBody: "The report has no conversation you can read.",
161
+ failed: "That change wasn't saved. Try again."
232
162
  },
233
163
  status: {
234
164
  open: "Open",
@@ -279,6 +209,6 @@ function fill(template, values) {
279
209
  return template.replace(/\{(\w+)\}/g, (token, name) => values[name] !== void 0 ? String(values[name]) : token);
280
210
  }
281
211
 
282
- export { CONTRACT_VERSION, DEVICE_MAX_BYTES, EXTRA_MAX_BYTES, FEEDBACK_REPORT_TYPES, FEEDBACK_STATUSES, FEEDBACK_TYPES, LIMITS, WIDGET_HEADERS, attachmentBytes, base64ToBytes, cleanRoute, composeCollectors, createContextCollector, fill, resolveSupportCopy, sanitizeContext, supportCopy, validateSubmitInput };
283
- //# sourceMappingURL=chunk-ZV4KJBUF.js.map
284
- //# sourceMappingURL=chunk-ZV4KJBUF.js.map
212
+ export { CONTRACT_VERSION, FEEDBACK_REPORT_TYPES, FEEDBACK_STATUSES, LIMITS, WIDGET_HEADERS, cleanRoute, fill, resolveSupportCopy, supportCopy, validateSubmitInput };
213
+ //# sourceMappingURL=chunk-OSB6G6Y3.js.map
214
+ //# sourceMappingURL=chunk-OSB6G6Y3.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/contract.ts","../src/copy.ts"],"names":[],"mappings":"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","file":"chunk-OSB6G6Y3.js","sourcesContent":["/**\n * The Genesis support wire contract: what a support client (the web loader, a\n * native app, any HTTP caller) and the `genesis-support-intake` Edge Function\n * say to each other. One file, kept byte-identical in two places:\n * `@marktiderman/genesis-support` (`src/contract.ts`) and the function template\n * (`functions/genesis-support-intake/contract.ts`); a test fails when they drift.\n *\n * Dependency-free and additive: `CONTRACT_VERSION` changes only with a shape\n * change, and a reader tolerates fields and widget types it doesn't know, so an\n * old client keeps working.\n */\n\n/** Bumped only on a shape change. Old clients must keep working. */\nexport const CONTRACT_VERSION = 1 as const;\n\n/** The widget kinds a boot can turn on. */\nexport type WidgetType = \"feedback\" | \"roadmap\" | \"changelog\" | \"survey\" | \"messaging\";\n\n/** On the wire the type is open: a client switches on the known ones and ignores the rest. */\nexport type WidgetTypeWire = WidgetType | (string & {});\n\n/**\n * How the person is known to the server.\n * - `genesis-session`: signed in to the app's own Supabase (a user access token). The default.\n * - `signed`: the app's backend mints a short-lived token for the person (reserved).\n * - `anonymous`: a device id only (reserved; the database refuses writes without a session).\n */\nexport type IdentityMode = \"anonymous\" | \"signed\" | \"genesis-session\";\n\nexport type LauncherPosition = \"bottom-right\" | \"bottom-left\" | \"top-right\" | \"top-left\";\n\n/** Theme hints a web launcher may use. Genesis apps use their own theme instead. */\nexport interface ThemeTokens {\n colorBg?: string;\n colorFg?: string;\n colorPrimary?: string;\n colorPrimaryFg?: string;\n radius?: string;\n fontFamily?: string;\n launcherLabel?: string;\n launcherPosition?: LauncherPosition;\n}\n\n/** One enabled widget and its public config (never a secret). */\nexport interface WidgetConfig {\n type: WidgetTypeWire;\n config: Record<string, unknown>;\n}\n\n/** The boot handshake (`GET`): world-readable, so only public values. */\nexport interface BootConfig {\n contractVersion: number;\n project: { id: string; name: string };\n identityMode: IdentityMode;\n theme: ThemeTokens;\n widgets: WidgetConfig[];\n flags: Record<string, boolean>;\n}\n\nexport type FeedbackReportType = \"bug\" | \"idea\" | \"question\" | \"praise\";\nexport type FeedbackStatus = \"open\" | \"submitted\" | \"in_progress\" | \"addressed\" | \"wont_fix\";\nexport type FindingSeverity = \"low\" | \"medium\" | \"high\" | \"critical\";\n\nexport const FEEDBACK_REPORT_TYPES: readonly FeedbackReportType[] = [\"bug\", \"idea\", \"question\", \"praise\"];\nexport const FEEDBACK_STATUSES: readonly FeedbackStatus[] = [\"open\", \"submitted\", \"in_progress\", \"addressed\", \"wont_fix\"];\nconst SEVERITIES: readonly FindingSeverity[] = [\"low\", \"medium\", \"high\", \"critical\"];\n\n/** Client to server: open a report. Screenshots go to storage, not in this body. */\nexport interface SubmitFeedbackInput {\n type?: FeedbackReportType;\n title?: string;\n body: string;\n route?: string;\n appVersion?: string;\n envType?: string;\n severity?: FindingSeverity;\n tags?: string[];\n /** Small, free-form context (breadcrumbs, a pin on the page). At most 16 KB as JSON. */\n context?: Record<string, unknown>;\n}\n\n/** Server to client: the report created. `findingId` is the report's id (one finding per report here). */\nexport interface SubmitFeedbackResult {\n reportId: string;\n findingId: string;\n status: FeedbackStatus;\n}\n\n/** One of the caller's own reports. */\nexport interface FeedbackReportSummary {\n id: string;\n type: FeedbackReportType;\n title: string;\n status: FeedbackStatus;\n route: string | null;\n createdAt: string;\n findingCount: number;\n}\n\n/** The operations the intake function multiplexes on (`{ op, ...args }`). */\nexport type FeedbackOp = \"submit\" | \"list\";\n\n/** Headers a client sends. The person's access token goes in `Authorization: Bearer …`. */\nexport const WIDGET_HEADERS = {\n /** The project's publishable key (an identifier, not a secret). */\n key: \"x-genesis-key\",\n /** `signed` mode: the minted short-lived token. */\n identity: \"x-genesis-identity\",\n /** `anonymous` mode: the device id. */\n device: \"x-genesis-device\",\n /** A proof-of-work or captcha token, for anonymous writes. */\n challenge: \"x-genesis-challenge\",\n /** The contract version the client was built against. */\n contract: \"x-genesis-contract\",\n} as const;\n\n/** Text limits, the same as the database's checks. */\nexport const LIMITS = { title: 300, body: 20000, route: 2000, appVersion: 200, tag: 60, tags: 50, contextBytes: 16384 } as const;\n\n/** A path segment that names someone or something private: an email, a UUID, a number, a long token. */\nfunction privateSegment(segment: string): string | null {\n let s = segment;\n try {\n s = decodeURIComponent(segment);\n } catch {\n // a malformed escape: judge it as written\n }\n if (s.includes(\"@\")) return \":email\";\n if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(s)) return \":id\";\n if (/^\\d+$/.test(s)) return \":id\";\n // A long run of letters and digits with a digit in it reads as a token or a code, not a word.\n if (s.length >= 16 && /\\d/.test(s) && /^[A-Za-z0-9_.~-]+$/.test(s)) return \":token\";\n return null;\n}\n\n/**\n * A route as a path only, safe to store and send: the query string and\n * fragment (tokens, codes, emails) are dropped, and path segments that look\n * private (an email, an id, a long token) become `:email`, `:id` or `:token`\n * (`/users/jane@x.test/reset/abc123…` → `/users/:email/reset/:token`).\n */\nexport function cleanRoute(route: string | null | undefined): string | undefined {\n if (typeof route !== \"string\") return undefined;\n const path = route.replace(/[?#].*$/, \"\").trim();\n if (!path) return undefined;\n const safe = path\n .split(\"/\")\n .map((segment) => (segment ? (privateSegment(segment) ?? segment) : segment))\n .join(\"/\");\n return safe.slice(0, LIMITS.route);\n}\n\nexport type SubmitValidation =\n | { ok: true; value: SubmitFeedbackInput }\n | { ok: false; error: \"invalid_args\" | \"payload_too_large\"; field: string };\n\nconst byteLength = (s: string) => new TextEncoder().encode(s).length;\n\n/** Checks a `submit` body the way the server does: known keys only, every limit. */\nexport function validateSubmitInput(raw: unknown): SubmitValidation {\n if (!raw || typeof raw !== \"object\" || Array.isArray(raw)) return { ok: false, error: \"invalid_args\", field: \"body\" };\n const input = raw as Record<string, unknown>;\n const known = new Set([\"type\", \"title\", \"body\", \"route\", \"appVersion\", \"envType\", \"severity\", \"tags\", \"context\"]);\n for (const key of Object.keys(input)) if (!known.has(key)) return { ok: false, error: \"invalid_args\", field: key };\n const optionalString = (key: string, max: number): boolean => input[key] == null || (typeof input[key] === \"string\" && (input[key] as string).length <= max);\n if (typeof input.body !== \"string\" || !input.body.trim()) return { ok: false, error: \"invalid_args\", field: \"body\" };\n if (input.body.length > LIMITS.body) return { ok: false, error: \"payload_too_large\", field: \"body\" };\n if (input.type != null && !FEEDBACK_REPORT_TYPES.includes(input.type as FeedbackReportType)) return { ok: false, error: \"invalid_args\", field: \"type\" };\n if (input.severity != null && !SEVERITIES.includes(input.severity as FindingSeverity)) return { ok: false, error: \"invalid_args\", field: \"severity\" };\n if (!optionalString(\"title\", LIMITS.title)) return { ok: false, error: \"invalid_args\", field: \"title\" };\n if (!optionalString(\"route\", LIMITS.route)) return { ok: false, error: \"invalid_args\", field: \"route\" };\n if (!optionalString(\"appVersion\", LIMITS.appVersion)) return { ok: false, error: \"invalid_args\", field: \"appVersion\" };\n if (!optionalString(\"envType\", 60)) return { ok: false, error: \"invalid_args\", field: \"envType\" };\n if (input.tags != null) {\n if (!Array.isArray(input.tags) || input.tags.length > LIMITS.tags) return { ok: false, error: \"invalid_args\", field: \"tags\" };\n if (!input.tags.every((t) => typeof t === \"string\" && t.length <= LIMITS.tag)) return { ok: false, error: \"invalid_args\", field: \"tags\" };\n }\n if (input.context != null) {\n if (typeof input.context !== \"object\" || Array.isArray(input.context)) return { ok: false, error: \"invalid_args\", field: \"context\" };\n if (byteLength(JSON.stringify(input.context)) > LIMITS.contextBytes) return { ok: false, error: \"payload_too_large\", field: \"context\" };\n }\n const value: SubmitFeedbackInput = { body: input.body };\n if (input.type != null) value.type = input.type as FeedbackReportType;\n if (input.title != null) value.title = input.title as string;\n const route = cleanRoute(input.route as string | undefined);\n if (route) value.route = route;\n if (input.appVersion != null) value.appVersion = input.appVersion as string;\n if (input.envType != null) value.envType = input.envType as string;\n if (input.severity != null) value.severity = input.severity as FindingSeverity;\n if (input.tags != null) value.tags = input.tags as string[];\n if (input.context != null) value.context = input.context as Record<string, unknown>;\n return { ok: true, value };\n}\n","/**\n * Every string the support block shows, in English. Pass `copy` (any part of\n * this shape) to translate or reword; what you leave out stays English.\n */\nexport const supportCopy = {\n settings: {\n section: \"Support\",\n sendFeedback: \"Send feedback\",\n help: \"Help\",\n /** `{count}` new replies. */\n unreadReplies: \"{count} new\",\n },\n feedback: {\n title: \"Send feedback\",\n typeLabel: \"What is it?\",\n types: { bug: \"Bug\", idea: \"Idea\", question: \"Question\", praise: \"Praise\" },\n messageLabel: \"Tell us more\",\n messagePlaceholder: \"What happened, and what did you expect?\",\n screenshotLabel: \"Screenshot\",\n includeScreenshot: \"Include this screenshot\",\n screenshotHint: \"Sent only if you include it. Mark it up to hide anything private first.\",\n annotate: \"Mark up\",\n pin: \"Point to it on the page\",\n pinned: \"Pinned\",\n removeScreenshot: \"Remove\",\n send: \"Send\",\n sending: \"Sending\",\n sentTitle: \"Thanks for the feedback\",\n sentBody: \"We read every report. You'll find it, and our replies, under Help.\",\n sentWithoutScreenshot: \"Your report was sent, but its screenshot couldn't be.\",\n failedTitle: \"Your feedback wasn't sent\",\n failedBody: \"Check your connection and try again.\",\n rateLimited: \"You've sent a lot of feedback in a short time. Try again later.\",\n signInFirst: \"Sign in to send feedback.\",\n messageRequired: \"Write a few words first.\",\n },\n annotate: {\n title: \"Mark up\",\n pen: \"Pen\",\n hide: \"Hide\",\n undo: \"Undo\",\n done: \"Done\",\n cancel: \"Cancel\",\n hint: \"Draw to point something out. Use Hide to cover anything private.\",\n failed: \"The marks couldn't be saved. Try again, or cancel and leave the screenshot out.\",\n },\n inbox: {\n title: \"Help\",\n sectionTitle: \"Your reports\",\n emptyTitle: \"No reports yet\",\n emptyBody: \"When you send feedback it shows up here, with our replies.\",\n errorTitle: \"Couldn't load your reports\",\n errorBody: \"Check your connection, then pull to refresh.\",\n sendFeedback: \"Send feedback\",\n ideaBoard: \"Idea board\",\n shipped: \"You asked, we shipped\",\n delete: \"Delete\",\n deleteConfirmTitle: \"Delete this report?\",\n deleteConfirmBody: \"The report, its replies and its screenshots are deleted and can't be opened again.\",\n cancel: \"Cancel\",\n unread: \"New reply\",\n },\n thread: {\n title: \"Report\",\n placeholder: \"Reply\",\n send: \"Send\",\n read: \"Read\",\n support: \"Support\",\n closed: \"This report is closed. Send new feedback if something's still wrong.\",\n emptyTitle: \"No replies yet\",\n emptyBody: \"We'll answer here.\",\n screenshot: \"Screenshot\",\n notFound: \"That report isn't there any more.\",\n you: \"You\",\n sending: \"Sending\",\n notSent: \"Not sent. Tap to retry.\",\n retryHint: \"Double tap to send again\",\n /** A message's file, where the thread can't show it. */\n voiceNote: \"Voice message\",\n video: \"Video\",\n file: \"File\",\n internalNote: \"Internal note\",\n },\n staff: {\n status: \"Status\",\n assignee: \"Assigned to\",\n unassigned: \"Nobody\",\n reporter: \"Reported by\",\n unavailableTitle: \"This conversation isn't available\",\n unavailableBody: \"The report has no conversation you can read.\",\n failed: \"That change wasn't saved. Try again.\",\n },\n status: {\n open: \"Open\",\n submitted: \"Received\",\n in_progress: \"In progress\",\n addressed: \"Fixed\",\n wont_fix: \"Closed\",\n },\n ideas: {\n title: \"Idea board\",\n emptyTitle: \"No ideas yet\",\n emptyBody: \"Suggest the first one.\",\n suggest: \"Suggest an idea\",\n suggestTitle: \"Suggest an idea\",\n titleLabel: \"Your idea\",\n titlePlaceholder: \"A short title\",\n bodyLabel: \"Why it would help (optional)\",\n submit: \"Suggest\",\n vote: \"Vote\",\n voted: \"Voted\",\n /** `{count}` votes. */\n votes: \"{count} votes\",\n oneVote: \"1 vote\",\n statuses: { open: \"Open\", planned: \"Planned\", in_progress: \"In progress\", shipped: \"Shipped\", declined: \"Not planned\", hidden: \"Hidden\" },\n },\n shipped: {\n title: \"You asked, we shipped\",\n emptyTitle: \"Nothing yet\",\n emptyBody: \"When an idea you suggested or voted for ships, it shows up here.\",\n /** `{feature}`: the FeatureId it shipped as. */\n shippedAs: \"Shipped as {feature}\",\n },\n};\n\n/** The shape a translation fills. */\nexport type SupportCopy = typeof supportCopy;\n\n/** Any part of the catalog. */\nexport type SupportCopyOverrides = { [K in keyof SupportCopy]?: DeepPartial<SupportCopy[K]> };\ntype DeepPartial<T> = { [K in keyof T]?: T[K] extends string ? string : DeepPartial<T[K]> };\n\n/** The catalog with overrides merged over the English. */\nexport function resolveSupportCopy(overrides: SupportCopyOverrides = {}): SupportCopy {\n const merge = (base: Record<string, unknown>, over: Record<string, unknown> | undefined): Record<string, unknown> => {\n if (!over) return base;\n const out: Record<string, unknown> = { ...base };\n for (const [key, value] of Object.entries(over)) {\n if (value === undefined) continue;\n const current = base[key];\n out[key] = current && typeof current === \"object\" && value && typeof value === \"object\" ? merge(current as Record<string, unknown>, value as Record<string, unknown>) : value;\n }\n return out;\n };\n return merge(supportCopy as unknown as Record<string, unknown>, overrides as Record<string, unknown>) as unknown as SupportCopy;\n}\n\n/** Fills `{name}` placeholders. */\nexport function fill(template: string, values: Readonly<Record<string, string | number>>): string {\n return template.replace(/\\{(\\w+)\\}/g, (token, name: string) => (values[name] !== undefined ? String(values[name]) : token));\n}\n"]}
@@ -1,6 +1,23 @@
1
- import { resolveSupportCopy, sanitizeContext, attachmentBytes } from './chunk-ZV4KJBUF.js';
1
+ import { sanitizeContext, attachmentBytes } from './chunk-MMBNG2NK.js';
2
+ import { resolveSupportCopy } from './chunk-OSB6G6Y3.js';
2
3
  import { defineBlock } from '@marktiderman/genesis-features/blocks';
3
4
 
5
+ // src/chat.ts
6
+ var SUPPORT_CHAT_BLOCK_ID = "support-chat";
7
+ var SUPPORT_CHAT_KIND = "support";
8
+ var SUPPORT_CHAT_SUBJECT = "support_report";
9
+ var SUPPORT_TO_CHAT_STATUS = {
10
+ open: "open",
11
+ submitted: "open",
12
+ in_progress: "pending",
13
+ addressed: "solved",
14
+ wont_fix: "closed"
15
+ };
16
+ function isMissingFunction(error) {
17
+ if (!error) return false;
18
+ return error.code === "PGRST202" || error.code === "42883";
19
+ }
20
+
4
21
  // src/client.ts
5
22
  var SUPPORT_BUCKET = "genesis-support";
6
23
  var SupportError = class extends Error {
@@ -21,7 +38,7 @@ function must(operation, result) {
21
38
  if (result.error) throw new SupportError(result.error.message, result.error.code, operation);
22
39
  return result.data;
23
40
  }
24
- var REPORT_COLUMNS = "id, type, title, message, status, feature_id, route, app_version, created_at, updated_at, last_staff_reply_at, last_user_reply_at, user_read_at, staff_read_at";
41
+ var REPORT_COLUMNS = "id, user_id, type, title, message, status, feature_id, route, app_version, created_at, updated_at, last_staff_reply_at, last_user_reply_at, user_read_at, staff_read_at";
25
42
  var IDEA_COLUMNS = "id, title, body, status, feature_id, shipped_at, vote_count, created_at";
26
43
  function isUnread(row) {
27
44
  if (!row.lastStaffReplyAt) return false;
@@ -42,7 +59,8 @@ function toReport(r) {
42
59
  lastStaffReplyAt: r.last_staff_reply_at ?? null,
43
60
  lastUserReplyAt: r.last_user_reply_at ?? null,
44
61
  userReadAt: r.user_read_at ?? null,
45
- staffReadAt: r.staff_read_at ?? null
62
+ staffReadAt: r.staff_read_at ?? null,
63
+ userId: r.user_id ?? null
46
64
  };
47
65
  return { ...report, unread: isUnread(report) };
48
66
  }
@@ -61,6 +79,15 @@ function toIdea(r, mine, voted) {
61
79
  };
62
80
  }
63
81
  var EXTENSIONS = { "image/png": "png", "image/jpeg": "jpg", "image/webp": "webp" };
82
+ function randomUuid() {
83
+ const c = globalThis.crypto;
84
+ if (c?.randomUUID) return c.randomUUID();
85
+ const hex = Array.from({ length: 32 }, () => Math.floor(Math.random() * 16).toString(16));
86
+ hex[12] = "4";
87
+ hex[16] = (parseInt(hex[16], 16) & 3 | 8).toString(16);
88
+ const h = hex.join("");
89
+ return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;
90
+ }
64
91
  function defaultRandomId() {
65
92
  const c = globalThis.crypto;
66
93
  if (c?.randomUUID) return c.randomUUID();
@@ -74,6 +101,46 @@ function createSupportClient(supabase, options = {}) {
74
101
  const { data } = await supabase.auth.getSession();
75
102
  return data.session?.user?.id ?? null;
76
103
  };
104
+ const chat = options.chat || null;
105
+ let chatInstalled = chat ? null : false;
106
+ const conversations = /* @__PURE__ */ new Map();
107
+ const warn = options.onChatUnavailable ?? ((message) => console.warn(message));
108
+ async function thread(reportId) {
109
+ if (!chat || chatInstalled === false) return { mode: "replies", conversationId: null };
110
+ const known = conversations.get(reportId);
111
+ if (known) return { mode: "chat", conversationId: known };
112
+ const result = await supabase.rpc("support_chat_conversation", { report: reportId });
113
+ if (result.error) {
114
+ if (isMissingFunction(result.error)) {
115
+ chatInstalled = false;
116
+ warn(
117
+ "genesis-support: a chat client was given, but the support-chat block's migration (0001_genesis_support_chat.sql) isn't applied. Report threads use support_replies."
118
+ );
119
+ return { mode: "replies", conversationId: null };
120
+ }
121
+ throw new SupportError(result.error.message, result.error.code, "thread");
122
+ }
123
+ chatInstalled = true;
124
+ const id = typeof result.data === "string" && result.data ? result.data : null;
125
+ if (!id) return { mode: "replies", conversationId: null };
126
+ conversations.set(reportId, id);
127
+ return { mode: "chat", conversationId: id };
128
+ }
129
+ async function chatReply(reportId, m, userId) {
130
+ const paths = m.attachments.map((a) => a.path).filter((p) => Boolean(p));
131
+ const signed = paths.length && chat ? await chat.fileUrls(paths).catch(() => ({})) : {};
132
+ return {
133
+ id: m.id,
134
+ reportId,
135
+ body: m.body,
136
+ fromStaff: m.senderRole === "observer",
137
+ mine: Boolean(userId) && m.senderId === userId,
138
+ createdAt: m.createdAt,
139
+ internal: m.visibility === "observers",
140
+ seq: m.seq,
141
+ attachments: m.attachments.map((a) => ({ ...a, url: (a.path ? signed[a.path] : null) ?? a.url ?? null }))
142
+ };
143
+ }
77
144
  const client = {
78
145
  async submit(record) {
79
146
  const userId = await me();
@@ -133,6 +200,12 @@ function createSupportClient(supabase, options = {}) {
133
200
  return row ? toReport(row) : null;
134
201
  },
135
202
  async replies(reportId) {
203
+ const where = await thread(reportId);
204
+ if (where.mode === "chat" && chat) {
205
+ const [page, userId] = await Promise.all([chat.messages(where.conversationId, { limit: 200 }), me()]);
206
+ const shown = page.messages.filter((m) => m.kind === "text" && !m.deletedAt);
207
+ return Promise.all(shown.map((m) => chatReply(reportId, m, userId)));
208
+ }
136
209
  const rows = must(
137
210
  "replies",
138
211
  await supabase.from("support_replies").select("id, report_id, body, from_staff, created_at").eq("report_id", reportId).order("created_at", { ascending: true })
@@ -146,7 +219,18 @@ function createSupportClient(supabase, options = {}) {
146
219
  createdAt: String(r.created_at)
147
220
  }));
148
221
  },
149
- async reply(reportId, body) {
222
+ async reply(reportId, body, { internal = false } = {}) {
223
+ const where = await thread(reportId);
224
+ if (where.mode === "chat" && chat) {
225
+ const [sent, userId] = await Promise.all([
226
+ chat.send({ conversationId: where.conversationId, clientId: randomUuid(), body, visibility: internal ? "observers" : "everyone" }).catch((error) => {
227
+ throw new SupportError(error?.message ?? "Not sent", error?.code, "reply");
228
+ }),
229
+ me()
230
+ ]);
231
+ return chatReply(reportId, sent, userId);
232
+ }
233
+ if (internal) throw new SupportError("Internal notes need the support-chat block.", "0A000", "reply");
150
234
  const row = must(
151
235
  "reply",
152
236
  await supabase.from("support_replies").insert({ report_id: reportId, body }).select("id, report_id, body, from_staff, created_at").single()
@@ -154,8 +238,42 @@ function createSupportClient(supabase, options = {}) {
154
238
  return { id: String(row.id), reportId: String(row.report_id), body: String(row.body), fromStaff: row.from_staff === true, mine: true, createdAt: String(row.created_at) };
155
239
  },
156
240
  async markRead(reportId) {
241
+ const where = await thread(reportId);
242
+ if (where.mode === "chat" && chat) {
243
+ await chat.markRead(where.conversationId);
244
+ return;
245
+ }
157
246
  must("markRead", await supabase.rpc("support_mark_read", { report: reportId }));
158
247
  },
248
+ thread,
249
+ async conversationId(reportId) {
250
+ return (await thread(reportId)).conversationId;
251
+ },
252
+ subscribe(reportId, onChange) {
253
+ let stopped = false;
254
+ let stop = () => {
255
+ };
256
+ void (async () => {
257
+ const where = await thread(reportId);
258
+ if (where.mode !== "chat" || !chat || stopped) return;
259
+ const sub = chat.subscribeConversation(where.conversationId, await me() ?? "", { onSignal: () => onChange(), onJoin: () => onChange() });
260
+ if (stopped) sub.unsubscribe();
261
+ else stop = () => sub.unsubscribe();
262
+ })().catch(() => void 0);
263
+ return () => {
264
+ stopped = true;
265
+ stop();
266
+ };
267
+ },
268
+ async isStaff() {
269
+ return must("isStaff", await supabase.rpc("support_is_staff")) === true;
270
+ },
271
+ async setStatus(reportId, status, featureId = null) {
272
+ must("setStatus", await supabase.rpc("support_set_report_status", { report: reportId, status, feature: featureId }));
273
+ },
274
+ async assign(reportId, userId) {
275
+ must("assign", await supabase.rpc("support_assign_report", { report: reportId, assignee: userId }));
276
+ },
159
277
  async deleteReport(reportId) {
160
278
  const rows = must("deleteReport", await supabase.from("support_reports").delete().eq("id", reportId).select("id"));
161
279
  if (!rows.length) throw new SupportError("That report wasn't deleted.", "42501", "deleteReport");
@@ -272,7 +390,18 @@ function defineSupportBlock(options = {}) {
272
390
  contributes: supportContributions(options)
273
391
  });
274
392
  }
393
+ function defineSupportChatBlock(options = {}) {
394
+ return defineBlock({
395
+ id: SUPPORT_CHAT_BLOCK_ID,
396
+ requires: options.requires ?? [SUPPORT_BLOCK_ID, "chat"],
397
+ contributes: {
398
+ migrations: [
399
+ { package: "@marktiderman/genesis-support", file: "migrations/0001_genesis_support_chat.sql", test: "migrations/tests/0001_genesis_support_chat.test.sql" }
400
+ ]
401
+ }
402
+ });
403
+ }
275
404
 
276
- export { SUPPORT_BLOCK_ID, SUPPORT_BUCKET, SupportError, createSupabaseSink, createSupportClient, defineSupportBlock, isUnread, supportContributions, supportPaths };
277
- //# sourceMappingURL=chunk-4VFYVQUF.js.map
278
- //# sourceMappingURL=chunk-4VFYVQUF.js.map
405
+ export { SUPPORT_BLOCK_ID, SUPPORT_BUCKET, SUPPORT_CHAT_BLOCK_ID, SUPPORT_CHAT_KIND, SUPPORT_CHAT_SUBJECT, SUPPORT_TO_CHAT_STATUS, SupportError, createSupabaseSink, createSupportClient, defineSupportBlock, defineSupportChatBlock, isMissingFunction, isUnread, supportContributions, supportPaths };
406
+ //# sourceMappingURL=chunk-QNUAKW7K.js.map
407
+ //# sourceMappingURL=chunk-QNUAKW7K.js.map