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,502 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createHash,
|
|
3
|
+
createHmac,
|
|
4
|
+
randomUUID,
|
|
5
|
+
timingSafeEqual,
|
|
6
|
+
} from "node:crypto";
|
|
7
|
+
import type { D1Database } from "@cloudflare/workers-types";
|
|
8
|
+
import { SQLiteAsyncDialect } from "drizzle-orm/sqlite-core";
|
|
9
|
+
import { sql, type SQL } from "drizzle-orm";
|
|
10
|
+
import { drizzle } from "drizzle-orm/d1";
|
|
11
|
+
import type { FeedbackModule, CommentThreadPage } from "./index";
|
|
12
|
+
import type { EmailSender } from "./email-delivery";
|
|
13
|
+
import type { VerifiedParticipant } from "./connected";
|
|
14
|
+
|
|
15
|
+
export function createParticipationModule(options: {
|
|
16
|
+
database: D1Database;
|
|
17
|
+
feedback: FeedbackModule;
|
|
18
|
+
participant?: VerifiedParticipant;
|
|
19
|
+
processing?: "small" | "standard";
|
|
20
|
+
now?: () => number;
|
|
21
|
+
email?: {
|
|
22
|
+
sender: EmailSender;
|
|
23
|
+
from: string;
|
|
24
|
+
siteUrl: string;
|
|
25
|
+
unsubscribeSecret: string;
|
|
26
|
+
};
|
|
27
|
+
}) {
|
|
28
|
+
const db = drizzle(options.database);
|
|
29
|
+
const now = () => options.now?.() ?? Date.now();
|
|
30
|
+
const identity = () => {
|
|
31
|
+
if (!options.participant) throw new Error("Sign in to participate.");
|
|
32
|
+
return options.participant;
|
|
33
|
+
};
|
|
34
|
+
const published = async (slug: string) => {
|
|
35
|
+
const post = await options.feedback.getPublicPost(slug);
|
|
36
|
+
if (!post) throw new Error("Post is unavailable.");
|
|
37
|
+
return post;
|
|
38
|
+
};
|
|
39
|
+
const visible = async (slug: string) => options.feedback.getPublicPost(slug);
|
|
40
|
+
const batch = async (statements: SQL[]) => {
|
|
41
|
+
const dialect = new SQLiteAsyncDialect();
|
|
42
|
+
await options.database.batch(
|
|
43
|
+
statements.map((statement) => {
|
|
44
|
+
const query = dialect.sqlToQuery(statement);
|
|
45
|
+
return options.database.prepare(query.sql).bind(...query.params);
|
|
46
|
+
}),
|
|
47
|
+
);
|
|
48
|
+
};
|
|
49
|
+
const projectFollow = (row?: { ready: number }): FollowState => ({
|
|
50
|
+
following: Boolean(row),
|
|
51
|
+
...(row && !row.ready ? { initializing: true } : {}),
|
|
52
|
+
});
|
|
53
|
+
const readFollow = async (slug: string, subject: string) =>
|
|
54
|
+
db.get<{ ready: number }>(
|
|
55
|
+
sql`SELECT ready FROM post_follows WHERE post_slug=${slug} AND subject=${subject}`,
|
|
56
|
+
);
|
|
57
|
+
const baseline = (
|
|
58
|
+
slug: string,
|
|
59
|
+
subject: string,
|
|
60
|
+
token: string,
|
|
61
|
+
startedAt: number,
|
|
62
|
+
page: CommentThreadPage,
|
|
63
|
+
) => {
|
|
64
|
+
const ids = JSON.stringify(
|
|
65
|
+
page.items
|
|
66
|
+
.flatMap((thread) => thread.comments)
|
|
67
|
+
.filter(
|
|
68
|
+
(comment) =>
|
|
69
|
+
comment.createdAt.getTime() >= startedAt - (startedAt % 60000),
|
|
70
|
+
)
|
|
71
|
+
.map((comment) => eventId(`comment:${slug}:${comment.id}`)),
|
|
72
|
+
);
|
|
73
|
+
return sql`INSERT INTO follow_comment_baselines(post_slug,subject,event_id) SELECT ${slug},${subject},value FROM json_each(${ids}) WHERE EXISTS(SELECT 1 FROM post_follows WHERE post_slug=${slug} AND subject=${subject} AND baseline_token=${token}) ON CONFLICT DO NOTHING`;
|
|
74
|
+
};
|
|
75
|
+
const initializeFollows = async () => {
|
|
76
|
+
const pending = await db.all<{ post_slug: string; subject: string }>(
|
|
77
|
+
sql`SELECT post_slug,subject FROM post_follows WHERE ready=0 AND baseline_lease_until <= ${now()} ORDER BY baseline_checked_at,followed_at LIMIT ${options.processing === "small" ? 1 : 10}`,
|
|
78
|
+
);
|
|
79
|
+
let failed = 0;
|
|
80
|
+
for (const follow of pending) {
|
|
81
|
+
const token = randomUUID();
|
|
82
|
+
const lease = await db.get<{
|
|
83
|
+
baseline_cursor: string | null;
|
|
84
|
+
followed_at: number;
|
|
85
|
+
}>(
|
|
86
|
+
sql`UPDATE post_follows SET baseline_token=${token},baseline_lease_until=${now() + 120000} WHERE post_slug=${follow.post_slug} AND subject=${follow.subject} AND ready=0 AND baseline_lease_until <= ${now()} RETURNING baseline_cursor,followed_at`,
|
|
87
|
+
);
|
|
88
|
+
if (!lease) continue;
|
|
89
|
+
try {
|
|
90
|
+
const page = await options.feedback.listCommentThreads({
|
|
91
|
+
slug: follow.post_slug,
|
|
92
|
+
cursor: lease.baseline_cursor ?? undefined,
|
|
93
|
+
});
|
|
94
|
+
const post = await published(follow.post_slug);
|
|
95
|
+
await batch([
|
|
96
|
+
baseline(
|
|
97
|
+
follow.post_slug,
|
|
98
|
+
follow.subject,
|
|
99
|
+
token,
|
|
100
|
+
lease.followed_at,
|
|
101
|
+
page,
|
|
102
|
+
),
|
|
103
|
+
sql`UPDATE post_follows SET baseline_cursor=${page.nextCursor ?? null},ready=${page.nextCursor ? 0 : 1},observed_status=${post.status},followed_at=${page.nextCursor ? lease.followed_at : now()},baseline_lease_until=0,baseline_checked_at=${now()},baseline_token=NULL WHERE post_slug=${follow.post_slug} AND subject=${follow.subject} AND baseline_token=${token}`,
|
|
104
|
+
]);
|
|
105
|
+
} catch {
|
|
106
|
+
failed++;
|
|
107
|
+
await db.run(
|
|
108
|
+
sql`UPDATE post_follows SET baseline_lease_until=${now() + 60000},baseline_checked_at=${now()},baseline_token=NULL WHERE post_slug=${follow.post_slug} AND subject=${follow.subject} AND baseline_token=${token}`,
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return failed;
|
|
113
|
+
};
|
|
114
|
+
type ObservedEvent = {
|
|
115
|
+
id: string;
|
|
116
|
+
slug: string;
|
|
117
|
+
kind: string;
|
|
118
|
+
title: string;
|
|
119
|
+
message: string;
|
|
120
|
+
createdAt: number;
|
|
121
|
+
actor?: string;
|
|
122
|
+
status?: string;
|
|
123
|
+
};
|
|
124
|
+
const record = async (
|
|
125
|
+
events: ObservedEvent[],
|
|
126
|
+
slug: string,
|
|
127
|
+
leaseToken: string,
|
|
128
|
+
checkpoint: SQL,
|
|
129
|
+
) => {
|
|
130
|
+
if (!events.length) {
|
|
131
|
+
await db.run(checkpoint);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
const payload = JSON.stringify(events);
|
|
135
|
+
// JSON keeps a complete Comment page below D1's 100-bound-parameter limit.
|
|
136
|
+
const statements: SQL[] = [
|
|
137
|
+
sql`INSERT INTO post_events(id,post_slug,kind,title,message,created_at,actor_subject,status) SELECT json_extract(value,'$.id'),json_extract(value,'$.slug'),json_extract(value,'$.kind'),json_extract(value,'$.title'),json_extract(value,'$.message'),json_extract(value,'$.createdAt'),json_extract(value,'$.actor'),json_extract(value,'$.status') FROM json_each(${payload}) WHERE EXISTS(SELECT 1 FROM post_observations WHERE post_slug=${slug} AND lease_token=${leaseToken}) ON CONFLICT DO NOTHING`,
|
|
138
|
+
sql`INSERT INTO participant_notifications(id,event_id,subject,created_at,email_state) SELECT lower(hex(randomblob(16))),e.id,f.subject,e.created_at,CASE WHEN c.email IS NOT NULL AND c.email_enabled=1 THEN 'pending' ELSE 'skipped' END FROM post_events e JOIN post_follows f ON f.post_slug=e.post_slug LEFT JOIN participant_contacts c ON c.subject=f.subject WHERE f.ready=1 AND e.id IN(SELECT json_extract(value,'$.id') FROM json_each(${payload})) AND ((e.kind='status' AND f.observed_status IS NOT e.status) OR (e.kind!='status' AND e.created_at >= f.followed_at - f.followed_at % 60000 AND NOT EXISTS(SELECT 1 FROM follow_comment_baselines b WHERE b.post_slug=f.post_slug AND b.subject=f.subject AND b.event_id=e.id))) AND f.subject != COALESCE(e.actor_subject,'') AND EXISTS(SELECT 1 FROM post_observations WHERE post_slug=${slug} AND lease_token=${leaseToken}) ON CONFLICT(event_id,subject) DO NOTHING`,
|
|
139
|
+
...(events.some((event) => event.kind === "status")
|
|
140
|
+
? [
|
|
141
|
+
sql`UPDATE post_follows SET observed_status=(SELECT json_extract(value,'$.status') FROM json_each(${payload}) WHERE json_extract(value,'$.kind')='status' LIMIT 1) WHERE post_slug=${slug} AND ready=1 AND EXISTS(SELECT 1 FROM post_observations WHERE post_slug=${slug} AND lease_token=${leaseToken})`,
|
|
142
|
+
]
|
|
143
|
+
: []),
|
|
144
|
+
checkpoint,
|
|
145
|
+
];
|
|
146
|
+
// D1 batches are transactions; Drizzle compiles every bound parameter.
|
|
147
|
+
await batch(statements);
|
|
148
|
+
};
|
|
149
|
+
return {
|
|
150
|
+
async follow(slug: string, following: boolean) {
|
|
151
|
+
const person = identity();
|
|
152
|
+
if (following) {
|
|
153
|
+
const post = await published(slug);
|
|
154
|
+
const existing = await readFollow(slug, person.subject);
|
|
155
|
+
if (existing) return projectFollow(existing);
|
|
156
|
+
const page =
|
|
157
|
+
options.processing === "small"
|
|
158
|
+
? undefined
|
|
159
|
+
: await options.feedback.listCommentThreads({ slug });
|
|
160
|
+
const startedAt = now();
|
|
161
|
+
const token = randomUUID();
|
|
162
|
+
await batch([
|
|
163
|
+
sql`INSERT INTO post_observations(post_slug,status,checked_at) VALUES(${slug},${post.status},${startedAt}) ON CONFLICT DO NOTHING`,
|
|
164
|
+
sql`INSERT INTO post_follows(post_slug,subject,followed_at,observed_status,baseline_token,ready,baseline_cursor) VALUES(${slug},${person.subject},${startedAt},${post.status},${token},${!page || page.nextCursor ? 0 : 1},${page?.nextCursor ?? null}) ON CONFLICT DO NOTHING`,
|
|
165
|
+
...(page
|
|
166
|
+
? [baseline(slug, person.subject, token, startedAt, page)]
|
|
167
|
+
: []),
|
|
168
|
+
]);
|
|
169
|
+
return projectFollow(await readFollow(slug, person.subject));
|
|
170
|
+
} else
|
|
171
|
+
await db.run(
|
|
172
|
+
sql`DELETE FROM post_follows WHERE post_slug=${slug} AND subject=${person.subject}`,
|
|
173
|
+
);
|
|
174
|
+
return { following };
|
|
175
|
+
},
|
|
176
|
+
async followState(slug: string) {
|
|
177
|
+
const person = identity();
|
|
178
|
+
await published(slug);
|
|
179
|
+
return projectFollow(await readFollow(slug, person.subject));
|
|
180
|
+
},
|
|
181
|
+
async preferences() {
|
|
182
|
+
const person = identity();
|
|
183
|
+
const contact = await db.get<{
|
|
184
|
+
email: string | null;
|
|
185
|
+
email_enabled: number;
|
|
186
|
+
}>(
|
|
187
|
+
sql`SELECT email,email_enabled FROM participant_contacts WHERE subject=${person.subject}`,
|
|
188
|
+
);
|
|
189
|
+
return {
|
|
190
|
+
emailAvailable: Boolean(options.email && contact?.email),
|
|
191
|
+
emailEnabled: Boolean(contact?.email_enabled),
|
|
192
|
+
};
|
|
193
|
+
},
|
|
194
|
+
async setEmailPreference(enabled: boolean) {
|
|
195
|
+
const person = identity();
|
|
196
|
+
const contact = await db.get<{ email: string | null }>(
|
|
197
|
+
sql`SELECT email FROM participant_contacts WHERE subject=${person.subject}`,
|
|
198
|
+
);
|
|
199
|
+
if (enabled && (!options.email || !contact?.email))
|
|
200
|
+
throw new Error(
|
|
201
|
+
"Email notifications require a verified application email and a configured sender.",
|
|
202
|
+
);
|
|
203
|
+
await db.run(
|
|
204
|
+
sql`UPDATE participant_contacts SET email_enabled=${enabled ? 1 : 0} WHERE subject=${person.subject}`,
|
|
205
|
+
);
|
|
206
|
+
},
|
|
207
|
+
async unsubscribe(token: string) {
|
|
208
|
+
if (!options.email || token.length > 256)
|
|
209
|
+
throw new Error("Invalid unsubscribe link.");
|
|
210
|
+
const [id, mac, extra] = token.split(".");
|
|
211
|
+
if (!id || !mac || extra || !/^[a-f0-9]{32}$/.test(id))
|
|
212
|
+
throw new Error("Invalid unsubscribe link.");
|
|
213
|
+
const expected = Buffer.from(
|
|
214
|
+
unsubscribeMac(id, options.email.unsubscribeSecret),
|
|
215
|
+
);
|
|
216
|
+
const received = Buffer.from(mac);
|
|
217
|
+
if (
|
|
218
|
+
received.length !== expected.length ||
|
|
219
|
+
!timingSafeEqual(received, expected)
|
|
220
|
+
)
|
|
221
|
+
throw new Error("Invalid unsubscribe link.");
|
|
222
|
+
await db.run(
|
|
223
|
+
sql`UPDATE participant_contacts SET email_enabled=0 WHERE subject IN(SELECT subject FROM participant_notifications WHERE id=${id})`,
|
|
224
|
+
);
|
|
225
|
+
},
|
|
226
|
+
async deliverEmails() {
|
|
227
|
+
if (!options.email) return { sent: 0, failed: 0 };
|
|
228
|
+
const email = options.email;
|
|
229
|
+
if (email.unsubscribeSecret.length < 32)
|
|
230
|
+
throw new Error(
|
|
231
|
+
"Notification signing secret must contain at least 32 characters.",
|
|
232
|
+
);
|
|
233
|
+
const rows = await db.all<{
|
|
234
|
+
id: string;
|
|
235
|
+
subject: string;
|
|
236
|
+
post_slug: string;
|
|
237
|
+
title: string;
|
|
238
|
+
message: string;
|
|
239
|
+
attempts: number;
|
|
240
|
+
created_at: number;
|
|
241
|
+
}>(
|
|
242
|
+
sql`SELECT n.id,n.subject,e.post_slug,e.title,e.message,n.attempts,n.created_at FROM participant_notifications n JOIN post_events e ON n.event_id=e.id WHERE n.email_state='pending' AND n.next_attempt_at <= ${now()} AND n.lease_until <= ${now()} ORDER BY n.created_at,n.id LIMIT ${options.processing === "small" ? 1 : 20}`,
|
|
243
|
+
);
|
|
244
|
+
let sent = 0,
|
|
245
|
+
failed = 0;
|
|
246
|
+
for (const row of rows) {
|
|
247
|
+
const leaseToken = randomUUID();
|
|
248
|
+
const lease = await db.get<{ attempts: number }>(
|
|
249
|
+
sql`UPDATE participant_notifications SET lease_until=${now() + 60000},lease_token=${leaseToken} WHERE id=${row.id} AND email_state='pending' AND next_attempt_at <= ${now()} AND lease_until <= ${now()} RETURNING attempts`,
|
|
250
|
+
);
|
|
251
|
+
if (!lease) continue;
|
|
252
|
+
try {
|
|
253
|
+
const contact = await db.get<{ email: string }>(
|
|
254
|
+
sql`SELECT c.email FROM participant_contacts c WHERE c.subject=${row.subject} AND c.email_enabled=1 AND c.email IS NOT NULL AND EXISTS(SELECT 1 FROM post_follows f WHERE f.subject=c.subject AND f.post_slug=${row.post_slug} AND f.ready=1)`,
|
|
255
|
+
);
|
|
256
|
+
if (
|
|
257
|
+
!contact ||
|
|
258
|
+
!(await visible(row.post_slug)) ||
|
|
259
|
+
now() - row.created_at >= 86400000 ||
|
|
260
|
+
lease.attempts >= 8
|
|
261
|
+
) {
|
|
262
|
+
await db.run(
|
|
263
|
+
sql`UPDATE participant_notifications SET email_state='skipped',lease_until=0,lease_token=NULL WHERE id=${row.id} AND lease_token=${leaseToken}`,
|
|
264
|
+
);
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
const unsubscribe = new URL(
|
|
268
|
+
"/internal/connected/unsubscribe",
|
|
269
|
+
email.siteUrl,
|
|
270
|
+
);
|
|
271
|
+
unsubscribe.searchParams.set(
|
|
272
|
+
"token",
|
|
273
|
+
`${row.id}.${unsubscribeMac(row.id, email.unsubscribeSecret)}`,
|
|
274
|
+
);
|
|
275
|
+
const postUrl = new URL(
|
|
276
|
+
`/p/${encodeURIComponent(row.post_slug)}`,
|
|
277
|
+
email.siteUrl,
|
|
278
|
+
);
|
|
279
|
+
await email.sender(
|
|
280
|
+
{
|
|
281
|
+
from: email.from,
|
|
282
|
+
to: [contact.email],
|
|
283
|
+
subject: row.title.replace(/[\r\n]/g, " ").slice(0, 120),
|
|
284
|
+
text: `${row.message}\n\n${postUrl}\n\nStop email notifications: ${unsubscribe}`,
|
|
285
|
+
headers: {
|
|
286
|
+
"List-Unsubscribe": `<${unsubscribe}>`,
|
|
287
|
+
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
|
|
288
|
+
},
|
|
289
|
+
},
|
|
290
|
+
`feedbax-notification/${row.id}`,
|
|
291
|
+
);
|
|
292
|
+
await db.run(
|
|
293
|
+
sql`UPDATE participant_notifications SET email_state='sent',lease_until=0,lease_token=NULL WHERE id=${row.id} AND lease_token=${leaseToken}`,
|
|
294
|
+
);
|
|
295
|
+
sent++;
|
|
296
|
+
} catch {
|
|
297
|
+
await db.run(
|
|
298
|
+
sql`UPDATE participant_notifications SET attempts=attempts+1,next_attempt_at=${now() + Math.min(21600000, 60000 * 2 ** lease.attempts)},lease_until=0,lease_token=NULL WHERE id=${row.id} AND lease_token=${leaseToken}`,
|
|
299
|
+
);
|
|
300
|
+
failed++;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
return { sent, failed };
|
|
304
|
+
},
|
|
305
|
+
async markRead(id?: string) {
|
|
306
|
+
const person = identity();
|
|
307
|
+
await db.run(
|
|
308
|
+
sql`UPDATE participant_notifications SET read_at=${now()} WHERE subject=${person.subject} AND (${id ?? null} IS NULL OR id=${id ?? null}) AND read_at IS NULL`,
|
|
309
|
+
);
|
|
310
|
+
},
|
|
311
|
+
async inbox(cursor?: string) {
|
|
312
|
+
const person = identity();
|
|
313
|
+
let before = Number.MAX_SAFE_INTEGER,
|
|
314
|
+
beforeId = "~";
|
|
315
|
+
if (cursor) {
|
|
316
|
+
try {
|
|
317
|
+
const value = JSON.parse(Buffer.from(cursor, "base64url").toString());
|
|
318
|
+
if (
|
|
319
|
+
!Number.isSafeInteger(value.time) ||
|
|
320
|
+
value.time < 0 ||
|
|
321
|
+
typeof value.id !== "string" ||
|
|
322
|
+
!/^[a-f0-9]{32}$/.test(value.id)
|
|
323
|
+
)
|
|
324
|
+
throw new Error();
|
|
325
|
+
before = value.time;
|
|
326
|
+
beforeId = value.id;
|
|
327
|
+
} catch {
|
|
328
|
+
throw new Error("Invalid notification cursor.");
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
const visibility = new Map<string, boolean>();
|
|
332
|
+
const isVisible = async (slug: string) => {
|
|
333
|
+
if (!visibility.has(slug))
|
|
334
|
+
visibility.set(slug, Boolean(await visible(slug)));
|
|
335
|
+
return visibility.get(slug)!;
|
|
336
|
+
};
|
|
337
|
+
const items: ParticipantNotification[] = [];
|
|
338
|
+
let scanned: NotificationRow | undefined;
|
|
339
|
+
let more: boolean;
|
|
340
|
+
do {
|
|
341
|
+
const rows = await db.all<NotificationRow>(
|
|
342
|
+
sql`SELECT n.id,n.created_at,n.read_at,e.post_slug,e.kind,e.title,e.message FROM participant_notifications n JOIN post_events e ON e.id=n.event_id WHERE n.subject=${person.subject} AND (n.created_at < ${before} OR (n.created_at=${before} AND n.id < ${beforeId})) ORDER BY n.created_at DESC,n.id DESC LIMIT 26`,
|
|
343
|
+
);
|
|
344
|
+
more = rows.length === 26;
|
|
345
|
+
for (const row of rows) {
|
|
346
|
+
if (items.length === 25) {
|
|
347
|
+
more = true;
|
|
348
|
+
break;
|
|
349
|
+
}
|
|
350
|
+
scanned = row;
|
|
351
|
+
if (await isVisible(row.post_slug))
|
|
352
|
+
items.push({
|
|
353
|
+
id: row.id,
|
|
354
|
+
slug: row.post_slug,
|
|
355
|
+
kind: row.kind,
|
|
356
|
+
title: row.title,
|
|
357
|
+
message: row.message,
|
|
358
|
+
createdAt: row.created_at,
|
|
359
|
+
read: row.read_at !== null,
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
if (scanned) {
|
|
363
|
+
before = scanned.created_at;
|
|
364
|
+
beforeId = scanned.id;
|
|
365
|
+
}
|
|
366
|
+
} while (more && items.length < 25);
|
|
367
|
+
const counts = await db.all<{ post_slug: string; total: number }>(
|
|
368
|
+
sql`SELECT e.post_slug,COUNT(*) as total FROM participant_notifications n JOIN post_events e ON n.event_id=e.id WHERE n.subject=${person.subject} AND n.read_at IS NULL GROUP BY e.post_slug`,
|
|
369
|
+
);
|
|
370
|
+
let unreadCount = 0;
|
|
371
|
+
for (const row of counts)
|
|
372
|
+
if (await isVisible(row.post_slug)) unreadCount += row.total;
|
|
373
|
+
return {
|
|
374
|
+
items,
|
|
375
|
+
unreadCount,
|
|
376
|
+
...(more && scanned
|
|
377
|
+
? {
|
|
378
|
+
nextCursor: Buffer.from(
|
|
379
|
+
JSON.stringify({ time: scanned.created_at, id: scanned.id }),
|
|
380
|
+
).toString("base64url"),
|
|
381
|
+
}
|
|
382
|
+
: {}),
|
|
383
|
+
};
|
|
384
|
+
},
|
|
385
|
+
async synchronize() {
|
|
386
|
+
let failed = await initializeFollows();
|
|
387
|
+
const posts = await db.all<{
|
|
388
|
+
post_slug: string;
|
|
389
|
+
status: string;
|
|
390
|
+
comment_cursor: string | null;
|
|
391
|
+
}>(
|
|
392
|
+
sql`SELECT post_slug,status,comment_cursor FROM post_observations o WHERE lease_until <= ${now()} AND EXISTS(SELECT 1 FROM post_follows f WHERE f.post_slug=o.post_slug AND f.ready=1) ORDER BY checked_at,post_slug LIMIT ${options.processing === "small" ? 1 : 10}`,
|
|
393
|
+
);
|
|
394
|
+
let checked = 0;
|
|
395
|
+
for (const observation of posts) {
|
|
396
|
+
const leaseToken = randomUUID();
|
|
397
|
+
const lease = await db.get<{
|
|
398
|
+
status: string;
|
|
399
|
+
comment_cursor: string | null;
|
|
400
|
+
}>(
|
|
401
|
+
sql`UPDATE post_observations SET lease_until=${now() + 120000},lease_token=${leaseToken} WHERE post_slug=${observation.post_slug} AND lease_until <= ${now()} RETURNING status,comment_cursor`,
|
|
402
|
+
);
|
|
403
|
+
if (!lease) continue;
|
|
404
|
+
try {
|
|
405
|
+
const post = await visible(observation.post_slug);
|
|
406
|
+
if (post) {
|
|
407
|
+
const events: ObservedEvent[] = [];
|
|
408
|
+
if (
|
|
409
|
+
await db.get(
|
|
410
|
+
sql`SELECT 1 FROM post_follows WHERE post_slug=${post.slug} AND ready=1 AND observed_status IS NOT ${post.status} LIMIT 1`,
|
|
411
|
+
)
|
|
412
|
+
)
|
|
413
|
+
events.push({
|
|
414
|
+
id: eventId(`status:${post.slug}:${leaseToken}`),
|
|
415
|
+
slug: post.slug,
|
|
416
|
+
kind: "status",
|
|
417
|
+
title: post.title,
|
|
418
|
+
message: `Status changed to ${post.status}.`,
|
|
419
|
+
createdAt: now(),
|
|
420
|
+
status: post.status,
|
|
421
|
+
});
|
|
422
|
+
await record(
|
|
423
|
+
events,
|
|
424
|
+
post.slug,
|
|
425
|
+
leaseToken,
|
|
426
|
+
sql`UPDATE post_observations SET status=${post.status} WHERE post_slug=${post.slug} AND lease_token=${leaseToken}`,
|
|
427
|
+
);
|
|
428
|
+
events.length = 0;
|
|
429
|
+
const page = await options.feedback.listCommentThreads({
|
|
430
|
+
slug: post.slug,
|
|
431
|
+
cursor: lease.comment_cursor ?? undefined,
|
|
432
|
+
});
|
|
433
|
+
const comments = page.items.flatMap((thread) => thread.comments);
|
|
434
|
+
const ids = JSON.stringify(comments.map((comment) => comment.id));
|
|
435
|
+
const authors = comments.length
|
|
436
|
+
? await db.all<{ comment_id: string; subject: string }>(
|
|
437
|
+
sql`SELECT comment_id,subject FROM comment_attribution WHERE comment_id IN(SELECT value FROM json_each(${ids}))`,
|
|
438
|
+
)
|
|
439
|
+
: [];
|
|
440
|
+
const subjects = new Map(
|
|
441
|
+
authors.map((author) => [author.comment_id, author.subject]),
|
|
442
|
+
);
|
|
443
|
+
for (const comment of comments)
|
|
444
|
+
events.push({
|
|
445
|
+
id: eventId(`comment:${post.slug}:${comment.id}`),
|
|
446
|
+
slug: post.slug,
|
|
447
|
+
kind: "comment",
|
|
448
|
+
title: post.title,
|
|
449
|
+
message: "A new Comment was added.",
|
|
450
|
+
createdAt: comment.createdAt.getTime(),
|
|
451
|
+
actor: subjects.get(comment.id),
|
|
452
|
+
});
|
|
453
|
+
await record(
|
|
454
|
+
events,
|
|
455
|
+
post.slug,
|
|
456
|
+
leaseToken,
|
|
457
|
+
sql`UPDATE post_observations SET comment_cursor=${page.nextCursor ?? null} WHERE post_slug=${post.slug} AND lease_token=${leaseToken}`,
|
|
458
|
+
);
|
|
459
|
+
}
|
|
460
|
+
checked++;
|
|
461
|
+
} catch {
|
|
462
|
+
failed++;
|
|
463
|
+
} finally {
|
|
464
|
+
await db.run(
|
|
465
|
+
sql`UPDATE post_observations SET checked_at=${now()},lease_until=0,lease_token=NULL WHERE post_slug=${observation.post_slug} AND lease_token=${leaseToken}`,
|
|
466
|
+
);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
return { checked, failed };
|
|
470
|
+
},
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
export interface ParticipantNotification {
|
|
475
|
+
id: string;
|
|
476
|
+
slug: string;
|
|
477
|
+
kind: "status" | "comment";
|
|
478
|
+
title: string;
|
|
479
|
+
message: string;
|
|
480
|
+
createdAt: number;
|
|
481
|
+
read: boolean;
|
|
482
|
+
}
|
|
483
|
+
export interface FollowState {
|
|
484
|
+
following: boolean;
|
|
485
|
+
initializing?: boolean;
|
|
486
|
+
}
|
|
487
|
+
type NotificationRow = {
|
|
488
|
+
id: string;
|
|
489
|
+
created_at: number;
|
|
490
|
+
read_at: number | null;
|
|
491
|
+
post_slug: string;
|
|
492
|
+
kind: "status" | "comment";
|
|
493
|
+
title: string;
|
|
494
|
+
message: string;
|
|
495
|
+
};
|
|
496
|
+
const eventId = (value: string) =>
|
|
497
|
+
createHash("sha256").update(value).digest("hex");
|
|
498
|
+
|
|
499
|
+
const unsubscribeMac = (id: string, secret: string) =>
|
|
500
|
+
createHmac("sha256", secret)
|
|
501
|
+
.update(`feedbax-unsubscribe-v1:${id}`)
|
|
502
|
+
.digest("base64url");
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { defineFeedbax } from "@feedbax/config";
|
|
2
|
+
|
|
3
|
+
// prettier-ignore
|
|
4
|
+
export default defineFeedbax({
|
|
5
|
+
"product": {
|
|
6
|
+
"name": "Feedbax",
|
|
7
|
+
"description": "Share feedback and follow product progress.",
|
|
8
|
+
"logo": "/feedbax-mark.svg"
|
|
9
|
+
},
|
|
10
|
+
"theme": {
|
|
11
|
+
"preset": "lyra-neutral"
|
|
12
|
+
},
|
|
13
|
+
"notion": {
|
|
14
|
+
"databaseId": "test-database",
|
|
15
|
+
"dataSourceId": "test-feedback",
|
|
16
|
+
"properties": {
|
|
17
|
+
"title": "test-title",
|
|
18
|
+
"slug": "test-slug",
|
|
19
|
+
"description": "test-description",
|
|
20
|
+
"type": "test-type",
|
|
21
|
+
"status": "test-status",
|
|
22
|
+
"published": "test-published",
|
|
23
|
+
"voteCount": "test-voteCount",
|
|
24
|
+
"teamResponse": "test-teamResponse",
|
|
25
|
+
"releaseNotesUrl": "test-releaseNotesUrl",
|
|
26
|
+
"submitterName": "test-submitterName",
|
|
27
|
+
"submitterEmail": "test-submitterEmail",
|
|
28
|
+
"source": "test-source",
|
|
29
|
+
"externalId": "test-externalId",
|
|
30
|
+
"editTokenHash": "test-editTokenHash",
|
|
31
|
+
"createdAt": "test-createdAt",
|
|
32
|
+
"updatedAt": "test-updatedAt"
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
"changelog": {
|
|
36
|
+
"databaseId": "test-database",
|
|
37
|
+
"dataSourceId": "test-changelog",
|
|
38
|
+
"properties": {
|
|
39
|
+
"title": "test-title",
|
|
40
|
+
"slug": "test-slug",
|
|
41
|
+
"date": "test-date",
|
|
42
|
+
"summary": "test-summary",
|
|
43
|
+
"body": "test-body",
|
|
44
|
+
"labels": "test-labels",
|
|
45
|
+
"image": "test-image",
|
|
46
|
+
"published": "test-published",
|
|
47
|
+
"createdAt": "test-createdAt",
|
|
48
|
+
"updatedAt": "test-updatedAt"
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
"features": {
|
|
52
|
+
"voting": true,
|
|
53
|
+
"comments": true,
|
|
54
|
+
"changelog": true
|
|
55
|
+
},
|
|
56
|
+
"submissions": {
|
|
57
|
+
"moderation": "required",
|
|
58
|
+
"maxTitleLength": 120,
|
|
59
|
+
"maxDescriptionLength": 5000
|
|
60
|
+
},
|
|
61
|
+
"turnstile": {
|
|
62
|
+
"enabled": false
|
|
63
|
+
}
|
|
64
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { defineFeedbax } from "@feedbax/config";
|
|
2
|
+
|
|
3
|
+
const turnstileSiteKey =
|
|
4
|
+
import.meta.env?.VITE_TURNSTILE_SITE_KEY ??
|
|
5
|
+
process.env.VITE_TURNSTILE_SITE_KEY;
|
|
6
|
+
|
|
7
|
+
const env = (name: string) => process.env[name] ?? "not-configured";
|
|
8
|
+
|
|
9
|
+
export default defineFeedbax({
|
|
10
|
+
product: {
|
|
11
|
+
name: "Feedbax",
|
|
12
|
+
description: "Share feedback and follow product progress.",
|
|
13
|
+
logo: "/feedbax-mark.svg",
|
|
14
|
+
siteUrl: process.env.FEEDBAX_SITE_URL,
|
|
15
|
+
},
|
|
16
|
+
theme: { preset: "lyra-neutral" },
|
|
17
|
+
notion: {
|
|
18
|
+
databaseId: env("NOTION_FEEDBACK_DATABASE_ID"),
|
|
19
|
+
dataSourceId: env("NOTION_FEEDBACK_DATA_SOURCE_ID"),
|
|
20
|
+
properties: {
|
|
21
|
+
title: env("NOTION_FEEDBACK_TITLE_PROPERTY_ID"),
|
|
22
|
+
slug: env("NOTION_FEEDBACK_SLUG_PROPERTY_ID"),
|
|
23
|
+
description: env("NOTION_FEEDBACK_DESCRIPTION_PROPERTY_ID"),
|
|
24
|
+
type: env("NOTION_FEEDBACK_TYPE_PROPERTY_ID"),
|
|
25
|
+
status: env("NOTION_FEEDBACK_STATUS_PROPERTY_ID"),
|
|
26
|
+
published: env("NOTION_FEEDBACK_PUBLISHED_PROPERTY_ID"),
|
|
27
|
+
voteCount: env("NOTION_FEEDBACK_VOTE_COUNT_PROPERTY_ID"),
|
|
28
|
+
teamResponse: env("NOTION_FEEDBACK_TEAM_RESPONSE_PROPERTY_ID"),
|
|
29
|
+
releaseNotesUrl: env("NOTION_FEEDBACK_RELEASE_NOTES_URL_PROPERTY_ID"),
|
|
30
|
+
submitterName: env("NOTION_FEEDBACK_SUBMITTER_NAME_PROPERTY_ID"),
|
|
31
|
+
submitterEmail: env("NOTION_FEEDBACK_SUBMITTER_EMAIL_PROPERTY_ID"),
|
|
32
|
+
source: env("NOTION_FEEDBACK_SOURCE_PROPERTY_ID"),
|
|
33
|
+
externalId: env("NOTION_FEEDBACK_EXTERNAL_ID_PROPERTY_ID"),
|
|
34
|
+
editTokenHash: env("NOTION_FEEDBACK_EDIT_TOKEN_HASH_PROPERTY_ID"),
|
|
35
|
+
createdAt: env("NOTION_FEEDBACK_CREATED_AT_PROPERTY_ID"),
|
|
36
|
+
updatedAt: env("NOTION_FEEDBACK_UPDATED_AT_PROPERTY_ID"),
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
changelog: {
|
|
40
|
+
databaseId: env("NOTION_CHANGELOG_DATABASE_ID"),
|
|
41
|
+
dataSourceId: env("NOTION_CHANGELOG_DATA_SOURCE_ID"),
|
|
42
|
+
properties: {
|
|
43
|
+
title: env("NOTION_CHANGELOG_TITLE_PROPERTY_ID"),
|
|
44
|
+
slug: env("NOTION_CHANGELOG_SLUG_PROPERTY_ID"),
|
|
45
|
+
date: env("NOTION_CHANGELOG_DATE_PROPERTY_ID"),
|
|
46
|
+
summary: env("NOTION_CHANGELOG_SUMMARY_PROPERTY_ID"),
|
|
47
|
+
body: env("NOTION_CHANGELOG_BODY_PROPERTY_ID"),
|
|
48
|
+
labels: env("NOTION_CHANGELOG_LABELS_PROPERTY_ID"),
|
|
49
|
+
image: env("NOTION_CHANGELOG_IMAGE_PROPERTY_ID"),
|
|
50
|
+
published: env("NOTION_CHANGELOG_PUBLISHED_PROPERTY_ID"),
|
|
51
|
+
createdAt: env("NOTION_CHANGELOG_CREATED_AT_PROPERTY_ID"),
|
|
52
|
+
updatedAt: env("NOTION_CHANGELOG_UPDATED_AT_PROPERTY_ID"),
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
features: {
|
|
56
|
+
voting: process.env.FEEDBAX_VOTING_ENABLED !== "false",
|
|
57
|
+
comments: process.env.FEEDBAX_COMMENTS_ENABLED !== "false",
|
|
58
|
+
changelog: process.env.FEEDBAX_CHANGELOG_ENABLED !== "false",
|
|
59
|
+
},
|
|
60
|
+
submissions: {
|
|
61
|
+
moderation: "required",
|
|
62
|
+
maxTitleLength: 120,
|
|
63
|
+
maxDescriptionLength: 5_000,
|
|
64
|
+
},
|
|
65
|
+
turnstile: {
|
|
66
|
+
enabled: Boolean(turnstileSiteKey),
|
|
67
|
+
siteKey: turnstileSiteKey,
|
|
68
|
+
},
|
|
69
|
+
});
|