create-feedbax 0.2.0 → 0.2.1

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.
Files changed (99) hide show
  1. package/dist/cli.js +2042 -16
  2. package/dist/index.js +1997 -5
  3. package/package.json +1 -1
  4. package/template/.feedbax-template.sha256 +1 -1
  5. package/template/AGENTS.md +13 -0
  6. package/template/CONNECTED.md +404 -0
  7. package/template/README.md +58 -0
  8. package/template/components.json +1 -1
  9. package/template/eslint.config.mjs +23 -0
  10. package/template/migrations/0001_connected.sql +6 -0
  11. package/template/migrations/0002_participation.sql +8 -0
  12. package/template/migrations/0003_images.sql +3 -0
  13. package/template/migrations/0004_followed_status.sql +9 -0
  14. package/template/package.json +9 -5
  15. package/template/src/authorized-draft-post.tsx +1 -0
  16. package/template/src/changelog-page.tsx +63 -54
  17. package/template/src/cloudflare-workers.d.ts +6 -0
  18. package/template/src/comment-creation.test.tsx +42 -1
  19. package/template/src/comment-thread-page.ts +11 -1
  20. package/template/src/components/command-palette.tsx +59 -30
  21. package/template/src/components/connected-identity-provider.tsx +108 -0
  22. package/template/src/components/device-profile-provider.tsx +41 -26
  23. package/template/src/components/follow-post-button.tsx +89 -0
  24. package/template/src/components/header.tsx +2 -0
  25. package/template/src/components/image-attachments.tsx +187 -0
  26. package/template/src/components/loader.tsx +48 -5
  27. package/template/src/components/notification-inbox.tsx +232 -0
  28. package/template/src/components/portal-footer.tsx +14 -0
  29. package/template/src/components/profile-menu.tsx +30 -0
  30. package/template/src/components/ui/components/command.tsx +30 -29
  31. package/template/src/components/ui/components/message-scroller.tsx +2 -2
  32. package/template/src/components/ui/components/scroll-area.tsx +0 -1
  33. package/template/src/components/ui/components/switch.tsx +29 -0
  34. package/template/src/components/ui/styles/globals.css +1 -1
  35. package/template/src/connected-identity.test.ts +144 -0
  36. package/template/src/connected-identity.ts +122 -0
  37. package/template/src/connected-participation.test.tsx +67 -0
  38. package/template/src/connected-runtime.test.ts +241 -0
  39. package/template/src/connected-runtime.ts +176 -0
  40. package/template/src/connected-social.test.tsx +158 -0
  41. package/template/src/feedback-cache-invalidation.ts +1 -0
  42. package/template/src/feedback-runtime.ts +79 -12
  43. package/template/src/feedbax/config/index.test.ts +28 -0
  44. package/template/src/feedbax/config/index.ts +93 -0
  45. package/template/src/feedbax/feedback/connected-feedback.ts +206 -0
  46. package/template/src/feedbax/feedback/connected.test.ts +272 -0
  47. package/template/src/feedbax/feedback/connected.ts +215 -0
  48. package/template/src/feedbax/feedback/email-delivery.test.ts +65 -0
  49. package/template/src/feedbax/feedback/email-delivery.ts +66 -0
  50. package/template/src/feedbax/feedback/identity-handoff.test.ts +81 -0
  51. package/template/src/feedbax/feedback/identity-handoff.ts +119 -0
  52. package/template/src/feedbax/feedback/image-feedback.ts +101 -0
  53. package/template/src/feedbax/feedback/images.test.ts +223 -0
  54. package/template/src/feedbax/feedback/images.ts +324 -0
  55. package/template/src/feedbax/feedback/index.ts +55 -1
  56. package/template/src/feedbax/feedback/notion-image-review.ts +55 -0
  57. package/template/src/feedbax/feedback/participation.test.ts +653 -0
  58. package/template/src/feedbax/feedback/participation.ts +502 -0
  59. package/template/src/images-runtime.test.ts +133 -0
  60. package/template/src/images-runtime.ts +125 -0
  61. package/template/src/index.css +97 -440
  62. package/template/src/lazy-post-detail-overlay.tsx +13 -2
  63. package/template/src/overlay-post-detail.test.tsx +59 -8
  64. package/template/src/overlay-post-detail.tsx +20 -69
  65. package/template/src/participation-client.ts +63 -0
  66. package/template/src/participation-runtime.test.ts +35 -0
  67. package/template/src/participation-runtime.ts +255 -0
  68. package/template/src/portal-comments.ts +24 -5
  69. package/template/src/portal-feedback-form.tsx +18 -3
  70. package/template/src/portal-feedback-runtime.ts +3 -0
  71. package/template/src/portal-feedback-server-function.ts +6 -3
  72. package/template/src/portal-feedback-submission.ts +10 -0
  73. package/template/src/portal-server-entry.ts +4 -0
  74. package/template/src/post-creation-flow.tsx +2 -2
  75. package/template/src/post-detail-content.test.tsx +233 -0
  76. package/template/src/post-detail-content.tsx +162 -0
  77. package/template/src/post-detail-query.ts +34 -0
  78. package/template/src/post-results.tsx +1 -1
  79. package/template/src/public-cache-policy.test.ts +11 -4
  80. package/template/src/public-cache-policy.ts +1 -1
  81. package/template/src/public-feedback-index.tsx +9 -39
  82. package/template/src/public-feedback-page.test.tsx +65 -0
  83. package/template/src/public-feedback-page.ts +12 -1
  84. package/template/src/public-post-detail-skeleton.tsx +54 -0
  85. package/template/src/public-post-detail.tsx +298 -208
  86. package/template/src/public-post-route.test.tsx +39 -3
  87. package/template/src/public-post-route.tsx +15 -28
  88. package/template/src/public-roadmap-route.test.tsx +38 -2
  89. package/template/src/public-roadmap-view.tsx +56 -23
  90. package/template/src/public-shell.test.tsx +26 -0
  91. package/template/src/routeTree.gen.ts +49 -3
  92. package/template/src/router.tsx +2 -0
  93. package/template/src/routes/__root.tsx +3 -5
  94. package/template/src/routes/email-preferences.tsx +77 -0
  95. package/template/src/routes/submit.tsx +1 -1
  96. package/template/src/safe-public-failure.ts +3 -1
  97. package/template/src/server.ts +41 -1
  98. package/template/src/trusted-feedback-handler.test.ts +1 -1
  99. package/template/src/vote-toggle.tsx +42 -9
@@ -0,0 +1,55 @@
1
+ import { requestNotion, type NotionRetryOptions } from "./notion-request";
2
+ import type { ImageAttachment } from "./images";
3
+ /** Private page-body links let Team Members review drafts without a second dashboard. */
4
+ export function createNotionImageReviewWriter(options: {
5
+ token: string;
6
+ reviewUrl(id: string): Promise<string>;
7
+ request?: typeof fetch;
8
+ retry?: NotionRetryOptions;
9
+ }) {
10
+ return async (postId: string, images: ImageAttachment[]) => {
11
+ const children = await Promise.all(
12
+ images.map(async (image) => ({
13
+ object: "block",
14
+ type: "paragraph",
15
+ paragraph: {
16
+ rich_text: [
17
+ {
18
+ type: "text",
19
+ text: {
20
+ content: "Review Participant image",
21
+ link: { url: await options.reviewUrl(image.id) },
22
+ },
23
+ },
24
+ ],
25
+ },
26
+ })),
27
+ );
28
+ const response = await requestNotion(
29
+ `https://api.notion.com/v1/blocks/${encodeURIComponent(postId)}/children`,
30
+ {
31
+ method: "PATCH",
32
+ headers: {
33
+ Authorization: `Bearer ${options.token}`,
34
+ "Notion-Version": "2025-09-03",
35
+ "Content-Type": "application/json",
36
+ },
37
+ body: JSON.stringify({ children }),
38
+ },
39
+ {
40
+ operation: "create",
41
+ retry: options.retry,
42
+ request: (input, init) =>
43
+ (options.request ?? fetch)(input, {
44
+ ...init,
45
+ signal: AbortSignal.timeout(10000),
46
+ }),
47
+ },
48
+ );
49
+ if (!response.ok) {
50
+ await response.body?.cancel();
51
+ throw new Error("Image review links could not be saved in Notion.");
52
+ }
53
+ await response.body?.cancel();
54
+ };
55
+ }
@@ -0,0 +1,653 @@
1
+ import { afterEach, expect, it } from "vitest";
2
+ import { Miniflare } from "miniflare";
3
+ import { existsSync } from "node:fs";
4
+ import { readFile } from "node:fs/promises";
5
+ import { createConnectedStore } from "./connected";
6
+ import { createFeedbackModule } from "./index";
7
+ import { createParticipationModule } from "./participation";
8
+ import type { StoredComment } from "./notion-comments";
9
+
10
+ let runtime: Miniflare | undefined;
11
+ afterEach(async () => {
12
+ const previous = runtime;
13
+ runtime = undefined;
14
+ await previous?.dispose();
15
+ });
16
+ const participant = { subject: "alice", displayName: "Alice" };
17
+
18
+ it("prepares migrated Follows without notifying about same-minute historical Comments", async () => {
19
+ const { database } = await setup(true);
20
+ const comments: StoredComment[] = [
21
+ "before-migration",
22
+ "after-preparation",
23
+ ].map((id) => ({
24
+ id,
25
+ discussionId: id,
26
+ postId: "public",
27
+ body: id,
28
+ author: { kind: "product-team", displayName: "Product Team" },
29
+ createdAt: new Date(60000),
30
+ resolved: false,
31
+ scope: "page",
32
+ }));
33
+ const snapshot = async (count: number) =>
34
+ createFeedbackModule({
35
+ initialItems: [
36
+ {
37
+ id: "public",
38
+ slug: "public",
39
+ title: "Public Post",
40
+ description: "Public",
41
+ type: "Feature Request",
42
+ status: "Planned",
43
+ published: true,
44
+ createdAt: new Date(1000),
45
+ updatedAt: new Date(60000),
46
+ },
47
+ ],
48
+ initialComments: comments.slice(0, count),
49
+ });
50
+ const follower = createParticipationModule({
51
+ database,
52
+ feedback: await snapshot(1),
53
+ participant,
54
+ now: () => 60020,
55
+ processing: "small",
56
+ });
57
+ expect(await follower.followState("public")).toEqual({
58
+ following: true,
59
+ initializing: true,
60
+ });
61
+ await follower.synchronize();
62
+ expect(await follower.followState("public")).toEqual({ following: true });
63
+ expect((await follower.inbox()).items).toHaveLength(0);
64
+ const worker = createParticipationModule({
65
+ database,
66
+ feedback: await snapshot(2),
67
+ now: () => 60030,
68
+ processing: "small",
69
+ });
70
+ await worker.synchronize();
71
+ expect((await follower.inbox()).items.map((item) => item.kind)).toEqual([
72
+ "comment",
73
+ ]);
74
+ });
75
+
76
+ it("prepares a longer Comment history across bounded runs before activating updates", async () => {
77
+ const { database } = await setup();
78
+ const comments: StoredComment[] = Array.from({ length: 52 }, (_, i) => ({
79
+ id: `c${i}`,
80
+ discussionId: `d${i}`,
81
+ postId: "history",
82
+ body: "History",
83
+ author: { kind: "product-team", displayName: "Product Team" },
84
+ createdAt: new Date(60000),
85
+ resolved: false,
86
+ scope: "page",
87
+ }));
88
+ const snapshot = (count: number) =>
89
+ createFeedbackModule({
90
+ initialItems: [
91
+ {
92
+ id: "history",
93
+ slug: "history",
94
+ title: "History",
95
+ description: "Public",
96
+ type: "Feature Request",
97
+ status: "Planned",
98
+ published: true,
99
+ createdAt: new Date(1000),
100
+ updatedAt: new Date(60000),
101
+ },
102
+ ],
103
+ initialComments: comments.slice(0, count),
104
+ });
105
+ const follower = createParticipationModule({
106
+ database,
107
+ feedback: snapshot(51),
108
+ participant,
109
+ now: () => 60010,
110
+ processing: "small",
111
+ });
112
+ expect(await follower.follow("history", true)).toEqual({
113
+ following: true,
114
+ initializing: true,
115
+ });
116
+ expect(await follower.followState("history")).toEqual({
117
+ following: true,
118
+ initializing: true,
119
+ });
120
+ const preparation = createParticipationModule({
121
+ database,
122
+ feedback: snapshot(51),
123
+ now: () => 60020,
124
+ processing: "small",
125
+ });
126
+ expect(await preparation.synchronize()).toEqual({ checked: 0, failed: 0 });
127
+ expect(await follower.followState("history")).toEqual({
128
+ following: true,
129
+ initializing: true,
130
+ });
131
+ await preparation.synchronize();
132
+ expect(await follower.followState("history")).toEqual({ following: true });
133
+ expect((await follower.inbox()).items).toHaveLength(0);
134
+ const worker = createParticipationModule({
135
+ database,
136
+ feedback: snapshot(52),
137
+ now: () => 60030,
138
+ processing: "small",
139
+ });
140
+ await worker.synchronize();
141
+ await worker.synchronize();
142
+ expect((await follower.inbox()).items.map((item) => item.kind)).toEqual([
143
+ "comment",
144
+ ]);
145
+ await follower.follow("history", false);
146
+ expect(await follower.follow("history", true)).toEqual({
147
+ following: true,
148
+ initializing: true,
149
+ });
150
+ await follower.follow("history", false);
151
+ expect(await preparation.synchronize()).toEqual({ checked: 0, failed: 0 });
152
+ expect(await follower.followState("history")).toEqual({ following: false });
153
+ });
154
+
155
+ it("distinguishes existing and new Comments within the same coarse Notion minute", async () => {
156
+ const { database } = await setup();
157
+ const comments: StoredComment[] = ["old", "new"].map((id) => ({
158
+ id,
159
+ discussionId: id,
160
+ postId: "precision",
161
+ body: id,
162
+ author: { kind: "product-team", displayName: "Product Team" },
163
+ createdAt: new Date(60000),
164
+ resolved: false,
165
+ scope: "page",
166
+ }));
167
+ const snapshot = (initialComments: StoredComment[]) =>
168
+ createFeedbackModule({
169
+ initialItems: [
170
+ {
171
+ id: "precision",
172
+ slug: "precision",
173
+ title: "Precision",
174
+ description: "Public",
175
+ type: "Feature Request",
176
+ status: "Planned",
177
+ published: true,
178
+ createdAt: new Date(1000),
179
+ updatedAt: new Date(60000),
180
+ },
181
+ ],
182
+ initialComments,
183
+ });
184
+ const follower = createParticipationModule({
185
+ database,
186
+ feedback: snapshot(comments.slice(0, 1)),
187
+ participant,
188
+ now: () => 60010,
189
+ });
190
+ await follower.follow("precision", true);
191
+ const worker = createParticipationModule({
192
+ database,
193
+ feedback: snapshot(comments),
194
+ now: () => 60030,
195
+ });
196
+ await createParticipationModule({
197
+ database,
198
+ feedback: snapshot(comments),
199
+ participant,
200
+ now: () => 60020,
201
+ }).follow("precision", true);
202
+ await worker.synchronize();
203
+ expect((await follower.inbox()).items.map((item) => item.kind)).toEqual([
204
+ "comment",
205
+ ]);
206
+ await worker.synchronize();
207
+ expect((await follower.inbox()).items).toHaveLength(1);
208
+ });
209
+
210
+ it("observes status transitions within Notion's timestamp precision without notifying a new follower about earlier changes", async () => {
211
+ const { database } = await setup();
212
+ const snapshot = (status: "Planned" | "In Progress") =>
213
+ createFeedbackModule({
214
+ initialItems: [
215
+ {
216
+ id: "precision",
217
+ slug: "precision",
218
+ title: "Status precision",
219
+ description: "Public",
220
+ type: "Feature Request",
221
+ status,
222
+ published: true,
223
+ createdAt: new Date(1000),
224
+ updatedAt: new Date(60000),
225
+ },
226
+ ],
227
+ });
228
+ const alice = createParticipationModule({
229
+ database,
230
+ feedback: snapshot("Planned"),
231
+ participant,
232
+ now: () => 60010,
233
+ });
234
+ await alice.follow("precision", true);
235
+ const bobIdentity = { subject: "bob", displayName: "Bob" };
236
+ await createConnectedStore(database).acceptIdentity({
237
+ ...bobIdentity,
238
+ nonce: "bob",
239
+ sessionHash: "bob",
240
+ expiresAt: 100000,
241
+ });
242
+ const bob = createParticipationModule({
243
+ database,
244
+ feedback: snapshot("In Progress"),
245
+ participant: bobIdentity,
246
+ now: () => 60020,
247
+ });
248
+ await bob.follow("precision", true);
249
+ let clock = 60030;
250
+ const worker = (status: "Planned" | "In Progress") =>
251
+ createParticipationModule({
252
+ database,
253
+ feedback: snapshot(status),
254
+ now: () => clock,
255
+ });
256
+ expect(await worker("In Progress").synchronize()).toEqual({
257
+ checked: 1,
258
+ failed: 0,
259
+ });
260
+ expect((await alice.inbox()).items).toHaveLength(1);
261
+ expect((await bob.inbox()).items).toHaveLength(0);
262
+ clock = 60040;
263
+ await worker("Planned").synchronize();
264
+ expect((await alice.inbox()).items).toHaveLength(2);
265
+ expect((await bob.inbox()).items).toHaveLength(1);
266
+ clock = 60050;
267
+ await worker("In Progress").synchronize();
268
+ expect((await alice.inbox()).items).toHaveLength(3);
269
+ expect((await bob.inbox()).items).toHaveLength(2);
270
+ });
271
+
272
+ it("small processing resumes one followed Post and one pending email per run", async () => {
273
+ const { database } = await setup();
274
+ const posts = (status: "Planned" | "In Progress", updatedAt: number) =>
275
+ createFeedbackModule({
276
+ initialItems: ["first", "second"].map((slug) => ({
277
+ id: slug,
278
+ slug,
279
+ title: slug,
280
+ description: "Public",
281
+ type: "Feature Request",
282
+ status,
283
+ published: true,
284
+ createdAt: new Date(1000),
285
+ updatedAt: new Date(updatedAt),
286
+ })),
287
+ });
288
+ await createConnectedStore(database).acceptIdentity({
289
+ ...participant,
290
+ email: "alice@example.com",
291
+ nonce: "email",
292
+ sessionHash: "email",
293
+ expiresAt: 100000,
294
+ });
295
+ const sent: string[] = [];
296
+ const email = {
297
+ sender: async (message: { subject: string }) => {
298
+ sent.push(message.subject);
299
+ },
300
+ from: "updates@example.com",
301
+ siteUrl: "https://feedback.example",
302
+ unsubscribeSecret: "test-unsubscribe-secret-at-least-32-characters",
303
+ };
304
+ const follower = createParticipationModule({
305
+ database,
306
+ feedback: posts("Planned", 1000),
307
+ participant,
308
+ now: () => 2000,
309
+ email,
310
+ });
311
+ await follower.follow("first", true);
312
+ await follower.follow("second", true);
313
+ await follower.setEmailPreference(true);
314
+ const worker = createParticipationModule({
315
+ database,
316
+ feedback: posts("In Progress", 3000),
317
+ participant,
318
+ now: () => 4000,
319
+ email,
320
+ processing: "small",
321
+ });
322
+ expect(await worker.synchronize()).toEqual({ checked: 1, failed: 0 });
323
+ expect((await worker.inbox()).items).toHaveLength(1);
324
+ expect(await worker.synchronize()).toEqual({ checked: 1, failed: 0 });
325
+ expect((await worker.inbox()).items).toHaveLength(2);
326
+ expect(await worker.deliverEmails()).toEqual({ sent: 1, failed: 0 });
327
+ expect(sent).toHaveLength(1);
328
+ expect(await worker.deliverEmails()).toEqual({ sent: 1, failed: 0 });
329
+ expect(sent).toHaveLength(2);
330
+ expect(await worker.deliverEmails()).toEqual({ sent: 0, failed: 0 });
331
+ });
332
+ async function setup(legacyFollow = false) {
333
+ runtime = new Miniflare({
334
+ modules: true,
335
+ script: "export default {fetch(){return new Response('ok')}}",
336
+ compatibilityDate: "2026-07-30",
337
+ d1Databases: ["DB"],
338
+ });
339
+ const database = await runtime.getD1Database("DB");
340
+ for (const name of ["0001_connected.sql", "0002_participation.sql"])
341
+ await database.exec(
342
+ await readFile(
343
+ `${existsSync("apps/portal/migrations") ? "apps/portal/migrations" : "migrations"}/${name}`,
344
+ "utf8",
345
+ ),
346
+ );
347
+ await createConnectedStore(database).acceptIdentity({
348
+ ...participant,
349
+ nonce: "a",
350
+ sessionHash: "a",
351
+ expiresAt: 100000,
352
+ });
353
+ if (legacyFollow)
354
+ await database.exec(
355
+ "INSERT INTO post_observations (post_slug,status,checked_at) VALUES ('public','Planned',60000); INSERT INTO post_follows (post_slug,subject,followed_at) VALUES ('public','alice',60010);",
356
+ );
357
+ await database.exec(
358
+ await readFile(
359
+ `${existsSync("apps/portal/migrations") ? "apps/portal/migrations" : "migrations"}/0004_followed_status.sql`,
360
+ "utf8",
361
+ ),
362
+ );
363
+ const feedback = createFeedbackModule({
364
+ initialItems: [
365
+ {
366
+ id: "public",
367
+ slug: "public",
368
+ title: "Public Post",
369
+ description: "Description",
370
+ type: "Feature Request",
371
+ status: "Planned",
372
+ published: true,
373
+ createdAt: new Date(1000),
374
+ updatedAt: new Date(1000),
375
+ },
376
+ {
377
+ id: "private",
378
+ slug: "private",
379
+ title: "Private Post",
380
+ description: "Private",
381
+ type: "Feature Request",
382
+ status: "New",
383
+ published: false,
384
+ createdAt: new Date(1000),
385
+ updatedAt: new Date(1000),
386
+ },
387
+ ],
388
+ });
389
+ return { database, feedback };
390
+ }
391
+ it("follows a Published Post once across browsers and rejects private Posts or unsigned Participants", async () => {
392
+ const options = await setup();
393
+ const first = createParticipationModule({
394
+ ...options,
395
+ participant,
396
+ now: () => 2000,
397
+ });
398
+ await Promise.all([
399
+ first.follow("public", true),
400
+ first.follow("public", true),
401
+ ]);
402
+ const second = createParticipationModule({ ...options, participant });
403
+ expect(await second.followState("public")).toEqual({ following: true });
404
+ await expect(first.follow("private", true)).rejects.toThrow("unavailable");
405
+ await expect(
406
+ createParticipationModule(options).follow("public", true),
407
+ ).rejects.toThrow("Sign in");
408
+ await second.follow("public", false);
409
+ expect(await first.followState("public")).toEqual({ following: false });
410
+ expect(await first.inbox()).toEqual({ items: [], unreadCount: 0 });
411
+ });
412
+
413
+ it("notifies only followers about later status changes and Comments, deduplicates synchronization, and keeps read state private", async () => {
414
+ const options = await setup();
415
+ const alice = createParticipationModule({
416
+ ...options,
417
+ participant,
418
+ now: () => 2000,
419
+ });
420
+ await alice.follow("public", true);
421
+ const changed = createFeedbackModule({
422
+ initialItems: [
423
+ {
424
+ id: "public",
425
+ slug: "public",
426
+ title: "Public Post",
427
+ description: "Description",
428
+ type: "Feature Request",
429
+ status: "Shipped",
430
+ published: true,
431
+ createdAt: new Date(1000),
432
+ updatedAt: new Date(3000),
433
+ },
434
+ ],
435
+ });
436
+ const sync = createParticipationModule({
437
+ ...options,
438
+ feedback: changed,
439
+ now: () => 4000,
440
+ });
441
+ await Promise.all([sync.synchronize(), sync.synchronize()]);
442
+ await sync.synchronize();
443
+ const inbox = await alice.inbox();
444
+ expect(inbox.items).toHaveLength(1);
445
+ expect(inbox.items[0]).toMatchObject({
446
+ slug: "public",
447
+ kind: "status",
448
+ message: "Status changed to Shipped.",
449
+ read: false,
450
+ });
451
+ expect(inbox.unreadCount).toBe(1);
452
+ expect(JSON.stringify(inbox)).not.toContain("alice");
453
+ const bob = createParticipationModule({
454
+ ...options,
455
+ participant: { subject: "bob", displayName: "Bob" },
456
+ });
457
+ expect(await bob.inbox()).toEqual({ items: [], unreadCount: 0 });
458
+ await bob.markRead(inbox.items[0]!.id);
459
+ expect((await alice.inbox()).unreadCount).toBe(1);
460
+ await alice.markRead(inbox.items[0]!.id);
461
+ expect((await alice.inbox()).unreadCount).toBe(0);
462
+ await changed.createComment({
463
+ slug: "public",
464
+ body: "Now available",
465
+ displayName: "Team",
466
+ });
467
+ await sync.synchronize();
468
+ expect((await alice.inbox()).items.map((item) => item.kind)).toEqual([
469
+ "comment",
470
+ "status",
471
+ ]);
472
+ const hidden = createParticipationModule({
473
+ ...options,
474
+ feedback: createFeedbackModule(),
475
+ participant,
476
+ });
477
+ expect(await hidden.inbox()).toEqual({ items: [], unreadCount: 0 });
478
+ });
479
+
480
+ it("emails only verified opted-in followers, retries with a stable delivery key, and honors unsubscribe", async () => {
481
+ const options = await setup();
482
+ const { createEmailSender } = await import("./email-delivery");
483
+ const calls: Request[] = [];
484
+ let fail = true;
485
+ const sender = createEmailSender({
486
+ provider: "resend",
487
+ token: "private-key",
488
+ request: async (input, init) => {
489
+ calls.push(new Request(input, init));
490
+ return new Response(
491
+ fail ? "unavailable" : JSON.stringify({ id: "sent" }),
492
+ { status: fail ? 503 : 200 },
493
+ );
494
+ },
495
+ });
496
+ let clock = 2000;
497
+ const email = {
498
+ sender,
499
+ from: "updates@example.com",
500
+ siteUrl: "https://feedback.example.com",
501
+ unsubscribeSecret: "x".repeat(32),
502
+ };
503
+ const alice = createParticipationModule({
504
+ ...options,
505
+ participant,
506
+ now: () => clock,
507
+ email,
508
+ });
509
+ await expect(alice.setEmailPreference(true)).rejects.toThrow("verified");
510
+ await createConnectedStore(options.database).acceptIdentity({
511
+ ...participant,
512
+ email: "alice@example.com",
513
+ nonce: "email-identity",
514
+ sessionHash: "email-identity",
515
+ expiresAt: 100000,
516
+ });
517
+ await alice.setEmailPreference(true);
518
+ await alice.follow("public", true);
519
+ const changed = createFeedbackModule({
520
+ initialItems: [
521
+ {
522
+ id: "public",
523
+ slug: "public",
524
+ title: "Public Post",
525
+ description: "Description",
526
+ type: "Feature Request",
527
+ status: "Shipped",
528
+ published: true,
529
+ createdAt: new Date(1000),
530
+ updatedAt: new Date(3000),
531
+ },
532
+ ],
533
+ });
534
+ const sync = createParticipationModule({
535
+ ...options,
536
+ feedback: changed,
537
+ now: () => clock,
538
+ email,
539
+ });
540
+ clock = 4000;
541
+ await sync.synchronize();
542
+ expect(await sync.deliverEmails()).toEqual({ sent: 0, failed: 1 });
543
+ fail = false;
544
+ clock += 61000;
545
+ expect(await sync.deliverEmails()).toEqual({ sent: 1, failed: 0 });
546
+ expect(calls[0]!.headers.get("Idempotency-Key")).toBe(
547
+ calls[1]!.headers.get("Idempotency-Key"),
548
+ );
549
+ expect(calls[1]!.headers.get("Authorization")).toBe("Bearer private-key");
550
+ const message = JSON.parse(await calls[1]!.text());
551
+ expect(message.to).toEqual(["alice@example.com"]);
552
+ const unsubscribe = new URL(message.headers["List-Unsubscribe"].slice(1, -1));
553
+ await sync.unsubscribe(unsubscribe.searchParams.get("token")!);
554
+ expect(await alice.preferences()).toEqual({
555
+ emailAvailable: true,
556
+ emailEnabled: false,
557
+ });
558
+ expect(await sync.deliverEmails()).toEqual({ sent: 0, failed: 0 });
559
+ expect(JSON.stringify(await alice.inbox())).not.toContain(
560
+ "alice@example.com",
561
+ );
562
+ });
563
+
564
+ it("rolls back an event and its notifications if the observation checkpoint fails", async () => {
565
+ const options = await setup();
566
+ const alice = createParticipationModule({
567
+ ...options,
568
+ participant,
569
+ now: () => 2000,
570
+ });
571
+ await alice.follow("public", true);
572
+ await options.database.exec(
573
+ "CREATE TRIGGER reject_checkpoint BEFORE UPDATE OF status ON post_observations WHEN NEW.status='Shipped' BEGIN SELECT RAISE(ABORT, 'checkpoint unavailable'); END;",
574
+ );
575
+ const changed = createFeedbackModule({
576
+ initialItems: [
577
+ {
578
+ id: "public",
579
+ slug: "public",
580
+ title: "Public Post",
581
+ description: "Description",
582
+ type: "Feature Request",
583
+ status: "Shipped",
584
+ published: true,
585
+ createdAt: new Date(1000),
586
+ updatedAt: new Date(3000),
587
+ },
588
+ ],
589
+ });
590
+ const sync = createParticipationModule({
591
+ ...options,
592
+ feedback: changed,
593
+ now: () => 4000,
594
+ });
595
+ expect((await sync.synchronize()).failed).toBe(1);
596
+ expect((await alice.inbox()).items).toHaveLength(0);
597
+ await options.database.exec("DROP TRIGGER reject_checkpoint;");
598
+ await sync.synchronize();
599
+ await sync.synchronize();
600
+ expect((await alice.inbox()).items).toHaveLength(1);
601
+ const hidden = createParticipationModule({
602
+ ...options,
603
+ feedback: createFeedbackModule(),
604
+ participant,
605
+ });
606
+ await hidden.follow("public", false);
607
+ expect(await alice.followState("public")).toEqual({ following: false });
608
+ });
609
+
610
+ it("supports Cloudflare structured sending and an authenticated custom HTTPS adapter without following redirects", async () => {
611
+ const { createEmailSender } = await import("./email-delivery");
612
+ const message = {
613
+ from: "updates@example.com",
614
+ to: ["recipient@example.com"],
615
+ subject: "An update",
616
+ text: "A Post changed.",
617
+ headers: {
618
+ "List-Unsubscribe": "<https://feedback.example.com/unsubscribe>",
619
+ },
620
+ };
621
+ let accepted: unknown;
622
+ await createEmailSender({
623
+ provider: "cloudflare",
624
+ binding: {
625
+ send: async (value) => {
626
+ accepted = value;
627
+ },
628
+ },
629
+ })(message, "delivery-1");
630
+ expect(accepted).toEqual(message);
631
+ let custom: Request | undefined;
632
+ await createEmailSender({
633
+ provider: "custom",
634
+ url: "https://app.example.com/email",
635
+ token: "private-adapter-key",
636
+ request: async (input, init) => {
637
+ custom = new Request(input, init);
638
+ return new Response(null, { status: 202 });
639
+ },
640
+ })(message, "delivery-2");
641
+ expect(custom?.headers.get("Idempotency-Key")).toBe("delivery-2");
642
+ expect(custom?.redirect).toBe("manual");
643
+ expect(custom?.headers.get("Authorization")).toBe(
644
+ "Bearer private-adapter-key",
645
+ );
646
+ expect(() =>
647
+ createEmailSender({
648
+ provider: "custom",
649
+ url: "http://app.example.com/email",
650
+ token: "x",
651
+ }),
652
+ ).toThrow("HTTPS");
653
+ });