create-feedbax 0.1.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 (251) hide show
  1. package/LICENSE +201 -21
  2. package/README.md +11 -1
  3. package/dist/cli.js +3283 -22
  4. package/dist/index.js +3394 -160
  5. package/package.json +40 -14
  6. package/template/.feedbax-template.sha256 +1 -0
  7. package/template/.gitignore.template +6 -0
  8. package/template/.npmignore.template +5 -0
  9. package/template/.prettierignore.template +4 -0
  10. package/template/AGENTS.md +13 -0
  11. package/template/CONNECTED.md +404 -0
  12. package/template/LICENSE +201 -0
  13. package/template/README.md +56 -5
  14. package/template/components.json +25 -0
  15. package/template/eslint.config.mjs +48 -0
  16. package/template/migrations/0001_connected.sql +6 -0
  17. package/template/migrations/0002_participation.sql +8 -0
  18. package/template/migrations/0003_images.sql +3 -0
  19. package/template/migrations/0004_followed_status.sql +9 -0
  20. package/template/package.json +74 -14
  21. package/template/public/feedbax-mark.svg +4 -0
  22. package/template/public/robots.txt +3 -0
  23. package/template/src/authorized-draft-post.test.tsx +212 -0
  24. package/template/src/authorized-draft-post.tsx +205 -0
  25. package/template/src/authorized-draft-query.ts +74 -0
  26. package/template/src/browser-post-state.test.ts +120 -0
  27. package/template/src/browser-post-state.ts +134 -0
  28. package/template/src/browser-vote-state.test.ts +14 -0
  29. package/template/src/browser-vote-state.ts +27 -0
  30. package/template/src/capability-authorization-error.ts +3 -0
  31. package/template/src/changelog-page.test.tsx +139 -0
  32. package/template/src/changelog-page.tsx +228 -0
  33. package/template/src/changelog-query.ts +13 -0
  34. package/template/src/changelog-runtime.ts +27 -0
  35. package/template/src/cloudflare-turnstile.ts +31 -0
  36. package/template/src/cloudflare-workers-test.ts +3 -0
  37. package/template/src/cloudflare-workers.d.ts +24 -0
  38. package/template/src/comment-creation.test.tsx +311 -0
  39. package/template/src/comment-profile.ts +5 -0
  40. package/template/src/comment-thread-page.ts +28 -0
  41. package/template/src/components/command-palette-context.tsx +71 -0
  42. package/template/src/components/command-palette.tsx +275 -0
  43. package/template/src/components/connected-identity-provider.tsx +108 -0
  44. package/template/src/components/create-post-trigger.tsx +44 -0
  45. package/template/src/components/device-profile-provider.tsx +324 -0
  46. package/template/src/components/follow-post-button.tsx +89 -0
  47. package/template/src/components/header.tsx +97 -0
  48. package/template/src/components/image-attachments.tsx +187 -0
  49. package/template/src/components/loader.tsx +53 -0
  50. package/template/src/components/notification-inbox.tsx +232 -0
  51. package/template/src/components/portal-footer.tsx +14 -0
  52. package/template/src/components/profile-menu.tsx +119 -0
  53. package/template/src/components/ui/components/alert-dialog.tsx +185 -0
  54. package/template/src/components/ui/components/attachment.tsx +207 -0
  55. package/template/src/components/ui/components/avatar.tsx +107 -0
  56. package/template/src/components/ui/components/badge.tsx +52 -0
  57. package/template/src/components/ui/components/bubble.tsx +128 -0
  58. package/template/src/components/ui/components/button-group.tsx +87 -0
  59. package/template/src/components/ui/components/button.tsx +56 -0
  60. package/template/src/components/ui/components/card.tsx +103 -0
  61. package/template/src/components/ui/components/checkbox.tsx +26 -0
  62. package/template/src/components/ui/components/command.tsx +195 -0
  63. package/template/src/components/ui/components/dialog.tsx +154 -0
  64. package/template/src/components/ui/components/drawer.tsx +229 -0
  65. package/template/src/components/ui/components/dropdown-menu.tsx +270 -0
  66. package/template/src/components/ui/components/empty.tsx +101 -0
  67. package/template/src/components/ui/components/field.tsx +236 -0
  68. package/template/src/components/ui/components/input-group.tsx +155 -0
  69. package/template/src/components/ui/components/input.tsx +20 -0
  70. package/template/src/components/ui/components/item.tsx +201 -0
  71. package/template/src/components/ui/components/kbd.tsx +26 -0
  72. package/template/src/components/ui/components/label.tsx +18 -0
  73. package/template/src/components/ui/components/marker.tsx +71 -0
  74. package/template/src/components/ui/components/message-scroller.tsx +128 -0
  75. package/template/src/components/ui/components/message.tsx +92 -0
  76. package/template/src/components/ui/components/native-select.tsx +65 -0
  77. package/template/src/components/ui/components/popover.tsx +88 -0
  78. package/template/src/components/ui/components/portal-primitives.test.tsx +85 -0
  79. package/template/src/components/ui/components/radio-group.tsx +36 -0
  80. package/template/src/components/ui/components/scroll-area.tsx +51 -0
  81. package/template/src/components/ui/components/secondary-form-actions.test.tsx +23 -0
  82. package/template/src/components/ui/components/select.tsx +199 -0
  83. package/template/src/components/ui/components/separator.tsx +23 -0
  84. package/template/src/components/ui/components/sheet.tsx +135 -0
  85. package/template/src/components/ui/components/skeleton.tsx +13 -0
  86. package/template/src/components/ui/components/sonner.tsx +43 -0
  87. package/template/src/components/ui/components/spinner.tsx +16 -0
  88. package/template/src/components/ui/components/switch.tsx +29 -0
  89. package/template/src/components/ui/components/tabs.tsx +80 -0
  90. package/template/src/components/ui/components/textarea.tsx +18 -0
  91. package/template/src/components/ui/components/tooltip.tsx +64 -0
  92. package/template/src/components/ui/hooks/.gitkeep +0 -0
  93. package/template/src/components/ui/lib/utils.ts +6 -0
  94. package/template/src/components/ui/styles/globals.css +151 -0
  95. package/template/src/connected-identity.test.ts +144 -0
  96. package/template/src/connected-identity.ts +122 -0
  97. package/template/src/connected-participation.test.tsx +67 -0
  98. package/template/src/connected-runtime.test.ts +241 -0
  99. package/template/src/connected-runtime.ts +176 -0
  100. package/template/src/connected-social.test.tsx +158 -0
  101. package/template/src/create-post-overlay.tsx +82 -0
  102. package/template/src/draft-post-list.tsx +39 -0
  103. package/template/src/feedback-cache-invalidation.test.ts +185 -0
  104. package/template/src/feedback-cache-invalidation.ts +98 -0
  105. package/template/src/feedback-runtime.ts +105 -0
  106. package/template/src/feedbax/changelog/index.test.ts +156 -0
  107. package/template/src/feedbax/changelog/index.ts +206 -0
  108. package/template/src/feedbax/changelog/notion-changelog.test.ts +395 -0
  109. package/template/src/feedbax/changelog/notion-changelog.ts +400 -0
  110. package/template/src/feedbax/config/index.test.ts +139 -0
  111. package/template/src/feedbax/config/index.ts +364 -0
  112. package/template/src/feedbax/feedback/connected-feedback.ts +206 -0
  113. package/template/src/feedbax/feedback/connected.test.ts +272 -0
  114. package/template/src/feedbax/feedback/connected.ts +215 -0
  115. package/template/src/feedbax/feedback/email-delivery.test.ts +65 -0
  116. package/template/src/feedbax/feedback/email-delivery.ts +66 -0
  117. package/template/src/feedbax/feedback/feedback-storage.ts +43 -0
  118. package/template/src/feedbax/feedback/identity-handoff.test.ts +81 -0
  119. package/template/src/feedbax/feedback/identity-handoff.ts +119 -0
  120. package/template/src/feedbax/feedback/image-feedback.ts +101 -0
  121. package/template/src/feedbax/feedback/images.test.ts +223 -0
  122. package/template/src/feedbax/feedback/images.ts +324 -0
  123. package/template/src/feedbax/feedback/index.test.ts +900 -0
  124. package/template/src/feedbax/feedback/index.ts +996 -0
  125. package/template/src/feedbax/feedback/notion-comments.test.ts +171 -0
  126. package/template/src/feedbax/feedback/notion-comments.ts +231 -0
  127. package/template/src/feedbax/feedback/notion-data-source.test.ts +364 -0
  128. package/template/src/feedbax/feedback/notion-data-source.ts +458 -0
  129. package/template/src/feedbax/feedback/notion-feedback.test.ts +998 -0
  130. package/template/src/feedbax/feedback/notion-feedback.ts +651 -0
  131. package/template/src/feedbax/feedback/notion-image-review.ts +55 -0
  132. package/template/src/feedbax/feedback/notion-request.ts +74 -0
  133. package/template/src/feedbax/feedback/participation.test.ts +653 -0
  134. package/template/src/feedbax/feedback/participation.ts +502 -0
  135. package/template/src/feedbax.test-fixture.ts +64 -0
  136. package/template/src/feedbax.ts +69 -0
  137. package/template/src/images-runtime.test.ts +133 -0
  138. package/template/src/images-runtime.ts +125 -0
  139. package/template/src/index.css +1795 -0
  140. package/template/src/lazy-create-post-overlay.tsx +6 -0
  141. package/template/src/lazy-post-detail-overlay.tsx +21 -0
  142. package/template/src/masked-post-link.tsx +27 -0
  143. package/template/src/overlay-post-detail.test.tsx +181 -0
  144. package/template/src/overlay-post-detail.tsx +43 -0
  145. package/template/src/participation-client.ts +63 -0
  146. package/template/src/participation-runtime.test.ts +35 -0
  147. package/template/src/participation-runtime.ts +255 -0
  148. package/template/src/portal-comments.test.ts +375 -0
  149. package/template/src/portal-comments.ts +403 -0
  150. package/template/src/portal-features.test.tsx +150 -0
  151. package/template/src/portal-feedback-form.tsx +530 -0
  152. package/template/src/portal-feedback-runtime.test.ts +32 -0
  153. package/template/src/portal-feedback-runtime.ts +58 -0
  154. package/template/src/portal-feedback-server-function.ts +96 -0
  155. package/template/src/portal-feedback-submission.test.ts +307 -0
  156. package/template/src/portal-feedback-submission.ts +224 -0
  157. package/template/src/portal-head.ts +58 -0
  158. package/template/src/portal-server-entry.ts +68 -0
  159. package/template/src/portal-voting.test.ts +106 -0
  160. package/template/src/portal-voting.ts +149 -0
  161. package/template/src/post-badges.tsx +27 -0
  162. package/template/src/post-creation-flow.test.tsx +230 -0
  163. package/template/src/post-creation-flow.tsx +88 -0
  164. package/template/src/post-detail-content.test.tsx +233 -0
  165. package/template/src/post-detail-content.tsx +162 -0
  166. package/template/src/post-detail-overlay.test.tsx +114 -0
  167. package/template/src/post-detail-overlay.tsx +67 -0
  168. package/template/src/post-detail-query.ts +34 -0
  169. package/template/src/post-feed-controls.test.tsx +239 -0
  170. package/template/src/post-feed-controls.tsx +298 -0
  171. package/template/src/post-presentation.ts +37 -0
  172. package/template/src/post-queries.test.ts +158 -0
  173. package/template/src/post-queries.ts +26 -0
  174. package/template/src/post-results.tsx +165 -0
  175. package/template/src/public-cache-policy.test.ts +148 -0
  176. package/template/src/public-cache-policy.ts +119 -0
  177. package/template/src/public-cache-tags.ts +7 -0
  178. package/template/src/public-changelog-page.ts +15 -0
  179. package/template/src/public-changelog-server-function.ts +27 -0
  180. package/template/src/public-date.ts +11 -0
  181. package/template/src/public-failure-state.test.tsx +69 -0
  182. package/template/src/public-feedback-index.tsx +94 -0
  183. package/template/src/public-feedback-page.test.tsx +355 -0
  184. package/template/src/public-feedback-page.ts +86 -0
  185. package/template/src/public-feedback-server-function.ts +22 -0
  186. package/template/src/public-portal-error.tsx +27 -0
  187. package/template/src/public-post-detail-skeleton.tsx +54 -0
  188. package/template/src/public-post-detail.tsx +772 -0
  189. package/template/src/public-post-page.test.tsx +173 -0
  190. package/template/src/public-post-page.ts +15 -0
  191. package/template/src/public-post-route.test.tsx +250 -0
  192. package/template/src/public-post-route.tsx +158 -0
  193. package/template/src/public-post-server-function.ts +48 -0
  194. package/template/src/public-post-unavailable.tsx +10 -0
  195. package/template/src/public-roadmap-page.test.tsx +139 -0
  196. package/template/src/public-roadmap-page.ts +24 -0
  197. package/template/src/public-roadmap-route.test.tsx +254 -0
  198. package/template/src/public-roadmap-route.tsx +50 -0
  199. package/template/src/public-roadmap-server-function.ts +44 -0
  200. package/template/src/public-roadmap-view.tsx +411 -0
  201. package/template/src/public-shell.test.tsx +544 -0
  202. package/template/src/roadmap-query.ts +21 -0
  203. package/template/src/root-branding.test.ts +53 -0
  204. package/template/src/routeTree.gen.ts +177 -0
  205. package/template/src/routed-post-creation-form.tsx +28 -0
  206. package/template/src/router.tsx +27 -0
  207. package/template/src/routes/__root.tsx +114 -0
  208. package/template/src/routes/changelog.tsx +39 -0
  209. package/template/src/routes/email-preferences.tsx +77 -0
  210. package/template/src/routes/index.tsx +61 -0
  211. package/template/src/routes/p.$slug.tsx +16 -0
  212. package/template/src/routes/roadmap.tsx +11 -0
  213. package/template/src/routes/submit.tsx +28 -0
  214. package/template/src/safe-public-failure.ts +47 -0
  215. package/template/src/server.ts +151 -0
  216. package/template/src/similar-posts.tsx +45 -0
  217. package/template/src/submit-route.test.tsx +134 -0
  218. package/template/src/trusted-feedback-handler.test.ts +425 -0
  219. package/template/src/trusted-feedback-handler.ts +181 -0
  220. package/template/src/trusted-feedback-runtime.ts +17 -0
  221. package/template/src/turnstile-configuration.test.ts +37 -0
  222. package/template/src/turnstile-configuration.ts +19 -0
  223. package/template/src/use-media-query.ts +17 -0
  224. package/template/src/vote-toggle.test.tsx +171 -0
  225. package/template/src/vote-toggle.tsx +244 -0
  226. package/template/tsconfig.json +45 -0
  227. package/template/vite.config.ts +20 -0
  228. package/template/vitest.config.ts +4 -0
  229. package/template/vitest.setup.ts +3 -0
  230. package/template/wrangler.jsonc +35 -0
  231. package/dist/cli.d.ts +0 -3
  232. package/dist/cli.d.ts.map +0 -1
  233. package/dist/cli.js.map +0 -1
  234. package/dist/index.d.ts +0 -21
  235. package/dist/index.d.ts.map +0 -1
  236. package/dist/index.js.map +0 -1
  237. package/template/.env.example +0 -4
  238. package/template/apps/portal/components.json +0 -20
  239. package/template/apps/portal/package.json +0 -31
  240. package/template/apps/portal/src/lib/utils.ts +0 -6
  241. package/template/apps/portal/src/routeTree.gen.ts +0 -59
  242. package/template/apps/portal/src/router.tsx +0 -10
  243. package/template/apps/portal/src/routes/__root.tsx +0 -31
  244. package/template/apps/portal/src/routes/index.tsx +0 -35
  245. package/template/apps/portal/src/styles.css +0 -91
  246. package/template/apps/portal/tsconfig.json +0 -15
  247. package/template/apps/portal/vite.config.ts +0 -13
  248. package/template/feedbax.config.ts +0 -13
  249. package/template/feedbax.jsonc +0 -11
  250. package/template/pnpm-workspace.yaml +0 -3
  251. package/template/turbo.json +0 -8
@@ -0,0 +1,185 @@
1
+ import { createFeedbackModule } from "@feedbax/feedback";
2
+ import { describe, expect, it, vi } from "vitest";
3
+
4
+ import {
5
+ createInvalidatingFeedbackModule,
6
+ createPublicCacheInvalidator,
7
+ } from "./feedback-cache-invalidation";
8
+
9
+ describe("feedback cache invalidation", () => {
10
+ it("purges only feedback-list and changed-Post tags after a successful write", async () => {
11
+ const purge = vi.fn().mockResolvedValue({ success: true, errors: [] });
12
+ const feedback = createInvalidatingFeedbackModule({
13
+ feedback: createFeedbackModule(),
14
+ invalidator: createPublicCacheInvalidator({ purge }),
15
+ });
16
+
17
+ const submitted = await feedback.submitPost({
18
+ title: "Keyboard navigation",
19
+ description: "Let participants navigate without a mouse.",
20
+ type: "Feature Request",
21
+ });
22
+
23
+ expect(purge).toHaveBeenCalledWith({
24
+ tags: ["feedbax-feedback", `feedbax-post-${submitted.slug}`],
25
+ });
26
+ expect(purge.mock.calls[0]?.[0].tags).not.toContain("feedbax-roadmap");
27
+ });
28
+
29
+ it("invalidates public feedback after a trusted submission", async () => {
30
+ const invalidatePost = vi.fn().mockResolvedValue(undefined);
31
+ const feedback = createInvalidatingFeedbackModule({
32
+ feedback: createFeedbackModule(),
33
+ invalidator: { invalidatePost, invalidateVote: invalidatePost },
34
+ });
35
+
36
+ const submitted = await feedback.submitTrustedPost({
37
+ externalId: "product-a:feedback-123",
38
+ title: "Keyboard navigation",
39
+ description: "Let participants navigate without a mouse.",
40
+ type: "Feature Request",
41
+ });
42
+
43
+ expect(invalidatePost).toHaveBeenCalledExactlyOnceWith(submitted.slug);
44
+ });
45
+
46
+ it("invalidates feed, Post detail, and roadmap projections after a confirmed Vote", async () => {
47
+ const purge = vi.fn().mockResolvedValue({ success: true, errors: [] });
48
+ const now = new Date("2026-08-25T10:00:00.000Z");
49
+ const feedback = createInvalidatingFeedbackModule({
50
+ feedback: createFeedbackModule({
51
+ initialItems: [
52
+ {
53
+ id: "post-id",
54
+ slug: "keyboard-navigation",
55
+ title: "Keyboard navigation",
56
+ description: "Navigate without a mouse.",
57
+ type: "Feature Request",
58
+ status: "Planned",
59
+ published: true,
60
+ voteCount: 2,
61
+ createdAt: now,
62
+ updatedAt: now,
63
+ },
64
+ ],
65
+ }),
66
+ invalidator: createPublicCacheInvalidator({ purge }),
67
+ });
68
+
69
+ await feedback.changeVote({
70
+ slug: "keyboard-navigation",
71
+ intention: "add",
72
+ });
73
+
74
+ expect(purge).toHaveBeenCalledWith({
75
+ tags: [
76
+ "feedbax-feedback",
77
+ "feedbax-post-keyboard-navigation",
78
+ "feedbax-roadmap",
79
+ ],
80
+ });
81
+ });
82
+
83
+ it("keeps a confirmed Vote successful when cache invalidation fails", async () => {
84
+ const now = new Date("2026-08-25T10:00:00.000Z");
85
+ const feedback = createInvalidatingFeedbackModule({
86
+ feedback: createFeedbackModule({
87
+ initialItems: [
88
+ {
89
+ id: "post-id",
90
+ slug: "keyboard-navigation",
91
+ title: "Keyboard navigation",
92
+ description: "Navigate without a mouse.",
93
+ type: "Feature Request",
94
+ status: "Planned",
95
+ published: true,
96
+ voteCount: 2,
97
+ createdAt: now,
98
+ updatedAt: now,
99
+ },
100
+ ],
101
+ }),
102
+ invalidator: {
103
+ invalidatePost: vi.fn(),
104
+ invalidateVote: vi.fn().mockRejectedValue(new Error("purge failed")),
105
+ },
106
+ });
107
+
108
+ await expect(
109
+ feedback.changeVote({
110
+ slug: "keyboard-navigation",
111
+ intention: "add",
112
+ }),
113
+ ).resolves.toEqual({ slug: "keyboard-navigation", voteCount: 3 });
114
+ });
115
+
116
+ it("invalidates after edits and withdrawals without passing private write data", async () => {
117
+ const invalidatePost = vi.fn().mockResolvedValue(undefined);
118
+ const feedback = createInvalidatingFeedbackModule({
119
+ feedback: createFeedbackModule(),
120
+ invalidator: { invalidatePost, invalidateVote: invalidatePost },
121
+ });
122
+ const submitted = await feedback.submitPost({
123
+ title: "Original title",
124
+ description: "Original description",
125
+ type: "General Feedback",
126
+ submitter: { email: "private@example.com" },
127
+ });
128
+ invalidatePost.mockClear();
129
+
130
+ await feedback.editDraftPost({
131
+ id: submitted.id,
132
+ browserCapability: submitted.browserCapability,
133
+ title: "Revised title",
134
+ description: "Revised description",
135
+ type: "Bug Report",
136
+ });
137
+ await feedback.withdrawDraftPost({
138
+ id: submitted.id,
139
+ browserCapability: submitted.browserCapability,
140
+ });
141
+
142
+ expect(invalidatePost.mock.calls).toEqual([
143
+ [submitted.slug],
144
+ [submitted.slug],
145
+ ]);
146
+ });
147
+
148
+ it("does not purge when a write fails", async () => {
149
+ const invalidatePost = vi.fn().mockResolvedValue(undefined);
150
+ const feedback = createInvalidatingFeedbackModule({
151
+ feedback: {
152
+ ...createFeedbackModule(),
153
+ submitPost: vi.fn().mockRejectedValue(new Error("write failed")),
154
+ },
155
+ invalidator: { invalidatePost, invalidateVote: invalidatePost },
156
+ });
157
+
158
+ await expect(
159
+ feedback.submitPost({
160
+ title: "Keyboard navigation",
161
+ description: "Let participants navigate without a mouse.",
162
+ type: "Feature Request",
163
+ }),
164
+ ).rejects.toThrow("write failed");
165
+ expect(invalidatePost).not.toHaveBeenCalled();
166
+ });
167
+
168
+ it("reports purge rejection without exposing Cloudflare details", async () => {
169
+ const invalidator = createPublicCacheInvalidator({
170
+ purge: vi.fn().mockResolvedValue({
171
+ success: false,
172
+ errors: [{ code: 1234, message: "private infrastructure detail" }],
173
+ }),
174
+ });
175
+
176
+ const invalidation = invalidator.invalidatePost("feedback-123");
177
+
178
+ await expect(invalidation).rejects.toThrow(
179
+ "Public feedback cache invalidation failed.",
180
+ );
181
+ await expect(invalidation).rejects.not.toThrow(
182
+ "private infrastructure detail",
183
+ );
184
+ });
185
+ });
@@ -0,0 +1,98 @@
1
+ import type { FeedbackModule } from "@feedbax/feedback";
2
+
3
+ import {
4
+ publicFeedbackCacheTag,
5
+ publicPostCacheTag,
6
+ publicRoadmapCacheTag,
7
+ } from "./public-cache-tags";
8
+
9
+ interface PublicCacheInvalidator {
10
+ invalidatePost(slug: string): Promise<void>;
11
+ invalidateVote(slug: string): Promise<void>;
12
+ }
13
+
14
+ interface CachePurgeResult {
15
+ success: boolean;
16
+ errors: Array<{ code: number; message: string }>;
17
+ }
18
+
19
+ type PurgePublicCache = (options: {
20
+ tags: string[];
21
+ }) => Promise<CachePurgeResult>;
22
+
23
+ export function createPublicCacheInvalidator({
24
+ purge,
25
+ }: {
26
+ purge: PurgePublicCache;
27
+ }): PublicCacheInvalidator {
28
+ async function purgePost(slug: string, tags: string[]) {
29
+ const result = await purge({
30
+ tags: [publicFeedbackCacheTag, publicPostCacheTag(slug), ...tags],
31
+ });
32
+ if (!result.success) {
33
+ throw new Error("Public feedback cache invalidation failed.");
34
+ }
35
+ }
36
+ return {
37
+ invalidatePost: (slug) => purgePost(slug, []),
38
+ invalidateVote: (slug) => purgePost(slug, [publicRoadmapCacheTag]),
39
+ };
40
+ }
41
+
42
+ export function createInvalidatingFeedbackModule({
43
+ feedback,
44
+ invalidator,
45
+ }: {
46
+ feedback: FeedbackModule;
47
+ invalidator: PublicCacheInvalidator;
48
+ }): FeedbackModule {
49
+ return {
50
+ getAttachmentPostState: (slug) => feedback.getAttachmentPostState(slug),
51
+ editComment: (input) => feedback.editComment(input),
52
+ deleteComment: (input) => feedback.deleteComment(input),
53
+ async createComment(input) {
54
+ const comment = await feedback.createComment(input);
55
+ await invalidator.invalidatePost(input.slug).catch(() => undefined);
56
+ return comment;
57
+ },
58
+ async replyToCommentThread(input) {
59
+ const comment = await feedback.replyToCommentThread(input);
60
+ await invalidator.invalidatePost(input.slug).catch(() => undefined);
61
+ return comment;
62
+ },
63
+ async changeVote(input) {
64
+ const result = await feedback.changeVote(input);
65
+ await invalidator.invalidateVote(result.slug).catch(() => undefined);
66
+ return result;
67
+ },
68
+ submitPost: (input) =>
69
+ runInvalidatingWrite(() => feedback.submitPost(input), invalidator),
70
+ editDraftPost: (input) =>
71
+ runInvalidatingWrite(() => feedback.editDraftPost(input), invalidator),
72
+ getPublicPost: (slug) => feedback.getPublicPost(slug),
73
+ listCommentThreads: (input) => feedback.listCommentThreads(input),
74
+ getDraftPost: (input) => feedback.getDraftPost(input),
75
+ listPublicPosts: (query) => feedback.listPublicPosts(query),
76
+ listPublicRoadmapPosts: (query) => feedback.listPublicRoadmapPosts(query),
77
+ submitTrustedPost: (input) =>
78
+ runInvalidatingWrite(
79
+ () => feedback.submitTrustedPost(input),
80
+ invalidator,
81
+ ),
82
+ async withdrawDraftPost(input) {
83
+ const draft = await feedback.getDraftPost(input);
84
+ await feedback.withdrawDraftPost(input);
85
+ await invalidator.invalidatePost(draft.slug);
86
+ },
87
+ getPublicPostRoadmap: () => feedback.getPublicPostRoadmap(),
88
+ };
89
+ }
90
+
91
+ async function runInvalidatingWrite<Item extends { slug: string }>(
92
+ write: () => Promise<Item>,
93
+ invalidator: PublicCacheInvalidator,
94
+ ): Promise<Item> {
95
+ const item = await write();
96
+ await invalidator.invalidatePost(item.slug);
97
+ return item;
98
+ }
@@ -0,0 +1,105 @@
1
+ import {
2
+ createImageModule,
3
+ createImageFeedbackModule,
4
+ createNotionImageReviewWriter,
5
+ createConnectedStore,
6
+ createConnectedFeedbackModule,
7
+ type VerifiedParticipant,
8
+ createNotionFeedbackModule,
9
+ } from "@feedbax/feedback";
10
+ import { cache, env } from "cloudflare:workers";
11
+
12
+ import {
13
+ createInvalidatingFeedbackModule,
14
+ createPublicCacheInvalidator,
15
+ } from "./feedback-cache-invalidation";
16
+ import feedbax from "./feedbax";
17
+
18
+ export const feedbackSubmissionLimits = {
19
+ maxTitleLength: feedbax.submissions.maxTitleLength,
20
+ maxDescriptionLength: feedbax.submissions.maxDescriptionLength,
21
+ } as const;
22
+
23
+ export function createConfiguredNotionFeedbackModule() {
24
+ configuredFeedback ??= createNotionFeedbackModule({
25
+ votingEnabled: feedbax.features.voting,
26
+ commentsEnabled: feedbax.features.comments,
27
+ token: requiredEnvironmentValue("NOTION_TOKEN"),
28
+ dataSourceId: feedbax.notion.dataSourceId,
29
+ propertyIds: feedbax.notion.properties,
30
+ });
31
+ return configuredFeedback;
32
+ }
33
+
34
+ export function createConfiguredFeedbackModule(
35
+ participant?: VerifiedParticipant,
36
+ ) {
37
+ const content = createConfiguredNotionFeedbackModule();
38
+ const feedback = feedbax.connected
39
+ ? createConnectedFeedbackModule(content, getConnectedStore(), participant)
40
+ : content;
41
+ const images = feedbax.connected?.images
42
+ ? configuredImages(feedback, participant)
43
+ : undefined;
44
+ const withImages = images
45
+ ? createImageFeedbackModule(
46
+ feedback,
47
+ images,
48
+ createNotionImageReviewWriter({
49
+ token: requiredEnvironmentValue("NOTION_TOKEN"),
50
+ reviewUrl: (id) => images.reviewUrl(id, feedbax.product.siteUrl!),
51
+ }),
52
+ )
53
+ : feedback;
54
+ return createInvalidatingFeedbackModule({
55
+ feedback: withImages,
56
+ invalidator: createPublicCacheInvalidator({
57
+ purge: (options) => cache.purge(options),
58
+ }),
59
+ });
60
+ }
61
+
62
+ let configuredFeedback:
63
+ ReturnType<typeof createInvalidatingFeedbackModule> | undefined;
64
+
65
+ export function requiredEnvironmentValue(name: string): string {
66
+ const value = process.env[name];
67
+ if (!value) throw new Error("Feedback configuration is missing.");
68
+ return value;
69
+ }
70
+
71
+ export function getConnectedStore() {
72
+ if (!feedbax.connected || !env.FEEDBAX_DB)
73
+ throw new Error("Connected Profile requires the FEEDBAX_DB D1 binding.");
74
+ return createConnectedStore(env.FEEDBAX_DB);
75
+ }
76
+
77
+ export function configuredImages(
78
+ feedback: import("@feedbax/feedback").FeedbackModule,
79
+ participant?: VerifiedParticipant,
80
+ ) {
81
+ if (
82
+ !env.FEEDBAX_DB ||
83
+ !env.FEEDBAX_IMAGES ||
84
+ !env.PARTICIPATION_SIGNING_SECRET ||
85
+ env.PARTICIPATION_SIGNING_SECRET.length < 32
86
+ )
87
+ throw new Error(
88
+ "Image attachments require D1, private R2, and the participation signing secret.",
89
+ );
90
+ return createImageModule({
91
+ database: env.FEEDBAX_DB,
92
+ bucket: env.FEEDBAX_IMAGES,
93
+ feedback,
94
+ participant,
95
+ reviewSecret: env.PARTICIPATION_SIGNING_SECRET,
96
+ processing: feedbax.connected?.processing ?? "small",
97
+ });
98
+ }
99
+ export async function requestParticipant(request: Request) {
100
+ if (!feedbax.connected) return;
101
+ const { readSessionToken, sessionHash } =
102
+ await import("./connected-identity");
103
+ const token = readSessionToken(request);
104
+ return token ? getConnectedStore().session(sessionHash(token)) : undefined;
105
+ }
@@ -0,0 +1,156 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import {
4
+ createChangelogModule,
5
+ createInMemoryChangelogStorage,
6
+ type ChangelogDate,
7
+ type StoredChangelogEntry,
8
+ } from ".";
9
+
10
+ describe("Changelog module", () => {
11
+ it("returns only Published Changelog Entries in deterministic Date order", async () => {
12
+ const changelog = createChangelogModule({
13
+ storage: createInMemoryChangelogStorage([
14
+ entry({ id: "z", slug: "draft", published: false, date: "2026-08-30" }),
15
+ entry({ id: "b", slug: "second", date: "2026-08-20" }),
16
+ entry({ id: "a", slug: "first", date: "2026-08-20" }),
17
+ entry({ id: "c", slug: "older", date: "2026-08-10" }),
18
+ ]),
19
+ });
20
+
21
+ await expect(changelog.listPublishedEntries()).resolves.toMatchObject({
22
+ items: [{ slug: "first" }, { slug: "second" }, { slug: "older" }],
23
+ });
24
+ });
25
+
26
+ it("filters Labels and continues in stable batches of 20", async () => {
27
+ const changelog = createChangelogModule({
28
+ storage: createInMemoryChangelogStorage(
29
+ Array.from({ length: 22 }, (_, index) =>
30
+ entry({
31
+ id: String(index).padStart(2, "0"),
32
+ slug: `entry-${index}`,
33
+ date: `2026-08-${String(22 - index).padStart(2, "0")}` as ChangelogDate,
34
+ labels: index === 21 ? ["Fixed"] : ["Improved"],
35
+ }),
36
+ ),
37
+ ),
38
+ });
39
+
40
+ const first = await changelog.listPublishedEntries({ label: "Improved" });
41
+ expect(first.items).toHaveLength(20);
42
+ expect(first.nextCursor).toBeTruthy();
43
+ const second = await changelog.listPublishedEntries({
44
+ label: "Improved",
45
+ cursor: first.nextCursor,
46
+ });
47
+ expect(second.items.map(({ slug }) => slug)).toEqual(["entry-20"]);
48
+ await expect(
49
+ changelog.listPublishedEntries({ label: "Fixed" }),
50
+ ).resolves.toMatchObject({ items: [{ slug: "entry-21" }] });
51
+ });
52
+
53
+ it("starts empty and projects at most one image", async () => {
54
+ await expect(
55
+ createChangelogModule({
56
+ storage: createInMemoryChangelogStorage(),
57
+ }).listPublishedEntries(),
58
+ ).resolves.toEqual({ items: [] });
59
+
60
+ const changelog = createChangelogModule({
61
+ storage: createInMemoryChangelogStorage([
62
+ entry({
63
+ images: [
64
+ { src: "https://files.example/first", alt: "First" },
65
+ { src: "https://files.example/second", alt: "Second" },
66
+ ],
67
+ }),
68
+ ]),
69
+ });
70
+ await expect(changelog.listPublishedEntries()).resolves.toMatchObject({
71
+ items: [{ image: { src: "https://files.example/first", alt: "First" } }],
72
+ });
73
+ });
74
+
75
+ it("preserves Changelog text while discarding an image before cache freshness could outlive it", async () => {
76
+ const changelog = createChangelogModule({
77
+ storage: createInMemoryChangelogStorage([
78
+ entry({
79
+ title: "Safe release",
80
+ body: "The release text remains available.",
81
+ images: [
82
+ {
83
+ src: "https://files.example/expiring",
84
+ alt: "Safe release image",
85
+ expiresAt: new Date("2026-08-26T12:00:30Z"),
86
+ },
87
+ ],
88
+ }),
89
+ ]),
90
+ now: () => new Date("2026-08-26T12:00:00Z"),
91
+ });
92
+
93
+ const page = await changelog.listPublishedEntries();
94
+ expect(page.items[0]).toMatchObject({
95
+ title: "Safe release",
96
+ body: "The release text remains available.",
97
+ });
98
+ expect(page.items[0]).not.toHaveProperty("image");
99
+ });
100
+
101
+ it("falls back to a bounded text-only projection when an upstream refresh fails", async () => {
102
+ let available = true;
103
+ let currentTime = new Date("2026-08-26T12:00:00Z");
104
+ const changelog = createChangelogModule({
105
+ storage: {
106
+ async listPublished() {
107
+ if (!available) throw new Error("Notion is unavailable");
108
+ return {
109
+ items: [
110
+ {
111
+ ...entry({ title: "Release remains readable" }),
112
+ image: {
113
+ src: "https://files.example/temporary",
114
+ alt: "Release image",
115
+ expiresAt: new Date("2026-08-26T12:02:00Z"),
116
+ },
117
+ },
118
+ ],
119
+ };
120
+ },
121
+ },
122
+ now: () => currentTime,
123
+ });
124
+
125
+ expect((await changelog.listPublishedEntries()).items[0]).toHaveProperty(
126
+ "image",
127
+ );
128
+ available = false;
129
+ currentTime = new Date("2026-08-26T12:03:00Z");
130
+ await expect(changelog.listPublishedEntries()).resolves.toMatchObject({
131
+ items: [{ title: "Release remains readable" }],
132
+ });
133
+ expect(
134
+ (await changelog.listPublishedEntries()).items[0],
135
+ ).not.toHaveProperty("image");
136
+ });
137
+ });
138
+
139
+ function entry(
140
+ overrides: Partial<StoredChangelogEntry> & { date?: ChangelogDate } = {},
141
+ ): StoredChangelogEntry {
142
+ return {
143
+ id: "entry",
144
+ slug: "entry",
145
+ date: "2026-08-01",
146
+ title: "Entry",
147
+ summary: "Summary",
148
+ body: "Body",
149
+ labels: ["Improved"],
150
+ images: [],
151
+ published: true,
152
+ createdAt: new Date("2026-08-01T00:00:00Z"),
153
+ updatedAt: new Date("2026-08-01T00:00:00Z"),
154
+ ...overrides,
155
+ };
156
+ }