create-feedbax 0.2.0 → 0.2.2
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/README.md +17 -5
- package/dist/cli.js +2610 -291
- package/dist/index.js +2375 -200
- package/package.json +7 -7
- package/template/.feedbax-template.sha256 +1 -1
- package/template/.gitignore.template +1 -0
- package/template/.npmignore.template +1 -0
- package/template/.nvmrc +1 -0
- package/template/AGENTS.md +13 -0
- package/template/CONNECTED.md +418 -0
- package/template/README.md +97 -0
- package/template/components.json +1 -1
- package/template/eslint.config.mjs +23 -0
- package/template/feedbax-vite.ts +79 -0
- package/template/migrations/0001_connected.sql +6 -0
- package/template/migrations/0002_participation.sql +8 -0
- package/template/migrations/0003_images.sql +3 -0
- package/template/migrations/0004_followed_status.sql +9 -0
- package/template/migrations/0005_github.sql +4 -0
- package/template/migrations/0006_integration.sql +3 -0
- package/template/migrations/0007_ownership.sql +2 -0
- package/template/migrations/0008_integration_recovery.sql +5 -0
- package/template/migrations/0009_observed_history.sql +3 -0
- package/template/package.json +39 -34
- package/template/src/authorized-draft-post.tsx +1 -0
- package/template/src/changelog-page.tsx +63 -54
- package/template/src/changelog-runtime.ts +2 -1
- package/template/src/cloudflare-workers.d.ts +9 -0
- package/template/src/comment-creation.test.tsx +95 -7
- package/template/src/comment-thread-page.ts +11 -1
- package/template/src/components/command-palette.tsx +59 -30
- package/template/src/components/connected-identity-provider.tsx +135 -0
- package/template/src/components/device-profile-provider.tsx +41 -26
- package/template/src/components/follow-post-button.tsx +78 -0
- package/template/src/components/header.tsx +2 -0
- package/template/src/components/image-attachments.tsx +187 -0
- package/template/src/components/loader.tsx +48 -5
- package/template/src/components/notification-inbox.tsx +232 -0
- package/template/src/components/portal-footer.tsx +14 -0
- package/template/src/components/post-history.tsx +101 -0
- package/template/src/components/profile-menu.tsx +37 -0
- package/template/src/components/ui/components/command.tsx +30 -29
- package/template/src/components/ui/components/message-scroller.tsx +2 -2
- package/template/src/components/ui/components/scroll-area.tsx +0 -1
- package/template/src/components/ui/components/switch.tsx +29 -0
- package/template/src/components/ui/styles/globals.css +1 -1
- package/template/src/configured-feedback.test.ts +82 -0
- package/template/src/configured-feedback.ts +61 -0
- package/template/src/connected-identity.test.ts +148 -0
- package/template/src/connected-identity.ts +127 -0
- package/template/src/connected-participation.test.tsx +67 -0
- package/template/src/connected-runtime.test.ts +244 -0
- package/template/src/connected-runtime.ts +176 -0
- package/template/src/connected-social.test.tsx +158 -0
- package/template/src/feedback-cache-invalidation.ts +3 -0
- package/template/src/feedback-runtime.ts +123 -13
- package/template/src/feedbax/changelog/index.test.ts +35 -0
- package/template/src/feedbax/changelog/index.ts +5 -1
- package/template/src/feedbax/changelog/notion-changelog.test.ts +1 -1
- package/template/src/feedbax/changelog/notion-changelog.ts +1 -1
- package/template/src/feedbax/config/index.test.ts +73 -1
- package/template/src/feedbax/config/index.ts +205 -9
- package/template/src/feedbax/feedback/comment-storage.ts +38 -0
- package/template/src/feedbax/feedback/connected-feedback.ts +235 -0
- package/template/src/feedbax/feedback/connected.test.ts +272 -0
- package/template/src/feedbax/feedback/connected.ts +225 -0
- package/template/src/feedbax/feedback/email-delivery.test.ts +65 -0
- package/template/src/feedbax/feedback/email-delivery.ts +66 -0
- package/template/src/feedbax/feedback/feedback-storage.ts +1 -0
- package/template/src/feedbax/feedback/github-comments.ts +390 -0
- package/template/src/feedbax/feedback/github-feedback.test.ts +1138 -0
- package/template/src/feedbax/feedback/github-feedback.ts +581 -0
- package/template/src/feedbax/feedback/github-request.ts +120 -0
- package/template/src/feedbax/feedback/github-service-client.ts +56 -0
- package/template/src/feedbax/feedback/identity-handoff.test.ts +81 -0
- package/template/src/feedbax/feedback/identity-handoff.ts +119 -0
- package/template/src/feedbax/feedback/image-feedback.ts +114 -0
- package/template/src/feedbax/feedback/images.test.ts +223 -0
- package/template/src/feedbax/feedback/images.ts +350 -0
- package/template/src/feedbax/feedback/index.ts +226 -16
- package/template/src/feedbax/feedback/integration-auth.ts +138 -0
- package/template/src/feedbax/feedback/notion-comments.ts +2 -35
- package/template/src/feedbax/feedback/notion-image-review.ts +55 -0
- package/template/src/feedbax/feedback/observed-history.test.ts +150 -0
- package/template/src/feedbax/feedback/observed-history.ts +219 -0
- package/template/src/feedbax/feedback/ownership.ts +170 -0
- package/template/src/feedbax/feedback/participation.test.ts +653 -0
- package/template/src/feedbax/feedback/participation.ts +502 -0
- package/template/src/history-runtime.test.ts +80 -0
- package/template/src/history-runtime.ts +37 -0
- package/template/src/images-runtime.test.ts +133 -0
- package/template/src/images-runtime.ts +125 -0
- package/template/src/index.css +103 -440
- package/template/src/integration-runtime.test.ts +365 -0
- package/template/src/integration-runtime.ts +289 -0
- package/template/src/lazy-post-detail-overlay.tsx +13 -2
- package/template/src/my-feedback-page.test.tsx +152 -0
- package/template/src/my-feedback-page.tsx +398 -0
- package/template/src/my-feedback-runtime.test.ts +93 -0
- package/template/src/my-feedback-runtime.ts +154 -0
- package/template/src/overlay-post-detail.test.tsx +59 -8
- package/template/src/overlay-post-detail.tsx +20 -69
- package/template/src/participation-client.ts +63 -0
- package/template/src/participation-runtime.test.ts +35 -0
- package/template/src/participation-runtime.ts +255 -0
- package/template/src/portal-comments.ts +35 -5
- package/template/src/portal-feedback-form.tsx +29 -6
- package/template/src/portal-feedback-runtime.ts +3 -0
- package/template/src/portal-feedback-server-function.ts +6 -3
- package/template/src/portal-feedback-submission.ts +19 -0
- package/template/src/portal-server-entry.ts +13 -0
- package/template/src/post-creation-flow.tsx +2 -2
- package/template/src/post-detail-content.test.tsx +233 -0
- package/template/src/post-detail-content.tsx +162 -0
- package/template/src/post-detail-query.ts +34 -0
- package/template/src/post-results.tsx +1 -1
- package/template/src/public-cache-policy.test.ts +11 -4
- package/template/src/public-cache-policy.ts +1 -1
- package/template/src/public-feedback-index.tsx +9 -39
- package/template/src/public-feedback-page.test.tsx +65 -0
- package/template/src/public-feedback-page.ts +12 -1
- package/template/src/public-portal-error.tsx +1 -1
- package/template/src/public-post-detail-skeleton.tsx +54 -0
- package/template/src/public-post-detail.tsx +437 -271
- package/template/src/public-post-route.test.tsx +39 -3
- package/template/src/public-post-route.tsx +15 -28
- package/template/src/public-release-links.test.ts +120 -0
- package/template/src/public-release-links.ts +130 -0
- package/template/src/public-roadmap-route.test.tsx +38 -2
- package/template/src/public-roadmap-view.tsx +56 -23
- package/template/src/public-shell.test.tsx +26 -0
- package/template/src/routeTree.gen.ts +70 -3
- package/template/src/router.tsx +2 -0
- package/template/src/routes/__root.tsx +3 -5
- package/template/src/routes/email-preferences.tsx +77 -0
- package/template/src/routes/my-feedback.tsx +16 -0
- package/template/src/routes/submit.tsx +1 -1
- package/template/src/safe-public-failure.ts +3 -1
- package/template/src/server.ts +87 -2
- package/template/src/submission-intent.ts +26 -0
- package/template/src/trusted-feedback-handler.test.ts +1 -1
- package/template/src/vote-toggle.tsx +42 -9
- package/template/vite.config.ts +2 -0
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { PostOwnership } from "./ownership";
|
|
1
2
|
import {
|
|
2
3
|
createHash,
|
|
3
4
|
randomBytes,
|
|
@@ -9,16 +10,18 @@ import {
|
|
|
9
10
|
createNotionFeedbackStorage,
|
|
10
11
|
type NotionFeedbackStorageOptions,
|
|
11
12
|
} from "./notion-feedback";
|
|
13
|
+
import {
|
|
14
|
+
createGitHubFeedbackStorage,
|
|
15
|
+
type GitHubFeedbackOptions,
|
|
16
|
+
} from "./github-feedback";
|
|
17
|
+
import { createGitHubCommentStorage } from "./github-comments";
|
|
18
|
+
import type { CommentStorage, StoredComment } from "./comment-storage";
|
|
12
19
|
import type {
|
|
13
20
|
FeedbackStorage,
|
|
14
21
|
NewStoredPost,
|
|
15
22
|
StoredPost,
|
|
16
23
|
} from "./feedback-storage";
|
|
17
|
-
import {
|
|
18
|
-
createNotionCommentStorage,
|
|
19
|
-
type CommentStorage,
|
|
20
|
-
type StoredComment,
|
|
21
|
-
} from "./notion-comments";
|
|
24
|
+
import { createNotionCommentStorage } from "./notion-comments";
|
|
22
25
|
|
|
23
26
|
export {
|
|
24
27
|
createNotionFeedbackDataSource,
|
|
@@ -35,6 +38,8 @@ export type PostStatus =
|
|
|
35
38
|
"New" | "Reviewing" | "Planned" | "In Progress" | "Shipped" | "Closed";
|
|
36
39
|
|
|
37
40
|
export interface SubmitPostInput {
|
|
41
|
+
submissionKey?: string;
|
|
42
|
+
imageIds?: string[];
|
|
38
43
|
title: string;
|
|
39
44
|
description: string;
|
|
40
45
|
type: PostType;
|
|
@@ -49,6 +54,7 @@ export interface SubmitTrustedPostInput extends SubmitPostInput {
|
|
|
49
54
|
}
|
|
50
55
|
|
|
51
56
|
export interface Post extends SubmitPostInput {
|
|
57
|
+
images?: import("./images").ImageAttachment[];
|
|
52
58
|
id: string;
|
|
53
59
|
slug: string;
|
|
54
60
|
status: PostStatus;
|
|
@@ -90,6 +96,7 @@ export type PublicPost = Pick<
|
|
|
90
96
|
voteCount?: number;
|
|
91
97
|
teamResponse?: string;
|
|
92
98
|
releaseNotesUrl?: string;
|
|
99
|
+
images?: import("./images").ImageAttachment[];
|
|
93
100
|
};
|
|
94
101
|
|
|
95
102
|
export interface ChangeVoteInput {
|
|
@@ -127,13 +134,14 @@ export type DraftPost = Pick<
|
|
|
127
134
|
| "createdAt"
|
|
128
135
|
| "updatedAt"
|
|
129
136
|
| "submitter"
|
|
137
|
+
| "images"
|
|
130
138
|
>;
|
|
131
139
|
|
|
132
140
|
export type TrustedSubmittedPost = Omit<Post, "published" | "submitter">;
|
|
133
141
|
|
|
134
142
|
export interface EditDraftPostInput {
|
|
135
143
|
id: string;
|
|
136
|
-
browserCapability
|
|
144
|
+
browserCapability?: BrowserCapability;
|
|
137
145
|
title?: string;
|
|
138
146
|
description?: string;
|
|
139
147
|
type?: PostType;
|
|
@@ -141,7 +149,7 @@ export interface EditDraftPostInput {
|
|
|
141
149
|
|
|
142
150
|
export interface WithdrawDraftPostInput {
|
|
143
151
|
id: string;
|
|
144
|
-
browserCapability
|
|
152
|
+
browserCapability?: BrowserCapability;
|
|
145
153
|
}
|
|
146
154
|
|
|
147
155
|
export type GetDraftPostInput = WithdrawDraftPostInput;
|
|
@@ -162,10 +170,11 @@ export interface PublicPostPage {
|
|
|
162
170
|
}
|
|
163
171
|
|
|
164
172
|
export type CommentAuthor =
|
|
165
|
-
| { kind: "participant"; displayName: string }
|
|
173
|
+
| { kind: "participant"; displayName: string; verified?: boolean }
|
|
166
174
|
| { kind: "product-team"; displayName: "Product Team" };
|
|
167
175
|
|
|
168
176
|
export interface Comment {
|
|
177
|
+
images?: import("./images").ImageAttachment[];
|
|
169
178
|
id: string;
|
|
170
179
|
body: string;
|
|
171
180
|
author: CommentAuthor;
|
|
@@ -188,6 +197,10 @@ export interface ListCommentThreadsInput {
|
|
|
188
197
|
}
|
|
189
198
|
|
|
190
199
|
export interface CreateCommentInput {
|
|
200
|
+
submissionKey?: string;
|
|
201
|
+
/** Internal recovery precondition; it never grants Comment authority. */
|
|
202
|
+
recoveryCommentId?: string;
|
|
203
|
+
imageIds?: string[];
|
|
191
204
|
slug: string;
|
|
192
205
|
body: string;
|
|
193
206
|
displayName: string;
|
|
@@ -197,7 +210,9 @@ export interface ReplyToCommentThreadInput extends CreateCommentInput {
|
|
|
197
210
|
discussionId: string;
|
|
198
211
|
}
|
|
199
212
|
|
|
213
|
+
export const recoverableComment = Symbol("recoverable-comment");
|
|
200
214
|
export interface CreatedComment extends Comment {
|
|
215
|
+
[recoverableComment]?: boolean;
|
|
201
216
|
discussionId: string;
|
|
202
217
|
}
|
|
203
218
|
|
|
@@ -354,7 +369,27 @@ class InMemoryFeedbackStorage implements FeedbackStorage {
|
|
|
354
369
|
}
|
|
355
370
|
}
|
|
356
371
|
|
|
372
|
+
export type ParticipantPost = Pick<
|
|
373
|
+
Post,
|
|
374
|
+
| "id"
|
|
375
|
+
| "slug"
|
|
376
|
+
| "title"
|
|
377
|
+
| "description"
|
|
378
|
+
| "type"
|
|
379
|
+
| "status"
|
|
380
|
+
| "published"
|
|
381
|
+
| "createdAt"
|
|
382
|
+
| "updatedAt"
|
|
383
|
+
> & { editable: boolean };
|
|
384
|
+
|
|
357
385
|
export interface FeedbackModule {
|
|
386
|
+
getParticipantPost(id: string): Promise<ParticipantPost | undefined>;
|
|
387
|
+
claimDraftPost(
|
|
388
|
+
input: GetDraftPostInput,
|
|
389
|
+
): Promise<ParticipantPost | undefined>;
|
|
390
|
+
getAttachmentPostState(
|
|
391
|
+
slug: string,
|
|
392
|
+
): Promise<"published" | "draft" | "private" | undefined>;
|
|
358
393
|
createComment(input: CreateCommentInput): Promise<CreatedComment>;
|
|
359
394
|
replyToCommentThread(
|
|
360
395
|
input: ReplyToCommentThreadInput,
|
|
@@ -384,6 +419,7 @@ export type FeedbackMutationModule = Pick<
|
|
|
384
419
|
>;
|
|
385
420
|
|
|
386
421
|
interface CreateFeedbackModuleOptions {
|
|
422
|
+
ownership?: PostOwnership;
|
|
387
423
|
initialItems?: StoredPost[];
|
|
388
424
|
storage?: FeedbackStorage;
|
|
389
425
|
votingEnabled?: boolean;
|
|
@@ -403,10 +439,39 @@ export function createFeedbackModule(
|
|
|
403
439
|
options.commentStorage ??
|
|
404
440
|
createInMemoryCommentStorage(options.initialComments);
|
|
405
441
|
const voteQueues = new Map<string, Promise<void>>();
|
|
442
|
+
const authorizedDraft = async (
|
|
443
|
+
item: StoredPost | undefined,
|
|
444
|
+
capability?: BrowserCapability,
|
|
445
|
+
): Promise<boolean> =>
|
|
446
|
+
Boolean(
|
|
447
|
+
item &&
|
|
448
|
+
isEditableDraft(item) &&
|
|
449
|
+
(authorizesDraft(item, capability) ||
|
|
450
|
+
(await options.ownership?.owns(item.id))),
|
|
451
|
+
);
|
|
452
|
+
const participantPost = async (
|
|
453
|
+
id: string,
|
|
454
|
+
): Promise<ParticipantPost | undefined> => {
|
|
455
|
+
if (!(await options.ownership?.owns(id))) return;
|
|
456
|
+
const item = await storage.find(id);
|
|
457
|
+
if (!item) return;
|
|
458
|
+
return {
|
|
459
|
+
id: item.id,
|
|
460
|
+
slug: item.slug,
|
|
461
|
+
title: item.title,
|
|
462
|
+
description: item.description,
|
|
463
|
+
type: item.type,
|
|
464
|
+
status: item.status,
|
|
465
|
+
published: item.published,
|
|
466
|
+
createdAt: item.createdAt,
|
|
467
|
+
updatedAt: item.updatedAt,
|
|
468
|
+
editable: isEditableDraft(item),
|
|
469
|
+
};
|
|
470
|
+
};
|
|
406
471
|
const editStoredDraft = async (input: EditDraftPostInput) => {
|
|
407
472
|
const item = await storage.find(input.id);
|
|
408
473
|
|
|
409
|
-
if (!
|
|
474
|
+
if (!item || !(await authorizedDraft(item, input.browserCapability))) {
|
|
410
475
|
throw new BrowserCapabilityAuthorizationError();
|
|
411
476
|
}
|
|
412
477
|
|
|
@@ -432,7 +497,29 @@ export function createFeedbackModule(
|
|
|
432
497
|
};
|
|
433
498
|
|
|
434
499
|
return {
|
|
500
|
+
getParticipantPost: participantPost,
|
|
501
|
+
async claimDraftPost(input) {
|
|
502
|
+
const item = await storage.find(input.id);
|
|
503
|
+
if (!options.ownership || !authorizesDraft(item, input.browserCapability))
|
|
504
|
+
throw new BrowserCapabilityAuthorizationError();
|
|
505
|
+
await options.ownership.register(toPost(item));
|
|
506
|
+
return participantPost(item.id);
|
|
507
|
+
},
|
|
508
|
+
async getAttachmentPostState(slug) {
|
|
509
|
+
const post = await storage.findBySlug(slug);
|
|
510
|
+
return !post
|
|
511
|
+
? undefined
|
|
512
|
+
: post.published
|
|
513
|
+
? "published"
|
|
514
|
+
: post.status === "New" && post.draftEditable !== false
|
|
515
|
+
? "draft"
|
|
516
|
+
: "private";
|
|
517
|
+
},
|
|
435
518
|
async createComment(input) {
|
|
519
|
+
if (input.recoveryCommentId && !commentStorage.supportsIntentRecovery)
|
|
520
|
+
throw new CommentValidationError(
|
|
521
|
+
"Attached Comment recovery is unavailable.",
|
|
522
|
+
);
|
|
436
523
|
const { post, body, displayName } = await validateCommentCreation(
|
|
437
524
|
input,
|
|
438
525
|
storage,
|
|
@@ -440,12 +527,22 @@ export function createFeedbackModule(
|
|
|
440
527
|
return toCreatedComment(
|
|
441
528
|
await commentStorage.createPageComment({
|
|
442
529
|
postId: post.id,
|
|
530
|
+
...(input.recoveryCommentId
|
|
531
|
+
? { recoveryCommentId: input.recoveryCommentId }
|
|
532
|
+
: {}),
|
|
533
|
+
...(input.submissionKey
|
|
534
|
+
? { submissionKey: input.submissionKey }
|
|
535
|
+
: {}),
|
|
443
536
|
body,
|
|
444
537
|
displayName,
|
|
445
538
|
}),
|
|
446
539
|
);
|
|
447
540
|
},
|
|
448
541
|
async replyToCommentThread(input) {
|
|
542
|
+
if (input.recoveryCommentId && !commentStorage.supportsIntentRecovery)
|
|
543
|
+
throw new CommentValidationError(
|
|
544
|
+
"Attached Comment recovery is unavailable.",
|
|
545
|
+
);
|
|
449
546
|
const { post, body, displayName } = await validateCommentCreation(
|
|
450
547
|
input,
|
|
451
548
|
storage,
|
|
@@ -472,6 +569,12 @@ export function createFeedbackModule(
|
|
|
472
569
|
await commentStorage.createDiscussionReply({
|
|
473
570
|
postId: post.id,
|
|
474
571
|
discussionId: input.discussionId,
|
|
572
|
+
...(input.recoveryCommentId
|
|
573
|
+
? { recoveryCommentId: input.recoveryCommentId }
|
|
574
|
+
: {}),
|
|
575
|
+
...(input.submissionKey
|
|
576
|
+
? { submissionKey: input.submissionKey }
|
|
577
|
+
: {}),
|
|
475
578
|
body,
|
|
476
579
|
displayName,
|
|
477
580
|
}),
|
|
@@ -536,10 +639,29 @@ export function createFeedbackModule(
|
|
|
536
639
|
submitPost(input) {
|
|
537
640
|
const submission = postCreationQueue.then(async () => {
|
|
538
641
|
const now = new Date();
|
|
539
|
-
const
|
|
642
|
+
const { submissionKey, ...content } = input;
|
|
643
|
+
if (
|
|
644
|
+
submissionKey !== undefined &&
|
|
645
|
+
!/^[A-Za-z0-9_-]{43}$/.test(submissionKey)
|
|
646
|
+
)
|
|
647
|
+
throw new Error("Submission intent is invalid.");
|
|
648
|
+
const browserCapability = submissionKey
|
|
649
|
+
? (submissionKey as BrowserCapability)
|
|
650
|
+
: createBrowserCapability();
|
|
651
|
+
const externalId = submissionKey
|
|
652
|
+
? `portal:${hashBrowserCapability(browserCapability)}`
|
|
653
|
+
: undefined;
|
|
654
|
+
if (externalId) {
|
|
655
|
+
const previous = await storage.findByExternalId(externalId);
|
|
656
|
+
if (previous) {
|
|
657
|
+
await options.ownership?.register(toPost(previous));
|
|
658
|
+
return { ...toPost(previous), browserCapability };
|
|
659
|
+
}
|
|
660
|
+
}
|
|
540
661
|
const slug = await createUniqueSlug(input.title, storage);
|
|
541
662
|
const item = await storage.create({
|
|
542
|
-
...
|
|
663
|
+
...content,
|
|
664
|
+
...(externalId ? { externalId } : {}),
|
|
543
665
|
slug,
|
|
544
666
|
status: "New",
|
|
545
667
|
published: false,
|
|
@@ -549,6 +671,7 @@ export function createFeedbackModule(
|
|
|
549
671
|
voteCount: 0,
|
|
550
672
|
});
|
|
551
673
|
|
|
674
|
+
await options.ownership?.register(toPost(item));
|
|
552
675
|
return {
|
|
553
676
|
...toPost(item),
|
|
554
677
|
browserCapability,
|
|
@@ -571,7 +694,7 @@ export function createFeedbackModule(
|
|
|
571
694
|
},
|
|
572
695
|
async getDraftPost(input) {
|
|
573
696
|
const item = await storage.find(input.id);
|
|
574
|
-
if (!
|
|
697
|
+
if (!item || !(await authorizedDraft(item, input.browserCapability))) {
|
|
575
698
|
throw new BrowserCapabilityAuthorizationError();
|
|
576
699
|
}
|
|
577
700
|
return toDraftPost(toPost(item));
|
|
@@ -619,11 +742,12 @@ export function createFeedbackModule(
|
|
|
619
742
|
async withdrawDraftPost(input) {
|
|
620
743
|
const item = await storage.find(input.id);
|
|
621
744
|
|
|
622
|
-
if (!
|
|
745
|
+
if (!item || !(await authorizedDraft(item, input.browserCapability))) {
|
|
623
746
|
throw new BrowserCapabilityAuthorizationError();
|
|
624
747
|
}
|
|
625
748
|
|
|
626
749
|
await storage.remove(item.id);
|
|
750
|
+
await options.ownership?.withdrawn(item.id);
|
|
627
751
|
},
|
|
628
752
|
async getPublicPostRoadmap() {
|
|
629
753
|
const pages = await Promise.all(
|
|
@@ -640,9 +764,11 @@ export function createNotionFeedbackModule(
|
|
|
640
764
|
options: NotionFeedbackStorageOptions & {
|
|
641
765
|
votingEnabled?: boolean;
|
|
642
766
|
commentsEnabled?: boolean;
|
|
767
|
+
ownership?: PostOwnership;
|
|
643
768
|
},
|
|
644
769
|
): FeedbackModule {
|
|
645
770
|
return createFeedbackModule({
|
|
771
|
+
ownership: options.ownership,
|
|
646
772
|
storage: createNotionFeedbackStorage(options),
|
|
647
773
|
commentStorage: createNotionCommentStorage(options),
|
|
648
774
|
votingEnabled: options.votingEnabled,
|
|
@@ -650,6 +776,21 @@ export function createNotionFeedbackModule(
|
|
|
650
776
|
});
|
|
651
777
|
}
|
|
652
778
|
|
|
779
|
+
export function createGitHubFeedbackModule(
|
|
780
|
+
options: GitHubFeedbackOptions & { ownership?: PostOwnership },
|
|
781
|
+
): FeedbackModule {
|
|
782
|
+
const storage = createGitHubFeedbackStorage(options);
|
|
783
|
+
return createFeedbackModule({
|
|
784
|
+
storage,
|
|
785
|
+
ownership: options.ownership,
|
|
786
|
+
commentStorage: createGitHubCommentStorage(options, storage),
|
|
787
|
+
votingEnabled: options.votingEnabled,
|
|
788
|
+
commentsEnabled: options.commentsEnabled,
|
|
789
|
+
});
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
export type { GitHubFeedbackOptions } from "./github-feedback";
|
|
793
|
+
|
|
653
794
|
function createInMemoryCommentStorage(
|
|
654
795
|
initialComments: StoredComment[] = [],
|
|
655
796
|
): CommentStorage {
|
|
@@ -743,13 +884,16 @@ async function validateCommentCreation(
|
|
|
743
884
|
}
|
|
744
885
|
|
|
745
886
|
function toCreatedComment(comment: StoredComment): CreatedComment {
|
|
746
|
-
|
|
887
|
+
const result: CreatedComment = {
|
|
747
888
|
id: comment.id,
|
|
748
889
|
discussionId: comment.discussionId,
|
|
749
890
|
body: comment.body,
|
|
750
891
|
author: comment.author,
|
|
751
892
|
createdAt: comment.createdAt,
|
|
752
893
|
};
|
|
894
|
+
if (comment.recoverable)
|
|
895
|
+
Object.defineProperty(result, recoverableComment, { value: true });
|
|
896
|
+
return result;
|
|
753
897
|
}
|
|
754
898
|
|
|
755
899
|
function encodePublicPostCursor(
|
|
@@ -915,9 +1059,15 @@ function hashBrowserCapability(browserCapability: BrowserCapability): string {
|
|
|
915
1059
|
|
|
916
1060
|
function authorizesDraft(
|
|
917
1061
|
item: StoredPost | undefined,
|
|
918
|
-
browserCapability: BrowserCapability,
|
|
1062
|
+
browserCapability: BrowserCapability | undefined,
|
|
919
1063
|
): item is StoredPost & { browserCapabilityHash: string } {
|
|
920
|
-
if (
|
|
1064
|
+
if (
|
|
1065
|
+
!browserCapability ||
|
|
1066
|
+
!item?.browserCapabilityHash ||
|
|
1067
|
+
item.status !== "New" ||
|
|
1068
|
+
item.published ||
|
|
1069
|
+
item.draftEditable === false
|
|
1070
|
+
) {
|
|
921
1071
|
return false;
|
|
922
1072
|
}
|
|
923
1073
|
|
|
@@ -940,3 +1090,63 @@ function isPublicPostRoadmapItem<Item extends Post>(
|
|
|
940
1090
|
item.status === "Shipped")
|
|
941
1091
|
);
|
|
942
1092
|
}
|
|
1093
|
+
|
|
1094
|
+
export {
|
|
1095
|
+
createConnectedStore,
|
|
1096
|
+
type ConnectedStore,
|
|
1097
|
+
type ConnectedDatabase,
|
|
1098
|
+
type VerifiedParticipant,
|
|
1099
|
+
} from "./connected";
|
|
1100
|
+
export {
|
|
1101
|
+
signIdentityHandoff,
|
|
1102
|
+
verifyIdentityHandoff,
|
|
1103
|
+
type IdentityHandoff,
|
|
1104
|
+
} from "./identity-handoff";
|
|
1105
|
+
|
|
1106
|
+
export { createConnectedFeedbackModule } from "./connected-feedback";
|
|
1107
|
+
|
|
1108
|
+
export {
|
|
1109
|
+
createParticipationModule,
|
|
1110
|
+
type ParticipantNotification,
|
|
1111
|
+
type FollowState,
|
|
1112
|
+
} from "./participation";
|
|
1113
|
+
export {
|
|
1114
|
+
createEmailSender,
|
|
1115
|
+
type EmailSender,
|
|
1116
|
+
type NotificationEmail,
|
|
1117
|
+
type CloudflareEmailBinding,
|
|
1118
|
+
} from "./email-delivery";
|
|
1119
|
+
|
|
1120
|
+
export {
|
|
1121
|
+
createImageModule,
|
|
1122
|
+
ImageAttachmentError,
|
|
1123
|
+
type ImageAttachment,
|
|
1124
|
+
} from "./images";
|
|
1125
|
+
|
|
1126
|
+
export { createImageFeedbackModule } from "./image-feedback";
|
|
1127
|
+
export { createNotionImageReviewWriter } from "./notion-image-review";
|
|
1128
|
+
|
|
1129
|
+
export { createGitHubServiceAccess } from "./github-service-client";
|
|
1130
|
+
export {
|
|
1131
|
+
createObservedHistoryModule,
|
|
1132
|
+
type ObservedHistoryEvent,
|
|
1133
|
+
type ObservedHistoryPage,
|
|
1134
|
+
} from "./observed-history";
|
|
1135
|
+
export {
|
|
1136
|
+
boundedBody,
|
|
1137
|
+
signIntegrationRequest,
|
|
1138
|
+
verifyIntegrationRequest,
|
|
1139
|
+
} from "./integration-auth";
|
|
1140
|
+
|
|
1141
|
+
function isEditableDraft(item: StoredPost) {
|
|
1142
|
+
return (
|
|
1143
|
+
item.status === "New" && !item.published && item.draftEditable !== false
|
|
1144
|
+
);
|
|
1145
|
+
}
|
|
1146
|
+
export {
|
|
1147
|
+
createPostOwnership,
|
|
1148
|
+
createMyFeedbackModule,
|
|
1149
|
+
type MyFeedbackFilter,
|
|
1150
|
+
type MyFeedbackItem,
|
|
1151
|
+
type MyFeedbackPage,
|
|
1152
|
+
} from "./ownership";
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { createHmac, randomUUID, timingSafeEqual } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
export function integrationSecret(
|
|
4
|
+
master: string,
|
|
5
|
+
installationId: string,
|
|
6
|
+
direction: "portal" | "service",
|
|
7
|
+
version: number,
|
|
8
|
+
) {
|
|
9
|
+
if (master.length < 32)
|
|
10
|
+
throw new Error(
|
|
11
|
+
"Integration signing secret must have at least 32 characters.",
|
|
12
|
+
);
|
|
13
|
+
return createHmac("sha256", master)
|
|
14
|
+
.update(
|
|
15
|
+
JSON.stringify([
|
|
16
|
+
"feedbax-integration-v1",
|
|
17
|
+
installationId,
|
|
18
|
+
direction,
|
|
19
|
+
version,
|
|
20
|
+
]),
|
|
21
|
+
)
|
|
22
|
+
.digest("base64url");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function signIntegrationRequest(input: {
|
|
26
|
+
url: string;
|
|
27
|
+
installationId: string;
|
|
28
|
+
secret: string;
|
|
29
|
+
body?: string;
|
|
30
|
+
method?: string;
|
|
31
|
+
now?: number;
|
|
32
|
+
nonce?: string;
|
|
33
|
+
}) {
|
|
34
|
+
const method = input.method ?? "POST";
|
|
35
|
+
const time = String(Math.floor((input.now ?? Date.now()) / 1000));
|
|
36
|
+
const nonce = input.nonce ?? randomUUID();
|
|
37
|
+
const body = input.body ?? "";
|
|
38
|
+
const signature = mac(
|
|
39
|
+
input.secret,
|
|
40
|
+
method,
|
|
41
|
+
input.url,
|
|
42
|
+
input.installationId,
|
|
43
|
+
time,
|
|
44
|
+
nonce,
|
|
45
|
+
body,
|
|
46
|
+
);
|
|
47
|
+
return new Request(input.url, {
|
|
48
|
+
method,
|
|
49
|
+
redirect: "manual",
|
|
50
|
+
headers: {
|
|
51
|
+
"Content-Type": "application/json",
|
|
52
|
+
"X-Feedbax-Installation": input.installationId,
|
|
53
|
+
"X-Feedbax-Time": time,
|
|
54
|
+
"X-Feedbax-Nonce": nonce,
|
|
55
|
+
"X-Feedbax-Signature": signature,
|
|
56
|
+
},
|
|
57
|
+
...(method === "GET" ? {} : { body }),
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function verifyIntegrationRequest(
|
|
62
|
+
request: Request,
|
|
63
|
+
body: string,
|
|
64
|
+
secret: string,
|
|
65
|
+
now = Date.now(),
|
|
66
|
+
) {
|
|
67
|
+
const installationId = request.headers.get("X-Feedbax-Installation") ?? "";
|
|
68
|
+
const time = request.headers.get("X-Feedbax-Time") ?? "";
|
|
69
|
+
const nonce = request.headers.get("X-Feedbax-Nonce") ?? "";
|
|
70
|
+
const signature = request.headers.get("X-Feedbax-Signature") ?? "";
|
|
71
|
+
if (
|
|
72
|
+
secret.length < 32 ||
|
|
73
|
+
!/^[A-Za-z0-9_-]{1,128}$/.test(installationId) ||
|
|
74
|
+
!/^\d{10}$/.test(time) ||
|
|
75
|
+
Math.abs(now - Number(time) * 1000) > 300_000 ||
|
|
76
|
+
!/^[A-Za-z0-9_-]{16,128}$/.test(nonce)
|
|
77
|
+
)
|
|
78
|
+
return false;
|
|
79
|
+
return equalSignature(
|
|
80
|
+
signature,
|
|
81
|
+
mac(secret, request.method, request.url, installationId, time, nonce, body),
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function equalSignature(actual: string, expected: string) {
|
|
86
|
+
const a = Buffer.from(actual);
|
|
87
|
+
const b = Buffer.from(expected);
|
|
88
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
89
|
+
}
|
|
90
|
+
function mac(
|
|
91
|
+
secret: string,
|
|
92
|
+
method: string,
|
|
93
|
+
url: string,
|
|
94
|
+
installation: string,
|
|
95
|
+
time: string,
|
|
96
|
+
nonce: string,
|
|
97
|
+
body: string,
|
|
98
|
+
) {
|
|
99
|
+
return createHmac("sha256", secret)
|
|
100
|
+
.update(
|
|
101
|
+
JSON.stringify([
|
|
102
|
+
"feedbax-integration-v1",
|
|
103
|
+
method,
|
|
104
|
+
url,
|
|
105
|
+
installation,
|
|
106
|
+
time,
|
|
107
|
+
nonce,
|
|
108
|
+
body,
|
|
109
|
+
]),
|
|
110
|
+
)
|
|
111
|
+
.digest("hex");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export async function boundedBody(request: Request, maximum: number) {
|
|
115
|
+
if (Number(request.headers.get("Content-Length")) > maximum)
|
|
116
|
+
throw new Error("Request body limit exceeded.");
|
|
117
|
+
const reader = request.body?.getReader();
|
|
118
|
+
const chunks: Uint8Array[] = [];
|
|
119
|
+
let size = 0;
|
|
120
|
+
if (reader)
|
|
121
|
+
while (true) {
|
|
122
|
+
const { done, value } = await reader.read();
|
|
123
|
+
if (done) break;
|
|
124
|
+
size += value.byteLength;
|
|
125
|
+
if (size > maximum) {
|
|
126
|
+
await reader.cancel();
|
|
127
|
+
throw new Error("Request body limit exceeded.");
|
|
128
|
+
}
|
|
129
|
+
chunks.push(value);
|
|
130
|
+
}
|
|
131
|
+
const bytes = new Uint8Array(size);
|
|
132
|
+
let offset = 0;
|
|
133
|
+
for (const chunk of chunks) {
|
|
134
|
+
bytes.set(chunk, offset);
|
|
135
|
+
offset += chunk.byteLength;
|
|
136
|
+
}
|
|
137
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
138
|
+
}
|
|
@@ -1,44 +1,11 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { CommentStorage, StoredComment } from "./comment-storage";
|
|
2
|
+
export type { CommentStorage, StoredComment } from "./comment-storage";
|
|
2
3
|
import type { NotionFeedbackStorageOptions } from "./notion-feedback";
|
|
3
4
|
import { requestNotion } from "./notion-request";
|
|
4
5
|
|
|
5
6
|
const NOTION_API_URL = "https://api.notion.com/v1";
|
|
6
7
|
const NOTION_API_VERSION = "2026-03-11";
|
|
7
8
|
|
|
8
|
-
export interface StoredComment {
|
|
9
|
-
id: string;
|
|
10
|
-
postId: string;
|
|
11
|
-
discussionId: string;
|
|
12
|
-
body: string;
|
|
13
|
-
author: CommentAuthor;
|
|
14
|
-
createdAt: Date;
|
|
15
|
-
resolved: boolean;
|
|
16
|
-
scope?: "page" | "block";
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
export interface CommentStorage {
|
|
20
|
-
createPageComment(input: {
|
|
21
|
-
postId: string;
|
|
22
|
-
body: string;
|
|
23
|
-
displayName: string;
|
|
24
|
-
}): Promise<StoredComment>;
|
|
25
|
-
createDiscussionReply(input: {
|
|
26
|
-
postId: string;
|
|
27
|
-
discussionId: string;
|
|
28
|
-
body: string;
|
|
29
|
-
displayName: string;
|
|
30
|
-
}): Promise<StoredComment>;
|
|
31
|
-
listPageComments(
|
|
32
|
-
postId: string,
|
|
33
|
-
cursor?: string,
|
|
34
|
-
): Promise<{
|
|
35
|
-
items: StoredComment[];
|
|
36
|
-
nextCursor?: string;
|
|
37
|
-
}>;
|
|
38
|
-
updateComment(commentId: string, body: string): Promise<StoredComment>;
|
|
39
|
-
deleteComment(commentId: string): Promise<void>;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
9
|
export function createNotionCommentStorage({
|
|
43
10
|
token,
|
|
44
11
|
request = fetch,
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { requestNotion, type NotionRetryOptions } from "./notion-request";
|
|
2
|
+
import type { ImageAttachment } from "./images";
|
|
3
|
+
/** Private page-body links let Team Members review drafts without a second dashboard. */
|
|
4
|
+
export function createNotionImageReviewWriter(options: {
|
|
5
|
+
token: string;
|
|
6
|
+
reviewUrl(id: string): Promise<string>;
|
|
7
|
+
request?: typeof fetch;
|
|
8
|
+
retry?: NotionRetryOptions;
|
|
9
|
+
}) {
|
|
10
|
+
return async (postId: string, images: ImageAttachment[]) => {
|
|
11
|
+
const children = await Promise.all(
|
|
12
|
+
images.map(async (image) => ({
|
|
13
|
+
object: "block",
|
|
14
|
+
type: "paragraph",
|
|
15
|
+
paragraph: {
|
|
16
|
+
rich_text: [
|
|
17
|
+
{
|
|
18
|
+
type: "text",
|
|
19
|
+
text: {
|
|
20
|
+
content: "Review Participant image",
|
|
21
|
+
link: { url: await options.reviewUrl(image.id) },
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
],
|
|
25
|
+
},
|
|
26
|
+
})),
|
|
27
|
+
);
|
|
28
|
+
const response = await requestNotion(
|
|
29
|
+
`https://api.notion.com/v1/blocks/${encodeURIComponent(postId)}/children`,
|
|
30
|
+
{
|
|
31
|
+
method: "PATCH",
|
|
32
|
+
headers: {
|
|
33
|
+
Authorization: `Bearer ${options.token}`,
|
|
34
|
+
"Notion-Version": "2025-09-03",
|
|
35
|
+
"Content-Type": "application/json",
|
|
36
|
+
},
|
|
37
|
+
body: JSON.stringify({ children }),
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
operation: "create",
|
|
41
|
+
retry: options.retry,
|
|
42
|
+
request: (input, init) =>
|
|
43
|
+
(options.request ?? fetch)(input, {
|
|
44
|
+
...init,
|
|
45
|
+
signal: AbortSignal.timeout(10000),
|
|
46
|
+
}),
|
|
47
|
+
},
|
|
48
|
+
);
|
|
49
|
+
if (!response.ok) {
|
|
50
|
+
await response.body?.cancel();
|
|
51
|
+
throw new Error("Image review links could not be saved in Notion.");
|
|
52
|
+
}
|
|
53
|
+
await response.body?.cancel();
|
|
54
|
+
};
|
|
55
|
+
}
|