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.
Files changed (210) hide show
  1. package/LICENSE +201 -21
  2. package/README.md +11 -1
  3. package/dist/cli.js +1261 -26
  4. package/dist/index.js +1402 -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/LICENSE +201 -0
  11. package/template/components.json +25 -0
  12. package/template/eslint.config.mjs +25 -0
  13. package/template/package.json +70 -14
  14. package/template/public/feedbax-mark.svg +4 -0
  15. package/template/public/robots.txt +3 -0
  16. package/template/src/authorized-draft-post.test.tsx +212 -0
  17. package/template/src/authorized-draft-post.tsx +204 -0
  18. package/template/src/authorized-draft-query.ts +74 -0
  19. package/template/src/browser-post-state.test.ts +120 -0
  20. package/template/src/browser-post-state.ts +134 -0
  21. package/template/src/browser-vote-state.test.ts +14 -0
  22. package/template/src/browser-vote-state.ts +27 -0
  23. package/template/src/capability-authorization-error.ts +3 -0
  24. package/template/src/changelog-page.test.tsx +139 -0
  25. package/template/src/changelog-page.tsx +219 -0
  26. package/template/src/changelog-query.ts +13 -0
  27. package/template/src/changelog-runtime.ts +27 -0
  28. package/template/src/cloudflare-turnstile.ts +31 -0
  29. package/template/src/cloudflare-workers-test.ts +3 -0
  30. package/template/src/cloudflare-workers.d.ts +18 -0
  31. package/template/src/comment-creation.test.tsx +270 -0
  32. package/template/src/comment-profile.ts +5 -0
  33. package/template/src/comment-thread-page.ts +18 -0
  34. package/template/src/components/command-palette-context.tsx +71 -0
  35. package/template/src/components/command-palette.tsx +246 -0
  36. package/template/src/components/create-post-trigger.tsx +44 -0
  37. package/template/src/components/device-profile-provider.tsx +309 -0
  38. package/template/src/components/header.tsx +95 -0
  39. package/template/src/components/loader.tsx +10 -0
  40. package/template/src/components/profile-menu.tsx +89 -0
  41. package/template/src/components/ui/components/alert-dialog.tsx +185 -0
  42. package/template/src/components/ui/components/attachment.tsx +207 -0
  43. package/template/src/components/ui/components/avatar.tsx +107 -0
  44. package/template/src/components/ui/components/badge.tsx +52 -0
  45. package/template/src/components/ui/components/bubble.tsx +128 -0
  46. package/template/src/components/ui/components/button-group.tsx +87 -0
  47. package/template/src/components/ui/components/button.tsx +56 -0
  48. package/template/src/components/ui/components/card.tsx +103 -0
  49. package/template/src/components/ui/components/checkbox.tsx +26 -0
  50. package/template/src/components/ui/components/command.tsx +194 -0
  51. package/template/src/components/ui/components/dialog.tsx +154 -0
  52. package/template/src/components/ui/components/drawer.tsx +229 -0
  53. package/template/src/components/ui/components/dropdown-menu.tsx +270 -0
  54. package/template/src/components/ui/components/empty.tsx +101 -0
  55. package/template/src/components/ui/components/field.tsx +236 -0
  56. package/template/src/components/ui/components/input-group.tsx +155 -0
  57. package/template/src/components/ui/components/input.tsx +20 -0
  58. package/template/src/components/ui/components/item.tsx +201 -0
  59. package/template/src/components/ui/components/kbd.tsx +26 -0
  60. package/template/src/components/ui/components/label.tsx +18 -0
  61. package/template/src/components/ui/components/marker.tsx +71 -0
  62. package/template/src/components/ui/components/message-scroller.tsx +128 -0
  63. package/template/src/components/ui/components/message.tsx +92 -0
  64. package/template/src/components/ui/components/native-select.tsx +65 -0
  65. package/template/src/components/ui/components/popover.tsx +88 -0
  66. package/template/src/components/ui/components/portal-primitives.test.tsx +85 -0
  67. package/template/src/components/ui/components/radio-group.tsx +36 -0
  68. package/template/src/components/ui/components/scroll-area.tsx +52 -0
  69. package/template/src/components/ui/components/secondary-form-actions.test.tsx +23 -0
  70. package/template/src/components/ui/components/select.tsx +199 -0
  71. package/template/src/components/ui/components/separator.tsx +23 -0
  72. package/template/src/components/ui/components/sheet.tsx +135 -0
  73. package/template/src/components/ui/components/skeleton.tsx +13 -0
  74. package/template/src/components/ui/components/sonner.tsx +43 -0
  75. package/template/src/components/ui/components/spinner.tsx +16 -0
  76. package/template/src/components/ui/components/tabs.tsx +80 -0
  77. package/template/src/components/ui/components/textarea.tsx +18 -0
  78. package/template/src/components/ui/components/tooltip.tsx +64 -0
  79. package/template/src/components/ui/hooks/.gitkeep +0 -0
  80. package/template/src/components/ui/lib/utils.ts +6 -0
  81. package/template/src/components/ui/styles/globals.css +151 -0
  82. package/template/src/create-post-overlay.tsx +82 -0
  83. package/template/src/draft-post-list.tsx +39 -0
  84. package/template/src/feedback-cache-invalidation.test.ts +185 -0
  85. package/template/src/feedback-cache-invalidation.ts +97 -0
  86. package/template/src/feedback-runtime.ts +38 -0
  87. package/template/src/feedbax/changelog/index.test.ts +156 -0
  88. package/template/src/feedbax/changelog/index.ts +206 -0
  89. package/template/src/feedbax/changelog/notion-changelog.test.ts +395 -0
  90. package/template/src/feedbax/changelog/notion-changelog.ts +400 -0
  91. package/template/src/feedbax/config/index.test.ts +111 -0
  92. package/template/src/feedbax/config/index.ts +271 -0
  93. package/template/src/feedbax/feedback/feedback-storage.ts +43 -0
  94. package/template/src/feedbax/feedback/index.test.ts +900 -0
  95. package/template/src/feedbax/feedback/index.ts +942 -0
  96. package/template/src/feedbax/feedback/notion-comments.test.ts +171 -0
  97. package/template/src/feedbax/feedback/notion-comments.ts +231 -0
  98. package/template/src/feedbax/feedback/notion-data-source.test.ts +364 -0
  99. package/template/src/feedbax/feedback/notion-data-source.ts +458 -0
  100. package/template/src/feedbax/feedback/notion-feedback.test.ts +998 -0
  101. package/template/src/feedbax/feedback/notion-feedback.ts +651 -0
  102. package/template/src/feedbax/feedback/notion-request.ts +74 -0
  103. package/template/src/feedbax.test-fixture.ts +64 -0
  104. package/template/src/feedbax.ts +69 -0
  105. package/template/src/index.css +2138 -0
  106. package/template/src/lazy-create-post-overlay.tsx +6 -0
  107. package/template/src/lazy-post-detail-overlay.tsx +10 -0
  108. package/template/src/masked-post-link.tsx +27 -0
  109. package/template/src/overlay-post-detail.test.tsx +130 -0
  110. package/template/src/overlay-post-detail.tsx +92 -0
  111. package/template/src/portal-comments.test.ts +375 -0
  112. package/template/src/portal-comments.ts +384 -0
  113. package/template/src/portal-features.test.tsx +150 -0
  114. package/template/src/portal-feedback-form.tsx +515 -0
  115. package/template/src/portal-feedback-runtime.test.ts +32 -0
  116. package/template/src/portal-feedback-runtime.ts +55 -0
  117. package/template/src/portal-feedback-server-function.ts +93 -0
  118. package/template/src/portal-feedback-submission.test.ts +307 -0
  119. package/template/src/portal-feedback-submission.ts +214 -0
  120. package/template/src/portal-head.ts +58 -0
  121. package/template/src/portal-server-entry.ts +64 -0
  122. package/template/src/portal-voting.test.ts +106 -0
  123. package/template/src/portal-voting.ts +149 -0
  124. package/template/src/post-badges.tsx +27 -0
  125. package/template/src/post-creation-flow.test.tsx +230 -0
  126. package/template/src/post-creation-flow.tsx +88 -0
  127. package/template/src/post-detail-overlay.test.tsx +114 -0
  128. package/template/src/post-detail-overlay.tsx +67 -0
  129. package/template/src/post-feed-controls.test.tsx +239 -0
  130. package/template/src/post-feed-controls.tsx +298 -0
  131. package/template/src/post-presentation.ts +37 -0
  132. package/template/src/post-queries.test.ts +158 -0
  133. package/template/src/post-queries.ts +26 -0
  134. package/template/src/post-results.tsx +165 -0
  135. package/template/src/public-cache-policy.test.ts +141 -0
  136. package/template/src/public-cache-policy.ts +119 -0
  137. package/template/src/public-cache-tags.ts +7 -0
  138. package/template/src/public-changelog-page.ts +15 -0
  139. package/template/src/public-changelog-server-function.ts +27 -0
  140. package/template/src/public-date.ts +11 -0
  141. package/template/src/public-failure-state.test.tsx +69 -0
  142. package/template/src/public-feedback-index.tsx +124 -0
  143. package/template/src/public-feedback-page.test.tsx +290 -0
  144. package/template/src/public-feedback-page.ts +75 -0
  145. package/template/src/public-feedback-server-function.ts +22 -0
  146. package/template/src/public-portal-error.tsx +27 -0
  147. package/template/src/public-post-detail.tsx +682 -0
  148. package/template/src/public-post-page.test.tsx +173 -0
  149. package/template/src/public-post-page.ts +15 -0
  150. package/template/src/public-post-route.test.tsx +214 -0
  151. package/template/src/public-post-route.tsx +171 -0
  152. package/template/src/public-post-server-function.ts +48 -0
  153. package/template/src/public-post-unavailable.tsx +10 -0
  154. package/template/src/public-roadmap-page.test.tsx +139 -0
  155. package/template/src/public-roadmap-page.ts +24 -0
  156. package/template/src/public-roadmap-route.test.tsx +218 -0
  157. package/template/src/public-roadmap-route.tsx +50 -0
  158. package/template/src/public-roadmap-server-function.ts +44 -0
  159. package/template/src/public-roadmap-view.tsx +378 -0
  160. package/template/src/public-shell.test.tsx +518 -0
  161. package/template/src/roadmap-query.ts +21 -0
  162. package/template/src/root-branding.test.ts +53 -0
  163. package/template/src/routeTree.gen.ts +131 -0
  164. package/template/src/routed-post-creation-form.tsx +28 -0
  165. package/template/src/router.tsx +25 -0
  166. package/template/src/routes/__root.tsx +116 -0
  167. package/template/src/routes/changelog.tsx +39 -0
  168. package/template/src/routes/index.tsx +61 -0
  169. package/template/src/routes/p.$slug.tsx +16 -0
  170. package/template/src/routes/roadmap.tsx +11 -0
  171. package/template/src/routes/submit.tsx +28 -0
  172. package/template/src/safe-public-failure.ts +45 -0
  173. package/template/src/server.ts +111 -0
  174. package/template/src/similar-posts.tsx +45 -0
  175. package/template/src/submit-route.test.tsx +134 -0
  176. package/template/src/trusted-feedback-handler.test.ts +425 -0
  177. package/template/src/trusted-feedback-handler.ts +181 -0
  178. package/template/src/trusted-feedback-runtime.ts +17 -0
  179. package/template/src/turnstile-configuration.test.ts +37 -0
  180. package/template/src/turnstile-configuration.ts +19 -0
  181. package/template/src/use-media-query.ts +17 -0
  182. package/template/src/vote-toggle.test.tsx +171 -0
  183. package/template/src/vote-toggle.tsx +211 -0
  184. package/template/tsconfig.json +45 -0
  185. package/template/vite.config.ts +20 -0
  186. package/template/vitest.config.ts +4 -0
  187. package/template/vitest.setup.ts +3 -0
  188. package/template/wrangler.jsonc +35 -0
  189. package/dist/cli.d.ts +0 -3
  190. package/dist/cli.d.ts.map +0 -1
  191. package/dist/cli.js.map +0 -1
  192. package/dist/index.d.ts +0 -21
  193. package/dist/index.d.ts.map +0 -1
  194. package/dist/index.js.map +0 -1
  195. package/template/.env.example +0 -4
  196. package/template/README.md +0 -7
  197. package/template/apps/portal/components.json +0 -20
  198. package/template/apps/portal/package.json +0 -31
  199. package/template/apps/portal/src/lib/utils.ts +0 -6
  200. package/template/apps/portal/src/routeTree.gen.ts +0 -59
  201. package/template/apps/portal/src/router.tsx +0 -10
  202. package/template/apps/portal/src/routes/__root.tsx +0 -31
  203. package/template/apps/portal/src/routes/index.tsx +0 -35
  204. package/template/apps/portal/src/styles.css +0 -91
  205. package/template/apps/portal/tsconfig.json +0 -15
  206. package/template/apps/portal/vite.config.ts +0 -13
  207. package/template/feedbax.config.ts +0 -13
  208. package/template/feedbax.jsonc +0 -11
  209. package/template/pnpm-workspace.yaml +0 -3
  210. package/template/turbo.json +0 -8
@@ -0,0 +1,998 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ import { describe, expect, it, vi } from "vitest";
4
+
5
+ import {
6
+ createNotionFeedbackModule,
7
+ type BrowserCapability,
8
+ type FeedbackPropertyIds,
9
+ } from "./index";
10
+
11
+ const propertyIds: FeedbackPropertyIds = {
12
+ title: "title-id",
13
+ slug: "slug-id",
14
+ description: "description-id",
15
+ type: "type-id",
16
+ status: "status-id",
17
+ published: "published-id",
18
+ voteCount: "vote-count-id",
19
+ teamResponse: "team-response-id",
20
+ releaseNotesUrl: "release-notes-url-id",
21
+ submitterName: "submitter-name-id",
22
+ submitterEmail: "submitter-email-id",
23
+ source: "source-id",
24
+ externalId: "external-id",
25
+ editTokenHash: "edit-token-hash-id",
26
+ createdAt: "created-at-id",
27
+ updatedAt: "updated-at-id",
28
+ };
29
+
30
+ describe("Notion-backed Feedback module", () => {
31
+ it("projects Team Response and only safe release-note URLs", async () => {
32
+ const request = vi.fn<typeof fetch>().mockResolvedValue(
33
+ Response.json({
34
+ results: [
35
+ notionPage({
36
+ title: "Shipped",
37
+ description: "Done",
38
+ type: "Feature Request",
39
+ editTokenHash: "",
40
+ published: true,
41
+ teamResponse: "Thanks — this is live.",
42
+ releaseNotesUrl: "https://example.com/releases/1",
43
+ }),
44
+ ],
45
+ has_more: false,
46
+ next_cursor: null,
47
+ }),
48
+ );
49
+ const feedback = createNotionFeedbackModule({
50
+ token: "token",
51
+ dataSourceId: "source",
52
+ propertyIds,
53
+ request,
54
+ });
55
+ await expect(
56
+ feedback.listPublicPosts({ sort: "top" }),
57
+ ).resolves.toMatchObject({
58
+ items: [
59
+ {
60
+ teamResponse: "Thanks — this is live.",
61
+ releaseNotesUrl: "https://example.com/releases/1",
62
+ },
63
+ ],
64
+ });
65
+ });
66
+ it("reads the current Vote Count and retries the absolute Number-property write", async () => {
67
+ const page = (voteCount: number) =>
68
+ notionPage({
69
+ title: "Keyboard-first search",
70
+ description: "Navigate without a mouse.",
71
+ type: "Feature Request",
72
+ editTokenHash: "private-hash",
73
+ published: true,
74
+ voteCount,
75
+ });
76
+ const request = vi
77
+ .fn<typeof fetch>()
78
+ .mockResolvedValueOnce(Response.json({ results: [page(4)] }))
79
+ .mockResolvedValueOnce(
80
+ Response.json(
81
+ { object: "error" },
82
+ { status: 429, headers: { "Retry-After": "0" } },
83
+ ),
84
+ )
85
+ .mockResolvedValueOnce(Response.json(page(5)));
86
+ const feedback = createNotionFeedbackModule({
87
+ token: "notion-token",
88
+ dataSourceId: "feedback-data-source",
89
+ propertyIds,
90
+ request,
91
+ retry: { sleep: async () => undefined, random: () => 0 },
92
+ });
93
+
94
+ await expect(
95
+ feedback.changeVote({ slug: "keyboard-first-search", intention: "add" }),
96
+ ).resolves.toEqual({
97
+ slug: "keyboard-first-search",
98
+ voteCount: 5,
99
+ });
100
+ expect(JSON.parse(String(request.mock.calls[1]?.[1]?.body))).toEqual({
101
+ properties: { "vote-count-id": { number: 5 } },
102
+ });
103
+ expect(request).toHaveBeenCalledTimes(3);
104
+ });
105
+
106
+ it("retries a 429 response according to Retry-After", async () => {
107
+ const delays: number[] = [];
108
+ const request = vi
109
+ .fn<typeof fetch>()
110
+ .mockResolvedValueOnce(
111
+ Response.json(
112
+ { object: "error", message: "rate limited" },
113
+ { status: 429, headers: { "Retry-After": "2" } },
114
+ ),
115
+ )
116
+ .mockResolvedValueOnce(
117
+ Response.json({
118
+ object: "list",
119
+ results: [
120
+ notionPage({
121
+ slug: "notion-page-id",
122
+ title: "Keyboard-first search",
123
+ description: "Open search without reaching for the mouse.",
124
+ type: "Feature Request",
125
+ status: "Planned",
126
+ published: true,
127
+ editTokenHash: "private-hash",
128
+ }),
129
+ ],
130
+ has_more: false,
131
+ }),
132
+ );
133
+ const feedback = createNotionFeedbackModule({
134
+ token: "notion-token",
135
+ dataSourceId: "feedback-data-source",
136
+ propertyIds,
137
+ request,
138
+ retry: {
139
+ sleep: async (milliseconds) => {
140
+ delays.push(milliseconds);
141
+ },
142
+ random: () => 0,
143
+ },
144
+ });
145
+
146
+ await expect(
147
+ feedback.getPublicPost("notion-page-id"),
148
+ ).resolves.toMatchObject({
149
+ title: "Keyboard-first search",
150
+ });
151
+ expect(request).toHaveBeenCalledTimes(2);
152
+ expect(delays).toEqual([2_000]);
153
+ });
154
+
155
+ it("bounds 529 retries with exponential jitter and a safe explicit failure", async () => {
156
+ const delays: number[] = [];
157
+ const random = vi.fn().mockReturnValueOnce(0).mockReturnValueOnce(1);
158
+ const request = vi
159
+ .fn<typeof fetch>()
160
+ .mockResolvedValue(
161
+ Response.json(
162
+ { object: "error", message: "temporary Notion outage" },
163
+ { status: 529 },
164
+ ),
165
+ );
166
+ const consoleError = vi
167
+ .spyOn(console, "error")
168
+ .mockImplementation(() => {});
169
+ const consoleLog = vi.spyOn(console, "log").mockImplementation(() => {});
170
+ const feedback = createNotionFeedbackModule({
171
+ token: "secret-notion-token",
172
+ dataSourceId: "feedback-data-source",
173
+ propertyIds,
174
+ request,
175
+ retry: {
176
+ sleep: async (milliseconds) => {
177
+ delays.push(milliseconds);
178
+ },
179
+ random,
180
+ },
181
+ });
182
+
183
+ const result = feedback.getPublicPost("private-feedback-id");
184
+
185
+ await expect(result).rejects.toThrow(
186
+ "Notion request failed after 3 attempts (529).",
187
+ );
188
+ expect(request).toHaveBeenCalledTimes(3);
189
+ expect(delays).toEqual([125, 500]);
190
+ expect(random).toHaveBeenCalledTimes(2);
191
+ expect(consoleError).not.toHaveBeenCalled();
192
+ expect(consoleLog).not.toHaveBeenCalled();
193
+ await expect(result).rejects.not.toThrow("secret-notion-token");
194
+ consoleError.mockRestore();
195
+ consoleLog.mockRestore();
196
+ });
197
+
198
+ it("does not repeat a non-idempotent create after a 529 response", async () => {
199
+ const request = vi
200
+ .fn<typeof fetch>()
201
+ .mockResolvedValueOnce(
202
+ Response.json({ object: "list", results: [], has_more: false }),
203
+ )
204
+ .mockResolvedValueOnce(
205
+ Response.json(
206
+ { object: "error", message: "temporary Notion outage" },
207
+ { status: 529 },
208
+ ),
209
+ );
210
+ const feedback = createNotionFeedbackModule({
211
+ token: "notion-token",
212
+ dataSourceId: "feedback-data-source",
213
+ propertyIds,
214
+ request,
215
+ });
216
+
217
+ await expect(
218
+ feedback.submitPost({
219
+ title: "Do not duplicate this",
220
+ description: "The create result is uncertain.",
221
+ type: "General Feedback",
222
+ }),
223
+ ).rejects.toThrow("Notion rejected the Post request (529).");
224
+ expect(request).toHaveBeenCalledTimes(2);
225
+ });
226
+
227
+ it("retrieves a Published Post through the approved public projection", async () => {
228
+ const request = vi.fn<typeof fetch>().mockResolvedValue(
229
+ Response.json({
230
+ object: "list",
231
+ results: [
232
+ notionPage({
233
+ slug: "notion-page-id",
234
+ title: "Keyboard-first search",
235
+ description: "Open search without reaching for the mouse.",
236
+ type: "Feature Request",
237
+ status: "Planned",
238
+ published: true,
239
+ editTokenHash: "must-not-reach-the-caller",
240
+ }),
241
+ ],
242
+ has_more: false,
243
+ }),
244
+ );
245
+ const feedback = createNotionFeedbackModule({
246
+ token: "notion-token",
247
+ dataSourceId: "feedback-data-source",
248
+ propertyIds,
249
+ request,
250
+ });
251
+
252
+ await expect(feedback.getPublicPost("notion-page-id")).resolves.toEqual({
253
+ slug: "notion-page-id",
254
+ title: "Keyboard-first search",
255
+ description: "Open search without reaching for the mouse.",
256
+ type: "Feature Request",
257
+ status: "Planned",
258
+ createdAt: new Date("2026-08-20T13:00:00.000Z"),
259
+ updatedAt: new Date("2026-08-20T13:00:00.000Z"),
260
+ voteCount: 0,
261
+ });
262
+ expect(request).toHaveBeenCalledWith(
263
+ expect.stringContaining(
264
+ "https://api.notion.com/v1/data_sources/feedback-data-source/query?",
265
+ ),
266
+ expect.objectContaining({ method: "POST" }),
267
+ );
268
+ const [url] = request.mock.calls[0]!;
269
+ expect(String(url)).toContain("filter_properties=title-id");
270
+ expect(String(url)).toContain("filter_properties=slug-id");
271
+ expect(String(url)).not.toContain("submitter-email-id");
272
+ expect(String(url)).not.toContain("edit-token-hash-id");
273
+ });
274
+
275
+ it("lists a filtered cursor page with one publication-gated Notion query", async () => {
276
+ const request = vi
277
+ .fn<typeof fetch>()
278
+ .mockResolvedValueOnce(
279
+ Response.json({
280
+ object: "list",
281
+ results: [],
282
+ has_more: true,
283
+ next_cursor: "notion-start-cursor",
284
+ }),
285
+ )
286
+ .mockResolvedValueOnce(
287
+ Response.json({
288
+ object: "list",
289
+ results: [
290
+ notionPage({
291
+ id: "published-page",
292
+ title: "Faster exports",
293
+ description: "Export large reports without timing out.",
294
+ type: "Feature Request",
295
+ status: "In Progress",
296
+ published: true,
297
+ editTokenHash: "private-hash",
298
+ }),
299
+ ],
300
+ has_more: true,
301
+ next_cursor: "notion-next-cursor",
302
+ }),
303
+ );
304
+ const feedback = createNotionFeedbackModule({
305
+ token: "notion-token",
306
+ dataSourceId: "feedback-data-source",
307
+ propertyIds,
308
+ request,
309
+ });
310
+
311
+ const query = {
312
+ type: "Feature Request" as const,
313
+ status: "In Progress" as const,
314
+ };
315
+ const firstPage = await feedback.listPublicPosts(query);
316
+ await expect(
317
+ feedback.listPublicPosts({ ...query, cursor: firstPage.nextCursor }),
318
+ ).resolves.toMatchObject({
319
+ items: [
320
+ {
321
+ slug: "faster-exports",
322
+ title: "Faster exports",
323
+ description: "Export large reports without timing out.",
324
+ type: "Feature Request",
325
+ status: "In Progress",
326
+ createdAt: new Date("2026-08-20T13:00:00.000Z"),
327
+ updatedAt: new Date("2026-08-20T13:00:00.000Z"),
328
+ voteCount: 0,
329
+ },
330
+ ],
331
+ nextCursor: expect.any(String),
332
+ });
333
+ expect(request).toHaveBeenCalledTimes(2);
334
+ const [url, init] = request.mock.calls[1]!;
335
+ expect(String(url)).toContain("/data_sources/feedback-data-source/query?");
336
+ expect(String(url)).not.toContain("published-id");
337
+ expect(String(url)).not.toContain("submitter-name-id");
338
+ expect(String(url)).not.toContain("edit-token-hash-id");
339
+ expect(JSON.parse(String(init?.body))).toEqual({
340
+ page_size: 25,
341
+ start_cursor: "notion-start-cursor",
342
+ filter: {
343
+ and: [
344
+ { property: "published-id", checkbox: { equals: true } },
345
+ { property: "type-id", select: { equals: "Feature Request" } },
346
+ { property: "status-id", select: { equals: "In Progress" } },
347
+ ],
348
+ },
349
+ sorts: [
350
+ { property: "vote-count-id", direction: "descending" },
351
+ { property: "created-at-id", direction: "descending" },
352
+ ],
353
+ });
354
+ });
355
+
356
+ it("pages one roadmap status and counts beyond the former 100-Post cap", async () => {
357
+ const firstPage = Array.from({ length: 25 }, (_, index) =>
358
+ notionPage({
359
+ id: `shipped-${index + 77}`,
360
+ title: `Shipped ${index + 77}`,
361
+ description: "Already delivered.",
362
+ type: "Feature Request",
363
+ status: "Shipped",
364
+ published: true,
365
+ editTokenHash: "private-hash",
366
+ }),
367
+ );
368
+ const remainingItems = Array.from({ length: 76 }, (_, index) =>
369
+ notionPage({
370
+ id: `shipped-${index + 1}`,
371
+ title: `Shipped ${index + 1}`,
372
+ description: "Already delivered.",
373
+ type: "Feature Request",
374
+ status: "Shipped",
375
+ published: true,
376
+ editTokenHash: "private-hash",
377
+ }),
378
+ );
379
+ const request = vi
380
+ .fn<typeof fetch>()
381
+ .mockResolvedValueOnce(
382
+ Response.json({
383
+ object: "list",
384
+ results: firstPage,
385
+ has_more: true,
386
+ next_cursor: "shipped-page-2",
387
+ }),
388
+ )
389
+ .mockResolvedValueOnce(
390
+ Response.json({
391
+ object: "list",
392
+ results: remainingItems,
393
+ has_more: false,
394
+ next_cursor: null,
395
+ }),
396
+ );
397
+ const feedback = createNotionFeedbackModule({
398
+ token: "notion-token",
399
+ dataSourceId: "feedback-data-source",
400
+ propertyIds,
401
+ request,
402
+ });
403
+
404
+ const page = await feedback.listPublicRoadmapPosts({ status: "Shipped" });
405
+
406
+ expect(page.items).toHaveLength(25);
407
+ expect(page.items[0]?.title).toBe("Shipped 77");
408
+ expect(page.nextCursor).toBe("shipped-page-2");
409
+ expect(page.totalCount).toBe(101);
410
+ expect(request).toHaveBeenCalledTimes(2);
411
+ const [, firstInit] = request.mock.calls[0]!;
412
+ const [, countInit] = request.mock.calls[1]!;
413
+ expect(JSON.parse(String(firstInit?.body))).toEqual({
414
+ page_size: 25,
415
+ filter: {
416
+ and: [
417
+ { property: "published-id", checkbox: { equals: true } },
418
+ { property: "status-id", select: { equals: "Shipped" } },
419
+ ],
420
+ },
421
+ sorts: [{ property: "updated-at-id", direction: "descending" }],
422
+ });
423
+ expect(JSON.parse(String(countInit?.body))).toEqual({
424
+ page_size: 100,
425
+ start_cursor: "shipped-page-2",
426
+ filter: {
427
+ and: [
428
+ { property: "published-id", checkbox: { equals: true } },
429
+ { property: "status-id", select: { equals: "Shipped" } },
430
+ ],
431
+ },
432
+ sorts: [{ property: "updated-at-id", direction: "descending" }],
433
+ });
434
+ });
435
+
436
+ it("submits canonical properties while keeping the raw Browser Capability out of Notion", async () => {
437
+ const request = vi
438
+ .fn<typeof fetch>()
439
+ .mockResolvedValueOnce(
440
+ Response.json({ object: "list", results: [], has_more: false }),
441
+ )
442
+ .mockResolvedValueOnce(
443
+ Response.json(
444
+ notionPage({
445
+ slug: "search-needs-keyboard-shortcuts",
446
+ title: "Search needs keyboard shortcuts",
447
+ description: "Let me open search without reaching for the mouse.",
448
+ type: "Feature Request",
449
+ submitterName: "Ama",
450
+ submitterEmail: "ama@example.com",
451
+ editTokenHash: "returned-hash",
452
+ }),
453
+ ),
454
+ );
455
+ const feedback = createNotionFeedbackModule({
456
+ token: "notion-token",
457
+ dataSourceId: "feedback-data-source",
458
+ propertyIds,
459
+ request,
460
+ });
461
+
462
+ const submitted = await feedback.submitPost({
463
+ title: "Search needs keyboard shortcuts",
464
+ description: "Let me open search without reaching for the mouse.",
465
+ type: "Feature Request",
466
+ submitter: { name: "Ama", email: "ama@example.com" },
467
+ });
468
+
469
+ expect(request).toHaveBeenCalledTimes(2);
470
+ const [url, init] = request.mock.calls[1]!;
471
+ expect(url).toBe("https://api.notion.com/v1/pages");
472
+ expect(init).toMatchObject({
473
+ method: "POST",
474
+ headers: {
475
+ Authorization: "Bearer notion-token",
476
+ "Content-Type": "application/json",
477
+ "Notion-Version": "2026-03-11",
478
+ },
479
+ });
480
+ const body = JSON.parse(String(init?.body)) as {
481
+ parent: unknown;
482
+ properties: Record<string, unknown>;
483
+ };
484
+ expect(body.parent).toEqual({
485
+ type: "data_source_id",
486
+ data_source_id: "feedback-data-source",
487
+ });
488
+ expect(body.properties).toMatchObject({
489
+ "title-id": {
490
+ title: [{ type: "text", text: { content: submitted.title } }],
491
+ },
492
+ "description-id": {
493
+ rich_text: [{ type: "text", text: { content: submitted.description } }],
494
+ },
495
+ "type-id": { select: { name: "Feature Request" } },
496
+ "status-id": { select: { name: "New" } },
497
+ "published-id": { checkbox: false },
498
+ "submitter-name-id": {
499
+ rich_text: [{ type: "text", text: { content: "Ama" } }],
500
+ },
501
+ "submitter-email-id": { email: "ama@example.com" },
502
+ "source-id": { select: { name: "Portal" } },
503
+ "edit-token-hash-id": {
504
+ rich_text: [
505
+ {
506
+ type: "text",
507
+ text: {
508
+ content: createHash("sha256")
509
+ .update(submitted.browserCapability)
510
+ .digest("base64url"),
511
+ },
512
+ },
513
+ ],
514
+ },
515
+ });
516
+ expect(JSON.stringify(body)).not.toContain(submitted.browserCapability);
517
+ expect(body.properties).not.toHaveProperty("created-at-id");
518
+ expect(body.properties).not.toHaveProperty("updated-at-id");
519
+ expect(submitted.id).toBe("notion-page-id");
520
+ expect(Object.keys(submitted).sort()).toEqual([
521
+ "browserCapability",
522
+ "createdAt",
523
+ "description",
524
+ "id",
525
+ "published",
526
+ "slug",
527
+ "status",
528
+ "submitter",
529
+ "title",
530
+ "type",
531
+ "updatedAt",
532
+ "voteCount",
533
+ ]);
534
+ expect(submitted).not.toHaveProperty("children");
535
+ expect(submitted).not.toHaveProperty("Product Team Priority");
536
+ });
537
+
538
+ it("persists a unique Post slug after checking the Notion data source", async () => {
539
+ const request = vi
540
+ .fn<typeof fetch>()
541
+ .mockResolvedValueOnce(
542
+ Response.json({
543
+ object: "list",
544
+ results: [
545
+ notionPage({
546
+ slug: "keyboard-navigation",
547
+ title: "Existing Post",
548
+ description: "Already claimed this slug.",
549
+ type: "Feature Request",
550
+ editTokenHash: "existing-hash",
551
+ }),
552
+ ],
553
+ has_more: false,
554
+ }),
555
+ )
556
+ .mockResolvedValueOnce(
557
+ Response.json({ object: "list", results: [], has_more: false }),
558
+ )
559
+ .mockResolvedValueOnce(
560
+ Response.json(
561
+ notionPage({
562
+ slug: "keyboard-navigation-2",
563
+ title: "Keyboard navigation",
564
+ description: "Navigate without a mouse.",
565
+ type: "Feature Request",
566
+ editTokenHash: "returned-hash",
567
+ }),
568
+ ),
569
+ );
570
+ const feedback = createNotionFeedbackModule({
571
+ token: "notion-token",
572
+ dataSourceId: "feedback-data-source",
573
+ propertyIds,
574
+ request,
575
+ });
576
+
577
+ const submitted = await feedback.submitPost({
578
+ title: "Keyboard navigation",
579
+ description: "Navigate without a mouse.",
580
+ type: "Feature Request",
581
+ });
582
+
583
+ const firstSlugLookup = JSON.parse(
584
+ String(request.mock.calls[0]?.[1]?.body),
585
+ ) as Record<string, unknown>;
586
+ expect(firstSlugLookup).toEqual({
587
+ page_size: 1,
588
+ filter: {
589
+ property: "slug-id",
590
+ rich_text: { equals: "keyboard-navigation" },
591
+ },
592
+ });
593
+ expect(JSON.parse(String(request.mock.calls[1]?.[1]?.body))).toEqual({
594
+ page_size: 1,
595
+ filter: {
596
+ property: "slug-id",
597
+ rich_text: { equals: "keyboard-navigation-2" },
598
+ },
599
+ });
600
+ const createBody = JSON.parse(String(request.mock.calls[2]?.[1]?.body)) as {
601
+ properties: Record<string, unknown>;
602
+ };
603
+ expect(createBody.properties["slug-id"]).toEqual({
604
+ rich_text: [{ type: "text", text: { content: "keyboard-navigation-2" } }],
605
+ });
606
+ expect(submitted).toMatchObject({
607
+ id: "notion-page-id",
608
+ slug: "keyboard-navigation-2",
609
+ });
610
+ });
611
+
612
+ it("keeps the persisted Notion slug unchanged when a Draft Post title is edited", async () => {
613
+ let page = notionPage({
614
+ slug: "original-title",
615
+ title: "Original title",
616
+ description: "Original description",
617
+ type: "General Feedback",
618
+ editTokenHash: "",
619
+ });
620
+ let editProperties: Record<string, unknown> | undefined;
621
+ const request = vi.fn<typeof fetch>(async (input, init) => {
622
+ const url = String(input);
623
+ if (url.includes("/query") && init?.method === "POST") {
624
+ return Response.json({ object: "list", results: [], has_more: false });
625
+ }
626
+ if (url.endsWith("/pages") && init?.method === "POST") {
627
+ const body = JSON.parse(String(init.body)) as {
628
+ properties: Record<
629
+ string,
630
+ { rich_text?: Array<{ text: { content: string } }> }
631
+ >;
632
+ };
633
+ page = notionPage({
634
+ slug: "original-title",
635
+ title: "Original title",
636
+ description: "Original description",
637
+ type: "General Feedback",
638
+ editTokenHash:
639
+ body.properties["edit-token-hash-id"]?.rich_text?.[0]?.text
640
+ .content ?? "",
641
+ });
642
+ return Response.json(page);
643
+ }
644
+ if (url.endsWith("/pages/notion-page-id") && init?.method === "GET") {
645
+ return Response.json(page);
646
+ }
647
+ if (url.endsWith("/pages/notion-page-id") && init?.method === "PATCH") {
648
+ const body = JSON.parse(String(init.body)) as {
649
+ properties: Record<string, unknown>;
650
+ };
651
+ editProperties = body.properties;
652
+ page = notionPage({
653
+ slug: "original-title",
654
+ title: "Renamed Post",
655
+ description: "Original description",
656
+ type: "General Feedback",
657
+ editTokenHash: textContent(
658
+ (page.properties as Record<string, unknown>)["Edit Token Hash"],
659
+ ),
660
+ });
661
+ return Response.json(page);
662
+ }
663
+ return Response.json({ message: "unexpected request" }, { status: 500 });
664
+ });
665
+ const feedback = createNotionFeedbackModule({
666
+ token: "notion-token",
667
+ dataSourceId: "feedback-data-source",
668
+ propertyIds,
669
+ request,
670
+ });
671
+ const submitted = await feedback.submitPost({
672
+ title: "Original title",
673
+ description: "Original description",
674
+ type: "General Feedback",
675
+ });
676
+
677
+ const edited = await feedback.editDraftPost({
678
+ id: submitted.id,
679
+ browserCapability: submitted.browserCapability,
680
+ title: "Renamed Post",
681
+ });
682
+
683
+ expect(editProperties).not.toHaveProperty("slug-id");
684
+ expect(edited).toMatchObject({
685
+ title: "Renamed Post",
686
+ slug: "original-title",
687
+ });
688
+ });
689
+
690
+ it("retrieves a published Post by slug through a public-only Notion projection", async () => {
691
+ const request = vi.fn<typeof fetch>().mockResolvedValue(
692
+ Response.json({
693
+ object: "list",
694
+ results: [
695
+ notionPage({
696
+ slug: "keyboard-navigation",
697
+ title: "Keyboard navigation",
698
+ description: "Navigate without a mouse.",
699
+ type: "Feature Request",
700
+ status: "Planned",
701
+ published: true,
702
+ submitterEmail: "private@example.com",
703
+ editTokenHash: "private-hash",
704
+ }),
705
+ ],
706
+ has_more: false,
707
+ }),
708
+ );
709
+ const feedback = createNotionFeedbackModule({
710
+ token: "notion-token",
711
+ dataSourceId: "feedback-data-source",
712
+ propertyIds,
713
+ request,
714
+ });
715
+
716
+ const post = await feedback.getPublicPost("keyboard-navigation");
717
+
718
+ const [url, init] = request.mock.calls[0]!;
719
+ expect(String(url)).toContain("filter_properties=slug-id");
720
+ expect(String(url)).not.toContain("filter_properties=submitter-email-id");
721
+ expect(JSON.parse(String(init?.body))).toEqual({
722
+ page_size: 1,
723
+ filter: {
724
+ and: [
725
+ { property: "slug-id", rich_text: { equals: "keyboard-navigation" } },
726
+ { property: "published-id", checkbox: { equals: true } },
727
+ ],
728
+ },
729
+ });
730
+ expect(post).toMatchObject({
731
+ slug: "keyboard-navigation",
732
+ title: "Keyboard navigation",
733
+ status: "Planned",
734
+ });
735
+ expect(post).not.toHaveProperty("id");
736
+ expect(post).not.toHaveProperty("submitter");
737
+ expect(post).not.toHaveProperty("browserCapabilityHash");
738
+ });
739
+
740
+ it("authorizes draft edits and withdrawal before mutating canonical Notion properties", async () => {
741
+ let page = notionPage({
742
+ title: "Original title",
743
+ description: "Original description",
744
+ type: "General Feedback",
745
+ submitterName: "Ama",
746
+ submitterEmail: "ama@example.com",
747
+ editTokenHash: "",
748
+ });
749
+ const mutations: Array<Record<string, unknown>> = [];
750
+ const request = vi.fn<typeof fetch>(async (input, init) => {
751
+ const url = String(input);
752
+ if (url.includes("/data_sources/") && init?.method === "POST") {
753
+ return Response.json({ object: "list", results: [], has_more: false });
754
+ }
755
+ if (url.endsWith("/pages") && init?.method === "POST") {
756
+ const body = JSON.parse(String(init.body)) as {
757
+ properties: Record<
758
+ string,
759
+ { rich_text?: Array<{ text: { content: string } }> }
760
+ >;
761
+ };
762
+ page = notionPage({
763
+ slug: "original-title",
764
+ title: "Original title",
765
+ description: "Original description",
766
+ type: "General Feedback",
767
+ submitterName: "Ama",
768
+ submitterEmail: "ama@example.com",
769
+ editTokenHash:
770
+ body.properties["edit-token-hash-id"]?.rich_text?.[0]?.text
771
+ .content ?? "",
772
+ });
773
+ return Response.json(page);
774
+ }
775
+ if (url.endsWith("/pages/notion-page-id") && init?.method === "GET") {
776
+ return Response.json(page);
777
+ }
778
+ if (url.endsWith("/pages/notion-page-id") && init?.method === "PATCH") {
779
+ const body = JSON.parse(String(init.body)) as Record<string, unknown>;
780
+ mutations.push(body);
781
+ if (body.in_trash === true)
782
+ return Response.json({ ...page, in_trash: true });
783
+ page = notionPage({
784
+ slug: "original-title",
785
+ title: "Corrected title",
786
+ description: "Original description",
787
+ type: "Bug Report",
788
+ submitterName: "Ama",
789
+ submitterEmail: "ama@example.com",
790
+ editTokenHash: textContent(
791
+ (page.properties as Record<string, unknown>)["Edit Token Hash"],
792
+ ),
793
+ });
794
+ return Response.json(page);
795
+ }
796
+ return Response.json({ message: "unexpected request" }, { status: 500 });
797
+ });
798
+ const feedback = createNotionFeedbackModule({
799
+ token: "notion-token",
800
+ dataSourceId: "feedback-data-source",
801
+ propertyIds,
802
+ request,
803
+ });
804
+ const submitted = await feedback.submitPost({
805
+ title: "Original title",
806
+ description: "Original description",
807
+ type: "General Feedback",
808
+ submitter: { name: "Ama", email: "ama@example.com" },
809
+ });
810
+
811
+ await expect(
812
+ feedback.editDraftPost({
813
+ id: submitted.id,
814
+ browserCapability:
815
+ "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" as BrowserCapability,
816
+ title: "Unauthorized title",
817
+ }),
818
+ ).rejects.toThrow("Browser Capability did not authorize this draft.");
819
+ expect(mutations).toHaveLength(0);
820
+
821
+ await expect(
822
+ feedback.editDraftPost({
823
+ id: submitted.id,
824
+ browserCapability: submitted.browserCapability,
825
+ title: "Corrected title",
826
+ type: "Bug Report",
827
+ }),
828
+ ).resolves.toMatchObject({
829
+ id: "notion-page-id",
830
+ title: "Corrected title",
831
+ description: "Original description",
832
+ type: "Bug Report",
833
+ submitter: { name: "Ama", email: "ama@example.com" },
834
+ });
835
+ expect(mutations[0]).toEqual({
836
+ properties: {
837
+ "title-id": {
838
+ title: [{ type: "text", text: { content: "Corrected title" } }],
839
+ },
840
+ "description-id": {
841
+ rich_text: [
842
+ { type: "text", text: { content: "Original description" } },
843
+ ],
844
+ },
845
+ "type-id": { select: { name: "Bug Report" } },
846
+ },
847
+ });
848
+
849
+ await expect(
850
+ feedback.withdrawDraftPost({
851
+ id: submitted.id,
852
+ browserCapability: submitted.browserCapability,
853
+ }),
854
+ ).resolves.toBeUndefined();
855
+ expect(mutations[1]).toEqual({ in_trash: true });
856
+ });
857
+
858
+ it("rejects Notion select values outside the Feedback domain", async () => {
859
+ const page = notionPage({
860
+ title: "Unexpected value",
861
+ description: "A Team Member changed a select option.",
862
+ type: "General Feedback",
863
+ editTokenHash: "stored-hash",
864
+ });
865
+ const typeProperty = (page.properties as Record<string, unknown>).Type;
866
+ Reflect.set(typeProperty as object, "select", { name: "Question" });
867
+ const request = vi
868
+ .fn<typeof fetch>()
869
+ .mockResolvedValue(Response.json(page));
870
+ const feedback = createNotionFeedbackModule({
871
+ token: "notion-token",
872
+ dataSourceId: "feedback-data-source",
873
+ propertyIds,
874
+ request,
875
+ });
876
+
877
+ await expect(
878
+ feedback.editDraftPost({
879
+ id: "notion-page-id",
880
+ browserCapability:
881
+ "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" as BrowserCapability,
882
+ title: "Do not map this",
883
+ }),
884
+ ).rejects.toThrow('Notion returned unsupported Feedback Type "Question".');
885
+ });
886
+ });
887
+
888
+ function textContent(property: unknown): string {
889
+ if (!property || typeof property !== "object") return "";
890
+ const richText = Reflect.get(property, "rich_text");
891
+ return Array.isArray(richText) && richText[0]?.plain_text
892
+ ? String(richText[0].plain_text)
893
+ : "";
894
+ }
895
+
896
+ function notionPage({
897
+ id = "notion-page-id",
898
+ slug,
899
+ title,
900
+ description,
901
+ type,
902
+ submitterName,
903
+ submitterEmail,
904
+ editTokenHash,
905
+ status = "New",
906
+ published = false,
907
+ voteCount = 0,
908
+ teamResponse,
909
+ releaseNotesUrl,
910
+ }: {
911
+ id?: string;
912
+ slug?: string;
913
+ title: string;
914
+ description: string;
915
+ type: "Feature Request" | "Bug Report" | "General Feedback";
916
+ submitterName?: string;
917
+ submitterEmail?: string;
918
+ editTokenHash: string;
919
+ status?:
920
+ "New" | "Reviewing" | "Planned" | "In Progress" | "Shipped" | "Closed";
921
+ published?: boolean;
922
+ voteCount?: number;
923
+ teamResponse?: string;
924
+ releaseNotesUrl?: string;
925
+ }) {
926
+ const canonicalSlug =
927
+ slug ??
928
+ title
929
+ .toLowerCase()
930
+ .replace(/[^a-z0-9]+/g, "-")
931
+ .replace(/^-|-$/g, "");
932
+ return {
933
+ object: "page",
934
+ id,
935
+ created_time: "2026-08-20T13:00:00.000Z",
936
+ last_edited_time: "2026-08-20T13:00:00.000Z",
937
+ properties: {
938
+ "Title from Notion": {
939
+ id: "title-id",
940
+ type: "title",
941
+ title: [{ plain_text: title }],
942
+ },
943
+ Slug: {
944
+ id: "slug-id",
945
+ type: "rich_text",
946
+ rich_text: [{ plain_text: canonicalSlug }],
947
+ },
948
+ "Description from Notion": {
949
+ id: "description-id",
950
+ type: "rich_text",
951
+ rich_text: [{ plain_text: description }],
952
+ },
953
+ Type: { id: "type-id", type: "select", select: { name: type } },
954
+ Status: { id: "status-id", type: "select", select: { name: status } },
955
+ Published: {
956
+ id: "published-id",
957
+ type: "checkbox",
958
+ checkbox: published,
959
+ },
960
+ "Vote Count": {
961
+ id: "vote-count-id",
962
+ type: "number",
963
+ number: voteCount,
964
+ },
965
+ "Team Response": {
966
+ id: "team-response-id",
967
+ type: "rich_text",
968
+ rich_text: teamResponse ? [{ plain_text: teamResponse }] : [],
969
+ },
970
+ "Release Notes URL": {
971
+ id: "release-notes-url-id",
972
+ type: "url",
973
+ url: releaseNotesUrl ?? null,
974
+ },
975
+ "Submitter Name": {
976
+ id: "submitter-name-id",
977
+ type: "rich_text",
978
+ rich_text: submitterName ? [{ plain_text: submitterName }] : [],
979
+ },
980
+ "Submitter Email": {
981
+ id: "submitter-email-id",
982
+ type: "email",
983
+ email: submitterEmail ?? null,
984
+ },
985
+ "Edit Token Hash": {
986
+ id: "edit-token-hash-id",
987
+ type: "rich_text",
988
+ rich_text: [{ plain_text: editTokenHash }],
989
+ },
990
+ "Product Team Priority": {
991
+ id: "custom-priority",
992
+ type: "number",
993
+ number: 1,
994
+ },
995
+ },
996
+ children: [{ type: "paragraph", paragraph: { private: "notes" } }],
997
+ };
998
+ }