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,996 @@
1
+ import {
2
+ createHash,
3
+ randomBytes,
4
+ randomUUID,
5
+ timingSafeEqual,
6
+ } from "node:crypto";
7
+
8
+ import {
9
+ createNotionFeedbackStorage,
10
+ type NotionFeedbackStorageOptions,
11
+ } from "./notion-feedback";
12
+ import type {
13
+ FeedbackStorage,
14
+ NewStoredPost,
15
+ StoredPost,
16
+ } from "./feedback-storage";
17
+ import {
18
+ createNotionCommentStorage,
19
+ type CommentStorage,
20
+ type StoredComment,
21
+ } from "./notion-comments";
22
+
23
+ export {
24
+ createNotionFeedbackDataSource,
25
+ createFeedbackWorkflowViews,
26
+ feedbackWorkflowProperties,
27
+ validateNotionFeedbackDataSource,
28
+ type FeedbackDataSourceConfiguration,
29
+ type FeedbackPropertyIds,
30
+ } from "./notion-data-source";
31
+
32
+ export type PostType = "Feature Request" | "Bug Report" | "General Feedback";
33
+
34
+ export type PostStatus =
35
+ "New" | "Reviewing" | "Planned" | "In Progress" | "Shipped" | "Closed";
36
+
37
+ export interface SubmitPostInput {
38
+ imageIds?: string[];
39
+ title: string;
40
+ description: string;
41
+ type: PostType;
42
+ submitter?: {
43
+ name?: string;
44
+ email?: string;
45
+ };
46
+ }
47
+
48
+ export interface SubmitTrustedPostInput extends SubmitPostInput {
49
+ externalId: string;
50
+ }
51
+
52
+ export interface Post extends SubmitPostInput {
53
+ images?: import("./images").ImageAttachment[];
54
+ id: string;
55
+ slug: string;
56
+ status: PostStatus;
57
+ published: boolean;
58
+ createdAt: Date;
59
+ updatedAt: Date;
60
+ voteCount?: number;
61
+ teamResponse?: string;
62
+ releaseNotesUrl?: string;
63
+ }
64
+
65
+ declare const browserCapabilityBrand: unique symbol;
66
+
67
+ export type BrowserCapability = string & {
68
+ readonly [browserCapabilityBrand]: true;
69
+ };
70
+
71
+ export class BrowserCapabilityAuthorizationError extends Error {
72
+ constructor() {
73
+ super("Browser Capability did not authorize this draft.");
74
+ this.name = "BrowserCapabilityAuthorizationError";
75
+ }
76
+ }
77
+
78
+ export type SubmittedPost = Post & {
79
+ browserCapability: BrowserCapability;
80
+ };
81
+
82
+ export type PublicPost = Pick<
83
+ Post,
84
+ | "slug"
85
+ | "title"
86
+ | "description"
87
+ | "type"
88
+ | "status"
89
+ | "createdAt"
90
+ | "updatedAt"
91
+ > & {
92
+ voteCount?: number;
93
+ teamResponse?: string;
94
+ releaseNotesUrl?: string;
95
+ images?: import("./images").ImageAttachment[];
96
+ };
97
+
98
+ export interface ChangeVoteInput {
99
+ slug: string;
100
+ intention: "add" | "remove";
101
+ }
102
+
103
+ export interface VoteResult {
104
+ slug: string;
105
+ voteCount: number;
106
+ }
107
+
108
+ export class VoteEligibilityError extends Error {
109
+ constructor() {
110
+ super("Votes are available only for Published Posts.");
111
+ this.name = "VoteEligibilityError";
112
+ }
113
+ }
114
+
115
+ export class PublicPostCursorError extends Error {
116
+ constructor() {
117
+ super("Public Post cursor does not match this feed view.");
118
+ this.name = "PublicPostCursorError";
119
+ }
120
+ }
121
+
122
+ export type DraftPost = Pick<
123
+ Post,
124
+ | "id"
125
+ | "slug"
126
+ | "title"
127
+ | "description"
128
+ | "type"
129
+ | "status"
130
+ | "createdAt"
131
+ | "updatedAt"
132
+ | "submitter"
133
+ | "images"
134
+ >;
135
+
136
+ export type TrustedSubmittedPost = Omit<Post, "published" | "submitter">;
137
+
138
+ export interface EditDraftPostInput {
139
+ id: string;
140
+ browserCapability: BrowserCapability;
141
+ title?: string;
142
+ description?: string;
143
+ type?: PostType;
144
+ }
145
+
146
+ export interface WithdrawDraftPostInput {
147
+ id: string;
148
+ browserCapability: BrowserCapability;
149
+ }
150
+
151
+ export type GetDraftPostInput = WithdrawDraftPostInput;
152
+
153
+ export interface PublicPostQuery {
154
+ cursor?: string;
155
+ type?: PostType;
156
+ status?: PostStatus;
157
+ types?: PostType[];
158
+ statuses?: PostStatus[];
159
+ search?: string;
160
+ sort?: "trending" | "top" | "new";
161
+ }
162
+
163
+ export interface PublicPostPage {
164
+ items: PublicPost[];
165
+ nextCursor?: string;
166
+ }
167
+
168
+ export type CommentAuthor =
169
+ | { kind: "participant"; displayName: string; verified?: boolean }
170
+ | { kind: "product-team"; displayName: "Product Team" };
171
+
172
+ export interface Comment {
173
+ images?: import("./images").ImageAttachment[];
174
+ id: string;
175
+ body: string;
176
+ author: CommentAuthor;
177
+ createdAt: Date;
178
+ }
179
+
180
+ export interface CommentThread {
181
+ id: string;
182
+ comments: Comment[];
183
+ }
184
+
185
+ export interface CommentThreadPage {
186
+ items: CommentThread[];
187
+ nextCursor?: string;
188
+ }
189
+
190
+ export interface ListCommentThreadsInput {
191
+ slug: string;
192
+ cursor?: string;
193
+ }
194
+
195
+ export interface CreateCommentInput {
196
+ imageIds?: string[];
197
+ slug: string;
198
+ body: string;
199
+ displayName: string;
200
+ }
201
+
202
+ export interface ReplyToCommentThreadInput extends CreateCommentInput {
203
+ discussionId: string;
204
+ }
205
+
206
+ export interface CreatedComment extends Comment {
207
+ discussionId: string;
208
+ }
209
+
210
+ export interface EditCommentInput {
211
+ commentId: string;
212
+ body: string;
213
+ }
214
+
215
+ export interface DeleteCommentInput {
216
+ commentId: string;
217
+ }
218
+
219
+ export class CommentEligibilityError extends Error {
220
+ constructor() {
221
+ super("Comments are available only for Published Posts.");
222
+ this.name = "CommentEligibilityError";
223
+ }
224
+ }
225
+
226
+ export class CommentValidationError extends Error {
227
+ constructor(message: string) {
228
+ super(message);
229
+ this.name = "CommentValidationError";
230
+ }
231
+ }
232
+
233
+ export type RoadmapStatus = Extract<
234
+ PostStatus,
235
+ "Planned" | "In Progress" | "Shipped"
236
+ >;
237
+
238
+ export interface PublicRoadmapPage {
239
+ items: PublicPost[];
240
+ nextCursor?: string;
241
+ totalCount: number;
242
+ }
243
+
244
+ export interface PublicRoadmapQuery {
245
+ status: RoadmapStatus;
246
+ cursor?: string;
247
+ }
248
+
249
+ export type PublicPostRoadmap = Record<RoadmapStatus, PublicRoadmapPage>;
250
+
251
+ export const roadmapStatuses = [
252
+ "Planned",
253
+ "In Progress",
254
+ "Shipped",
255
+ ] as const satisfies readonly RoadmapStatus[];
256
+
257
+ let postCreationQueue: Promise<void> = Promise.resolve();
258
+
259
+ class InMemoryFeedbackStorage implements FeedbackStorage {
260
+ readonly #items: Map<string, StoredPost>;
261
+
262
+ constructor(initialItems: StoredPost[] = []) {
263
+ this.#items = new Map(
264
+ initialItems.map((item) => [item.id, structuredClone(item)]),
265
+ );
266
+ }
267
+
268
+ async create(item: NewStoredPost): Promise<StoredPost> {
269
+ return this.save({ ...item, id: randomUUID() });
270
+ }
271
+
272
+ async save(item: StoredPost): Promise<StoredPost> {
273
+ this.#items.set(item.id, structuredClone(item));
274
+ return structuredClone(item);
275
+ }
276
+
277
+ async updateVoteCount(
278
+ item: StoredPost,
279
+ voteCount: number,
280
+ ): Promise<StoredPost> {
281
+ return this.save({ ...item, voteCount });
282
+ }
283
+
284
+ async find(id: string): Promise<StoredPost | undefined> {
285
+ const item = this.#items.get(id);
286
+ return item ? structuredClone(item) : undefined;
287
+ }
288
+
289
+ async findBySlug(slug: string): Promise<StoredPost | undefined> {
290
+ const item = [...this.#items.values()].find(
291
+ (candidate) => candidate.slug === slug,
292
+ );
293
+ return item ? structuredClone(item) : undefined;
294
+ }
295
+
296
+ async findPublicBySlug(slug: string): Promise<StoredPost | undefined> {
297
+ return this.findBySlug(slug);
298
+ }
299
+
300
+ async findByExternalId(externalId: string): Promise<StoredPost | undefined> {
301
+ const item = [...this.#items.values()].find(
302
+ (candidate) => candidate.externalId === externalId,
303
+ );
304
+ return item ? structuredClone(item) : undefined;
305
+ }
306
+
307
+ async list(): Promise<StoredPost[]> {
308
+ return [...this.#items.values()].map((item) => structuredClone(item));
309
+ }
310
+
311
+ async listPublic(query: PublicPostQuery): Promise<{
312
+ items: StoredPost[];
313
+ nextCursor?: string;
314
+ }> {
315
+ const items = (await this.list())
316
+ .filter(
317
+ (item) =>
318
+ item.published &&
319
+ (!query.type || item.type === query.type) &&
320
+ (!query.status || item.status === query.status) &&
321
+ (!query.types?.length || query.types.includes(item.type)) &&
322
+ (!query.statuses?.length || query.statuses.includes(item.status)) &&
323
+ (!query.search ||
324
+ `${item.title}\n${item.description}`
325
+ .toLocaleLowerCase()
326
+ .includes(query.search.toLocaleLowerCase())),
327
+ )
328
+ .sort((left, right) =>
329
+ query.sort === "new"
330
+ ? right.createdAt.getTime() - left.createdAt.getTime() ||
331
+ right.id.localeCompare(left.id)
332
+ : (right.voteCount ?? 0) - (left.voteCount ?? 0) ||
333
+ right.createdAt.getTime() - left.createdAt.getTime() ||
334
+ right.id.localeCompare(left.id),
335
+ );
336
+ return paginateStoredPosts(items, query.cursor);
337
+ }
338
+
339
+ async listPublicRoadmap(query: PublicRoadmapQuery): Promise<{
340
+ items: StoredPost[];
341
+ nextCursor?: string;
342
+ totalCount: number;
343
+ }> {
344
+ const items = (await this.list())
345
+ .filter(isPublicPostRoadmapItem)
346
+ .filter((item) => item.status === query.status)
347
+ .sort(
348
+ (left, right) =>
349
+ right.updatedAt.getTime() - left.updatedAt.getTime() ||
350
+ right.id.localeCompare(left.id),
351
+ );
352
+ return {
353
+ ...paginateStoredPosts(items, query.cursor),
354
+ totalCount: items.length,
355
+ };
356
+ }
357
+
358
+ async remove(id: string): Promise<void> {
359
+ this.#items.delete(id);
360
+ }
361
+ }
362
+
363
+ export interface FeedbackModule {
364
+ getAttachmentPostState(
365
+ slug: string,
366
+ ): Promise<"published" | "draft" | "private" | undefined>;
367
+ createComment(input: CreateCommentInput): Promise<CreatedComment>;
368
+ replyToCommentThread(
369
+ input: ReplyToCommentThreadInput,
370
+ ): Promise<CreatedComment>;
371
+ editComment(input: EditCommentInput): Promise<CreatedComment>;
372
+ deleteComment(input: DeleteCommentInput): Promise<void>;
373
+ changeVote(input: ChangeVoteInput): Promise<VoteResult>;
374
+ submitPost(input: SubmitPostInput): Promise<SubmittedPost>;
375
+ editDraftPost(input: EditDraftPostInput): Promise<Post>;
376
+ getPublicPost(slug: string): Promise<PublicPost | undefined>;
377
+ getDraftPost(input: GetDraftPostInput): Promise<DraftPost>;
378
+ listPublicPosts(query?: PublicPostQuery): Promise<PublicPostPage>;
379
+ listPublicRoadmapPosts(query: PublicRoadmapQuery): Promise<PublicRoadmapPage>;
380
+ submitTrustedPost(
381
+ input: SubmitTrustedPostInput,
382
+ ): Promise<TrustedSubmittedPost>;
383
+ withdrawDraftPost(input: WithdrawDraftPostInput): Promise<void>;
384
+ getPublicPostRoadmap(): Promise<PublicPostRoadmap>;
385
+ listCommentThreads(
386
+ input: ListCommentThreadsInput,
387
+ ): Promise<CommentThreadPage>;
388
+ }
389
+
390
+ export type FeedbackMutationModule = Pick<
391
+ FeedbackModule,
392
+ "submitPost" | "editDraftPost" | "withdrawDraftPost"
393
+ >;
394
+
395
+ interface CreateFeedbackModuleOptions {
396
+ initialItems?: StoredPost[];
397
+ storage?: FeedbackStorage;
398
+ votingEnabled?: boolean;
399
+ commentsEnabled?: boolean;
400
+ initialComments?: StoredComment[];
401
+ commentStorage?: CommentStorage;
402
+ }
403
+
404
+ export function createFeedbackModule(
405
+ options: CreateFeedbackModuleOptions = {},
406
+ ): FeedbackModule {
407
+ const storage =
408
+ options.storage ?? new InMemoryFeedbackStorage(options.initialItems);
409
+ const votingEnabled = options.votingEnabled ?? true;
410
+ const commentsEnabled = options.commentsEnabled ?? true;
411
+ const commentStorage =
412
+ options.commentStorage ??
413
+ createInMemoryCommentStorage(options.initialComments);
414
+ const voteQueues = new Map<string, Promise<void>>();
415
+ const editStoredDraft = async (input: EditDraftPostInput) => {
416
+ const item = await storage.find(input.id);
417
+
418
+ if (!authorizesDraft(item, input.browserCapability)) {
419
+ throw new BrowserCapabilityAuthorizationError();
420
+ }
421
+
422
+ return storage.save({
423
+ ...item,
424
+ ...(input.title === undefined ? {} : { title: input.title }),
425
+ ...(input.description === undefined
426
+ ? {}
427
+ : { description: input.description }),
428
+ ...(input.type === undefined ? {} : { type: input.type }),
429
+ updatedAt: new Date(),
430
+ });
431
+ };
432
+ const listPublicRoadmapPosts = async (
433
+ query: PublicRoadmapQuery,
434
+ ): Promise<PublicRoadmapPage> => {
435
+ const page = await storage.listPublicRoadmap(query);
436
+ return {
437
+ items: page.items.map((item) => toPublicPost(toPost(item))),
438
+ ...(page.nextCursor ? { nextCursor: page.nextCursor } : {}),
439
+ totalCount: page.totalCount,
440
+ };
441
+ };
442
+
443
+ return {
444
+ async getAttachmentPostState(slug) {
445
+ const post = await storage.findBySlug(slug);
446
+ return !post
447
+ ? undefined
448
+ : post.published
449
+ ? "published"
450
+ : post.status === "New"
451
+ ? "draft"
452
+ : "private";
453
+ },
454
+ async createComment(input) {
455
+ const { post, body, displayName } = await validateCommentCreation(
456
+ input,
457
+ storage,
458
+ );
459
+ return toCreatedComment(
460
+ await commentStorage.createPageComment({
461
+ postId: post.id,
462
+ body,
463
+ displayName,
464
+ }),
465
+ );
466
+ },
467
+ async replyToCommentThread(input) {
468
+ const { post, body, displayName } = await validateCommentCreation(
469
+ input,
470
+ storage,
471
+ );
472
+ if (!input.discussionId.trim())
473
+ throw new CommentValidationError("Comment Thread is required.");
474
+ let cursor: string | undefined;
475
+ let eligible: boolean;
476
+ do {
477
+ const page = await commentStorage.listPageComments(post.id, cursor);
478
+ eligible = page.items.some(
479
+ (comment) =>
480
+ comment.discussionId === input.discussionId &&
481
+ !comment.resolved &&
482
+ (comment.scope ?? "page") === "page",
483
+ );
484
+ cursor = page.nextCursor;
485
+ } while (!eligible && cursor);
486
+ if (!eligible)
487
+ throw new CommentValidationError(
488
+ "Replies require an existing open Comment Thread on this Post.",
489
+ );
490
+ return toCreatedComment(
491
+ await commentStorage.createDiscussionReply({
492
+ postId: post.id,
493
+ discussionId: input.discussionId,
494
+ body,
495
+ displayName,
496
+ }),
497
+ );
498
+ },
499
+ async editComment(input) {
500
+ const body = input.body.trim();
501
+ if (!body) throw new CommentValidationError("Comment text is required.");
502
+ return toCreatedComment(
503
+ await commentStorage.updateComment(input.commentId, body),
504
+ );
505
+ },
506
+ deleteComment(input) {
507
+ return commentStorage.deleteComment(input.commentId);
508
+ },
509
+ async listCommentThreads({ slug, cursor }) {
510
+ if (!commentsEnabled) return { items: [] };
511
+ const post = await storage.findPublicBySlug(slug);
512
+ if (!post?.published) return { items: [] };
513
+ const page = await commentStorage.listPageComments(post.id, cursor);
514
+ const threads = new Map<string, CommentThread>();
515
+ for (const comment of page.items) {
516
+ if (comment.resolved || (comment.scope ?? "page") !== "page") continue;
517
+ const thread = threads.get(comment.discussionId) ?? {
518
+ id: comment.discussionId,
519
+ comments: [],
520
+ };
521
+ thread.comments.push({
522
+ id: comment.id,
523
+ body: comment.body,
524
+ author: comment.author,
525
+ createdAt: comment.createdAt,
526
+ });
527
+ threads.set(comment.discussionId, thread);
528
+ }
529
+ return {
530
+ items: [...threads.values()],
531
+ ...(page.nextCursor ? { nextCursor: page.nextCursor } : {}),
532
+ };
533
+ },
534
+ changeVote(input) {
535
+ const previous = voteQueues.get(input.slug) ?? Promise.resolve();
536
+ const mutation = previous.then(async () => {
537
+ const item = await storage.findBySlug(input.slug);
538
+ if (!item?.published) throw new VoteEligibilityError();
539
+ const voteCount = Math.max(
540
+ 0,
541
+ (item.voteCount ?? 0) + (input.intention === "add" ? 1 : -1),
542
+ );
543
+ const saved = await storage.updateVoteCount(item, voteCount);
544
+ return { slug: saved.slug, voteCount: saved.voteCount ?? voteCount };
545
+ });
546
+ voteQueues.set(
547
+ input.slug,
548
+ mutation.then(
549
+ () => undefined,
550
+ () => undefined,
551
+ ),
552
+ );
553
+ return mutation;
554
+ },
555
+ submitPost(input) {
556
+ const submission = postCreationQueue.then(async () => {
557
+ const now = new Date();
558
+ const browserCapability = createBrowserCapability();
559
+ const slug = await createUniqueSlug(input.title, storage);
560
+ const item = await storage.create({
561
+ ...input,
562
+ slug,
563
+ status: "New",
564
+ published: false,
565
+ createdAt: now,
566
+ updatedAt: now,
567
+ browserCapabilityHash: hashBrowserCapability(browserCapability),
568
+ voteCount: 0,
569
+ });
570
+
571
+ return {
572
+ ...toPost(item),
573
+ browserCapability,
574
+ };
575
+ });
576
+ postCreationQueue = submission.then(
577
+ () => undefined,
578
+ () => undefined,
579
+ );
580
+ return submission;
581
+ },
582
+ async editDraftPost(input) {
583
+ return toPost(await editStoredDraft(input));
584
+ },
585
+ async getPublicPost(slug) {
586
+ const item = await storage.findPublicBySlug(slug);
587
+ return item?.published
588
+ ? toPublicPost(toPost(item), votingEnabled)
589
+ : undefined;
590
+ },
591
+ async getDraftPost(input) {
592
+ const item = await storage.find(input.id);
593
+ if (!authorizesDraft(item, input.browserCapability)) {
594
+ throw new BrowserCapabilityAuthorizationError();
595
+ }
596
+ return toDraftPost(toPost(item));
597
+ },
598
+ async listPublicPosts(query = {}) {
599
+ const storageCursor = query.cursor
600
+ ? decodePublicPostCursor(query.cursor, query)
601
+ : undefined;
602
+ const page = await storage.listPublic({
603
+ ...query,
604
+ cursor: storageCursor,
605
+ });
606
+ return {
607
+ items: page.items.map((item) =>
608
+ toPublicPost(toPost(item), votingEnabled),
609
+ ),
610
+ ...(page.nextCursor
611
+ ? { nextCursor: encodePublicPostCursor(page.nextCursor, query) }
612
+ : {}),
613
+ };
614
+ },
615
+ listPublicRoadmapPosts,
616
+ async submitTrustedPost(input) {
617
+ const existingItem = await storage.findByExternalId(input.externalId);
618
+ if (existingItem) return toTrustedSubmittedPost(existingItem);
619
+
620
+ const now = new Date();
621
+ const slug = await createUniqueSlug(input.title, storage);
622
+ const item = await storage.create({
623
+ title: input.title,
624
+ slug,
625
+ description: input.description,
626
+ type: input.type,
627
+ ...(input.submitter ? { submitter: input.submitter } : {}),
628
+ status: "New",
629
+ published: false,
630
+ createdAt: now,
631
+ updatedAt: now,
632
+ source: "API",
633
+ externalId: input.externalId,
634
+ voteCount: 0,
635
+ });
636
+ return toTrustedSubmittedPost(item);
637
+ },
638
+ async withdrawDraftPost(input) {
639
+ const item = await storage.find(input.id);
640
+
641
+ if (!authorizesDraft(item, input.browserCapability)) {
642
+ throw new BrowserCapabilityAuthorizationError();
643
+ }
644
+
645
+ await storage.remove(item.id);
646
+ },
647
+ async getPublicPostRoadmap() {
648
+ const pages = await Promise.all(
649
+ roadmapStatuses.map((status) => listPublicRoadmapPosts({ status })),
650
+ );
651
+ return Object.fromEntries(
652
+ roadmapStatuses.map((status, index) => [status, pages[index]]),
653
+ ) as PublicPostRoadmap;
654
+ },
655
+ };
656
+ }
657
+
658
+ export function createNotionFeedbackModule(
659
+ options: NotionFeedbackStorageOptions & {
660
+ votingEnabled?: boolean;
661
+ commentsEnabled?: boolean;
662
+ },
663
+ ): FeedbackModule {
664
+ return createFeedbackModule({
665
+ storage: createNotionFeedbackStorage(options),
666
+ commentStorage: createNotionCommentStorage(options),
667
+ votingEnabled: options.votingEnabled,
668
+ commentsEnabled: options.commentsEnabled,
669
+ });
670
+ }
671
+
672
+ function createInMemoryCommentStorage(
673
+ initialComments: StoredComment[] = [],
674
+ ): CommentStorage {
675
+ const comments = initialComments.map((comment) => structuredClone(comment));
676
+ return {
677
+ async createPageComment(input) {
678
+ const id = randomUUID();
679
+ const comment: StoredComment = {
680
+ id,
681
+ postId: input.postId,
682
+ discussionId: id,
683
+ body: input.body,
684
+ author: { kind: "participant", displayName: input.displayName },
685
+ createdAt: new Date(),
686
+ resolved: false,
687
+ scope: "page",
688
+ };
689
+ comments.push(comment);
690
+ return structuredClone(comment);
691
+ },
692
+ async createDiscussionReply(input) {
693
+ const comment: StoredComment = {
694
+ id: randomUUID(),
695
+ postId: input.postId,
696
+ discussionId: input.discussionId,
697
+ body: input.body,
698
+ author: { kind: "participant", displayName: input.displayName },
699
+ createdAt: new Date(),
700
+ resolved: false,
701
+ scope: "page",
702
+ };
703
+ comments.push(comment);
704
+ return structuredClone(comment);
705
+ },
706
+ async listPageComments(postId, cursor) {
707
+ const matching = comments.filter((comment) => comment.postId === postId);
708
+ const start = cursor
709
+ ? Math.max(
710
+ 0,
711
+ matching.findIndex((comment) => comment.id === cursor) + 1,
712
+ )
713
+ : 0;
714
+ const items = matching
715
+ .slice(start, start + 50)
716
+ .map((comment) => structuredClone(comment));
717
+ const last = items.at(-1);
718
+ return {
719
+ items,
720
+ ...(last && start + items.length < matching.length
721
+ ? { nextCursor: last.id }
722
+ : {}),
723
+ };
724
+ },
725
+ async updateComment(commentId, body) {
726
+ const index = comments.findIndex((comment) => comment.id === commentId);
727
+ const comment = comments[index];
728
+ if (!comment || comment.resolved)
729
+ throw new Error("Comment is no longer open.");
730
+ if (comment.author.kind !== "participant")
731
+ throw new Error("Product Team Comments cannot be changed.");
732
+ const updated = { ...comment, body };
733
+ comments[index] = updated;
734
+ return structuredClone(updated);
735
+ },
736
+ async deleteComment(commentId) {
737
+ const index = comments.findIndex((comment) => comment.id === commentId);
738
+ const comment = comments[index];
739
+ if (!comment || comment.resolved)
740
+ throw new Error("Comment is no longer open.");
741
+ if (comment.author.kind !== "participant")
742
+ throw new Error("Product Team Comments cannot be changed.");
743
+ comments.splice(index, 1);
744
+ },
745
+ };
746
+ }
747
+
748
+ async function validateCommentCreation(
749
+ input: CreateCommentInput,
750
+ storage: FeedbackStorage,
751
+ ) {
752
+ const post = await storage.findPublicBySlug(input.slug);
753
+ if (!post?.published) throw new CommentEligibilityError();
754
+ const displayName = input.displayName.trim();
755
+ if (!displayName)
756
+ throw new CommentValidationError(
757
+ "Device Profile display name is required.",
758
+ );
759
+ const body = input.body.trim();
760
+ if (!body) throw new CommentValidationError("Comment text is required.");
761
+ return { post, displayName, body };
762
+ }
763
+
764
+ function toCreatedComment(comment: StoredComment): CreatedComment {
765
+ return {
766
+ id: comment.id,
767
+ discussionId: comment.discussionId,
768
+ body: comment.body,
769
+ author: comment.author,
770
+ createdAt: comment.createdAt,
771
+ };
772
+ }
773
+
774
+ function encodePublicPostCursor(
775
+ storageCursor: string,
776
+ query: PublicPostQuery,
777
+ ): string {
778
+ return Buffer.from(
779
+ JSON.stringify({ storageCursor, scope: publicPostCursorScope(query) }),
780
+ "utf8",
781
+ ).toString("base64url");
782
+ }
783
+
784
+ function decodePublicPostCursor(
785
+ cursor: string,
786
+ query: PublicPostQuery,
787
+ ): string {
788
+ try {
789
+ const value: unknown = JSON.parse(
790
+ Buffer.from(cursor, "base64url").toString("utf8"),
791
+ );
792
+ if (
793
+ !value ||
794
+ typeof value !== "object" ||
795
+ typeof Reflect.get(value, "storageCursor") !== "string" ||
796
+ Reflect.get(value, "scope") !== publicPostCursorScope(query)
797
+ ) {
798
+ throw new PublicPostCursorError();
799
+ }
800
+ return Reflect.get(value, "storageCursor") as string;
801
+ } catch (error) {
802
+ if (error instanceof PublicPostCursorError) throw error;
803
+ throw new PublicPostCursorError();
804
+ }
805
+ }
806
+
807
+ function publicPostCursorScope(query: PublicPostQuery): string {
808
+ return JSON.stringify({
809
+ sort: query.sort === "trending" ? "top" : (query.sort ?? "top"),
810
+ type: query.type,
811
+ status: query.status,
812
+ types: query.types,
813
+ statuses: query.statuses,
814
+ search: query.search,
815
+ });
816
+ }
817
+
818
+ function encodeCursor(id: string): string {
819
+ return Buffer.from(id, "utf8").toString("base64url");
820
+ }
821
+
822
+ function decodeCursor(cursor: string): string {
823
+ return Buffer.from(cursor, "base64url").toString("utf8");
824
+ }
825
+
826
+ function paginateStoredPosts(
827
+ items: StoredPost[],
828
+ cursor?: string,
829
+ ): { items: StoredPost[]; nextCursor?: string } {
830
+ const cursorId = cursor ? decodeCursor(cursor) : undefined;
831
+ const cursorIndex = cursorId
832
+ ? items.findIndex((item) => item.id === cursorId)
833
+ : -1;
834
+ const startIndex = cursorIndex + 1;
835
+ const pageItems = items.slice(startIndex, startIndex + 25);
836
+ const hasNextPage = startIndex + pageItems.length < items.length;
837
+ const lastItem = pageItems.at(-1);
838
+ return {
839
+ items: pageItems,
840
+ ...(hasNextPage && lastItem
841
+ ? { nextCursor: encodeCursor(lastItem.id) }
842
+ : {}),
843
+ };
844
+ }
845
+
846
+ function toPost(item: StoredPost): Post {
847
+ return {
848
+ id: item.id,
849
+ slug: item.slug,
850
+ title: item.title,
851
+ description: item.description,
852
+ type: item.type,
853
+ ...(item.submitter === undefined ? {} : { submitter: item.submitter }),
854
+ status: item.status,
855
+ published: item.published,
856
+ createdAt: item.createdAt,
857
+ updatedAt: item.updatedAt,
858
+ voteCount: item.voteCount ?? 0,
859
+ ...(item.teamResponse ? { teamResponse: item.teamResponse } : {}),
860
+ ...(item.releaseNotesUrl ? { releaseNotesUrl: item.releaseNotesUrl } : {}),
861
+ };
862
+ }
863
+
864
+ function toPublicPost(post: Post, votingEnabled = true): PublicPost {
865
+ return {
866
+ slug: post.slug,
867
+ title: post.title,
868
+ description: post.description,
869
+ type: post.type,
870
+ status: post.status,
871
+ createdAt: post.createdAt,
872
+ updatedAt: post.updatedAt,
873
+ ...(votingEnabled ? { voteCount: post.voteCount ?? 0 } : {}),
874
+ ...(post.teamResponse ? { teamResponse: post.teamResponse } : {}),
875
+ ...(post.releaseNotesUrl ? { releaseNotesUrl: post.releaseNotesUrl } : {}),
876
+ };
877
+ }
878
+
879
+ function toDraftPost(post: Post): DraftPost {
880
+ return {
881
+ id: post.id,
882
+ slug: post.slug,
883
+ title: post.title,
884
+ description: post.description,
885
+ type: post.type,
886
+ status: post.status,
887
+ createdAt: post.createdAt,
888
+ updatedAt: post.updatedAt,
889
+ ...(post.submitter ? { submitter: { ...post.submitter } } : {}),
890
+ };
891
+ }
892
+
893
+ async function createUniqueSlug(
894
+ title: string,
895
+ storage: FeedbackStorage,
896
+ ): Promise<string> {
897
+ const baseSlug =
898
+ title
899
+ .normalize("NFKD")
900
+ .replace(/[\u0300-\u036f]/g, "")
901
+ .toLowerCase()
902
+ .replace(/[^a-z0-9]+/g, "-")
903
+ .replace(/^-|-$/g, "") || "post";
904
+
905
+ let slug = baseSlug;
906
+ let suffix = 2;
907
+ while (await storage.findBySlug(slug)) {
908
+ slug = `${baseSlug}-${suffix}`;
909
+ suffix += 1;
910
+ }
911
+ return slug;
912
+ }
913
+
914
+ function toTrustedSubmittedPost(item: Post): TrustedSubmittedPost {
915
+ return {
916
+ id: item.id,
917
+ slug: item.slug,
918
+ title: item.title,
919
+ description: item.description,
920
+ type: item.type,
921
+ status: item.status,
922
+ createdAt: item.createdAt,
923
+ updatedAt: item.updatedAt,
924
+ };
925
+ }
926
+
927
+ function createBrowserCapability(): BrowserCapability {
928
+ return randomBytes(32).toString("base64url") as BrowserCapability;
929
+ }
930
+
931
+ function hashBrowserCapability(browserCapability: BrowserCapability): string {
932
+ return createHash("sha256").update(browserCapability).digest("base64url");
933
+ }
934
+
935
+ function authorizesDraft(
936
+ item: StoredPost | undefined,
937
+ browserCapability: BrowserCapability,
938
+ ): item is StoredPost & { browserCapabilityHash: string } {
939
+ if (!item?.browserCapabilityHash || item.status !== "New" || item.published) {
940
+ return false;
941
+ }
942
+
943
+ const storedHash = Buffer.from(item.browserCapabilityHash);
944
+ const presentedHash = Buffer.from(hashBrowserCapability(browserCapability));
945
+
946
+ return (
947
+ storedHash.length === presentedHash.length &&
948
+ timingSafeEqual(storedHash, presentedHash)
949
+ );
950
+ }
951
+
952
+ function isPublicPostRoadmapItem<Item extends Post>(
953
+ item: Item,
954
+ ): item is Item & { status: RoadmapStatus } {
955
+ return (
956
+ item.published &&
957
+ (item.status === "Planned" ||
958
+ item.status === "In Progress" ||
959
+ item.status === "Shipped")
960
+ );
961
+ }
962
+
963
+ export {
964
+ createConnectedStore,
965
+ type ConnectedStore,
966
+ type ConnectedDatabase,
967
+ type VerifiedParticipant,
968
+ } from "./connected";
969
+ export {
970
+ signIdentityHandoff,
971
+ verifyIdentityHandoff,
972
+ type IdentityHandoff,
973
+ } from "./identity-handoff";
974
+
975
+ export { createConnectedFeedbackModule } from "./connected-feedback";
976
+
977
+ export {
978
+ createParticipationModule,
979
+ type ParticipantNotification,
980
+ type FollowState,
981
+ } from "./participation";
982
+ export {
983
+ createEmailSender,
984
+ type EmailSender,
985
+ type NotificationEmail,
986
+ type CloudflareEmailBinding,
987
+ } from "./email-delivery";
988
+
989
+ export {
990
+ createImageModule,
991
+ ImageAttachmentError,
992
+ type ImageAttachment,
993
+ } from "./images";
994
+
995
+ export { createImageFeedbackModule } from "./image-feedback";
996
+ export { createNotionImageReviewWriter } from "./notion-image-review";