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