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,364 @@
1
+ export type PortalFeatures = {
2
+ readonly voting: boolean;
3
+ readonly comments: boolean;
4
+ readonly changelog: boolean;
5
+ };
6
+ export type ProductConfiguration = {
7
+ readonly name: string;
8
+ readonly description: string;
9
+ readonly logo: string;
10
+ readonly siteUrl?: string;
11
+ readonly supportUrl?: string;
12
+ readonly privacyUrl?: string;
13
+ };
14
+ export type FeedbackPropertyIds = {
15
+ readonly title: string;
16
+ readonly slug: string;
17
+ readonly description: string;
18
+ readonly type: string;
19
+ readonly status: string;
20
+ readonly published: string;
21
+ readonly voteCount: string;
22
+ readonly teamResponse: string;
23
+ readonly releaseNotesUrl: string;
24
+ readonly submitterName: string;
25
+ readonly submitterEmail: string;
26
+ readonly source: string;
27
+ readonly externalId: string;
28
+ readonly editTokenHash: string;
29
+ readonly createdAt: string;
30
+ readonly updatedAt: string;
31
+ };
32
+ export type ChangelogPropertyIds = {
33
+ readonly title: string;
34
+ readonly slug: string;
35
+ readonly date: string;
36
+ readonly summary: string;
37
+ readonly body: string;
38
+ readonly labels: string;
39
+ readonly image: string;
40
+ readonly published: string;
41
+ readonly createdAt: string;
42
+ readonly updatedAt: string;
43
+ };
44
+ export type NotionConfiguration = {
45
+ readonly databaseId: string;
46
+ readonly dataSourceId: string;
47
+ readonly properties: FeedbackPropertyIds;
48
+ };
49
+ export type ChangelogConfiguration = {
50
+ readonly databaseId: string;
51
+ readonly dataSourceId: string;
52
+ readonly properties: ChangelogPropertyIds;
53
+ };
54
+ export type EmailConfiguration = {
55
+ readonly provider: "cloudflare" | "resend" | "custom";
56
+ readonly from: string;
57
+ readonly endpoint?: string;
58
+ };
59
+ export type ConnectedConfiguration = {
60
+ readonly processing?: "small" | "standard";
61
+ readonly email?: EmailConfiguration;
62
+ readonly images?: boolean;
63
+ readonly issuer: string;
64
+ readonly loginUrl: string;
65
+ };
66
+ export type FeedbaxConfig = {
67
+ readonly connected?: ConnectedConfiguration;
68
+ readonly product: ProductConfiguration;
69
+ readonly theme: { readonly preset: string };
70
+ readonly notion: NotionConfiguration;
71
+ readonly changelog?: ChangelogConfiguration;
72
+ readonly features: PortalFeatures;
73
+ readonly submissions: {
74
+ readonly moderation: "required";
75
+ readonly maxTitleLength: number;
76
+ readonly maxDescriptionLength: number;
77
+ };
78
+ readonly turnstile: { readonly enabled: boolean; readonly siteKey?: string };
79
+ };
80
+
81
+ const rootKeys = [
82
+ "connected",
83
+ "product",
84
+ "theme",
85
+ "notion",
86
+ "changelog",
87
+ "features",
88
+ "submissions",
89
+ "turnstile",
90
+ ];
91
+ const featureKeys = ["voting", "comments", "changelog"] as const;
92
+ const feedbackPropertyKeys = [
93
+ "title",
94
+ "slug",
95
+ "description",
96
+ "type",
97
+ "status",
98
+ "published",
99
+ "voteCount",
100
+ "teamResponse",
101
+ "releaseNotesUrl",
102
+ "submitterName",
103
+ "submitterEmail",
104
+ "source",
105
+ "externalId",
106
+ "editTokenHash",
107
+ "createdAt",
108
+ "updatedAt",
109
+ ] as const;
110
+ const changelogPropertyKeys = [
111
+ "title",
112
+ "slug",
113
+ "date",
114
+ "summary",
115
+ "body",
116
+ "labels",
117
+ "image",
118
+ "published",
119
+ "createdAt",
120
+ "updatedAt",
121
+ ] as const;
122
+
123
+ export function defineFeedbax(config: FeedbaxConfig): FeedbaxConfig;
124
+ export function defineFeedbax(config: unknown): FeedbaxConfig {
125
+ const value = object(config, "Feedbax configuration");
126
+ rejectUnknown(value, rootKeys, "Feedbax configuration");
127
+ const product = object(value.product, "product");
128
+ rejectUnknown(
129
+ product,
130
+ ["name", "description", "logo", "siteUrl", "supportUrl", "privacyUrl"],
131
+ "product",
132
+ );
133
+ const validatedProduct: ProductConfiguration = {
134
+ name: text(product.name, "product.name"),
135
+ description: text(product.description, "product.description"),
136
+ logo: publicUrl(product.logo, "product.logo", true),
137
+ ...optionalPublicUrl(product, "siteUrl"),
138
+ ...optionalPublicUrl(product, "supportUrl"),
139
+ ...optionalPublicUrl(product, "privacyUrl"),
140
+ };
141
+ let connected: ConnectedConfiguration | undefined;
142
+ if (value.connected !== undefined) {
143
+ const input = object(value.connected, "connected");
144
+ rejectUnknown(
145
+ input,
146
+ ["issuer", "loginUrl", "email", "images", "processing"],
147
+ "connected",
148
+ );
149
+ const issuer = publicUrl(input.issuer, "connected.issuer", false);
150
+ const loginUrl = publicUrl(input.loginUrl, "connected.loginUrl", false);
151
+ if (!validatedProduct.siteUrl)
152
+ throw new Error("Connected Profile requires product.siteUrl.");
153
+ if (
154
+ new URL(loginUrl).username ||
155
+ new URL(loginUrl).password ||
156
+ new URL(loginUrl).hash
157
+ )
158
+ throw new Error(
159
+ "connected.loginUrl must not contain credentials or a fragment.",
160
+ );
161
+ let email: EmailConfiguration | undefined;
162
+ if (input.email !== undefined) {
163
+ const sender = object(input.email, "connected.email");
164
+ rejectUnknown(
165
+ sender,
166
+ ["provider", "from", "endpoint"],
167
+ "connected.email",
168
+ );
169
+ if (!["cloudflare", "resend", "custom"].includes(String(sender.provider)))
170
+ throw new Error(
171
+ "connected.email.provider must be cloudflare, resend, or custom.",
172
+ );
173
+ const from = text(sender.from, "connected.email.from");
174
+ if (from.length > 254 || !/^[^\s@<>]+@[^\s@<>]+\.[^\s@<>]+$/.test(from))
175
+ throw new Error(
176
+ "connected.email.from must be a plain sender email address.",
177
+ );
178
+ const endpoint =
179
+ sender.endpoint === undefined
180
+ ? undefined
181
+ : publicUrl(sender.endpoint, "connected.email.endpoint", false);
182
+ if (sender.provider === "custom" && !endpoint)
183
+ throw new Error("Custom email requires connected.email.endpoint.");
184
+ if (sender.provider !== "custom" && endpoint)
185
+ throw new Error("Only custom email uses connected.email.endpoint.");
186
+ if (
187
+ endpoint &&
188
+ (new URL(endpoint).username ||
189
+ new URL(endpoint).password ||
190
+ new URL(endpoint).hash)
191
+ )
192
+ throw new Error(
193
+ "Custom email endpoint must not contain credentials or a fragment.",
194
+ );
195
+ email = {
196
+ provider: sender.provider as EmailConfiguration["provider"],
197
+ from,
198
+ ...(endpoint ? { endpoint } : {}),
199
+ };
200
+ }
201
+ if (input.images !== undefined && typeof input.images !== "boolean")
202
+ throw new Error("connected.images must be true or false.");
203
+ if (
204
+ input.processing !== undefined &&
205
+ input.processing !== "small" &&
206
+ input.processing !== "standard"
207
+ )
208
+ throw new Error("connected.processing must be small or standard.");
209
+ connected = {
210
+ issuer,
211
+ loginUrl,
212
+ ...(input.processing !== undefined
213
+ ? { processing: input.processing }
214
+ : {}),
215
+ ...(email ? { email } : {}),
216
+ ...(input.images !== undefined ? { images: input.images } : {}),
217
+ };
218
+ }
219
+ const theme = object(value.theme, "theme");
220
+ rejectUnknown(theme, ["preset"], "theme");
221
+ const notion = storage(
222
+ value.notion,
223
+ feedbackPropertyKeys,
224
+ "notion",
225
+ ) as NotionConfiguration;
226
+ const features = object(value.features, "features");
227
+ rejectUnknown(features, featureKeys, "features");
228
+ const validatedFeatures = Object.fromEntries(
229
+ featureKeys.map((key) => {
230
+ if (typeof features[key] !== "boolean")
231
+ throw new Error(`features.${key} must be true or false.`);
232
+ return [key, features[key]];
233
+ }),
234
+ ) as PortalFeatures;
235
+ const changelog =
236
+ value.changelog === undefined
237
+ ? undefined
238
+ : (storage(
239
+ value.changelog,
240
+ changelogPropertyKeys,
241
+ "changelog",
242
+ ) as ChangelogConfiguration);
243
+ if (validatedFeatures.changelog && !changelog)
244
+ throw new Error(
245
+ "Changelog is enabled but changelog storage is not configured. Add changelog identifiers and property mappings, or set features.changelog to false.",
246
+ );
247
+ const submissions = object(value.submissions, "submissions");
248
+ rejectUnknown(
249
+ submissions,
250
+ ["moderation", "maxTitleLength", "maxDescriptionLength"],
251
+ "submissions",
252
+ );
253
+ if (submissions.moderation !== "required")
254
+ throw new Error('submissions.moderation must be "required".');
255
+ const turnstile = object(value.turnstile, "turnstile");
256
+ rejectUnknown(turnstile, ["enabled", "siteKey"], "turnstile");
257
+ if (typeof turnstile.enabled !== "boolean")
258
+ throw new Error("turnstile.enabled must be true or false.");
259
+ const siteKey =
260
+ turnstile.siteKey === undefined
261
+ ? undefined
262
+ : text(turnstile.siteKey, "turnstile.siteKey");
263
+ if (turnstile.enabled && !siteKey)
264
+ throw new Error("Turnstile is enabled but turnstile.siteKey is missing.");
265
+ return {
266
+ product: validatedProduct,
267
+ ...(connected ? { connected } : {}),
268
+ theme: { preset: text(theme.preset, "theme.preset") },
269
+ notion,
270
+ ...(changelog ? { changelog } : {}),
271
+ features: validatedFeatures,
272
+ submissions: {
273
+ moderation: "required",
274
+ maxTitleLength: positiveInteger(
275
+ submissions.maxTitleLength,
276
+ "submissions.maxTitleLength",
277
+ ),
278
+ maxDescriptionLength: positiveInteger(
279
+ submissions.maxDescriptionLength,
280
+ "submissions.maxDescriptionLength",
281
+ ),
282
+ },
283
+ turnstile: { enabled: turnstile.enabled, ...(siteKey ? { siteKey } : {}) },
284
+ };
285
+ }
286
+
287
+ export function assertProductionConfiguration(
288
+ config: FeedbaxConfig,
289
+ environment = process.env.NODE_ENV,
290
+ ): void {
291
+ if (environment === "production" && !config.product.siteUrl)
292
+ throw new Error(
293
+ "product.siteUrl is required in production so canonical and social metadata use an absolute URL.",
294
+ );
295
+ }
296
+ function storage(value: unknown, keys: readonly string[], label: string) {
297
+ const input = object(value, label);
298
+ rejectUnknown(input, ["databaseId", "dataSourceId", "properties"], label);
299
+ const properties = object(input.properties, `${label}.properties`);
300
+ rejectUnknown(properties, keys, `${label}.properties`);
301
+ for (const key of keys) text(properties[key], `${label}.properties.${key}`);
302
+ return {
303
+ databaseId: text(input.databaseId, `${label}.databaseId`),
304
+ dataSourceId: text(input.dataSourceId, `${label}.dataSourceId`),
305
+ properties: { ...properties },
306
+ };
307
+ }
308
+ function object(value: unknown, label: string): Record<string, unknown> {
309
+ if (!value || typeof value !== "object" || Array.isArray(value))
310
+ throw new Error(`${label} must be an object.`);
311
+ return value as Record<string, unknown>;
312
+ }
313
+ function text(value: unknown, label: string): string {
314
+ if (typeof value !== "string" || !value.trim())
315
+ throw new Error(`${label} must be a non-empty string.`);
316
+ return value.trim();
317
+ }
318
+ function positiveInteger(value: unknown, label: string): number {
319
+ if (!Number.isInteger(value) || Number(value) <= 0)
320
+ throw new Error(`${label} must be a positive integer.`);
321
+ return Number(value);
322
+ }
323
+ function rejectUnknown(
324
+ value: Record<string, unknown>,
325
+ allowed: readonly string[],
326
+ label: string,
327
+ ) {
328
+ const key = Object.keys(value).find(
329
+ (candidate) => !allowed.includes(candidate),
330
+ );
331
+ if (key) throw new Error(`Unknown ${label} key "${key}".`);
332
+ }
333
+ function optionalPublicUrl(
334
+ value: Record<string, unknown>,
335
+ key: "siteUrl" | "supportUrl" | "privacyUrl",
336
+ ) {
337
+ return value[key] === undefined
338
+ ? {}
339
+ : { [key]: publicUrl(value[key], `product.${key}`, false) };
340
+ }
341
+ function publicUrl(
342
+ value: unknown,
343
+ label: string,
344
+ allowRelative: boolean,
345
+ ): string {
346
+ const url = text(value, label);
347
+ if (
348
+ allowRelative &&
349
+ url.startsWith("/") &&
350
+ !url.startsWith("//") &&
351
+ !url.includes("\\") &&
352
+ !Array.from(url).some((character) => character.charCodeAt(0) <= 32)
353
+ )
354
+ return url;
355
+ try {
356
+ if (new URL(url).protocol === "https:") return url;
357
+ } catch {
358
+ // The actionable validation error below is intentionally shared by parse
359
+ // failures and unsupported protocols.
360
+ }
361
+ throw new Error(
362
+ `${label} must be ${allowRelative ? "root-relative or " : ""}an HTTPS URL.`,
363
+ );
364
+ }
@@ -0,0 +1,206 @@
1
+ import {
2
+ type FeedbackModule,
3
+ type PublicPost,
4
+ PublicPostCursorError,
5
+ VoteEligibilityError,
6
+ roadmapStatuses,
7
+ type PublicPostRoadmap,
8
+ } from "./index";
9
+ import type { ConnectedStore, VerifiedParticipant } from "./connected";
10
+
11
+ export function createConnectedFeedbackModule(
12
+ base: FeedbackModule,
13
+ store: ConnectedStore,
14
+ participant?: VerifiedParticipant,
15
+ ): FeedbackModule {
16
+ const enrich = async (post: PublicPost): Promise<PublicPost> => ({
17
+ ...post,
18
+ ...(post.voteCount !== undefined
19
+ ? { voteCount: (await store.voteState(post.slug)).voteCount }
20
+ : {}),
21
+ });
22
+ const enrichPage = async (posts: PublicPost[]) => {
23
+ const counts = await store.voteCounts(
24
+ posts
25
+ .filter((post) => post.voteCount !== undefined)
26
+ .map((post) => post.slug),
27
+ );
28
+ return posts.map((post) => ({
29
+ ...post,
30
+ ...(post.voteCount !== undefined
31
+ ? { voteCount: counts.get(post.slug) ?? 0 }
32
+ : {}),
33
+ }));
34
+ };
35
+ const authorizeComment = async (commentId: string) => {
36
+ const identity = requireParticipant();
37
+ const subject = await store.commentSubject(commentId);
38
+ if (subject && subject !== identity.subject)
39
+ throw new Error("This Comment belongs to another Participant.");
40
+ };
41
+ const requireParticipant = () => {
42
+ if (!participant) throw new Error("Sign in to participate.");
43
+ return participant;
44
+ };
45
+ const create = async (
46
+ input: Parameters<FeedbackModule["createComment"]>[0] & {
47
+ discussionId?: string;
48
+ },
49
+ ) => {
50
+ const identity = requireParticipant();
51
+ const command = { ...input, displayName: identity.displayName };
52
+ const comment = input.discussionId
53
+ ? await base.replyToCommentThread({
54
+ ...command,
55
+ discussionId: input.discussionId,
56
+ })
57
+ : await base.createComment(command);
58
+ // If attribution cannot be stored, remove the just-created Comment. Never claim verification without durable evidence.
59
+ try {
60
+ await store.attributeComment(comment.id, identity);
61
+ } catch (error) {
62
+ await base
63
+ .deleteComment({ commentId: comment.id })
64
+ .catch(() => undefined);
65
+ throw error;
66
+ }
67
+ return {
68
+ ...comment,
69
+ author: {
70
+ kind: "participant" as const,
71
+ displayName: identity.displayName,
72
+ verified: true,
73
+ },
74
+ };
75
+ };
76
+ const listPublicRoadmapPosts: FeedbackModule["listPublicRoadmapPosts"] =
77
+ async (query) => {
78
+ const page = await base.listPublicRoadmapPosts(query);
79
+ return { ...page, items: await enrichPage(page.items) };
80
+ };
81
+ return {
82
+ ...base,
83
+ async getPublicPost(slug) {
84
+ const post = await base.getPublicPost(slug);
85
+ return post ? enrich(post) : undefined;
86
+ },
87
+ async listPublicPosts(query = {}) {
88
+ if (query.sort === "new") {
89
+ const page = await base.listPublicPosts(query);
90
+ return { ...page, items: await enrichPage(page.items) };
91
+ }
92
+ // Rank the complete eligible set, not an already paginated Notion counter projection.
93
+ const { cursor, ...filters } = query;
94
+ const fingerprint = JSON.stringify(filters);
95
+ let offset = 0;
96
+ if (cursor) {
97
+ try {
98
+ const value = JSON.parse(Buffer.from(cursor, "base64url").toString());
99
+ if (
100
+ value.query !== fingerprint ||
101
+ !Number.isSafeInteger(value.offset) ||
102
+ value.offset < 0
103
+ )
104
+ throw new Error();
105
+ offset = value.offset;
106
+ } catch {
107
+ throw new PublicPostCursorError();
108
+ }
109
+ }
110
+ const all: PublicPost[] = [];
111
+ let next: string | undefined;
112
+ do {
113
+ const page = await base.listPublicPosts({
114
+ ...filters,
115
+ sort: "new",
116
+ cursor: next,
117
+ });
118
+ all.push(...(await enrichPage(page.items)));
119
+ next = page.nextCursor;
120
+ } while (next);
121
+ all.sort(
122
+ (a, b) =>
123
+ (b.voteCount ?? 0) - (a.voteCount ?? 0) ||
124
+ b.createdAt.getTime() - a.createdAt.getTime() ||
125
+ b.slug.localeCompare(a.slug),
126
+ );
127
+ return {
128
+ items: all.slice(offset, offset + 25),
129
+ ...(offset + 25 < all.length
130
+ ? {
131
+ nextCursor: Buffer.from(
132
+ JSON.stringify({ query: fingerprint, offset: offset + 25 }),
133
+ ).toString("base64url"),
134
+ }
135
+ : {}),
136
+ };
137
+ },
138
+ listPublicRoadmapPosts,
139
+ async getPublicPostRoadmap() {
140
+ return Object.fromEntries(
141
+ await Promise.all(
142
+ roadmapStatuses.map(async (status) => [
143
+ status,
144
+ await listPublicRoadmapPosts({ status }),
145
+ ]),
146
+ ),
147
+ ) as PublicPostRoadmap;
148
+ },
149
+ async changeVote(input) {
150
+ const identity = requireParticipant();
151
+ const post = await base.getPublicPost(input.slug);
152
+ if (!post || post.voteCount === undefined)
153
+ throw new VoteEligibilityError();
154
+ await store.setVote(
155
+ input.slug,
156
+ identity.subject,
157
+ input.intention === "add",
158
+ );
159
+ return {
160
+ slug: input.slug,
161
+ ...(await store.voteState(input.slug, identity.subject)),
162
+ };
163
+ },
164
+ async editComment(input) {
165
+ await authorizeComment(input.commentId);
166
+ const comment = await base.editComment(input);
167
+ return {
168
+ ...comment,
169
+ author: (await store.commentAuthor(comment.id)) ?? comment.author,
170
+ };
171
+ },
172
+ async deleteComment(input) {
173
+ await authorizeComment(input.commentId);
174
+ await base.deleteComment(input);
175
+ },
176
+ createComment: create,
177
+ replyToCommentThread: create,
178
+ async listCommentThreads(input) {
179
+ const page = await base.listCommentThreads(input);
180
+ const authors = await store.commentAuthors(
181
+ page.items.flatMap((thread) =>
182
+ thread.comments
183
+ .filter((comment) => comment.author.kind !== "product-team")
184
+ .map((comment) => comment.id),
185
+ ),
186
+ );
187
+ return {
188
+ ...page,
189
+ items: await Promise.all(
190
+ page.items.map(async (thread) => ({
191
+ ...thread,
192
+ comments: await Promise.all(
193
+ thread.comments.map(async (comment) => ({
194
+ ...comment,
195
+ author:
196
+ comment.author.kind === "product-team"
197
+ ? comment.author
198
+ : (authors.get(comment.id) ?? comment.author),
199
+ })),
200
+ ),
201
+ })),
202
+ ),
203
+ };
204
+ },
205
+ };
206
+ }