create-feedbax 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.
Files changed (143) hide show
  1. package/README.md +17 -5
  2. package/dist/cli.js +2610 -291
  3. package/dist/index.js +2375 -200
  4. package/package.json +7 -7
  5. package/template/.feedbax-template.sha256 +1 -1
  6. package/template/.gitignore.template +1 -0
  7. package/template/.npmignore.template +1 -0
  8. package/template/.nvmrc +1 -0
  9. package/template/AGENTS.md +13 -0
  10. package/template/CONNECTED.md +418 -0
  11. package/template/README.md +97 -0
  12. package/template/components.json +1 -1
  13. package/template/eslint.config.mjs +23 -0
  14. package/template/feedbax-vite.ts +79 -0
  15. package/template/migrations/0001_connected.sql +6 -0
  16. package/template/migrations/0002_participation.sql +8 -0
  17. package/template/migrations/0003_images.sql +3 -0
  18. package/template/migrations/0004_followed_status.sql +9 -0
  19. package/template/migrations/0005_github.sql +4 -0
  20. package/template/migrations/0006_integration.sql +3 -0
  21. package/template/migrations/0007_ownership.sql +2 -0
  22. package/template/migrations/0008_integration_recovery.sql +5 -0
  23. package/template/migrations/0009_observed_history.sql +3 -0
  24. package/template/package.json +39 -34
  25. package/template/src/authorized-draft-post.tsx +1 -0
  26. package/template/src/changelog-page.tsx +63 -54
  27. package/template/src/changelog-runtime.ts +2 -1
  28. package/template/src/cloudflare-workers.d.ts +9 -0
  29. package/template/src/comment-creation.test.tsx +95 -7
  30. package/template/src/comment-thread-page.ts +11 -1
  31. package/template/src/components/command-palette.tsx +59 -30
  32. package/template/src/components/connected-identity-provider.tsx +135 -0
  33. package/template/src/components/device-profile-provider.tsx +41 -26
  34. package/template/src/components/follow-post-button.tsx +78 -0
  35. package/template/src/components/header.tsx +2 -0
  36. package/template/src/components/image-attachments.tsx +187 -0
  37. package/template/src/components/loader.tsx +48 -5
  38. package/template/src/components/notification-inbox.tsx +232 -0
  39. package/template/src/components/portal-footer.tsx +14 -0
  40. package/template/src/components/post-history.tsx +101 -0
  41. package/template/src/components/profile-menu.tsx +37 -0
  42. package/template/src/components/ui/components/command.tsx +30 -29
  43. package/template/src/components/ui/components/message-scroller.tsx +2 -2
  44. package/template/src/components/ui/components/scroll-area.tsx +0 -1
  45. package/template/src/components/ui/components/switch.tsx +29 -0
  46. package/template/src/components/ui/styles/globals.css +1 -1
  47. package/template/src/configured-feedback.test.ts +82 -0
  48. package/template/src/configured-feedback.ts +61 -0
  49. package/template/src/connected-identity.test.ts +148 -0
  50. package/template/src/connected-identity.ts +127 -0
  51. package/template/src/connected-participation.test.tsx +67 -0
  52. package/template/src/connected-runtime.test.ts +244 -0
  53. package/template/src/connected-runtime.ts +176 -0
  54. package/template/src/connected-social.test.tsx +158 -0
  55. package/template/src/feedback-cache-invalidation.ts +3 -0
  56. package/template/src/feedback-runtime.ts +123 -13
  57. package/template/src/feedbax/changelog/index.test.ts +35 -0
  58. package/template/src/feedbax/changelog/index.ts +5 -1
  59. package/template/src/feedbax/changelog/notion-changelog.test.ts +1 -1
  60. package/template/src/feedbax/changelog/notion-changelog.ts +1 -1
  61. package/template/src/feedbax/config/index.test.ts +73 -1
  62. package/template/src/feedbax/config/index.ts +205 -9
  63. package/template/src/feedbax/feedback/comment-storage.ts +38 -0
  64. package/template/src/feedbax/feedback/connected-feedback.ts +235 -0
  65. package/template/src/feedbax/feedback/connected.test.ts +272 -0
  66. package/template/src/feedbax/feedback/connected.ts +225 -0
  67. package/template/src/feedbax/feedback/email-delivery.test.ts +65 -0
  68. package/template/src/feedbax/feedback/email-delivery.ts +66 -0
  69. package/template/src/feedbax/feedback/feedback-storage.ts +1 -0
  70. package/template/src/feedbax/feedback/github-comments.ts +390 -0
  71. package/template/src/feedbax/feedback/github-feedback.test.ts +1138 -0
  72. package/template/src/feedbax/feedback/github-feedback.ts +581 -0
  73. package/template/src/feedbax/feedback/github-request.ts +120 -0
  74. package/template/src/feedbax/feedback/github-service-client.ts +56 -0
  75. package/template/src/feedbax/feedback/identity-handoff.test.ts +81 -0
  76. package/template/src/feedbax/feedback/identity-handoff.ts +119 -0
  77. package/template/src/feedbax/feedback/image-feedback.ts +114 -0
  78. package/template/src/feedbax/feedback/images.test.ts +223 -0
  79. package/template/src/feedbax/feedback/images.ts +350 -0
  80. package/template/src/feedbax/feedback/index.ts +226 -16
  81. package/template/src/feedbax/feedback/integration-auth.ts +138 -0
  82. package/template/src/feedbax/feedback/notion-comments.ts +2 -35
  83. package/template/src/feedbax/feedback/notion-image-review.ts +55 -0
  84. package/template/src/feedbax/feedback/observed-history.test.ts +150 -0
  85. package/template/src/feedbax/feedback/observed-history.ts +219 -0
  86. package/template/src/feedbax/feedback/ownership.ts +170 -0
  87. package/template/src/feedbax/feedback/participation.test.ts +653 -0
  88. package/template/src/feedbax/feedback/participation.ts +502 -0
  89. package/template/src/history-runtime.test.ts +80 -0
  90. package/template/src/history-runtime.ts +37 -0
  91. package/template/src/images-runtime.test.ts +133 -0
  92. package/template/src/images-runtime.ts +125 -0
  93. package/template/src/index.css +103 -440
  94. package/template/src/integration-runtime.test.ts +365 -0
  95. package/template/src/integration-runtime.ts +289 -0
  96. package/template/src/lazy-post-detail-overlay.tsx +13 -2
  97. package/template/src/my-feedback-page.test.tsx +152 -0
  98. package/template/src/my-feedback-page.tsx +398 -0
  99. package/template/src/my-feedback-runtime.test.ts +93 -0
  100. package/template/src/my-feedback-runtime.ts +154 -0
  101. package/template/src/overlay-post-detail.test.tsx +59 -8
  102. package/template/src/overlay-post-detail.tsx +20 -69
  103. package/template/src/participation-client.ts +63 -0
  104. package/template/src/participation-runtime.test.ts +35 -0
  105. package/template/src/participation-runtime.ts +255 -0
  106. package/template/src/portal-comments.ts +35 -5
  107. package/template/src/portal-feedback-form.tsx +29 -6
  108. package/template/src/portal-feedback-runtime.ts +3 -0
  109. package/template/src/portal-feedback-server-function.ts +6 -3
  110. package/template/src/portal-feedback-submission.ts +19 -0
  111. package/template/src/portal-server-entry.ts +13 -0
  112. package/template/src/post-creation-flow.tsx +2 -2
  113. package/template/src/post-detail-content.test.tsx +233 -0
  114. package/template/src/post-detail-content.tsx +162 -0
  115. package/template/src/post-detail-query.ts +34 -0
  116. package/template/src/post-results.tsx +1 -1
  117. package/template/src/public-cache-policy.test.ts +11 -4
  118. package/template/src/public-cache-policy.ts +1 -1
  119. package/template/src/public-feedback-index.tsx +9 -39
  120. package/template/src/public-feedback-page.test.tsx +65 -0
  121. package/template/src/public-feedback-page.ts +12 -1
  122. package/template/src/public-portal-error.tsx +1 -1
  123. package/template/src/public-post-detail-skeleton.tsx +54 -0
  124. package/template/src/public-post-detail.tsx +437 -271
  125. package/template/src/public-post-route.test.tsx +39 -3
  126. package/template/src/public-post-route.tsx +15 -28
  127. package/template/src/public-release-links.test.ts +120 -0
  128. package/template/src/public-release-links.ts +130 -0
  129. package/template/src/public-roadmap-route.test.tsx +38 -2
  130. package/template/src/public-roadmap-view.tsx +56 -23
  131. package/template/src/public-shell.test.tsx +26 -0
  132. package/template/src/routeTree.gen.ts +70 -3
  133. package/template/src/router.tsx +2 -0
  134. package/template/src/routes/__root.tsx +3 -5
  135. package/template/src/routes/email-preferences.tsx +77 -0
  136. package/template/src/routes/my-feedback.tsx +16 -0
  137. package/template/src/routes/submit.tsx +1 -1
  138. package/template/src/safe-public-failure.ts +3 -1
  139. package/template/src/server.ts +87 -2
  140. package/template/src/submission-intent.ts +26 -0
  141. package/template/src/trusted-feedback-handler.test.ts +1 -1
  142. package/template/src/vote-toggle.tsx +42 -9
  143. package/template/vite.config.ts +2 -0
@@ -0,0 +1,244 @@
1
+ import { issueParticipationPass } from "./portal-voting";
2
+ import { expect, it } from "vitest";
3
+ import { Miniflare } from "miniflare";
4
+ import { existsSync } from "node:fs";
5
+ import { readFile } from "node:fs/promises";
6
+ import {
7
+ createConnectedStore,
8
+ createConnectedFeedbackModule,
9
+ createFeedbackModule,
10
+ } from "@feedbax/feedback";
11
+ import { createConnectedRequestHandler } from "./connected-runtime";
12
+ import { sessionHash } from "./connected-identity";
13
+ import feedbax from "./feedbax";
14
+
15
+ it("requires a valid session and same-origin mutations, and ignores browser-supplied Comment identity", async () => {
16
+ const runtime = new Miniflare({
17
+ modules: true,
18
+ script: "export default {fetch(){return new Response('ok')}}",
19
+ compatibilityDate: "2026-07-30",
20
+ d1Databases: ["DB"],
21
+ });
22
+ try {
23
+ const db = await runtime.getD1Database("DB");
24
+ await db.exec(
25
+ (await readFile(
26
+ existsSync("apps/portal/migrations/0001_connected.sql")
27
+ ? "apps/portal/migrations/0001_connected.sql"
28
+ : "migrations/0001_connected.sql",
29
+ "utf8",
30
+ )) +
31
+ "\n" +
32
+ (await readFile(
33
+ existsSync("apps/portal/migrations/0002_participation.sql")
34
+ ? "apps/portal/migrations/0002_participation.sql"
35
+ : "migrations/0002_participation.sql",
36
+ "utf8",
37
+ )),
38
+ );
39
+ const store = createConnectedStore(db);
40
+ const participant = { subject: "alice", displayName: "Alice" };
41
+ await store.acceptIdentity({
42
+ ...participant,
43
+ nonce: "nonce",
44
+ sessionHash: sessionHash("opaque-token"),
45
+ expiresAt: Date.now() + 60000,
46
+ });
47
+ const config = {
48
+ ...feedbax,
49
+ product: { ...feedbax.product, siteUrl: "https://feedback.example" },
50
+ connected: {
51
+ issuer: "https://app.example",
52
+ loginUrl: "https://app.example/login",
53
+ },
54
+ };
55
+ const base = createFeedbackModule({
56
+ initialItems: [
57
+ {
58
+ id: "post",
59
+ slug: "post",
60
+ title: "Post",
61
+ description: "Public",
62
+ type: "Feature Request",
63
+ status: "Planned",
64
+ published: true,
65
+ createdAt: new Date(),
66
+ updatedAt: new Date(),
67
+ voteCount: 99,
68
+ },
69
+ ],
70
+ });
71
+ const options = {
72
+ config,
73
+ environment: {
74
+ PORTAL_SUBMISSIONS_RATE_LIMITER: {
75
+ limit: async () => ({ success: true }),
76
+ },
77
+ PARTICIPATION_SIGNING_SECRET:
78
+ "test-participation-secret-at-least-32-characters",
79
+ },
80
+ getStore: () => store,
81
+ getFeedback: (identity: typeof participant) =>
82
+ createConnectedFeedbackModule(base, store, identity),
83
+ };
84
+ const handle = createConnectedRequestHandler(options);
85
+ const request = (
86
+ path: string,
87
+ body: unknown,
88
+ cookie = "__Host-feedbax-session=opaque-token",
89
+ origin = "https://feedback.example",
90
+ ) =>
91
+ new Request(`https://feedback.example${path}`, {
92
+ method: "POST",
93
+ headers: {
94
+ "Content-Type": "application/json",
95
+ Cookie: cookie,
96
+ Origin: origin,
97
+ },
98
+ body: JSON.stringify(body),
99
+ });
100
+ expect(
101
+ (
102
+ await handle(
103
+ request("/internal/votes", { slug: "post", intention: "add" }, ""),
104
+ )
105
+ )?.status,
106
+ ).toBe(401);
107
+ expect(
108
+ (
109
+ await handle(
110
+ request(
111
+ "/internal/votes",
112
+ { slug: "post", intention: "add" },
113
+ undefined,
114
+ "https://evil.example",
115
+ ),
116
+ )
117
+ )?.status,
118
+ ).toBe(403);
119
+ for (let i = 0; i < 2; i++)
120
+ expect(
121
+ await (
122
+ await handle(
123
+ request("/internal/votes", { slug: "post", intention: "add" }),
124
+ )
125
+ )?.json(),
126
+ ).toMatchObject({ voteCount: 1 });
127
+ const state = await handle(
128
+ new Request(
129
+ "https://feedback.example/internal/connected/votes?slug=post",
130
+ { headers: { Cookie: "__Host-feedbax-session=opaque-token" } },
131
+ ),
132
+ );
133
+ expect(state?.headers.get("cache-control")).toBe("no-store");
134
+ expect(await state?.json()).toEqual({ voteCount: 1, voted: true });
135
+ const comment = await handle(
136
+ request("/internal/comments", {
137
+ slug: "post",
138
+ body: "Hello",
139
+ displayName: "Fake Admin",
140
+ email: "private@example.com",
141
+ subject: "someone-else",
142
+ }),
143
+ );
144
+ expect(comment?.status).toBe(200);
145
+ expect(comment?.headers.get("cache-control")).toBe("no-store");
146
+ const result = await comment?.json();
147
+ expect(result.comment.author).toEqual({
148
+ kind: "participant",
149
+ displayName: "Alice",
150
+ verified: true,
151
+ });
152
+ expect(JSON.stringify(result)).not.toContain("private@example.com");
153
+ expect(JSON.stringify(result)).not.toContain("someone-else");
154
+ const disabled = createConnectedRequestHandler({
155
+ ...options,
156
+ config: {
157
+ ...config,
158
+ features: { ...config.features, comments: false, voting: false },
159
+ },
160
+ });
161
+ expect(
162
+ (
163
+ await disabled(
164
+ request("/internal/comments", { slug: "post", body: "No" }),
165
+ )
166
+ )?.status,
167
+ ).toBe(400);
168
+ expect(
169
+ (
170
+ await disabled(
171
+ request("/internal/votes", { slug: "post", intention: "add" }),
172
+ )
173
+ )?.status,
174
+ ).not.toBe(200);
175
+ const verified = createConnectedRequestHandler({
176
+ ...options,
177
+ config: {
178
+ ...config,
179
+ turnstile: { enabled: true, siteKey: "test-site-key" },
180
+ },
181
+ environment: {
182
+ ...options.environment,
183
+ TURNSTILE_SECRET_KEY: "test-secret",
184
+ },
185
+ });
186
+ const expiredPass = issueParticipationPass(
187
+ options.environment.PARTICIPATION_SIGNING_SECRET,
188
+ Date.now() - 31 * 60 * 1000,
189
+ );
190
+ const expiredResponse = await verified(
191
+ request("/internal/votes", {
192
+ slug: "post",
193
+ intention: "add",
194
+ participationPass: expiredPass,
195
+ }),
196
+ );
197
+ expect(expiredResponse?.status).toBe(400);
198
+ expect(await expiredResponse?.json()).toMatchObject({
199
+ code: "verification_required",
200
+ });
201
+ const limited = createConnectedRequestHandler({
202
+ ...options,
203
+ environment: {
204
+ ...options.environment,
205
+ IDENTITY_HANDOFF_SECRET: "test-identity-secret-at-least-32-characters",
206
+ PORTAL_SUBMISSIONS_RATE_LIMITER: {
207
+ limit: async () => ({ success: false }),
208
+ },
209
+ },
210
+ });
211
+ const limitResponse = await limited(
212
+ request("/internal/votes", { slug: "post", intention: "add" }),
213
+ );
214
+ expect(limitResponse?.status).toBe(429);
215
+ expect(await limitResponse?.json()).toMatchObject({ code: "rate_limited" });
216
+ const sessionRead = await limited(
217
+ new Request("https://feedback.example/internal/identity/session", {
218
+ headers: { Cookie: "__Host-feedbax-session=opaque-token" },
219
+ }),
220
+ );
221
+ expect(sessionRead?.status).toBe(200);
222
+ expect(await sessionRead?.json()).toMatchObject({
223
+ participant: {
224
+ displayName: "Alice",
225
+ sessionKey: expect.stringMatching(/^[A-Za-z0-9_-]{43}$/),
226
+ },
227
+ });
228
+ const unavailable = createConnectedRequestHandler({
229
+ ...options,
230
+ getStore: () => {
231
+ throw new Error("D1 unavailable");
232
+ },
233
+ });
234
+ expect(
235
+ (
236
+ await unavailable(
237
+ request("/internal/votes", { slug: "post", intention: "add" }),
238
+ )
239
+ )?.status,
240
+ ).toBe(503);
241
+ } finally {
242
+ await runtime.dispose();
243
+ }
244
+ });
@@ -0,0 +1,176 @@
1
+ import { ActionablePortalFailure } from "./safe-public-failure";
2
+ import { VoteEligibilityError } from "@feedbax/feedback";
3
+ import type { FeedbaxConfig } from "@feedbax/config";
4
+ import type {
5
+ ConnectedStore,
6
+ FeedbackModule,
7
+ VerifiedParticipant,
8
+ } from "@feedbax/feedback";
9
+ import { env } from "cloudflare:workers";
10
+ import feedbax from "./feedbax";
11
+ import {
12
+ createConfiguredFeedbackModule,
13
+ getConnectedStore,
14
+ } from "./feedback-runtime";
15
+ import {
16
+ createConnectedIdentityHandler,
17
+ readSessionToken,
18
+ sameOrigin,
19
+ sessionHash,
20
+ } from "./connected-identity";
21
+ import { createPortalVoteHandler } from "./portal-voting";
22
+ import {
23
+ createPortalCommentRequestHandler,
24
+ createPortalCommentMutationRequestHandler,
25
+ } from "./portal-comments";
26
+ import { createPortalSubmissionSecurity } from "./portal-feedback-runtime";
27
+
28
+ type ConnectedEnvironment = Parameters<
29
+ typeof createPortalSubmissionSecurity
30
+ >[0]["environment"] & {
31
+ IDENTITY_HANDOFF_SECRET?: string;
32
+ PARTICIPATION_SIGNING_SECRET?: string;
33
+ };
34
+ export function createConnectedRequestHandler(options: {
35
+ config: FeedbaxConfig;
36
+ environment: ConnectedEnvironment;
37
+ getStore: () => ConnectedStore;
38
+ getFeedback: (participant: VerifiedParticipant) => FeedbackModule;
39
+ }) {
40
+ const { config: feedbax, environment: env } = options;
41
+ return async (request: Request): Promise<Response | undefined> => {
42
+ const url = new URL(request.url);
43
+ const identity = url.pathname.startsWith("/internal/identity/");
44
+ const voteState = url.pathname === "/internal/connected/votes";
45
+ const mutation =
46
+ url.pathname === "/internal/votes" ||
47
+ url.pathname === "/internal/comments";
48
+ if (!identity && !voteState && !mutation) return;
49
+ const json = (value: unknown, status = 200) =>
50
+ Response.json(value, {
51
+ status,
52
+ headers: { "Cache-Control": "no-store" },
53
+ });
54
+ if (!feedbax.connected)
55
+ return identity || voteState
56
+ ? json({ error: "Not found." }, 404)
57
+ : undefined;
58
+ try {
59
+ const store = options.getStore();
60
+ const siteUrl = feedbax.product.siteUrl!;
61
+ if (identity) {
62
+ if (
63
+ url.pathname !== "/internal/identity/session" &&
64
+ !(
65
+ await env.PORTAL_SUBMISSIONS_RATE_LIMITER.limit({
66
+ key: `identity:${url.pathname}:${request.headers.get("CF-Connecting-IP") ?? "unknown"}`,
67
+ })
68
+ ).success
69
+ )
70
+ return json(
71
+ { error: "Too many sign-in attempts. Try again shortly." },
72
+ 429,
73
+ );
74
+ if (
75
+ !env.IDENTITY_HANDOFF_SECRET ||
76
+ env.IDENTITY_HANDOFF_SECRET.length < 32
77
+ )
78
+ throw new Error("Missing identity secret.");
79
+ return createConnectedIdentityHandler({
80
+ ...feedbax.connected,
81
+ siteUrl,
82
+ secret: env.IDENTITY_HANDOFF_SECRET,
83
+ store,
84
+ })(request);
85
+ }
86
+ if (mutation && !sameOrigin(request, siteUrl))
87
+ return json({ error: "Request origin is invalid." }, 403);
88
+ if (mutation && !["POST", "PATCH", "DELETE"].includes(request.method))
89
+ return json({ error: "Method not allowed." }, 405);
90
+ const token = readSessionToken(request);
91
+ const participant = token
92
+ ? await store.session(sessionHash(token))
93
+ : undefined;
94
+ if (!participant)
95
+ return json(
96
+ {
97
+ error: "Sign in through the product to participate.",
98
+ code: "sign_in_required",
99
+ },
100
+ 401,
101
+ );
102
+ const feedback = options.getFeedback(participant);
103
+ if (voteState && request.method === "GET") {
104
+ const slug = url.searchParams.get("slug") ?? "";
105
+ const post = await feedback.getPublicPost(slug);
106
+ if (!feedbax.features.voting || !post)
107
+ return json({ error: "Post is unavailable." }, 404);
108
+ return json(await store.voteState(slug, participant.subject));
109
+ }
110
+ const security = createPortalSubmissionSecurity({
111
+ turnstile: feedbax.turnstile,
112
+ environment: env,
113
+ rateLimitKey:
114
+ request.headers.get("CF-Connecting-IP") ?? "unknown-participant-ip",
115
+ });
116
+ const mutationOptions = {
117
+ feedback,
118
+ ...security,
119
+ participationSigningSecret: env.PARTICIPATION_SIGNING_SECRET,
120
+ };
121
+ if (url.pathname === "/internal/votes" && request.method === "POST")
122
+ return json(
123
+ await createPortalVoteHandler({
124
+ ...mutationOptions,
125
+ votingEnabled: feedbax.features.voting,
126
+ })(await request.json()),
127
+ );
128
+ if (url.pathname === "/internal/comments" && request.method === "POST")
129
+ return noStore(
130
+ createPortalCommentRequestHandler({
131
+ ...mutationOptions,
132
+ verifiedParticipant: participant,
133
+ commentsEnabled: feedbax.features.comments,
134
+ })(request),
135
+ );
136
+ if (
137
+ url.pathname === "/internal/comments" &&
138
+ (request.method === "PATCH" || request.method === "DELETE")
139
+ )
140
+ return noStore(
141
+ createPortalCommentMutationRequestHandler({
142
+ ...mutationOptions,
143
+ commentsEnabled: feedbax.features.comments,
144
+ })(request),
145
+ );
146
+ return json({ error: "Method not allowed." }, 405);
147
+ } catch (error) {
148
+ if (error instanceof ActionablePortalFailure)
149
+ return json(
150
+ { error: error.message, code: error.code ?? "action_failed" },
151
+ error.code === "rate_limited" ? 429 : 400,
152
+ );
153
+ if (error instanceof VoteEligibilityError)
154
+ return json({ error: error.message, code: "ineligible_post" }, 400);
155
+ return json(
156
+ {
157
+ error:
158
+ "Connected participation is temporarily unavailable. Try again.",
159
+ },
160
+ 503,
161
+ );
162
+ }
163
+ };
164
+ }
165
+ export const handleConnectedRequest = createConnectedRequestHandler({
166
+ config: feedbax,
167
+ environment: env,
168
+ getStore: getConnectedStore,
169
+ getFeedback: createConfiguredFeedbackModule,
170
+ });
171
+
172
+ async function noStore(response: Promise<Response>) {
173
+ const result = await response;
174
+ result.headers.set("Cache-Control", "no-store");
175
+ return result;
176
+ }
@@ -0,0 +1,158 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, expect, it, vi } from "vitest";
3
+ import {
4
+ cleanup,
5
+ fireEvent,
6
+ render,
7
+ screen,
8
+ waitFor,
9
+ } from "@testing-library/react";
10
+ import { useState } from "react";
11
+ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
12
+ import { ConnectedIdentityProvider } from "./components/connected-identity-provider";
13
+ import { FollowPostButton } from "./components/follow-post-button";
14
+ import { NotificationInbox } from "./components/notification-inbox";
15
+ import { ImageAttachments } from "./components/image-attachments";
16
+ import type { ImageAttachment } from "@feedbax/feedback";
17
+ vi.mock("./feedbax", async (importOriginal) => {
18
+ const actual = await importOriginal<typeof import("./feedbax")>();
19
+ return {
20
+ default: {
21
+ ...actual.default,
22
+ connected: {
23
+ issuer: "https://app.example",
24
+ loginUrl: "https://app.example/login",
25
+ images: true,
26
+ },
27
+ },
28
+ };
29
+ });
30
+ afterEach(() => {
31
+ cleanup();
32
+ vi.unstubAllGlobals();
33
+ });
34
+ it("follows a Post and manages private read and email preferences from the inbox", async () => {
35
+ let following = false,
36
+ read = false,
37
+ email = false;
38
+ vi.stubGlobal(
39
+ "fetch",
40
+ vi.fn<typeof fetch>().mockImplementation(async (input, init) => {
41
+ const path = String(input);
42
+ const body = init?.body ? JSON.parse(String(init.body)) : undefined;
43
+ if (path === "/internal/identity/session")
44
+ return Response.json({ participant: { displayName: "Alice" } });
45
+ if (path.includes("/follow")) {
46
+ if (body) following = body.following;
47
+ return Response.json({ following });
48
+ }
49
+ if (path.includes("/preferences")) {
50
+ if (body) email = body.emailEnabled;
51
+ return Response.json({ emailAvailable: true, emailEnabled: email });
52
+ }
53
+ if (body) {
54
+ read = true;
55
+ return Response.json({ updated: true });
56
+ }
57
+ return Response.json({
58
+ unreadCount: read ? 0 : 1,
59
+ items: [
60
+ {
61
+ id: "a".repeat(32),
62
+ slug: "post",
63
+ kind: "status",
64
+ title: "Image uploads",
65
+ message: "Status changed to Shipped.",
66
+ createdAt: Date.now(),
67
+ read,
68
+ },
69
+ ],
70
+ });
71
+ }),
72
+ );
73
+ const client = new QueryClient({
74
+ defaultOptions: { queries: { retry: false } },
75
+ });
76
+ render(
77
+ <QueryClientProvider client={client}>
78
+ <ConnectedIdentityProvider>
79
+ <FollowPostButton slug="post" />
80
+ <NotificationInbox />
81
+ </ConnectedIdentityProvider>
82
+ </QueryClientProvider>,
83
+ );
84
+ const follow = await screen.findByRole("button", { name: "Follow updates" });
85
+ await waitFor(() => expect(follow.hasAttribute("disabled")).toBe(false));
86
+ fireEvent.click(follow);
87
+ await screen.findByRole("button", { name: "Following" });
88
+ expect(following).toBe(true);
89
+ fireEvent.click(
90
+ await screen.findByRole("button", { name: "Notifications, 1 unread" }),
91
+ );
92
+ await screen.findByText("Status changed to Shipped.");
93
+ fireEvent.click(screen.getByRole("button", { name: "Mark all read" }));
94
+ await screen.findByText("0 unread");
95
+ const toggle = await screen.findByRole("switch", {
96
+ name: "Email notifications",
97
+ });
98
+ fireEvent.click(toggle);
99
+ await waitFor(() => expect(email).toBe(true));
100
+ client.clear();
101
+ });
102
+ it("uploads and removes image previews without submitting the surrounding form", async () => {
103
+ let submitted = false;
104
+ const calls: string[] = [];
105
+ vi.stubGlobal(
106
+ "fetch",
107
+ vi.fn<typeof fetch>().mockImplementation(async (input, init) => {
108
+ if (String(input).includes("identity/session"))
109
+ return Response.json({ participant: { displayName: "Alice" } });
110
+ calls.push(init?.method ?? "GET");
111
+ return Response.json(
112
+ init?.method === "DELETE"
113
+ ? { removed: true }
114
+ : {
115
+ id: "11111111-2222-3333-4444-555555555555",
116
+ src: "/internal/images/example",
117
+ contentType: "image/png",
118
+ bytes: 68,
119
+ },
120
+ );
121
+ }),
122
+ );
123
+ function Composer() {
124
+ const [images, setImages] = useState<ImageAttachment[]>([]);
125
+ return (
126
+ <form
127
+ onSubmit={(event) => {
128
+ event.preventDefault();
129
+ submitted = true;
130
+ }}
131
+ >
132
+ <ImageAttachments value={images} onChange={setImages} />
133
+ </form>
134
+ );
135
+ }
136
+ render(
137
+ <ConnectedIdentityProvider>
138
+ <Composer />
139
+ </ConnectedIdentityProvider>,
140
+ );
141
+ const input = await screen.findByLabelText("Choose image attachments");
142
+ fireEvent.change(input, {
143
+ target: {
144
+ files: [
145
+ new File([new Uint8Array([137, 80, 78, 71])], "sample.png", {
146
+ type: "image/png",
147
+ }),
148
+ ],
149
+ },
150
+ });
151
+ await screen.findByAltText("Attached image 1");
152
+ fireEvent.click(screen.getByRole("button", { name: "Remove image 1" }));
153
+ await waitFor(() =>
154
+ expect(screen.queryByAltText("Attached image 1")).toBeNull(),
155
+ );
156
+ expect(calls).toEqual(["POST", "DELETE"]);
157
+ expect(submitted).toBe(false);
158
+ });
@@ -47,6 +47,9 @@ export function createInvalidatingFeedbackModule({
47
47
  invalidator: PublicCacheInvalidator;
48
48
  }): FeedbackModule {
49
49
  return {
50
+ getParticipantPost: (id) => feedback.getParticipantPost(id),
51
+ claimDraftPost: (input) => feedback.claimDraftPost(input),
52
+ getAttachmentPostState: (slug) => feedback.getAttachmentPostState(slug),
50
53
  editComment: (input) => feedback.editComment(input),
51
54
  deleteComment: (input) => feedback.deleteComment(input),
52
55
  async createComment(input) {