create-feedbax 0.2.1 → 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 +502 -209
- package/dist/index.js +251 -68
- 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 +1 -1
- package/template/CONNECTED.md +16 -2
- package/template/README.md +52 -13
- package/template/feedbax-vite.ts +79 -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 +34 -33
- package/template/src/changelog-runtime.ts +2 -1
- package/template/src/cloudflare-workers.d.ts +3 -0
- package/template/src/comment-creation.test.tsx +53 -6
- package/template/src/components/connected-identity-provider.tsx +44 -17
- package/template/src/components/follow-post-button.tsx +2 -13
- package/template/src/components/post-history.tsx +101 -0
- package/template/src/components/profile-menu.tsx +7 -0
- package/template/src/configured-feedback.test.ts +82 -0
- package/template/src/configured-feedback.ts +61 -0
- package/template/src/connected-identity.test.ts +7 -3
- package/template/src/connected-identity.ts +6 -1
- package/template/src/connected-runtime.test.ts +5 -2
- package/template/src/feedback-cache-invalidation.ts +2 -0
- package/template/src/feedback-runtime.ts +61 -18
- 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 +45 -1
- package/template/src/feedbax/config/index.ts +112 -9
- package/template/src/feedbax/feedback/comment-storage.ts +38 -0
- package/template/src/feedbax/feedback/connected-feedback.ts +41 -12
- package/template/src/feedbax/feedback/connected.ts +15 -5
- 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/image-feedback.ts +23 -10
- package/template/src/feedbax/feedback/images.ts +26 -0
- package/template/src/feedbax/feedback/index.ts +172 -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/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/history-runtime.test.ts +80 -0
- package/template/src/history-runtime.ts +37 -0
- package/template/src/index.css +6 -0
- package/template/src/integration-runtime.test.ts +365 -0
- package/template/src/integration-runtime.ts +289 -0
- 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/participation-runtime.ts +3 -3
- package/template/src/portal-comments.ts +11 -0
- package/template/src/portal-feedback-form.tsx +11 -3
- package/template/src/portal-feedback-submission.ts +9 -0
- package/template/src/portal-server-entry.ts +9 -0
- package/template/src/public-portal-error.tsx +1 -1
- package/template/src/public-post-detail.tsx +332 -256
- package/template/src/public-release-links.test.ts +120 -0
- package/template/src/public-release-links.ts +130 -0
- package/template/src/routeTree.gen.ts +21 -0
- package/template/src/routes/my-feedback.tsx +16 -0
- package/template/src/server.ts +46 -1
- package/template/src/submission-intent.ts +26 -0
- package/template/vite.config.ts +2 -0
|
@@ -8,7 +8,7 @@ import { followQuery, participationRequest } from "../participation-client";
|
|
|
8
8
|
|
|
9
9
|
export function FollowPostButton({ slug }: { slug: string }) {
|
|
10
10
|
const identity = useConnectedIdentity();
|
|
11
|
-
if (!identity.enabled) return null;
|
|
11
|
+
if (!identity.enabled || !identity.displayName) return null;
|
|
12
12
|
return <ConnectedFollowPostButton slug={slug} />;
|
|
13
13
|
}
|
|
14
14
|
function ConnectedFollowPostButton({ slug }: { slug: string }) {
|
|
@@ -20,18 +20,7 @@ function ConnectedFollowPostButton({ slug }: { slug: string }) {
|
|
|
20
20
|
});
|
|
21
21
|
const [pending, setPending] = useState(false);
|
|
22
22
|
const [error, setError] = useState<string>();
|
|
23
|
-
if (!identity.enabled) return null;
|
|
24
|
-
if (!identity.displayName)
|
|
25
|
-
return (
|
|
26
|
-
<Button
|
|
27
|
-
variant="outline"
|
|
28
|
-
size="sm"
|
|
29
|
-
nativeButton={false}
|
|
30
|
-
render={<a href="/internal/identity/start" />}
|
|
31
|
-
>
|
|
32
|
-
Sign in to follow
|
|
33
|
-
</Button>
|
|
34
|
-
);
|
|
23
|
+
if (!identity.enabled || !identity.displayName) return null;
|
|
35
24
|
async function toggle() {
|
|
36
25
|
setPending(true);
|
|
37
26
|
setError(undefined);
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import type { ObservedHistoryPage } from "@feedbax/feedback";
|
|
2
|
+
import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query";
|
|
3
|
+
import { Button } from "@feedbax/ui/components/button";
|
|
4
|
+
import { Skeleton } from "@feedbax/ui/components/skeleton";
|
|
5
|
+
import { formatPublicDate } from "../public-date";
|
|
6
|
+
|
|
7
|
+
export function PostHistory({ slug }: { slug: string }) {
|
|
8
|
+
const client = useQueryClient();
|
|
9
|
+
const clearPublicPost = () => {
|
|
10
|
+
client.setQueryData(["public-post", slug], null);
|
|
11
|
+
client.setQueryData(["public-post-comments", slug], {
|
|
12
|
+
pages: [{ items: [] }],
|
|
13
|
+
pageParams: [undefined],
|
|
14
|
+
});
|
|
15
|
+
void client.invalidateQueries({ queryKey: ["public-post-roadmap"] });
|
|
16
|
+
};
|
|
17
|
+
const query = useInfiniteQuery({
|
|
18
|
+
queryKey: ["public-post-history", slug],
|
|
19
|
+
initialPageParam: undefined as string | undefined,
|
|
20
|
+
queryFn: async ({ pageParam }) => {
|
|
21
|
+
const params = new URLSearchParams({
|
|
22
|
+
slug,
|
|
23
|
+
...(pageParam ? { cursor: pageParam } : {}),
|
|
24
|
+
});
|
|
25
|
+
try {
|
|
26
|
+
const response = await fetch(`/internal/post-history?${params}`, {
|
|
27
|
+
cache: "no-store",
|
|
28
|
+
});
|
|
29
|
+
if (!response.ok) throw Error("Progress history is unavailable.");
|
|
30
|
+
const page = (await response.json()) as ObservedHistoryPage;
|
|
31
|
+
if (!page.available) clearPublicPost();
|
|
32
|
+
return page;
|
|
33
|
+
} catch (error) {
|
|
34
|
+
clearPublicPost();
|
|
35
|
+
throw error;
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
getNextPageParam: (page) => page.nextCursor,
|
|
39
|
+
staleTime: 0,
|
|
40
|
+
retry: false,
|
|
41
|
+
});
|
|
42
|
+
const events = query.isError
|
|
43
|
+
? []
|
|
44
|
+
: (query.data?.pages.flatMap((page) => page.items) ?? []);
|
|
45
|
+
return (
|
|
46
|
+
<section className="space-y-3" aria-label="Activities">
|
|
47
|
+
<h2 className="sr-only">Activities</h2>
|
|
48
|
+
{query.isPending ? (
|
|
49
|
+
<Skeleton className="h-12 w-full" />
|
|
50
|
+
) : query.isError ? (
|
|
51
|
+
<div role="alert" className="space-y-2">
|
|
52
|
+
<p className="text-sm">
|
|
53
|
+
Progress history is temporarily unavailable.
|
|
54
|
+
</p>
|
|
55
|
+
<Button
|
|
56
|
+
variant="outline"
|
|
57
|
+
size="sm"
|
|
58
|
+
onClick={() => void query.refetch()}
|
|
59
|
+
>
|
|
60
|
+
Retry history
|
|
61
|
+
</Button>
|
|
62
|
+
</div>
|
|
63
|
+
) : events.length ? (
|
|
64
|
+
<ol className="space-y-4 border-l pl-4">
|
|
65
|
+
{events.map((event) => (
|
|
66
|
+
<li key={event.id} className="space-y-1 text-sm">
|
|
67
|
+
<time
|
|
68
|
+
dateTime={new Date(event.observedAt).toISOString()}
|
|
69
|
+
className="text-xs text-muted-foreground"
|
|
70
|
+
>
|
|
71
|
+
Observed {formatPublicDate(new Date(event.observedAt))}
|
|
72
|
+
</time>
|
|
73
|
+
{event.kind === "status" ? (
|
|
74
|
+
<p>
|
|
75
|
+
Moved to <span className="font-medium">{event.status}</span>
|
|
76
|
+
</p>
|
|
77
|
+
) : (
|
|
78
|
+
<>
|
|
79
|
+
<p className="font-medium">Product Team update</p>
|
|
80
|
+
<p className="whitespace-pre-wrap">{event.body}</p>
|
|
81
|
+
</>
|
|
82
|
+
)}
|
|
83
|
+
</li>
|
|
84
|
+
))}
|
|
85
|
+
</ol>
|
|
86
|
+
) : (
|
|
87
|
+
<p className="text-sm text-muted-foreground">No activities yet.</p>
|
|
88
|
+
)}
|
|
89
|
+
{query.hasNextPage && !query.isError ? (
|
|
90
|
+
<Button
|
|
91
|
+
variant="outline"
|
|
92
|
+
size="sm"
|
|
93
|
+
disabled={query.isFetchingNextPage}
|
|
94
|
+
onClick={() => void query.fetchNextPage()}
|
|
95
|
+
>
|
|
96
|
+
{query.isFetchingNextPage ? "Loading history…" : "More history"}
|
|
97
|
+
</Button>
|
|
98
|
+
) : null}
|
|
99
|
+
</section>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
@@ -33,6 +33,13 @@ export function ProfileMenu() {
|
|
|
33
33
|
</Button>
|
|
34
34
|
{identity.displayName ? (
|
|
35
35
|
<>
|
|
36
|
+
<Button
|
|
37
|
+
nativeButton={false}
|
|
38
|
+
variant="ghost"
|
|
39
|
+
render={<a href="/my-feedback" />}
|
|
40
|
+
>
|
|
41
|
+
My feedback
|
|
42
|
+
</Button>
|
|
36
43
|
<span>{identity.displayName}</span>
|
|
37
44
|
<Button variant="outline" onClick={() => void identity.signOut()}>
|
|
38
45
|
Sign out
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { afterEach, expect, it } from "vitest";
|
|
2
|
+
import { Miniflare } from "miniflare";
|
|
3
|
+
import { readFile, readdir } from "node:fs/promises";
|
|
4
|
+
import { resolve } from "node:path";
|
|
5
|
+
import { createContentFeedbackModule } from "./configured-feedback";
|
|
6
|
+
import type { FeedbaxConfig } from "@feedbax/config";
|
|
7
|
+
|
|
8
|
+
let runtime: Miniflare | undefined;
|
|
9
|
+
afterEach(async () => {
|
|
10
|
+
await runtime?.dispose();
|
|
11
|
+
});
|
|
12
|
+
it("runs a GitHub-only portal without Notion or verified identity and requires D1", async () => {
|
|
13
|
+
const config: FeedbaxConfig = {
|
|
14
|
+
provider: "github",
|
|
15
|
+
product: {
|
|
16
|
+
name: "Acme",
|
|
17
|
+
description: "Feedback",
|
|
18
|
+
logo: "/logo.svg",
|
|
19
|
+
siteUrl: "https://portal.example",
|
|
20
|
+
},
|
|
21
|
+
theme: { preset: "lyra-neutral" },
|
|
22
|
+
github: {
|
|
23
|
+
installationId: "portal-acme",
|
|
24
|
+
serviceUrl: "https://service.example",
|
|
25
|
+
repository: { id: 123, owner: "acme", name: "feedback" },
|
|
26
|
+
botUserId: 456,
|
|
27
|
+
},
|
|
28
|
+
features: { voting: true, comments: true, changelog: false },
|
|
29
|
+
submissions: {
|
|
30
|
+
moderation: "required",
|
|
31
|
+
maxTitleLength: 160,
|
|
32
|
+
maxDescriptionLength: 5000,
|
|
33
|
+
},
|
|
34
|
+
turnstile: { enabled: false },
|
|
35
|
+
};
|
|
36
|
+
expect(() => createContentFeedbackModule(config, {})).toThrow("FEEDBAX_DB");
|
|
37
|
+
runtime = new Miniflare({
|
|
38
|
+
modules: true,
|
|
39
|
+
script: "export default { fetch() { return new Response('ok') } }",
|
|
40
|
+
d1Databases: ["DB"],
|
|
41
|
+
compatibilityDate: "2026-07-30",
|
|
42
|
+
});
|
|
43
|
+
const database = await runtime.getD1Database("DB");
|
|
44
|
+
const directory = resolve(import.meta.dirname, "../migrations");
|
|
45
|
+
for (const file of (await readdir(directory)).sort())
|
|
46
|
+
await database.exec(
|
|
47
|
+
(await readFile(resolve(directory, file), "utf8"))
|
|
48
|
+
.replace(/--[^\n]*/g, "")
|
|
49
|
+
.replace(/\s+/g, " "),
|
|
50
|
+
);
|
|
51
|
+
const urls: string[] = [];
|
|
52
|
+
const feedback = createContentFeedbackModule(
|
|
53
|
+
config,
|
|
54
|
+
{ FEEDBAX_DB: database, GITHUB_SERVICE_SECRET: "p".repeat(40) },
|
|
55
|
+
async (input, init) => {
|
|
56
|
+
const request =
|
|
57
|
+
input instanceof Request ? input : new Request(input, init);
|
|
58
|
+
urls.push(request.url);
|
|
59
|
+
if (request.url === "https://service.example/v1/token") {
|
|
60
|
+
expect(request.headers.get("X-Feedbax-Installation")).toBe(
|
|
61
|
+
"portal-acme",
|
|
62
|
+
);
|
|
63
|
+
expect(request.headers.get("X-Feedbax-Signature")).toMatch(
|
|
64
|
+
/^[a-f0-9]{64}$/,
|
|
65
|
+
);
|
|
66
|
+
return Response.json({
|
|
67
|
+
token: "scoped-access",
|
|
68
|
+
expiresAt: new Date(Date.now() + 3600_000).toISOString(),
|
|
69
|
+
repositoryId: 123,
|
|
70
|
+
botUserId: 456,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
expect(request.headers.get("Authorization")).toBe("Bearer scoped-access");
|
|
74
|
+
if (request.url === "https://api.github.com/repos/acme/feedback")
|
|
75
|
+
return Response.json({ id: 123 });
|
|
76
|
+
return Response.json([]);
|
|
77
|
+
},
|
|
78
|
+
);
|
|
79
|
+
expect(await feedback.listPublicPosts()).toMatchObject({ items: [] });
|
|
80
|
+
expect(urls.some((url) => url.includes("notion"))).toBe(false);
|
|
81
|
+
expect(urls).toContain("https://service.example/v1/token");
|
|
82
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { assertRuntimeBindings, type FeedbaxConfig } from "@feedbax/config";
|
|
2
|
+
import {
|
|
3
|
+
createGitHubFeedbackModule,
|
|
4
|
+
createGitHubServiceAccess,
|
|
5
|
+
createNotionFeedbackModule,
|
|
6
|
+
type ConnectedDatabase,
|
|
7
|
+
type VerifiedParticipant,
|
|
8
|
+
createPostOwnership,
|
|
9
|
+
} from "@feedbax/feedback";
|
|
10
|
+
|
|
11
|
+
export function createContentFeedbackModule(
|
|
12
|
+
config: FeedbaxConfig,
|
|
13
|
+
environment: {
|
|
14
|
+
FEEDBAX_DB?: ConnectedDatabase;
|
|
15
|
+
NOTION_TOKEN?: string;
|
|
16
|
+
GITHUB_SERVICE_SECRET?: string;
|
|
17
|
+
getGitHubServiceSecret?: () => Promise<string>;
|
|
18
|
+
},
|
|
19
|
+
request: typeof fetch = fetch,
|
|
20
|
+
participant?: VerifiedParticipant,
|
|
21
|
+
) {
|
|
22
|
+
assertRuntimeBindings(config, environment);
|
|
23
|
+
const features = {
|
|
24
|
+
votingEnabled: config.features.voting,
|
|
25
|
+
commentsEnabled: config.features.comments,
|
|
26
|
+
...(config.connected && participant && environment.FEEDBAX_DB
|
|
27
|
+
? { ownership: createPostOwnership(environment.FEEDBAX_DB, participant) }
|
|
28
|
+
: {}),
|
|
29
|
+
};
|
|
30
|
+
if (config.provider === "github") {
|
|
31
|
+
if (
|
|
32
|
+
!environment.getGitHubServiceSecret &&
|
|
33
|
+
(!environment.GITHUB_SERVICE_SECRET ||
|
|
34
|
+
environment.GITHUB_SERVICE_SECRET.length < 32)
|
|
35
|
+
)
|
|
36
|
+
throw new Error(
|
|
37
|
+
"GitHub requires GITHUB_SERVICE_SECRET. Pair this portal with the GitHub App service.",
|
|
38
|
+
);
|
|
39
|
+
return createGitHubFeedbackModule({
|
|
40
|
+
...features,
|
|
41
|
+
...config.github,
|
|
42
|
+
database: environment.FEEDBAX_DB!,
|
|
43
|
+
request,
|
|
44
|
+
getToken: createGitHubServiceAccess({
|
|
45
|
+
config: config.github,
|
|
46
|
+
secret: environment.GITHUB_SERVICE_SECRET,
|
|
47
|
+
getSecret: environment.getGitHubServiceSecret,
|
|
48
|
+
request,
|
|
49
|
+
}),
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
if (!environment.NOTION_TOKEN)
|
|
53
|
+
throw new Error("Notion requires NOTION_TOKEN.");
|
|
54
|
+
return createNotionFeedbackModule({
|
|
55
|
+
...features,
|
|
56
|
+
token: environment.NOTION_TOKEN,
|
|
57
|
+
dataSourceId: config.notion.dataSourceId,
|
|
58
|
+
propertyIds: config.notion.properties,
|
|
59
|
+
request,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
@@ -112,9 +112,13 @@ it("exchanges a browser-bound handoff once, keeps identity private, and revokes
|
|
|
112
112
|
});
|
|
113
113
|
const response = await handler(sessionRequest());
|
|
114
114
|
expect(response.headers.get("cache-control")).toBe("no-store");
|
|
115
|
-
|
|
116
|
-
participant: { displayName:
|
|
117
|
-
}
|
|
115
|
+
const identity = (await response.json()) as {
|
|
116
|
+
participant: { displayName: string; sessionKey: string };
|
|
117
|
+
};
|
|
118
|
+
expect(identity.participant.displayName).toBe("Alice");
|
|
119
|
+
expect(identity.participant.sessionKey).toMatch(/^[A-Za-z0-9_-]{43}$/);
|
|
120
|
+
expect(JSON.stringify(identity)).not.toContain("private-subject");
|
|
121
|
+
expect(JSON.stringify(identity)).not.toContain(session.split("=")[1]);
|
|
118
122
|
expect(
|
|
119
123
|
(
|
|
120
124
|
await handler(
|
|
@@ -89,7 +89,12 @@ export function createConnectedIdentityHandler(options: {
|
|
|
89
89
|
: undefined;
|
|
90
90
|
return json({
|
|
91
91
|
participant: participant
|
|
92
|
-
? {
|
|
92
|
+
? {
|
|
93
|
+
displayName: participant.displayName,
|
|
94
|
+
sessionKey: createHash("sha256")
|
|
95
|
+
.update(`feedbax-ui-session-v1:${token}`)
|
|
96
|
+
.digest("base64url"),
|
|
97
|
+
}
|
|
93
98
|
: null,
|
|
94
99
|
});
|
|
95
100
|
}
|
|
@@ -219,8 +219,11 @@ it("requires a valid session and same-origin mutations, and ignores browser-supp
|
|
|
219
219
|
}),
|
|
220
220
|
);
|
|
221
221
|
expect(sessionRead?.status).toBe(200);
|
|
222
|
-
expect(await sessionRead?.json()).
|
|
223
|
-
participant: {
|
|
222
|
+
expect(await sessionRead?.json()).toMatchObject({
|
|
223
|
+
participant: {
|
|
224
|
+
displayName: "Alice",
|
|
225
|
+
sessionKey: expect.stringMatching(/^[A-Za-z0-9_-]{43}$/),
|
|
226
|
+
},
|
|
224
227
|
});
|
|
225
228
|
const unavailable = createConnectedRequestHandler({
|
|
226
229
|
...options,
|
|
@@ -47,6 +47,8 @@ export function createInvalidatingFeedbackModule({
|
|
|
47
47
|
invalidator: PublicCacheInvalidator;
|
|
48
48
|
}): FeedbackModule {
|
|
49
49
|
return {
|
|
50
|
+
getParticipantPost: (id) => feedback.getParticipantPost(id),
|
|
51
|
+
claimDraftPost: (input) => feedback.claimDraftPost(input),
|
|
50
52
|
getAttachmentPostState: (slug) => feedback.getAttachmentPostState(slug),
|
|
51
53
|
editComment: (input) => feedback.editComment(input),
|
|
52
54
|
deleteComment: (input) => feedback.deleteComment(input),
|
|
@@ -4,8 +4,8 @@ import {
|
|
|
4
4
|
createNotionImageReviewWriter,
|
|
5
5
|
createConnectedStore,
|
|
6
6
|
createConnectedFeedbackModule,
|
|
7
|
+
createObservedHistoryModule,
|
|
7
8
|
type VerifiedParticipant,
|
|
8
|
-
createNotionFeedbackModule,
|
|
9
9
|
} from "@feedbax/feedback";
|
|
10
10
|
import { cache, env } from "cloudflare:workers";
|
|
11
11
|
|
|
@@ -14,27 +14,45 @@ import {
|
|
|
14
14
|
createPublicCacheInvalidator,
|
|
15
15
|
} from "./feedback-cache-invalidation";
|
|
16
16
|
import feedbax from "./feedbax";
|
|
17
|
+
import { createContentFeedbackModule } from "./configured-feedback";
|
|
18
|
+
import { createPortalIntegration } from "./integration-runtime";
|
|
19
|
+
import { createPublicReleaseFeedbackModule } from "./public-release-links";
|
|
20
|
+
import { createConfiguredChangelogModule } from "./changelog-runtime";
|
|
17
21
|
|
|
18
22
|
export const feedbackSubmissionLimits = {
|
|
19
23
|
maxTitleLength: feedbax.submissions.maxTitleLength,
|
|
20
24
|
maxDescriptionLength: feedbax.submissions.maxDescriptionLength,
|
|
21
25
|
} as const;
|
|
22
26
|
|
|
23
|
-
export function
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
27
|
+
export function createConfiguredContentFeedbackModule(
|
|
28
|
+
participant?: VerifiedParticipant,
|
|
29
|
+
) {
|
|
30
|
+
return createContentFeedbackModule(
|
|
31
|
+
feedbax,
|
|
32
|
+
{
|
|
33
|
+
FEEDBAX_DB: env.FEEDBAX_DB,
|
|
34
|
+
NOTION_TOKEN: process.env.NOTION_TOKEN,
|
|
35
|
+
GITHUB_SERVICE_SECRET: env.GITHUB_SERVICE_SECRET,
|
|
36
|
+
...(feedbax.provider === "github" && env.FEEDBAX_DB
|
|
37
|
+
? {
|
|
38
|
+
getGitHubServiceSecret: () =>
|
|
39
|
+
configuredIntegration().portalSecret(),
|
|
40
|
+
}
|
|
41
|
+
: {}),
|
|
42
|
+
},
|
|
43
|
+
fetch,
|
|
44
|
+
participant,
|
|
45
|
+
);
|
|
32
46
|
}
|
|
33
47
|
|
|
34
48
|
export function createConfiguredFeedbackModule(
|
|
35
49
|
participant?: VerifiedParticipant,
|
|
36
50
|
) {
|
|
37
|
-
const
|
|
51
|
+
const source = createConfiguredContentFeedbackModule(participant);
|
|
52
|
+
const history = feedbax.history ? configuredHistory(source) : undefined;
|
|
53
|
+
const content = history
|
|
54
|
+
? { ...source, getPublicPost: history.observe }
|
|
55
|
+
: source;
|
|
38
56
|
const feedback = feedbax.connected
|
|
39
57
|
? createConnectedFeedbackModule(content, getConnectedStore(), participant)
|
|
40
58
|
: content;
|
|
@@ -45,22 +63,37 @@ export function createConfiguredFeedbackModule(
|
|
|
45
63
|
? createImageFeedbackModule(
|
|
46
64
|
feedback,
|
|
47
65
|
images,
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
66
|
+
feedbax.provider === "github"
|
|
67
|
+
? undefined
|
|
68
|
+
: createNotionImageReviewWriter({
|
|
69
|
+
token: requiredEnvironmentValue("NOTION_TOKEN"),
|
|
70
|
+
reviewUrl: (id) => images.reviewUrl(id, feedbax.product.siteUrl!),
|
|
71
|
+
}),
|
|
52
72
|
)
|
|
53
73
|
: feedback;
|
|
54
74
|
return createInvalidatingFeedbackModule({
|
|
55
|
-
feedback:
|
|
75
|
+
feedback: createPublicReleaseFeedbackModule({
|
|
76
|
+
feedback: withImages,
|
|
77
|
+
siteUrl: feedbax.product.siteUrl,
|
|
78
|
+
...(feedbax.features.changelog && feedbax.changelog
|
|
79
|
+
? { changelog: createConfiguredChangelogModule(true) }
|
|
80
|
+
: {}),
|
|
81
|
+
}),
|
|
56
82
|
invalidator: createPublicCacheInvalidator({
|
|
57
83
|
purge: (options) => cache.purge(options),
|
|
58
84
|
}),
|
|
59
85
|
});
|
|
60
86
|
}
|
|
61
87
|
|
|
62
|
-
|
|
63
|
-
|
|
88
|
+
export function configuredHistory(
|
|
89
|
+
feedback = createConfiguredContentFeedbackModule(),
|
|
90
|
+
) {
|
|
91
|
+
if (!feedbax.history || !env.FEEDBAX_DB)
|
|
92
|
+
throw new Error(
|
|
93
|
+
"Observed history requires explicit history configuration and FEEDBAX_DB.",
|
|
94
|
+
);
|
|
95
|
+
return createObservedHistoryModule({ database: env.FEEDBAX_DB, feedback });
|
|
96
|
+
}
|
|
64
97
|
|
|
65
98
|
export function requiredEnvironmentValue(name: string): string {
|
|
66
99
|
const value = process.env[name];
|
|
@@ -103,3 +136,13 @@ export async function requestParticipant(request: Request) {
|
|
|
103
136
|
const token = readSessionToken(request);
|
|
104
137
|
return token ? getConnectedStore().session(sessionHash(token)) : undefined;
|
|
105
138
|
}
|
|
139
|
+
|
|
140
|
+
export function configuredIntegration() {
|
|
141
|
+
if (!env.FEEDBAX_DB)
|
|
142
|
+
throw new Error("GitHub requires the FEEDBAX_DB D1 binding.");
|
|
143
|
+
return createPortalIntegration({
|
|
144
|
+
config: feedbax,
|
|
145
|
+
database: env.FEEDBAX_DB,
|
|
146
|
+
pairingSecret: env.GITHUB_PAIRING_SECRET,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
@@ -8,6 +8,41 @@ import {
|
|
|
8
8
|
} from ".";
|
|
9
9
|
|
|
10
10
|
describe("Changelog module", () => {
|
|
11
|
+
it("puts newer same-day entries first and uses Slug for exact ties", async () => {
|
|
12
|
+
const changelog = createChangelogModule({
|
|
13
|
+
storage: createInMemoryChangelogStorage([
|
|
14
|
+
entry({ id: "a", slug: "older-creation", date: "2026-10-15" }),
|
|
15
|
+
entry({
|
|
16
|
+
id: "z",
|
|
17
|
+
slug: "a-newer-creation",
|
|
18
|
+
date: "2026-10-15",
|
|
19
|
+
createdAt: new Date("2026-10-15T12:00:00Z"),
|
|
20
|
+
}),
|
|
21
|
+
entry({
|
|
22
|
+
id: "b",
|
|
23
|
+
slug: "z-newer-creation",
|
|
24
|
+
date: "2026-10-15",
|
|
25
|
+
createdAt: new Date("2026-10-15T12:00:00Z"),
|
|
26
|
+
}),
|
|
27
|
+
entry({
|
|
28
|
+
id: "c",
|
|
29
|
+
slug: "older-release-date",
|
|
30
|
+
date: "2026-10-14",
|
|
31
|
+
createdAt: new Date("2026-10-16T12:00:00Z"),
|
|
32
|
+
}),
|
|
33
|
+
]),
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
expect(
|
|
37
|
+
(await changelog.listPublishedEntries()).items.map(({ slug }) => slug),
|
|
38
|
+
).toEqual([
|
|
39
|
+
"a-newer-creation",
|
|
40
|
+
"z-newer-creation",
|
|
41
|
+
"older-creation",
|
|
42
|
+
"older-release-date",
|
|
43
|
+
]);
|
|
44
|
+
});
|
|
45
|
+
|
|
11
46
|
it("returns only Published Changelog Entries in deterministic Date order", async () => {
|
|
12
47
|
const changelog = createChangelogModule({
|
|
13
48
|
storage: createInMemoryChangelogStorage([
|
|
@@ -160,7 +160,11 @@ function compareEntries(
|
|
|
160
160
|
left: StoredChangelogEntry,
|
|
161
161
|
right: StoredChangelogEntry,
|
|
162
162
|
): number {
|
|
163
|
-
return
|
|
163
|
+
return (
|
|
164
|
+
right.date.localeCompare(left.date) ||
|
|
165
|
+
right.createdAt.getTime() - left.createdAt.getTime() ||
|
|
166
|
+
left.slug.localeCompare(right.slug)
|
|
167
|
+
);
|
|
164
168
|
}
|
|
165
169
|
|
|
166
170
|
function toPublicEntry(entry: StoredChangelogEntry): PublicChangelogEntry {
|
|
@@ -183,7 +183,7 @@ describe("Notion Changelog query contract", () => {
|
|
|
183
183
|
},
|
|
184
184
|
sorts: [
|
|
185
185
|
{ property: propertyIds.date, direction: "descending" },
|
|
186
|
-
{ timestamp: "created_time", direction: "
|
|
186
|
+
{ timestamp: "created_time", direction: "descending" },
|
|
187
187
|
{ property: propertyIds.slug, direction: "ascending" },
|
|
188
188
|
],
|
|
189
189
|
});
|
|
@@ -168,7 +168,7 @@ export function createNotionChangelogStorage({
|
|
|
168
168
|
},
|
|
169
169
|
sorts: [
|
|
170
170
|
{ property: propertyIds.date, direction: "descending" },
|
|
171
|
-
{ timestamp: "created_time", direction: "
|
|
171
|
+
{ timestamp: "created_time", direction: "descending" },
|
|
172
172
|
{ property: propertyIds.slug, direction: "ascending" },
|
|
173
173
|
],
|
|
174
174
|
}),
|
|
@@ -1,10 +1,22 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
2
|
import {
|
|
3
|
+
assertRuntimeBindings,
|
|
3
4
|
assertProductionConfiguration,
|
|
4
5
|
defineFeedbax,
|
|
5
6
|
type FeedbaxConfig,
|
|
6
7
|
} from "./index";
|
|
7
8
|
|
|
9
|
+
it("keeps observed history opt-in and requires explicitly provisioned D1 without enabling identity", () => {
|
|
10
|
+
expect(defineFeedbax(input).history).toBeUndefined();
|
|
11
|
+
const config = defineFeedbax({ ...input, history: true });
|
|
12
|
+
expect(config.connected).toBeUndefined();
|
|
13
|
+
expect(() => assertRuntimeBindings(config, {})).toThrow("FEEDBAX_DB");
|
|
14
|
+
expect(() => assertRuntimeBindings(config, { FEEDBAX_DB: {} })).not.toThrow();
|
|
15
|
+
expect(() =>
|
|
16
|
+
defineFeedbax({ ...input, history: "yes" } as unknown as FeedbaxConfig),
|
|
17
|
+
).toThrow("history must be true or false");
|
|
18
|
+
});
|
|
19
|
+
|
|
8
20
|
const properties = Object.fromEntries(
|
|
9
21
|
[
|
|
10
22
|
"title",
|
|
@@ -24,7 +36,7 @@ const properties = Object.fromEntries(
|
|
|
24
36
|
"createdAt",
|
|
25
37
|
"updatedAt",
|
|
26
38
|
].map((key) => [key, `${key}-id`]),
|
|
27
|
-
) as FeedbaxConfig["notion"]["properties"];
|
|
39
|
+
) as NonNullable<FeedbaxConfig["notion"]>["properties"];
|
|
28
40
|
const changelogProperties = Object.fromEntries(
|
|
29
41
|
[
|
|
30
42
|
"title",
|
|
@@ -63,6 +75,38 @@ const input: FeedbaxConfig = {
|
|
|
63
75
|
};
|
|
64
76
|
|
|
65
77
|
describe("Feedbax configuration", () => {
|
|
78
|
+
it("selects GitHub without Notion or verified identity and rejects mixed providers", () => {
|
|
79
|
+
const github = {
|
|
80
|
+
installationId: "portal-acme",
|
|
81
|
+
serviceUrl: "https://app.feedbax.example",
|
|
82
|
+
repository: { id: 123, owner: "acme", name: "feedback" },
|
|
83
|
+
botUserId: 456,
|
|
84
|
+
};
|
|
85
|
+
const configuration = {
|
|
86
|
+
...input,
|
|
87
|
+
provider: "github",
|
|
88
|
+
notion: undefined,
|
|
89
|
+
changelog: undefined,
|
|
90
|
+
features: { ...input.features, changelog: false },
|
|
91
|
+
github,
|
|
92
|
+
};
|
|
93
|
+
const parsed = defineFeedbax(configuration as unknown as FeedbaxConfig);
|
|
94
|
+
expect(parsed).toMatchObject({ provider: "github", github });
|
|
95
|
+
expect(parsed.notion).toBeUndefined();
|
|
96
|
+
expect(parsed.connected).toBeUndefined();
|
|
97
|
+
expect(() =>
|
|
98
|
+
defineFeedbax({
|
|
99
|
+
...configuration,
|
|
100
|
+
notion: input.notion,
|
|
101
|
+
} as unknown as FeedbaxConfig),
|
|
102
|
+
).toThrow("one Content Provider");
|
|
103
|
+
expect(() =>
|
|
104
|
+
defineFeedbax({
|
|
105
|
+
...configuration,
|
|
106
|
+
features: input.features,
|
|
107
|
+
} as unknown as FeedbaxConfig),
|
|
108
|
+
).toThrow("disable Changelog");
|
|
109
|
+
});
|
|
66
110
|
it("accepts the complete generated contract", () =>
|
|
67
111
|
expect(defineFeedbax(input)).toEqual(input));
|
|
68
112
|
it("requires enabled capability dependencies", () =>
|