create-feedbax 0.1.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -21
- package/README.md +11 -1
- package/dist/cli.js +3283 -22
- package/dist/index.js +3394 -160
- package/package.json +40 -14
- package/template/.feedbax-template.sha256 +1 -0
- package/template/.gitignore.template +6 -0
- package/template/.npmignore.template +5 -0
- package/template/.prettierignore.template +4 -0
- package/template/AGENTS.md +13 -0
- package/template/CONNECTED.md +404 -0
- package/template/LICENSE +201 -0
- package/template/README.md +56 -5
- package/template/components.json +25 -0
- package/template/eslint.config.mjs +48 -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/package.json +74 -14
- package/template/public/feedbax-mark.svg +4 -0
- package/template/public/robots.txt +3 -0
- package/template/src/authorized-draft-post.test.tsx +212 -0
- package/template/src/authorized-draft-post.tsx +205 -0
- package/template/src/authorized-draft-query.ts +74 -0
- package/template/src/browser-post-state.test.ts +120 -0
- package/template/src/browser-post-state.ts +134 -0
- package/template/src/browser-vote-state.test.ts +14 -0
- package/template/src/browser-vote-state.ts +27 -0
- package/template/src/capability-authorization-error.ts +3 -0
- package/template/src/changelog-page.test.tsx +139 -0
- package/template/src/changelog-page.tsx +228 -0
- package/template/src/changelog-query.ts +13 -0
- package/template/src/changelog-runtime.ts +27 -0
- package/template/src/cloudflare-turnstile.ts +31 -0
- package/template/src/cloudflare-workers-test.ts +3 -0
- package/template/src/cloudflare-workers.d.ts +24 -0
- package/template/src/comment-creation.test.tsx +311 -0
- package/template/src/comment-profile.ts +5 -0
- package/template/src/comment-thread-page.ts +28 -0
- package/template/src/components/command-palette-context.tsx +71 -0
- package/template/src/components/command-palette.tsx +275 -0
- package/template/src/components/connected-identity-provider.tsx +108 -0
- package/template/src/components/create-post-trigger.tsx +44 -0
- package/template/src/components/device-profile-provider.tsx +324 -0
- package/template/src/components/follow-post-button.tsx +89 -0
- package/template/src/components/header.tsx +97 -0
- package/template/src/components/image-attachments.tsx +187 -0
- package/template/src/components/loader.tsx +53 -0
- package/template/src/components/notification-inbox.tsx +232 -0
- package/template/src/components/portal-footer.tsx +14 -0
- package/template/src/components/profile-menu.tsx +119 -0
- package/template/src/components/ui/components/alert-dialog.tsx +185 -0
- package/template/src/components/ui/components/attachment.tsx +207 -0
- package/template/src/components/ui/components/avatar.tsx +107 -0
- package/template/src/components/ui/components/badge.tsx +52 -0
- package/template/src/components/ui/components/bubble.tsx +128 -0
- package/template/src/components/ui/components/button-group.tsx +87 -0
- package/template/src/components/ui/components/button.tsx +56 -0
- package/template/src/components/ui/components/card.tsx +103 -0
- package/template/src/components/ui/components/checkbox.tsx +26 -0
- package/template/src/components/ui/components/command.tsx +195 -0
- package/template/src/components/ui/components/dialog.tsx +154 -0
- package/template/src/components/ui/components/drawer.tsx +229 -0
- package/template/src/components/ui/components/dropdown-menu.tsx +270 -0
- package/template/src/components/ui/components/empty.tsx +101 -0
- package/template/src/components/ui/components/field.tsx +236 -0
- package/template/src/components/ui/components/input-group.tsx +155 -0
- package/template/src/components/ui/components/input.tsx +20 -0
- package/template/src/components/ui/components/item.tsx +201 -0
- package/template/src/components/ui/components/kbd.tsx +26 -0
- package/template/src/components/ui/components/label.tsx +18 -0
- package/template/src/components/ui/components/marker.tsx +71 -0
- package/template/src/components/ui/components/message-scroller.tsx +128 -0
- package/template/src/components/ui/components/message.tsx +92 -0
- package/template/src/components/ui/components/native-select.tsx +65 -0
- package/template/src/components/ui/components/popover.tsx +88 -0
- package/template/src/components/ui/components/portal-primitives.test.tsx +85 -0
- package/template/src/components/ui/components/radio-group.tsx +36 -0
- package/template/src/components/ui/components/scroll-area.tsx +51 -0
- package/template/src/components/ui/components/secondary-form-actions.test.tsx +23 -0
- package/template/src/components/ui/components/select.tsx +199 -0
- package/template/src/components/ui/components/separator.tsx +23 -0
- package/template/src/components/ui/components/sheet.tsx +135 -0
- package/template/src/components/ui/components/skeleton.tsx +13 -0
- package/template/src/components/ui/components/sonner.tsx +43 -0
- package/template/src/components/ui/components/spinner.tsx +16 -0
- package/template/src/components/ui/components/switch.tsx +29 -0
- package/template/src/components/ui/components/tabs.tsx +80 -0
- package/template/src/components/ui/components/textarea.tsx +18 -0
- package/template/src/components/ui/components/tooltip.tsx +64 -0
- package/template/src/components/ui/hooks/.gitkeep +0 -0
- package/template/src/components/ui/lib/utils.ts +6 -0
- package/template/src/components/ui/styles/globals.css +151 -0
- package/template/src/connected-identity.test.ts +144 -0
- package/template/src/connected-identity.ts +122 -0
- package/template/src/connected-participation.test.tsx +67 -0
- package/template/src/connected-runtime.test.ts +241 -0
- package/template/src/connected-runtime.ts +176 -0
- package/template/src/connected-social.test.tsx +158 -0
- package/template/src/create-post-overlay.tsx +82 -0
- package/template/src/draft-post-list.tsx +39 -0
- package/template/src/feedback-cache-invalidation.test.ts +185 -0
- package/template/src/feedback-cache-invalidation.ts +98 -0
- package/template/src/feedback-runtime.ts +105 -0
- package/template/src/feedbax/changelog/index.test.ts +156 -0
- package/template/src/feedbax/changelog/index.ts +206 -0
- package/template/src/feedbax/changelog/notion-changelog.test.ts +395 -0
- package/template/src/feedbax/changelog/notion-changelog.ts +400 -0
- package/template/src/feedbax/config/index.test.ts +139 -0
- package/template/src/feedbax/config/index.ts +364 -0
- package/template/src/feedbax/feedback/connected-feedback.ts +206 -0
- package/template/src/feedbax/feedback/connected.test.ts +272 -0
- package/template/src/feedbax/feedback/connected.ts +215 -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 +43 -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 +101 -0
- package/template/src/feedbax/feedback/images.test.ts +223 -0
- package/template/src/feedbax/feedback/images.ts +324 -0
- package/template/src/feedbax/feedback/index.test.ts +900 -0
- package/template/src/feedbax/feedback/index.ts +996 -0
- package/template/src/feedbax/feedback/notion-comments.test.ts +171 -0
- package/template/src/feedbax/feedback/notion-comments.ts +231 -0
- package/template/src/feedbax/feedback/notion-data-source.test.ts +364 -0
- package/template/src/feedbax/feedback/notion-data-source.ts +458 -0
- package/template/src/feedbax/feedback/notion-feedback.test.ts +998 -0
- package/template/src/feedbax/feedback/notion-feedback.ts +651 -0
- package/template/src/feedbax/feedback/notion-image-review.ts +55 -0
- package/template/src/feedbax/feedback/notion-request.ts +74 -0
- package/template/src/feedbax/feedback/participation.test.ts +653 -0
- package/template/src/feedbax/feedback/participation.ts +502 -0
- package/template/src/feedbax.test-fixture.ts +64 -0
- package/template/src/feedbax.ts +69 -0
- package/template/src/images-runtime.test.ts +133 -0
- package/template/src/images-runtime.ts +125 -0
- package/template/src/index.css +1795 -0
- package/template/src/lazy-create-post-overlay.tsx +6 -0
- package/template/src/lazy-post-detail-overlay.tsx +21 -0
- package/template/src/masked-post-link.tsx +27 -0
- package/template/src/overlay-post-detail.test.tsx +181 -0
- package/template/src/overlay-post-detail.tsx +43 -0
- 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.test.ts +375 -0
- package/template/src/portal-comments.ts +403 -0
- package/template/src/portal-features.test.tsx +150 -0
- package/template/src/portal-feedback-form.tsx +530 -0
- package/template/src/portal-feedback-runtime.test.ts +32 -0
- package/template/src/portal-feedback-runtime.ts +58 -0
- package/template/src/portal-feedback-server-function.ts +96 -0
- package/template/src/portal-feedback-submission.test.ts +307 -0
- package/template/src/portal-feedback-submission.ts +224 -0
- package/template/src/portal-head.ts +58 -0
- package/template/src/portal-server-entry.ts +68 -0
- package/template/src/portal-voting.test.ts +106 -0
- package/template/src/portal-voting.ts +149 -0
- package/template/src/post-badges.tsx +27 -0
- package/template/src/post-creation-flow.test.tsx +230 -0
- package/template/src/post-creation-flow.tsx +88 -0
- package/template/src/post-detail-content.test.tsx +233 -0
- package/template/src/post-detail-content.tsx +162 -0
- package/template/src/post-detail-overlay.test.tsx +114 -0
- package/template/src/post-detail-overlay.tsx +67 -0
- package/template/src/post-detail-query.ts +34 -0
- package/template/src/post-feed-controls.test.tsx +239 -0
- package/template/src/post-feed-controls.tsx +298 -0
- package/template/src/post-presentation.ts +37 -0
- package/template/src/post-queries.test.ts +158 -0
- package/template/src/post-queries.ts +26 -0
- package/template/src/post-results.tsx +165 -0
- package/template/src/public-cache-policy.test.ts +148 -0
- package/template/src/public-cache-policy.ts +119 -0
- package/template/src/public-cache-tags.ts +7 -0
- package/template/src/public-changelog-page.ts +15 -0
- package/template/src/public-changelog-server-function.ts +27 -0
- package/template/src/public-date.ts +11 -0
- package/template/src/public-failure-state.test.tsx +69 -0
- package/template/src/public-feedback-index.tsx +94 -0
- package/template/src/public-feedback-page.test.tsx +355 -0
- package/template/src/public-feedback-page.ts +86 -0
- package/template/src/public-feedback-server-function.ts +22 -0
- package/template/src/public-portal-error.tsx +27 -0
- package/template/src/public-post-detail-skeleton.tsx +54 -0
- package/template/src/public-post-detail.tsx +772 -0
- package/template/src/public-post-page.test.tsx +173 -0
- package/template/src/public-post-page.ts +15 -0
- package/template/src/public-post-route.test.tsx +250 -0
- package/template/src/public-post-route.tsx +158 -0
- package/template/src/public-post-server-function.ts +48 -0
- package/template/src/public-post-unavailable.tsx +10 -0
- package/template/src/public-roadmap-page.test.tsx +139 -0
- package/template/src/public-roadmap-page.ts +24 -0
- package/template/src/public-roadmap-route.test.tsx +254 -0
- package/template/src/public-roadmap-route.tsx +50 -0
- package/template/src/public-roadmap-server-function.ts +44 -0
- package/template/src/public-roadmap-view.tsx +411 -0
- package/template/src/public-shell.test.tsx +544 -0
- package/template/src/roadmap-query.ts +21 -0
- package/template/src/root-branding.test.ts +53 -0
- package/template/src/routeTree.gen.ts +177 -0
- package/template/src/routed-post-creation-form.tsx +28 -0
- package/template/src/router.tsx +27 -0
- package/template/src/routes/__root.tsx +114 -0
- package/template/src/routes/changelog.tsx +39 -0
- package/template/src/routes/email-preferences.tsx +77 -0
- package/template/src/routes/index.tsx +61 -0
- package/template/src/routes/p.$slug.tsx +16 -0
- package/template/src/routes/roadmap.tsx +11 -0
- package/template/src/routes/submit.tsx +28 -0
- package/template/src/safe-public-failure.ts +47 -0
- package/template/src/server.ts +151 -0
- package/template/src/similar-posts.tsx +45 -0
- package/template/src/submit-route.test.tsx +134 -0
- package/template/src/trusted-feedback-handler.test.ts +425 -0
- package/template/src/trusted-feedback-handler.ts +181 -0
- package/template/src/trusted-feedback-runtime.ts +17 -0
- package/template/src/turnstile-configuration.test.ts +37 -0
- package/template/src/turnstile-configuration.ts +19 -0
- package/template/src/use-media-query.ts +17 -0
- package/template/src/vote-toggle.test.tsx +171 -0
- package/template/src/vote-toggle.tsx +244 -0
- package/template/tsconfig.json +45 -0
- package/template/vite.config.ts +20 -0
- package/template/vitest.config.ts +4 -0
- package/template/vitest.setup.ts +3 -0
- package/template/wrangler.jsonc +35 -0
- package/dist/cli.d.ts +0 -3
- package/dist/cli.d.ts.map +0 -1
- package/dist/cli.js.map +0 -1
- package/dist/index.d.ts +0 -21
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/template/.env.example +0 -4
- package/template/apps/portal/components.json +0 -20
- package/template/apps/portal/package.json +0 -31
- package/template/apps/portal/src/lib/utils.ts +0 -6
- package/template/apps/portal/src/routeTree.gen.ts +0 -59
- package/template/apps/portal/src/router.tsx +0 -10
- package/template/apps/portal/src/routes/__root.tsx +0 -31
- package/template/apps/portal/src/routes/index.tsx +0 -35
- package/template/apps/portal/src/styles.css +0 -91
- package/template/apps/portal/tsconfig.json +0 -15
- package/template/apps/portal/vite.config.ts +0 -13
- package/template/feedbax.config.ts +0 -13
- package/template/feedbax.jsonc +0 -11
- package/template/pnpm-workspace.yaml +0 -3
- package/template/turbo.json +0 -8
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { lazy, Suspense } from "react";
|
|
2
|
+
import { PostDetailOverlay } from "./post-detail-overlay";
|
|
3
|
+
import { PublicPostDetailSkeleton } from "./public-post-detail-skeleton";
|
|
4
|
+
|
|
5
|
+
const OverlayPostDetail = lazy(() =>
|
|
6
|
+
import("./overlay-post-detail").then((module) => ({
|
|
7
|
+
default: module.OverlayPostDetail,
|
|
8
|
+
})),
|
|
9
|
+
);
|
|
10
|
+
|
|
11
|
+
export function LazyPostDetailOverlay() {
|
|
12
|
+
return (
|
|
13
|
+
<PostDetailOverlay
|
|
14
|
+
renderDetail={(slug) => (
|
|
15
|
+
<Suspense fallback={<PublicPostDetailSkeleton display="overlay" />}>
|
|
16
|
+
<OverlayPostDetail key={slug} slug={slug} />
|
|
17
|
+
</Suspense>
|
|
18
|
+
)}
|
|
19
|
+
/>
|
|
20
|
+
);
|
|
21
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { Link } from "@tanstack/react-router";
|
|
2
|
+
import type { AnchorHTMLAttributes } from "react";
|
|
3
|
+
|
|
4
|
+
export function PostLink({
|
|
5
|
+
slug,
|
|
6
|
+
contextual = false,
|
|
7
|
+
...props
|
|
8
|
+
}: AnchorHTMLAttributes<HTMLAnchorElement> & {
|
|
9
|
+
slug: string;
|
|
10
|
+
contextual?: boolean;
|
|
11
|
+
}) {
|
|
12
|
+
if (!contextual) {
|
|
13
|
+
return <a {...props} href={`/p/${encodeURIComponent(slug)}`} />;
|
|
14
|
+
}
|
|
15
|
+
return (
|
|
16
|
+
<Link
|
|
17
|
+
{...props}
|
|
18
|
+
to="."
|
|
19
|
+
state={{ postDetailOverlay: { slug } }}
|
|
20
|
+
mask={{
|
|
21
|
+
to: "/p/$slug",
|
|
22
|
+
params: { slug },
|
|
23
|
+
unmaskOnReload: true,
|
|
24
|
+
}}
|
|
25
|
+
/>
|
|
26
|
+
);
|
|
27
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
cleanup,
|
|
5
|
+
fireEvent,
|
|
6
|
+
render as renderView,
|
|
7
|
+
screen,
|
|
8
|
+
within,
|
|
9
|
+
} from "@testing-library/react";
|
|
10
|
+
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
|
11
|
+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
12
|
+
import type { ReactNode } from "react";
|
|
13
|
+
|
|
14
|
+
import { OverlayPostDetail } from "./overlay-post-detail";
|
|
15
|
+
import {
|
|
16
|
+
getPublicPost,
|
|
17
|
+
getPublicPostComments,
|
|
18
|
+
} from "./public-post-server-function";
|
|
19
|
+
import { saveDeviceProfile } from "./browser-post-state";
|
|
20
|
+
|
|
21
|
+
vi.mock("./public-post-server-function", () => ({
|
|
22
|
+
getPublicPost: vi.fn(),
|
|
23
|
+
getPublicPostComments: vi.fn().mockResolvedValue({ items: [] }),
|
|
24
|
+
}));
|
|
25
|
+
vi.mock("./authorized-draft-post", () => ({
|
|
26
|
+
AuthorizedDraftPost: () => <p>Draft fallback</p>,
|
|
27
|
+
}));
|
|
28
|
+
|
|
29
|
+
beforeEach(() => {
|
|
30
|
+
saveDeviceProfile(localStorage, {
|
|
31
|
+
name: "Ari",
|
|
32
|
+
email: "ari@example.com",
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
afterEach(() => {
|
|
37
|
+
cleanup();
|
|
38
|
+
localStorage.clear();
|
|
39
|
+
vi.clearAllMocks();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("shows loading instead of stale content when the requested slug changes", async () => {
|
|
43
|
+
let resolveSecond!: (post: typeof firstPost) => void;
|
|
44
|
+
vi.mocked(getPublicPost)
|
|
45
|
+
.mockResolvedValueOnce(firstPost)
|
|
46
|
+
.mockReturnValueOnce(
|
|
47
|
+
new Promise((resolve) => {
|
|
48
|
+
resolveSecond = resolve;
|
|
49
|
+
}),
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
const view = render(<OverlayPostDetail slug="first-post" />);
|
|
53
|
+
expect(
|
|
54
|
+
await screen.findByRole("heading", { name: "First Post" }),
|
|
55
|
+
).toBeTruthy();
|
|
56
|
+
|
|
57
|
+
view.rerender(<OverlayPostDetail slug="second-post" />);
|
|
58
|
+
expect(
|
|
59
|
+
screen
|
|
60
|
+
.getByRole("main", { name: "Loading Post details" })
|
|
61
|
+
.getAttribute("aria-busy"),
|
|
62
|
+
).toBe("true");
|
|
63
|
+
expect(screen.queryByRole("heading", { name: "First Post" })).toBeNull();
|
|
64
|
+
|
|
65
|
+
resolveSecond({ ...firstPost, slug: "second-post", title: "Second Post" });
|
|
66
|
+
expect(
|
|
67
|
+
await screen.findByRole("heading", { name: "Second Post" }),
|
|
68
|
+
).toBeTruthy();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("shows Post details without the redundant full-page toolbar", async () => {
|
|
72
|
+
vi.mocked(getPublicPost).mockResolvedValue(firstPost);
|
|
73
|
+
vi.mocked(getPublicPostComments).mockResolvedValue({
|
|
74
|
+
items: [
|
|
75
|
+
{
|
|
76
|
+
id: "discussion-1",
|
|
77
|
+
comments: [
|
|
78
|
+
{
|
|
79
|
+
id: "comment-1",
|
|
80
|
+
body: "Visible in the dialog",
|
|
81
|
+
author: { kind: "participant", displayName: "Ari" },
|
|
82
|
+
createdAt: new Date("2026-08-25T01:00:00.000Z"),
|
|
83
|
+
},
|
|
84
|
+
],
|
|
85
|
+
},
|
|
86
|
+
],
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
render(<OverlayPostDetail slug="first-post" />);
|
|
90
|
+
|
|
91
|
+
expect(
|
|
92
|
+
await screen.findByRole("heading", { name: "First Post" }),
|
|
93
|
+
).toBeTruthy();
|
|
94
|
+
expect(screen.queryByRole("link", { name: "Open full page" })).toBeNull();
|
|
95
|
+
expect(screen.getByRole("complementary", { name: "Details" })).toBeTruthy();
|
|
96
|
+
expect(
|
|
97
|
+
await within(screen.getByRole("region", { name: "Comments" })).findByText(
|
|
98
|
+
"Visible in the dialog",
|
|
99
|
+
),
|
|
100
|
+
).toBeTruthy();
|
|
101
|
+
expect(
|
|
102
|
+
await screen.findByRole("form", { name: "Comment composer" }),
|
|
103
|
+
).toBeTruthy();
|
|
104
|
+
const composer = screen.getByRole("form", { name: "Comment composer" });
|
|
105
|
+
const threads = screen.getByRole("region", { name: "Comment threads" });
|
|
106
|
+
expect(
|
|
107
|
+
composer.compareDocumentPosition(threads) &
|
|
108
|
+
Node.DOCUMENT_POSITION_FOLLOWING,
|
|
109
|
+
).toBeTruthy();
|
|
110
|
+
expect(
|
|
111
|
+
within(threads).queryByRole("form", { name: "Comment composer" }),
|
|
112
|
+
).toBeNull();
|
|
113
|
+
expect(getPublicPostComments).toHaveBeenCalledWith({
|
|
114
|
+
data: { slug: "first-post" },
|
|
115
|
+
});
|
|
116
|
+
expect(screen.queryByRole("link", { name: "Back to Feedback" })).toBeNull();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("shows the Post while Comments are still loading", async () => {
|
|
120
|
+
vi.mocked(getPublicPost).mockResolvedValue(firstPost);
|
|
121
|
+
let finish!: (page: { items: [] }) => void;
|
|
122
|
+
vi.mocked(getPublicPostComments).mockReturnValue(
|
|
123
|
+
new Promise((resolve) => {
|
|
124
|
+
finish = resolve;
|
|
125
|
+
}),
|
|
126
|
+
);
|
|
127
|
+
render(<OverlayPostDetail slug="first-post" />);
|
|
128
|
+
expect(
|
|
129
|
+
await screen.findByRole("heading", { name: "First Post" }),
|
|
130
|
+
).toBeTruthy();
|
|
131
|
+
expect(screen.getByRole("status", { name: "Loading Comments" })).toBeTruthy();
|
|
132
|
+
expect(screen.queryByText("No comments yet")).toBeNull();
|
|
133
|
+
finish({ items: [] });
|
|
134
|
+
expect(await screen.findByText("No comments yet")).toBeTruthy();
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("keeps the Post available and retries a failed Comment load", async () => {
|
|
138
|
+
vi.mocked(getPublicPost).mockResolvedValue(firstPost);
|
|
139
|
+
vi.mocked(getPublicPostComments).mockRejectedValue(
|
|
140
|
+
new Error("Comments temporarily unavailable"),
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
render(<OverlayPostDetail slug="first-post" />);
|
|
144
|
+
|
|
145
|
+
expect(
|
|
146
|
+
await screen.findByRole("heading", { name: "First Post" }),
|
|
147
|
+
).toBeTruthy();
|
|
148
|
+
expect(
|
|
149
|
+
await screen.findByRole("form", { name: "Comment composer" }),
|
|
150
|
+
).toBeTruthy();
|
|
151
|
+
expect(await screen.findByRole("alert")).toHaveProperty(
|
|
152
|
+
"textContent",
|
|
153
|
+
"Comments could not be loaded.",
|
|
154
|
+
);
|
|
155
|
+
expect(screen.queryByText("No comments yet")).toBeNull();
|
|
156
|
+
vi.mocked(getPublicPostComments).mockResolvedValue({ items: [] });
|
|
157
|
+
fireEvent.click(screen.getByRole("button", { name: "Retry Comments" }));
|
|
158
|
+
expect(await screen.findByText("No comments yet")).toBeTruthy();
|
|
159
|
+
expect(screen.queryByText("Draft fallback")).toBeNull();
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
const firstPost = {
|
|
163
|
+
slug: "first-post",
|
|
164
|
+
title: "First Post",
|
|
165
|
+
description: "First description",
|
|
166
|
+
type: "Feature Request" as const,
|
|
167
|
+
status: "New" as const,
|
|
168
|
+
createdAt: new Date("2026-08-22T10:00:00.000Z"),
|
|
169
|
+
updatedAt: new Date("2026-08-22T10:00:00.000Z"),
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
function render(ui: ReactNode) {
|
|
173
|
+
const client = new QueryClient({
|
|
174
|
+
defaultOptions: { queries: { retry: false } },
|
|
175
|
+
});
|
|
176
|
+
return renderView(ui, {
|
|
177
|
+
wrapper: ({ children }) => (
|
|
178
|
+
<QueryClientProvider client={client}>{children}</QueryClientProvider>
|
|
179
|
+
),
|
|
180
|
+
});
|
|
181
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { useQuery } from "@tanstack/react-query";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
getPublicPost,
|
|
5
|
+
getPublicPostComments,
|
|
6
|
+
} from "./public-post-server-function";
|
|
7
|
+
import { PublicPostDetailSkeleton } from "./public-post-detail-skeleton";
|
|
8
|
+
import { publicPostQuery } from "./post-detail-query";
|
|
9
|
+
import { PostDetailContent } from "./post-detail-content";
|
|
10
|
+
import { AuthorizedDraftPost } from "./authorized-draft-post";
|
|
11
|
+
import { mutatePublicComment, submitPublicComment } from "./public-post-route";
|
|
12
|
+
|
|
13
|
+
const loadComments = (slug: string, cursor?: string) =>
|
|
14
|
+
getPublicPostComments({ data: { slug, ...(cursor ? { cursor } : {}) } });
|
|
15
|
+
|
|
16
|
+
export function OverlayPostDetail({ slug }: { slug: string }) {
|
|
17
|
+
return <OverlayPost key={slug} slug={slug} />;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function OverlayPost({ slug }: { slug: string }) {
|
|
21
|
+
const query = useQuery(
|
|
22
|
+
publicPostQuery(slug, (value) => getPublicPost({ data: { slug: value } })),
|
|
23
|
+
);
|
|
24
|
+
const post = query.data;
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<div className="overlay-post-detail">
|
|
28
|
+
{query.isPending ? (
|
|
29
|
+
<PublicPostDetailSkeleton display="overlay" />
|
|
30
|
+
) : post ? (
|
|
31
|
+
<PostDetailContent
|
|
32
|
+
post={post}
|
|
33
|
+
display="overlay"
|
|
34
|
+
loadComments={loadComments}
|
|
35
|
+
submitComment={submitPublicComment}
|
|
36
|
+
mutateComment={mutatePublicComment}
|
|
37
|
+
/>
|
|
38
|
+
) : (
|
|
39
|
+
<AuthorizedDraftPost slug={slug} />
|
|
40
|
+
)}
|
|
41
|
+
</div>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { queryOptions } from "@tanstack/react-query";
|
|
2
|
+
import type { ParticipantNotification, FollowState } from "@feedbax/feedback";
|
|
3
|
+
export interface NotificationPage {
|
|
4
|
+
items: ParticipantNotification[];
|
|
5
|
+
unreadCount: number;
|
|
6
|
+
nextCursor?: string;
|
|
7
|
+
}
|
|
8
|
+
export interface EmailPreferences {
|
|
9
|
+
emailAvailable: boolean;
|
|
10
|
+
emailEnabled: boolean;
|
|
11
|
+
}
|
|
12
|
+
export async function participationRequest<T>(
|
|
13
|
+
path: string,
|
|
14
|
+
body?: unknown,
|
|
15
|
+
): Promise<T> {
|
|
16
|
+
const response = await fetch(`/internal/connected/${path}`, {
|
|
17
|
+
cache: "no-store",
|
|
18
|
+
...(body === undefined
|
|
19
|
+
? {}
|
|
20
|
+
: {
|
|
21
|
+
method: "POST",
|
|
22
|
+
headers: { "Content-Type": "application/json" },
|
|
23
|
+
body: JSON.stringify(body),
|
|
24
|
+
}),
|
|
25
|
+
});
|
|
26
|
+
const value = await response.json();
|
|
27
|
+
if (!response.ok)
|
|
28
|
+
throw new Error(
|
|
29
|
+
value && typeof value === "object" && "error" in value
|
|
30
|
+
? String(value.error)
|
|
31
|
+
: "Updates are temporarily unavailable.",
|
|
32
|
+
);
|
|
33
|
+
return value as T;
|
|
34
|
+
}
|
|
35
|
+
export const followQuery = (slug: string) =>
|
|
36
|
+
queryOptions({
|
|
37
|
+
queryKey: ["participant-follow", slug],
|
|
38
|
+
queryFn: () =>
|
|
39
|
+
participationRequest<FollowState>(
|
|
40
|
+
`follow?slug=${encodeURIComponent(slug)}`,
|
|
41
|
+
),
|
|
42
|
+
staleTime: 0,
|
|
43
|
+
retry: false,
|
|
44
|
+
refetchInterval: (query) =>
|
|
45
|
+
query.state.data?.initializing ? 15000 : false,
|
|
46
|
+
});
|
|
47
|
+
export const preferencesQuery = () =>
|
|
48
|
+
queryOptions({
|
|
49
|
+
queryKey: ["participant-email-preferences"],
|
|
50
|
+
queryFn: () => participationRequest<EmailPreferences>("preferences"),
|
|
51
|
+
staleTime: 0,
|
|
52
|
+
retry: false,
|
|
53
|
+
});
|
|
54
|
+
export const notificationQuery = {
|
|
55
|
+
queryKey: ["participant-notifications"],
|
|
56
|
+
initialPageParam: undefined as string | undefined,
|
|
57
|
+
queryFn: ({ pageParam }: { pageParam: string | undefined }) =>
|
|
58
|
+
participationRequest<NotificationPage>(
|
|
59
|
+
`notifications${pageParam ? `?cursor=${encodeURIComponent(pageParam)}` : ""}`,
|
|
60
|
+
),
|
|
61
|
+
getNextPageParam: (last: NotificationPage) => last.nextCursor,
|
|
62
|
+
retry: false as const,
|
|
63
|
+
};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { expect, it } from "vitest";
|
|
2
|
+
import { createParticipationRequestHandler } from "./participation-runtime";
|
|
3
|
+
|
|
4
|
+
it("keeps social routes private and rejects cross-origin mutations before touching participation", async () => {
|
|
5
|
+
let read = false;
|
|
6
|
+
const handler = createParticipationRequestHandler({
|
|
7
|
+
siteUrl: "https://feedback.example.com",
|
|
8
|
+
enabled: true,
|
|
9
|
+
getParticipant: async () => undefined,
|
|
10
|
+
getModule: () => {
|
|
11
|
+
read = true;
|
|
12
|
+
throw new Error();
|
|
13
|
+
},
|
|
14
|
+
rateLimit: async () => true,
|
|
15
|
+
});
|
|
16
|
+
const privateRead = await handler(
|
|
17
|
+
new Request(
|
|
18
|
+
"https://feedback.example.com/internal/connected/notifications",
|
|
19
|
+
),
|
|
20
|
+
);
|
|
21
|
+
expect(privateRead?.status).toBe(401);
|
|
22
|
+
expect(privateRead?.headers.get("Cache-Control")).toBe("no-store");
|
|
23
|
+
const mutation = await handler(
|
|
24
|
+
new Request("https://feedback.example.com/internal/connected/follow", {
|
|
25
|
+
method: "POST",
|
|
26
|
+
headers: { Origin: "https://evil.example.com" },
|
|
27
|
+
body: JSON.stringify({ slug: "public", following: true }),
|
|
28
|
+
}),
|
|
29
|
+
);
|
|
30
|
+
expect(mutation?.status).toBe(403);
|
|
31
|
+
expect(read).toBe(false);
|
|
32
|
+
expect(
|
|
33
|
+
await handler(new Request("https://feedback.example.com/roadmap")),
|
|
34
|
+
).toBeUndefined();
|
|
35
|
+
});
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createEmailSender,
|
|
3
|
+
createParticipationModule,
|
|
4
|
+
type VerifiedParticipant,
|
|
5
|
+
} from "@feedbax/feedback";
|
|
6
|
+
import { env } from "cloudflare:workers";
|
|
7
|
+
import feedbax from "./feedbax";
|
|
8
|
+
import {
|
|
9
|
+
configuredImages,
|
|
10
|
+
createConfiguredNotionFeedbackModule,
|
|
11
|
+
getConnectedStore,
|
|
12
|
+
} from "./feedback-runtime";
|
|
13
|
+
import {
|
|
14
|
+
readSessionToken,
|
|
15
|
+
sameOrigin,
|
|
16
|
+
sessionHash,
|
|
17
|
+
} from "./connected-identity";
|
|
18
|
+
|
|
19
|
+
type ParticipationModule = ReturnType<typeof createParticipationModule>;
|
|
20
|
+
const paths = new Set([
|
|
21
|
+
"follow",
|
|
22
|
+
"notifications",
|
|
23
|
+
"preferences",
|
|
24
|
+
"unsubscribe",
|
|
25
|
+
]);
|
|
26
|
+
export function createParticipationRequestHandler(options: {
|
|
27
|
+
siteUrl: string;
|
|
28
|
+
enabled: boolean;
|
|
29
|
+
getParticipant(request: Request): Promise<VerifiedParticipant | undefined>;
|
|
30
|
+
getModule(participant?: VerifiedParticipant): ParticipationModule;
|
|
31
|
+
rateLimit(request: Request): Promise<boolean>;
|
|
32
|
+
}) {
|
|
33
|
+
return async (request: Request): Promise<Response | undefined> => {
|
|
34
|
+
const url = new URL(request.url);
|
|
35
|
+
const route = url.pathname.replace("/internal/connected/", "");
|
|
36
|
+
if (!url.pathname.startsWith("/internal/connected/") || !paths.has(route))
|
|
37
|
+
return;
|
|
38
|
+
const json = (body: unknown, status = 200) =>
|
|
39
|
+
Response.json(body, { status, headers: { "Cache-Control": "no-store" } });
|
|
40
|
+
if (!options.enabled) return json({ error: "Not found." }, 404);
|
|
41
|
+
if (!["GET", "POST"].includes(request.method))
|
|
42
|
+
return json({ error: "Method not allowed." }, 405);
|
|
43
|
+
if (
|
|
44
|
+
route !== "unsubscribe" &&
|
|
45
|
+
request.method !== "GET" &&
|
|
46
|
+
!sameOrigin(request, options.siteUrl)
|
|
47
|
+
)
|
|
48
|
+
return json({ error: "Request origin is invalid." }, 403);
|
|
49
|
+
try {
|
|
50
|
+
if (route === "unsubscribe") {
|
|
51
|
+
const token = url.searchParams.get("token") ?? "";
|
|
52
|
+
if (!/^[a-f0-9]{32}\.[A-Za-z0-9_-]{43}$/.test(token))
|
|
53
|
+
return json({ error: "Invalid unsubscribe link." }, 400);
|
|
54
|
+
if (request.method === "GET")
|
|
55
|
+
return new Response(null, {
|
|
56
|
+
status: 303,
|
|
57
|
+
headers: {
|
|
58
|
+
Location: `/email-preferences?token=${encodeURIComponent(token)}`,
|
|
59
|
+
"Cache-Control": "no-store",
|
|
60
|
+
"Referrer-Policy": "no-referrer",
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
if (!(await options.rateLimit(request)))
|
|
64
|
+
return json({ error: "Try again shortly." }, 429);
|
|
65
|
+
await options.getModule().unsubscribe(token);
|
|
66
|
+
return new Response(
|
|
67
|
+
"Email notifications are turned off. You can turn them on again in Feedbax.",
|
|
68
|
+
{
|
|
69
|
+
headers: {
|
|
70
|
+
"Content-Type": "text/plain; charset=utf-8",
|
|
71
|
+
"Cache-Control": "no-store",
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
const participant = await options.getParticipant(request);
|
|
77
|
+
if (!participant)
|
|
78
|
+
return json(
|
|
79
|
+
{
|
|
80
|
+
error: "Sign in through the product to continue.",
|
|
81
|
+
code: "sign_in_required",
|
|
82
|
+
},
|
|
83
|
+
401,
|
|
84
|
+
);
|
|
85
|
+
const module = options.getModule(participant);
|
|
86
|
+
if (request.method === "GET") {
|
|
87
|
+
if (route === "follow")
|
|
88
|
+
return json(
|
|
89
|
+
await module.followState(url.searchParams.get("slug") ?? ""),
|
|
90
|
+
);
|
|
91
|
+
if (route === "notifications")
|
|
92
|
+
return json(
|
|
93
|
+
await module.inbox(url.searchParams.get("cursor") ?? undefined),
|
|
94
|
+
);
|
|
95
|
+
if (route === "preferences") return json(await module.preferences());
|
|
96
|
+
}
|
|
97
|
+
if (!(await options.rateLimit(request)))
|
|
98
|
+
return json({ error: "Too many changes. Try again shortly." }, 429);
|
|
99
|
+
if (Number(request.headers.get("Content-Length")) > 4096)
|
|
100
|
+
return json({ error: "Request is too large." }, 413);
|
|
101
|
+
const reader = request.body?.getReader();
|
|
102
|
+
const chunks: Uint8Array[] = [];
|
|
103
|
+
let length = 0;
|
|
104
|
+
if (reader)
|
|
105
|
+
while (true) {
|
|
106
|
+
const { done, value } = await reader.read();
|
|
107
|
+
if (done) break;
|
|
108
|
+
length += value.byteLength;
|
|
109
|
+
if (length > 4096) {
|
|
110
|
+
await reader.cancel();
|
|
111
|
+
return json({ error: "Request is too large." }, 413);
|
|
112
|
+
}
|
|
113
|
+
chunks.push(value);
|
|
114
|
+
}
|
|
115
|
+
const bytes = new Uint8Array(length);
|
|
116
|
+
let offset = 0;
|
|
117
|
+
for (const chunk of chunks) {
|
|
118
|
+
bytes.set(chunk, offset);
|
|
119
|
+
offset += chunk.byteLength;
|
|
120
|
+
}
|
|
121
|
+
const text = new TextDecoder().decode(bytes);
|
|
122
|
+
if (text.length > 4096)
|
|
123
|
+
return json({ error: "Request is too large." }, 413);
|
|
124
|
+
let body: Record<string, unknown>;
|
|
125
|
+
try {
|
|
126
|
+
body = JSON.parse(text);
|
|
127
|
+
} catch {
|
|
128
|
+
return json({ error: "Invalid request." }, 400);
|
|
129
|
+
}
|
|
130
|
+
if (!body || typeof body !== "object" || Array.isArray(body))
|
|
131
|
+
return json({ error: "Invalid request." }, 400);
|
|
132
|
+
if (
|
|
133
|
+
route === "follow" &&
|
|
134
|
+
typeof body.slug === "string" &&
|
|
135
|
+
body.slug.length <= 512 &&
|
|
136
|
+
typeof body.following === "boolean"
|
|
137
|
+
)
|
|
138
|
+
return json(await module.follow(body.slug, body.following));
|
|
139
|
+
if (
|
|
140
|
+
route === "notifications" &&
|
|
141
|
+
(body.id === undefined ||
|
|
142
|
+
(typeof body.id === "string" && /^[a-f0-9]{32}$/.test(body.id)))
|
|
143
|
+
) {
|
|
144
|
+
await module.markRead(body.id as string | undefined);
|
|
145
|
+
return json({ updated: true });
|
|
146
|
+
}
|
|
147
|
+
if (route === "preferences" && typeof body.emailEnabled === "boolean") {
|
|
148
|
+
await module.setEmailPreference(body.emailEnabled);
|
|
149
|
+
return json(await module.preferences());
|
|
150
|
+
}
|
|
151
|
+
return json({ error: "Invalid request." }, 400);
|
|
152
|
+
} catch (error) {
|
|
153
|
+
const message = error instanceof Error ? error.message : "";
|
|
154
|
+
const known = [
|
|
155
|
+
"Post is unavailable.",
|
|
156
|
+
"Invalid notification cursor.",
|
|
157
|
+
"Invalid unsubscribe link.",
|
|
158
|
+
"Email notifications require a verified application email and a configured sender.",
|
|
159
|
+
];
|
|
160
|
+
return json(
|
|
161
|
+
{
|
|
162
|
+
error: known.includes(message)
|
|
163
|
+
? message
|
|
164
|
+
: "Updates are temporarily unavailable. Try again.",
|
|
165
|
+
},
|
|
166
|
+
message === "Post is unavailable."
|
|
167
|
+
? 404
|
|
168
|
+
: known.includes(message)
|
|
169
|
+
? 400
|
|
170
|
+
: 503,
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function configuredParticipation(participant?: VerifiedParticipant) {
|
|
177
|
+
return createConfiguredParticipation(
|
|
178
|
+
createConfiguredNotionFeedbackModule(),
|
|
179
|
+
participant,
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function createConfiguredParticipation(
|
|
184
|
+
feedback: import("@feedbax/feedback").FeedbackModule,
|
|
185
|
+
participant?: VerifiedParticipant,
|
|
186
|
+
) {
|
|
187
|
+
if (!feedbax.connected || !env.FEEDBAX_DB)
|
|
188
|
+
throw new Error("Connected database is missing.");
|
|
189
|
+
const config = feedbax.connected.email;
|
|
190
|
+
let email: Parameters<typeof createParticipationModule>[0]["email"];
|
|
191
|
+
if (config) {
|
|
192
|
+
const sender =
|
|
193
|
+
config.provider === "cloudflare"
|
|
194
|
+
? createEmailSender({
|
|
195
|
+
provider: "cloudflare",
|
|
196
|
+
binding: requireBinding(env.FEEDBAX_EMAIL),
|
|
197
|
+
})
|
|
198
|
+
: config.provider === "resend"
|
|
199
|
+
? createEmailSender({
|
|
200
|
+
provider: "resend",
|
|
201
|
+
token: env.EMAIL_PROVIDER_SECRET ?? "",
|
|
202
|
+
})
|
|
203
|
+
: createEmailSender({
|
|
204
|
+
provider: "custom",
|
|
205
|
+
url: config.endpoint!,
|
|
206
|
+
token: env.EMAIL_PROVIDER_SECRET ?? "",
|
|
207
|
+
});
|
|
208
|
+
email = {
|
|
209
|
+
sender,
|
|
210
|
+
from: config.from,
|
|
211
|
+
siteUrl: feedbax.product.siteUrl!,
|
|
212
|
+
unsubscribeSecret: env.PARTICIPATION_SIGNING_SECRET ?? "",
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
return createParticipationModule({
|
|
216
|
+
database: env.FEEDBAX_DB,
|
|
217
|
+
feedback,
|
|
218
|
+
participant,
|
|
219
|
+
processing: feedbax.connected.processing ?? "small",
|
|
220
|
+
email,
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
const requireBinding = <T>(value: T | undefined): T => {
|
|
224
|
+
if (!value) throw new Error("Email binding is missing.");
|
|
225
|
+
return value;
|
|
226
|
+
};
|
|
227
|
+
export const handleParticipationRequest = createParticipationRequestHandler({
|
|
228
|
+
enabled: Boolean(feedbax.connected),
|
|
229
|
+
siteUrl: feedbax.product.siteUrl ?? "https://not-configured.invalid",
|
|
230
|
+
getParticipant: async (request) => {
|
|
231
|
+
const token = readSessionToken(request);
|
|
232
|
+
return token ? getConnectedStore().session(sessionHash(token)) : undefined;
|
|
233
|
+
},
|
|
234
|
+
getModule: configuredParticipation,
|
|
235
|
+
rateLimit: async (request) =>
|
|
236
|
+
(
|
|
237
|
+
await env.PORTAL_SUBMISSIONS_RATE_LIMITER.limit({
|
|
238
|
+
key: `updates:${request.headers.get("CF-Connecting-IP") ?? "unknown"}`,
|
|
239
|
+
})
|
|
240
|
+
).success,
|
|
241
|
+
});
|
|
242
|
+
export async function synchronizeConnectedUpdates() {
|
|
243
|
+
if (!feedbax.connected) return;
|
|
244
|
+
const content = createConfiguredNotionFeedbackModule();
|
|
245
|
+
const module = createConfiguredParticipation(content);
|
|
246
|
+
const observation = await module.synchronize();
|
|
247
|
+
const email = await module.deliverEmails();
|
|
248
|
+
if (feedbax.connected.images) await configuredImages(content).cleanup();
|
|
249
|
+
// Counts only: never log identities, addresses, provider responses, or message bodies.
|
|
250
|
+
if (observation.failed || email.failed)
|
|
251
|
+
console.warn("Connected update processing will retry", {
|
|
252
|
+
observations: observation.failed,
|
|
253
|
+
deliveries: email.failed,
|
|
254
|
+
});
|
|
255
|
+
}
|