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,651 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
PostStatus,
|
|
3
|
+
PostType,
|
|
4
|
+
PublicPostQuery,
|
|
5
|
+
PublicRoadmapQuery,
|
|
6
|
+
} from "./index";
|
|
7
|
+
import type {
|
|
8
|
+
FeedbackStorage,
|
|
9
|
+
NewStoredPost,
|
|
10
|
+
StoredPost,
|
|
11
|
+
} from "./feedback-storage";
|
|
12
|
+
import type { FeedbackPropertyIds } from "./notion-data-source";
|
|
13
|
+
import { requestNotion, type NotionRetryOptions } from "./notion-request";
|
|
14
|
+
|
|
15
|
+
const NOTION_API_URL = "https://api.notion.com/v1";
|
|
16
|
+
const NOTION_API_VERSION = "2026-03-11";
|
|
17
|
+
|
|
18
|
+
export interface NotionFeedbackStorageOptions {
|
|
19
|
+
token: string;
|
|
20
|
+
dataSourceId: string;
|
|
21
|
+
propertyIds: FeedbackPropertyIds;
|
|
22
|
+
request?: typeof fetch;
|
|
23
|
+
retry?: NotionRetryOptions;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function createNotionFeedbackStorage({
|
|
27
|
+
token,
|
|
28
|
+
dataSourceId,
|
|
29
|
+
propertyIds,
|
|
30
|
+
request = fetch,
|
|
31
|
+
retry,
|
|
32
|
+
}: NotionFeedbackStorageOptions): FeedbackStorage {
|
|
33
|
+
const headers = {
|
|
34
|
+
Authorization: `Bearer ${token}`,
|
|
35
|
+
"Content-Type": "application/json",
|
|
36
|
+
"Notion-Version": NOTION_API_VERSION,
|
|
37
|
+
};
|
|
38
|
+
const notionRequest = (
|
|
39
|
+
input: Parameters<typeof fetch>[0],
|
|
40
|
+
init: RequestInit = {},
|
|
41
|
+
) => requestNotion(input, init, { request, retry, operation: "idempotent" });
|
|
42
|
+
const queryPublicPostPages = (body: Record<string, unknown>) =>
|
|
43
|
+
queryPublicPosts({
|
|
44
|
+
request: notionRequest,
|
|
45
|
+
headers,
|
|
46
|
+
dataSourceId,
|
|
47
|
+
propertyIds,
|
|
48
|
+
body,
|
|
49
|
+
});
|
|
50
|
+
const countPublicRoadmapPosts = async (
|
|
51
|
+
firstPage: Awaited<ReturnType<typeof queryPublicPostPages>>,
|
|
52
|
+
status: PublicRoadmapQuery["status"],
|
|
53
|
+
) => {
|
|
54
|
+
let totalCount = firstPage.results.length;
|
|
55
|
+
let nextCursor = firstPage.hasMore ? firstPage.nextCursor : undefined;
|
|
56
|
+
if (firstPage.hasMore && !nextCursor) {
|
|
57
|
+
throw new Error("Notion returned an invalid Post list cursor.");
|
|
58
|
+
}
|
|
59
|
+
while (nextCursor) {
|
|
60
|
+
const page = await queryPublicPostPages(
|
|
61
|
+
publicRoadmapQuery({ status, cursor: nextCursor }, propertyIds, 100),
|
|
62
|
+
);
|
|
63
|
+
totalCount += page.results.length;
|
|
64
|
+
nextCursor = page.hasMore ? page.nextCursor : undefined;
|
|
65
|
+
if (page.hasMore && !nextCursor) {
|
|
66
|
+
throw new Error("Notion returned an invalid Post list cursor.");
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return totalCount;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const createStoredPost = async (item: NewStoredPost) => {
|
|
73
|
+
const response = await requestNotion(
|
|
74
|
+
`${NOTION_API_URL}/pages`,
|
|
75
|
+
{
|
|
76
|
+
method: "POST",
|
|
77
|
+
headers,
|
|
78
|
+
body: JSON.stringify({
|
|
79
|
+
parent: { type: "data_source_id", data_source_id: dataSourceId },
|
|
80
|
+
properties: propertiesForCreate(item, propertyIds),
|
|
81
|
+
}),
|
|
82
|
+
},
|
|
83
|
+
{ request, retry, operation: "create" },
|
|
84
|
+
);
|
|
85
|
+
return postFromPage(await readNotionPage(response), propertyIds);
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
async create(item) {
|
|
90
|
+
return createStoredPost(item);
|
|
91
|
+
},
|
|
92
|
+
async save(item) {
|
|
93
|
+
const response = await notionRequest(
|
|
94
|
+
`${NOTION_API_URL}/pages/${item.id}`,
|
|
95
|
+
{
|
|
96
|
+
method: "PATCH",
|
|
97
|
+
headers,
|
|
98
|
+
body: JSON.stringify({
|
|
99
|
+
properties: propertiesForDraftEdit(item, propertyIds),
|
|
100
|
+
}),
|
|
101
|
+
},
|
|
102
|
+
);
|
|
103
|
+
return postFromPage(await readNotionPage(response), propertyIds);
|
|
104
|
+
},
|
|
105
|
+
async updateVoteCount(item, voteCount) {
|
|
106
|
+
const response = await notionRequest(
|
|
107
|
+
`${NOTION_API_URL}/pages/${item.id}`,
|
|
108
|
+
{
|
|
109
|
+
method: "PATCH",
|
|
110
|
+
headers,
|
|
111
|
+
body: JSON.stringify({
|
|
112
|
+
properties: { [propertyIds.voteCount]: { number: voteCount } },
|
|
113
|
+
}),
|
|
114
|
+
},
|
|
115
|
+
);
|
|
116
|
+
return postFromPage(await readNotionPage(response), propertyIds);
|
|
117
|
+
},
|
|
118
|
+
async find(id) {
|
|
119
|
+
const response = await notionRequest(`${NOTION_API_URL}/pages/${id}`, {
|
|
120
|
+
method: "GET",
|
|
121
|
+
headers,
|
|
122
|
+
});
|
|
123
|
+
if (response.status === 404) return undefined;
|
|
124
|
+
return postFromPage(await readNotionPage(response), propertyIds);
|
|
125
|
+
},
|
|
126
|
+
async findBySlug(slug) {
|
|
127
|
+
const response = await notionRequest(
|
|
128
|
+
`${NOTION_API_URL}/data_sources/${dataSourceId}/query`,
|
|
129
|
+
{
|
|
130
|
+
method: "POST",
|
|
131
|
+
headers,
|
|
132
|
+
body: JSON.stringify(slugQuery(slug, propertyIds)),
|
|
133
|
+
},
|
|
134
|
+
);
|
|
135
|
+
const page = firstQueryResult(await readNotionPage(response));
|
|
136
|
+
return page ? postFromPage(page, propertyIds) : undefined;
|
|
137
|
+
},
|
|
138
|
+
async findPublicBySlug(slug) {
|
|
139
|
+
const query = new URLSearchParams();
|
|
140
|
+
for (const propertyId of publicProjectionPropertyIds(propertyIds)) {
|
|
141
|
+
query.append("filter_properties", propertyId);
|
|
142
|
+
}
|
|
143
|
+
const response = await notionRequest(
|
|
144
|
+
`${NOTION_API_URL}/data_sources/${dataSourceId}/query?${query.toString()}`,
|
|
145
|
+
{
|
|
146
|
+
method: "POST",
|
|
147
|
+
headers,
|
|
148
|
+
body: JSON.stringify({
|
|
149
|
+
page_size: 1,
|
|
150
|
+
filter: {
|
|
151
|
+
and: [
|
|
152
|
+
{ property: propertyIds.slug, rich_text: { equals: slug } },
|
|
153
|
+
{
|
|
154
|
+
property: propertyIds.published,
|
|
155
|
+
checkbox: { equals: true },
|
|
156
|
+
},
|
|
157
|
+
],
|
|
158
|
+
},
|
|
159
|
+
}),
|
|
160
|
+
},
|
|
161
|
+
);
|
|
162
|
+
const page = firstQueryResult(await readNotionPage(response));
|
|
163
|
+
return page ? postFromPublicPage(page, propertyIds, true) : undefined;
|
|
164
|
+
},
|
|
165
|
+
async findByExternalId(externalId) {
|
|
166
|
+
const response = await notionRequest(
|
|
167
|
+
`${NOTION_API_URL}/data_sources/${dataSourceId}/query`,
|
|
168
|
+
{
|
|
169
|
+
method: "POST",
|
|
170
|
+
headers,
|
|
171
|
+
body: JSON.stringify({
|
|
172
|
+
page_size: 1,
|
|
173
|
+
filter: {
|
|
174
|
+
property: propertyIds.externalId,
|
|
175
|
+
rich_text: { equals: externalId },
|
|
176
|
+
},
|
|
177
|
+
}),
|
|
178
|
+
},
|
|
179
|
+
);
|
|
180
|
+
const value = await readNotionPage(response);
|
|
181
|
+
const results = value.results;
|
|
182
|
+
if (!Array.isArray(results) || results.length === 0) return undefined;
|
|
183
|
+
const page = results[0];
|
|
184
|
+
if (!page || typeof page !== "object" || Array.isArray(page)) {
|
|
185
|
+
throw new Error("Notion returned an invalid Post list.");
|
|
186
|
+
}
|
|
187
|
+
return postFromPage(page as Record<string, unknown>, propertyIds);
|
|
188
|
+
},
|
|
189
|
+
async list() {
|
|
190
|
+
throw new Error("Notion public retrieval is not implemented yet.");
|
|
191
|
+
},
|
|
192
|
+
async listPublic(query) {
|
|
193
|
+
const response = await queryPublicPostPages(
|
|
194
|
+
publicListQuery(query, propertyIds),
|
|
195
|
+
);
|
|
196
|
+
return {
|
|
197
|
+
items: response.results.map((page) =>
|
|
198
|
+
postFromPublicPage(page, propertyIds, true),
|
|
199
|
+
),
|
|
200
|
+
...(response.nextCursor ? { nextCursor: response.nextCursor } : {}),
|
|
201
|
+
};
|
|
202
|
+
},
|
|
203
|
+
async listPublicRoadmap(query) {
|
|
204
|
+
const response = await queryPublicPostPages(
|
|
205
|
+
publicRoadmapQuery(query, propertyIds, 25),
|
|
206
|
+
);
|
|
207
|
+
const totalCount = await countPublicRoadmapPosts(
|
|
208
|
+
query.cursor
|
|
209
|
+
? await queryPublicPostPages(
|
|
210
|
+
publicRoadmapQuery({ status: query.status }, propertyIds, 100),
|
|
211
|
+
)
|
|
212
|
+
: response,
|
|
213
|
+
query.status,
|
|
214
|
+
);
|
|
215
|
+
return {
|
|
216
|
+
items: response.results.map((page) =>
|
|
217
|
+
postFromPublicPage(page, propertyIds, true),
|
|
218
|
+
),
|
|
219
|
+
...(response.nextCursor ? { nextCursor: response.nextCursor } : {}),
|
|
220
|
+
totalCount,
|
|
221
|
+
};
|
|
222
|
+
},
|
|
223
|
+
async remove(id) {
|
|
224
|
+
const response = await notionRequest(`${NOTION_API_URL}/pages/${id}`, {
|
|
225
|
+
method: "PATCH",
|
|
226
|
+
headers,
|
|
227
|
+
body: JSON.stringify({ in_trash: true }),
|
|
228
|
+
});
|
|
229
|
+
await readNotionPage(response);
|
|
230
|
+
},
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function publicListQuery(
|
|
235
|
+
query: PublicPostQuery,
|
|
236
|
+
ids: FeedbackPropertyIds,
|
|
237
|
+
): Record<string, unknown> {
|
|
238
|
+
return {
|
|
239
|
+
page_size: 25,
|
|
240
|
+
...(query.cursor ? { start_cursor: query.cursor } : {}),
|
|
241
|
+
filter: {
|
|
242
|
+
and: [
|
|
243
|
+
{ property: ids.published, checkbox: { equals: true } },
|
|
244
|
+
...(query.type
|
|
245
|
+
? [{ property: ids.type, select: { equals: query.type } }]
|
|
246
|
+
: []),
|
|
247
|
+
...(query.status
|
|
248
|
+
? [{ property: ids.status, select: { equals: query.status } }]
|
|
249
|
+
: []),
|
|
250
|
+
...(query.types?.length
|
|
251
|
+
? [
|
|
252
|
+
{
|
|
253
|
+
or: query.types.map((type) => ({
|
|
254
|
+
property: ids.type,
|
|
255
|
+
select: { equals: type },
|
|
256
|
+
})),
|
|
257
|
+
},
|
|
258
|
+
]
|
|
259
|
+
: []),
|
|
260
|
+
...(query.statuses?.length
|
|
261
|
+
? [
|
|
262
|
+
{
|
|
263
|
+
or: query.statuses.map((status) => ({
|
|
264
|
+
property: ids.status,
|
|
265
|
+
select: { equals: status },
|
|
266
|
+
})),
|
|
267
|
+
},
|
|
268
|
+
]
|
|
269
|
+
: []),
|
|
270
|
+
...(query.search
|
|
271
|
+
? [
|
|
272
|
+
{
|
|
273
|
+
or: [
|
|
274
|
+
{ property: ids.title, title: { contains: query.search } },
|
|
275
|
+
{
|
|
276
|
+
property: ids.description,
|
|
277
|
+
rich_text: { contains: query.search },
|
|
278
|
+
},
|
|
279
|
+
],
|
|
280
|
+
},
|
|
281
|
+
]
|
|
282
|
+
: []),
|
|
283
|
+
],
|
|
284
|
+
},
|
|
285
|
+
sorts:
|
|
286
|
+
query.sort === "new"
|
|
287
|
+
? [{ property: ids.createdAt, direction: "descending" }]
|
|
288
|
+
: [
|
|
289
|
+
{ property: ids.voteCount, direction: "descending" },
|
|
290
|
+
{ property: ids.createdAt, direction: "descending" },
|
|
291
|
+
],
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function slugQuery(
|
|
296
|
+
slug: string,
|
|
297
|
+
ids: FeedbackPropertyIds,
|
|
298
|
+
): Record<string, unknown> {
|
|
299
|
+
return {
|
|
300
|
+
page_size: 1,
|
|
301
|
+
filter: { property: ids.slug, rich_text: { equals: slug } },
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function publicRoadmapQuery(
|
|
306
|
+
query: PublicRoadmapQuery,
|
|
307
|
+
ids: FeedbackPropertyIds,
|
|
308
|
+
pageSize: 25 | 100,
|
|
309
|
+
): Record<string, unknown> {
|
|
310
|
+
return {
|
|
311
|
+
page_size: pageSize,
|
|
312
|
+
...(query.cursor ? { start_cursor: query.cursor } : {}),
|
|
313
|
+
filter: {
|
|
314
|
+
and: [
|
|
315
|
+
{ property: ids.published, checkbox: { equals: true } },
|
|
316
|
+
{ property: ids.status, select: { equals: query.status } },
|
|
317
|
+
],
|
|
318
|
+
},
|
|
319
|
+
sorts: [{ property: ids.updatedAt, direction: "descending" }],
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
async function queryPublicPosts({
|
|
324
|
+
request,
|
|
325
|
+
headers,
|
|
326
|
+
dataSourceId,
|
|
327
|
+
propertyIds,
|
|
328
|
+
body,
|
|
329
|
+
}: {
|
|
330
|
+
request: typeof fetch;
|
|
331
|
+
headers: Record<string, string>;
|
|
332
|
+
dataSourceId: string;
|
|
333
|
+
propertyIds: FeedbackPropertyIds;
|
|
334
|
+
body: Record<string, unknown>;
|
|
335
|
+
}): Promise<{
|
|
336
|
+
results: Array<Record<string, unknown>>;
|
|
337
|
+
nextCursor?: string;
|
|
338
|
+
hasMore: boolean;
|
|
339
|
+
}> {
|
|
340
|
+
const query = new URLSearchParams();
|
|
341
|
+
for (const propertyId of publicProjectionPropertyIds(propertyIds)) {
|
|
342
|
+
query.append("filter_properties", propertyId);
|
|
343
|
+
}
|
|
344
|
+
const response = await request(
|
|
345
|
+
`${NOTION_API_URL}/data_sources/${dataSourceId}/query?${query.toString()}`,
|
|
346
|
+
{ method: "POST", headers, body: JSON.stringify(body) },
|
|
347
|
+
);
|
|
348
|
+
const value = await readNotionPage(response);
|
|
349
|
+
const results = value.results;
|
|
350
|
+
if (!Array.isArray(results)) {
|
|
351
|
+
throw new Error("Notion returned an invalid Post list.");
|
|
352
|
+
}
|
|
353
|
+
return {
|
|
354
|
+
results: results.map((result) => {
|
|
355
|
+
if (!result || typeof result !== "object" || Array.isArray(result)) {
|
|
356
|
+
throw new Error("Notion returned an invalid Post list.");
|
|
357
|
+
}
|
|
358
|
+
return result as Record<string, unknown>;
|
|
359
|
+
}),
|
|
360
|
+
...(typeof value.next_cursor === "string"
|
|
361
|
+
? { nextCursor: value.next_cursor }
|
|
362
|
+
: {}),
|
|
363
|
+
hasMore: value.has_more === true,
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function publicProjectionPropertyIds(ids: FeedbackPropertyIds): string[] {
|
|
368
|
+
return [
|
|
369
|
+
ids.title,
|
|
370
|
+
ids.slug,
|
|
371
|
+
ids.description,
|
|
372
|
+
ids.type,
|
|
373
|
+
ids.status,
|
|
374
|
+
ids.createdAt,
|
|
375
|
+
ids.updatedAt,
|
|
376
|
+
ids.voteCount,
|
|
377
|
+
ids.teamResponse,
|
|
378
|
+
ids.releaseNotesUrl,
|
|
379
|
+
];
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function postFromPublicPage(
|
|
383
|
+
page: Record<string, unknown>,
|
|
384
|
+
ids: FeedbackPropertyIds,
|
|
385
|
+
published?: boolean,
|
|
386
|
+
): StoredPost {
|
|
387
|
+
const properties = recordField(page, "properties");
|
|
388
|
+
const property = (propertyId: string) => propertyById(properties, propertyId);
|
|
389
|
+
return {
|
|
390
|
+
id: stringField(page, "id"),
|
|
391
|
+
slug: requiredText(property(ids.slug), "rich_text", "Slug"),
|
|
392
|
+
title: requiredText(property(ids.title), "title", "Title"),
|
|
393
|
+
description: requiredText(
|
|
394
|
+
property(ids.description),
|
|
395
|
+
"rich_text",
|
|
396
|
+
"Description",
|
|
397
|
+
),
|
|
398
|
+
type: postTypeFromNotion(requiredSelect(property(ids.type), "Type")),
|
|
399
|
+
status: postStatusFromNotion(
|
|
400
|
+
requiredSelect(property(ids.status), "Status"),
|
|
401
|
+
),
|
|
402
|
+
published: published ?? booleanField(property(ids.published), "checkbox"),
|
|
403
|
+
createdAt: new Date(stringField(page, "created_time")),
|
|
404
|
+
updatedAt: new Date(stringField(page, "last_edited_time")),
|
|
405
|
+
voteCount: numberField(property(ids.voteCount), "number"),
|
|
406
|
+
...(optionalText(property(ids.teamResponse), "rich_text")
|
|
407
|
+
? { teamResponse: optionalText(property(ids.teamResponse), "rich_text") }
|
|
408
|
+
: {}),
|
|
409
|
+
...(safeReleaseNotesUrl(
|
|
410
|
+
optionalString(property(ids.releaseNotesUrl), "url"),
|
|
411
|
+
)
|
|
412
|
+
? {
|
|
413
|
+
releaseNotesUrl: safeReleaseNotesUrl(
|
|
414
|
+
optionalString(property(ids.releaseNotesUrl), "url"),
|
|
415
|
+
),
|
|
416
|
+
}
|
|
417
|
+
: {}),
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function propertiesForDraftEdit(
|
|
422
|
+
item: StoredPost,
|
|
423
|
+
ids: FeedbackPropertyIds,
|
|
424
|
+
): Record<string, unknown> {
|
|
425
|
+
return {
|
|
426
|
+
[ids.title]: richTitle(item.title),
|
|
427
|
+
[ids.description]: richText(item.description),
|
|
428
|
+
[ids.type]: { select: { name: item.type } },
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function propertiesForCreate(
|
|
433
|
+
item: NewStoredPost,
|
|
434
|
+
ids: FeedbackPropertyIds,
|
|
435
|
+
): Record<string, unknown> {
|
|
436
|
+
return {
|
|
437
|
+
[ids.title]: richTitle(item.title),
|
|
438
|
+
[ids.slug]: richText(item.slug),
|
|
439
|
+
[ids.description]: richText(item.description),
|
|
440
|
+
[ids.type]: { select: { name: item.type } },
|
|
441
|
+
[ids.status]: { select: { name: item.status } },
|
|
442
|
+
[ids.published]: { checkbox: item.published },
|
|
443
|
+
[ids.voteCount]: { number: item.voteCount ?? 0 },
|
|
444
|
+
[ids.submitterName]: richText(item.submitter?.name),
|
|
445
|
+
[ids.submitterEmail]: { email: item.submitter?.email ?? null },
|
|
446
|
+
[ids.source]: { select: { name: item.source ?? "Portal" } },
|
|
447
|
+
[ids.externalId]: richText(
|
|
448
|
+
typeof item.externalId === "string" ? item.externalId : undefined,
|
|
449
|
+
),
|
|
450
|
+
[ids.editTokenHash]: richText(item.browserCapabilityHash),
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function richTitle(content: string): Record<string, unknown> {
|
|
455
|
+
return { title: [{ type: "text", text: { content } }] };
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function richText(content?: string): Record<string, unknown> {
|
|
459
|
+
return {
|
|
460
|
+
rich_text: content ? [{ type: "text", text: { content } }] : [],
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
async function readNotionPage(
|
|
465
|
+
response: Response,
|
|
466
|
+
): Promise<Record<string, unknown>> {
|
|
467
|
+
const body: unknown = await response.json();
|
|
468
|
+
if (!response.ok) {
|
|
469
|
+
throw new Error(`Notion rejected the Post request (${response.status}).`);
|
|
470
|
+
}
|
|
471
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) {
|
|
472
|
+
throw new Error("Notion returned an invalid Post response.");
|
|
473
|
+
}
|
|
474
|
+
return body as Record<string, unknown>;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function postFromPage(
|
|
478
|
+
page: Record<string, unknown>,
|
|
479
|
+
ids: FeedbackPropertyIds,
|
|
480
|
+
): StoredPost {
|
|
481
|
+
const publicItem = postFromPublicPage(page, ids);
|
|
482
|
+
const properties = recordField(page, "properties");
|
|
483
|
+
const property = (propertyId: string) => propertyById(properties, propertyId);
|
|
484
|
+
const name = optionalText(property(ids.submitterName), "rich_text");
|
|
485
|
+
const email = optionalString(property(ids.submitterEmail), "email");
|
|
486
|
+
|
|
487
|
+
return {
|
|
488
|
+
...publicItem,
|
|
489
|
+
...(name || email
|
|
490
|
+
? {
|
|
491
|
+
submitter: { ...(name ? { name } : {}), ...(email ? { email } : {}) },
|
|
492
|
+
}
|
|
493
|
+
: {}),
|
|
494
|
+
browserCapabilityHash: optionalText(
|
|
495
|
+
property(ids.editTokenHash),
|
|
496
|
+
"rich_text",
|
|
497
|
+
),
|
|
498
|
+
} satisfies StoredPost;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function firstQueryResult(
|
|
502
|
+
response: Record<string, unknown>,
|
|
503
|
+
): Record<string, unknown> | undefined {
|
|
504
|
+
const results = response.results;
|
|
505
|
+
if (!Array.isArray(results)) {
|
|
506
|
+
throw new Error("Notion returned an invalid Post list.");
|
|
507
|
+
}
|
|
508
|
+
const first = results[0];
|
|
509
|
+
if (first === undefined) return undefined;
|
|
510
|
+
if (!first || typeof first !== "object" || Array.isArray(first)) {
|
|
511
|
+
throw new Error("Notion returned an invalid Post list.");
|
|
512
|
+
}
|
|
513
|
+
return first as Record<string, unknown>;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
function postTypeFromNotion(value: string): PostType {
|
|
517
|
+
if (
|
|
518
|
+
value === "Feature Request" ||
|
|
519
|
+
value === "Bug Report" ||
|
|
520
|
+
value === "General Feedback"
|
|
521
|
+
) {
|
|
522
|
+
return value;
|
|
523
|
+
}
|
|
524
|
+
throw new Error(`Notion returned unsupported Feedback Type "${value}".`);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function postStatusFromNotion(value: string): PostStatus {
|
|
528
|
+
if (
|
|
529
|
+
value === "New" ||
|
|
530
|
+
value === "Reviewing" ||
|
|
531
|
+
value === "Planned" ||
|
|
532
|
+
value === "In Progress" ||
|
|
533
|
+
value === "Shipped" ||
|
|
534
|
+
value === "Closed"
|
|
535
|
+
) {
|
|
536
|
+
return value;
|
|
537
|
+
}
|
|
538
|
+
throw new Error(`Notion returned unsupported Feedback Status "${value}".`);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function propertyById(
|
|
542
|
+
properties: Record<string, unknown>,
|
|
543
|
+
id: string,
|
|
544
|
+
): Record<string, unknown> {
|
|
545
|
+
const property = optionalPropertyById(properties, id);
|
|
546
|
+
if (property) return property;
|
|
547
|
+
throw new Error(`Notion omitted configured Feedback property ID "${id}".`);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
function optionalPropertyById(
|
|
551
|
+
properties: Record<string, unknown>,
|
|
552
|
+
id: string,
|
|
553
|
+
): Record<string, unknown> | undefined {
|
|
554
|
+
for (const value of Object.values(properties)) {
|
|
555
|
+
if (value && typeof value === "object" && Reflect.get(value, "id") === id) {
|
|
556
|
+
return value as Record<string, unknown>;
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
return undefined;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
function requiredText(
|
|
563
|
+
property: Record<string, unknown>,
|
|
564
|
+
key: string,
|
|
565
|
+
label: string,
|
|
566
|
+
): string {
|
|
567
|
+
const value = optionalText(property, key);
|
|
568
|
+
if (!value) throw new Error(`Notion returned an empty ${label} property.`);
|
|
569
|
+
return value;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
function optionalText(
|
|
573
|
+
property: Record<string, unknown>,
|
|
574
|
+
key: string,
|
|
575
|
+
): string | undefined {
|
|
576
|
+
const values = property[key];
|
|
577
|
+
if (!Array.isArray(values)) return undefined;
|
|
578
|
+
const text = values
|
|
579
|
+
.map((value) =>
|
|
580
|
+
value && typeof value === "object"
|
|
581
|
+
? Reflect.get(value, "plain_text")
|
|
582
|
+
: undefined,
|
|
583
|
+
)
|
|
584
|
+
.filter((value): value is string => typeof value === "string")
|
|
585
|
+
.join("");
|
|
586
|
+
return text || undefined;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
function numberField(property: Record<string, unknown>, key: string): number {
|
|
590
|
+
const value = property[key];
|
|
591
|
+
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
function requiredSelect(
|
|
595
|
+
property: Record<string, unknown>,
|
|
596
|
+
label: string,
|
|
597
|
+
): string {
|
|
598
|
+
const select = property.select;
|
|
599
|
+
const name =
|
|
600
|
+
select && typeof select === "object"
|
|
601
|
+
? Reflect.get(select, "name")
|
|
602
|
+
: undefined;
|
|
603
|
+
if (typeof name !== "string")
|
|
604
|
+
throw new Error(`Notion returned an invalid ${label} property.`);
|
|
605
|
+
return name;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
function booleanField(record: Record<string, unknown>, key: string): boolean {
|
|
609
|
+
const value = record[key];
|
|
610
|
+
if (typeof value !== "boolean")
|
|
611
|
+
throw new Error(`Notion returned an invalid ${key} value.`);
|
|
612
|
+
return value;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
function optionalString(
|
|
616
|
+
record: Record<string, unknown>,
|
|
617
|
+
key: string,
|
|
618
|
+
): string | undefined {
|
|
619
|
+
const value = record[key];
|
|
620
|
+
return typeof value === "string" ? value : undefined;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
export function safeReleaseNotesUrl(
|
|
624
|
+
value: string | undefined,
|
|
625
|
+
): string | undefined {
|
|
626
|
+
if (!value) return undefined;
|
|
627
|
+
if (value.startsWith("/") && !value.startsWith("//")) return value;
|
|
628
|
+
try {
|
|
629
|
+
return new URL(value).protocol === "https:" ? value : undefined;
|
|
630
|
+
} catch {
|
|
631
|
+
return undefined;
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
function stringField(record: Record<string, unknown>, key: string): string {
|
|
636
|
+
const value = record[key];
|
|
637
|
+
if (typeof value !== "string")
|
|
638
|
+
throw new Error(`Notion returned an invalid ${key} value.`);
|
|
639
|
+
return value;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
function recordField(
|
|
643
|
+
record: Record<string, unknown>,
|
|
644
|
+
key: string,
|
|
645
|
+
): Record<string, unknown> {
|
|
646
|
+
const value = record[key];
|
|
647
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
648
|
+
throw new Error(`Notion returned an invalid ${key} value.`);
|
|
649
|
+
}
|
|
650
|
+
return value as Record<string, unknown>;
|
|
651
|
+
}
|