create-feedbax 0.2.0 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -5
- package/dist/cli.js +2610 -291
- package/dist/index.js +2375 -200
- package/package.json +7 -7
- package/template/.feedbax-template.sha256 +1 -1
- package/template/.gitignore.template +1 -0
- package/template/.npmignore.template +1 -0
- package/template/.nvmrc +1 -0
- package/template/AGENTS.md +13 -0
- package/template/CONNECTED.md +418 -0
- package/template/README.md +97 -0
- package/template/components.json +1 -1
- package/template/eslint.config.mjs +23 -0
- package/template/feedbax-vite.ts +79 -0
- package/template/migrations/0001_connected.sql +6 -0
- package/template/migrations/0002_participation.sql +8 -0
- package/template/migrations/0003_images.sql +3 -0
- package/template/migrations/0004_followed_status.sql +9 -0
- package/template/migrations/0005_github.sql +4 -0
- package/template/migrations/0006_integration.sql +3 -0
- package/template/migrations/0007_ownership.sql +2 -0
- package/template/migrations/0008_integration_recovery.sql +5 -0
- package/template/migrations/0009_observed_history.sql +3 -0
- package/template/package.json +39 -34
- package/template/src/authorized-draft-post.tsx +1 -0
- package/template/src/changelog-page.tsx +63 -54
- package/template/src/changelog-runtime.ts +2 -1
- package/template/src/cloudflare-workers.d.ts +9 -0
- package/template/src/comment-creation.test.tsx +95 -7
- package/template/src/comment-thread-page.ts +11 -1
- package/template/src/components/command-palette.tsx +59 -30
- package/template/src/components/connected-identity-provider.tsx +135 -0
- package/template/src/components/device-profile-provider.tsx +41 -26
- package/template/src/components/follow-post-button.tsx +78 -0
- package/template/src/components/header.tsx +2 -0
- package/template/src/components/image-attachments.tsx +187 -0
- package/template/src/components/loader.tsx +48 -5
- package/template/src/components/notification-inbox.tsx +232 -0
- package/template/src/components/portal-footer.tsx +14 -0
- package/template/src/components/post-history.tsx +101 -0
- package/template/src/components/profile-menu.tsx +37 -0
- package/template/src/components/ui/components/command.tsx +30 -29
- package/template/src/components/ui/components/message-scroller.tsx +2 -2
- package/template/src/components/ui/components/scroll-area.tsx +0 -1
- package/template/src/components/ui/components/switch.tsx +29 -0
- package/template/src/components/ui/styles/globals.css +1 -1
- package/template/src/configured-feedback.test.ts +82 -0
- package/template/src/configured-feedback.ts +61 -0
- package/template/src/connected-identity.test.ts +148 -0
- package/template/src/connected-identity.ts +127 -0
- package/template/src/connected-participation.test.tsx +67 -0
- package/template/src/connected-runtime.test.ts +244 -0
- package/template/src/connected-runtime.ts +176 -0
- package/template/src/connected-social.test.tsx +158 -0
- package/template/src/feedback-cache-invalidation.ts +3 -0
- package/template/src/feedback-runtime.ts +123 -13
- package/template/src/feedbax/changelog/index.test.ts +35 -0
- package/template/src/feedbax/changelog/index.ts +5 -1
- package/template/src/feedbax/changelog/notion-changelog.test.ts +1 -1
- package/template/src/feedbax/changelog/notion-changelog.ts +1 -1
- package/template/src/feedbax/config/index.test.ts +73 -1
- package/template/src/feedbax/config/index.ts +205 -9
- package/template/src/feedbax/feedback/comment-storage.ts +38 -0
- package/template/src/feedbax/feedback/connected-feedback.ts +235 -0
- package/template/src/feedbax/feedback/connected.test.ts +272 -0
- package/template/src/feedbax/feedback/connected.ts +225 -0
- package/template/src/feedbax/feedback/email-delivery.test.ts +65 -0
- package/template/src/feedbax/feedback/email-delivery.ts +66 -0
- package/template/src/feedbax/feedback/feedback-storage.ts +1 -0
- package/template/src/feedbax/feedback/github-comments.ts +390 -0
- package/template/src/feedbax/feedback/github-feedback.test.ts +1138 -0
- package/template/src/feedbax/feedback/github-feedback.ts +581 -0
- package/template/src/feedbax/feedback/github-request.ts +120 -0
- package/template/src/feedbax/feedback/github-service-client.ts +56 -0
- package/template/src/feedbax/feedback/identity-handoff.test.ts +81 -0
- package/template/src/feedbax/feedback/identity-handoff.ts +119 -0
- package/template/src/feedbax/feedback/image-feedback.ts +114 -0
- package/template/src/feedbax/feedback/images.test.ts +223 -0
- package/template/src/feedbax/feedback/images.ts +350 -0
- package/template/src/feedbax/feedback/index.ts +226 -16
- package/template/src/feedbax/feedback/integration-auth.ts +138 -0
- package/template/src/feedbax/feedback/notion-comments.ts +2 -35
- package/template/src/feedbax/feedback/notion-image-review.ts +55 -0
- package/template/src/feedbax/feedback/observed-history.test.ts +150 -0
- package/template/src/feedbax/feedback/observed-history.ts +219 -0
- package/template/src/feedbax/feedback/ownership.ts +170 -0
- package/template/src/feedbax/feedback/participation.test.ts +653 -0
- package/template/src/feedbax/feedback/participation.ts +502 -0
- package/template/src/history-runtime.test.ts +80 -0
- package/template/src/history-runtime.ts +37 -0
- package/template/src/images-runtime.test.ts +133 -0
- package/template/src/images-runtime.ts +125 -0
- package/template/src/index.css +103 -440
- package/template/src/integration-runtime.test.ts +365 -0
- package/template/src/integration-runtime.ts +289 -0
- package/template/src/lazy-post-detail-overlay.tsx +13 -2
- package/template/src/my-feedback-page.test.tsx +152 -0
- package/template/src/my-feedback-page.tsx +398 -0
- package/template/src/my-feedback-runtime.test.ts +93 -0
- package/template/src/my-feedback-runtime.ts +154 -0
- package/template/src/overlay-post-detail.test.tsx +59 -8
- package/template/src/overlay-post-detail.tsx +20 -69
- package/template/src/participation-client.ts +63 -0
- package/template/src/participation-runtime.test.ts +35 -0
- package/template/src/participation-runtime.ts +255 -0
- package/template/src/portal-comments.ts +35 -5
- package/template/src/portal-feedback-form.tsx +29 -6
- package/template/src/portal-feedback-runtime.ts +3 -0
- package/template/src/portal-feedback-server-function.ts +6 -3
- package/template/src/portal-feedback-submission.ts +19 -0
- package/template/src/portal-server-entry.ts +13 -0
- package/template/src/post-creation-flow.tsx +2 -2
- package/template/src/post-detail-content.test.tsx +233 -0
- package/template/src/post-detail-content.tsx +162 -0
- package/template/src/post-detail-query.ts +34 -0
- package/template/src/post-results.tsx +1 -1
- package/template/src/public-cache-policy.test.ts +11 -4
- package/template/src/public-cache-policy.ts +1 -1
- package/template/src/public-feedback-index.tsx +9 -39
- package/template/src/public-feedback-page.test.tsx +65 -0
- package/template/src/public-feedback-page.ts +12 -1
- package/template/src/public-portal-error.tsx +1 -1
- package/template/src/public-post-detail-skeleton.tsx +54 -0
- package/template/src/public-post-detail.tsx +437 -271
- package/template/src/public-post-route.test.tsx +39 -3
- package/template/src/public-post-route.tsx +15 -28
- package/template/src/public-release-links.test.ts +120 -0
- package/template/src/public-release-links.ts +130 -0
- package/template/src/public-roadmap-route.test.tsx +38 -2
- package/template/src/public-roadmap-view.tsx +56 -23
- package/template/src/public-shell.test.tsx +26 -0
- package/template/src/routeTree.gen.ts +70 -3
- package/template/src/router.tsx +2 -0
- package/template/src/routes/__root.tsx +3 -5
- package/template/src/routes/email-preferences.tsx +77 -0
- package/template/src/routes/my-feedback.tsx +16 -0
- package/template/src/routes/submit.tsx +1 -1
- package/template/src/safe-public-failure.ts +3 -1
- package/template/src/server.ts +87 -2
- package/template/src/submission-intent.ts +26 -0
- package/template/src/trusted-feedback-handler.test.ts +1 -1
- package/template/src/vote-toggle.tsx +42 -9
- package/template/vite.config.ts +2 -0
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { afterEach, expect, it } from "vitest";
|
|
2
|
+
import { Miniflare } from "miniflare";
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
import { readFile } from "node:fs/promises";
|
|
5
|
+
import { createFeedbackModule, type Post } from "./index";
|
|
6
|
+
import { createObservedHistoryModule } from "./observed-history";
|
|
7
|
+
|
|
8
|
+
let runtime: Miniflare | undefined;
|
|
9
|
+
afterEach(async () => {
|
|
10
|
+
await runtime?.dispose();
|
|
11
|
+
runtime = undefined;
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("records only observed public progress, deduplicates retries and revokes history while unpublished", async () => {
|
|
15
|
+
runtime = new Miniflare({
|
|
16
|
+
modules: true,
|
|
17
|
+
script: "export default {}",
|
|
18
|
+
d1Databases: ["DB"],
|
|
19
|
+
compatibilityDate: "2026-07-30",
|
|
20
|
+
});
|
|
21
|
+
const database = await runtime.getD1Database("DB");
|
|
22
|
+
const path = "0009_observed_history.sql";
|
|
23
|
+
await database.exec(
|
|
24
|
+
await readFile(
|
|
25
|
+
existsSync(`apps/portal/migrations/${path}`)
|
|
26
|
+
? `apps/portal/migrations/${path}`
|
|
27
|
+
: `migrations/${path}`,
|
|
28
|
+
"utf8",
|
|
29
|
+
),
|
|
30
|
+
);
|
|
31
|
+
let time = 10000;
|
|
32
|
+
const post: Post = {
|
|
33
|
+
id: "private-provider-id",
|
|
34
|
+
slug: "export-reports",
|
|
35
|
+
title: "Export reports",
|
|
36
|
+
description: "Public description",
|
|
37
|
+
type: "Feature Request",
|
|
38
|
+
status: "New",
|
|
39
|
+
published: false,
|
|
40
|
+
createdAt: new Date(1000),
|
|
41
|
+
updatedAt: new Date(2000),
|
|
42
|
+
submitter: { name: "Private name", email: "private@example.com" },
|
|
43
|
+
teamResponse: "Private draft response",
|
|
44
|
+
};
|
|
45
|
+
const view = () =>
|
|
46
|
+
createObservedHistoryModule({
|
|
47
|
+
database,
|
|
48
|
+
feedback: createFeedbackModule({ initialItems: [{ ...post }] }),
|
|
49
|
+
now: () => time,
|
|
50
|
+
});
|
|
51
|
+
expect((await view().list({ slug: post.slug })).items).toEqual([]);
|
|
52
|
+
post.status = "Planned";
|
|
53
|
+
post.published = true;
|
|
54
|
+
post.teamResponse = "Approved current response";
|
|
55
|
+
expect((await view().list({ slug: post.slug })).items).toEqual([]);
|
|
56
|
+
post.status = "In Progress";
|
|
57
|
+
post.teamResponse = "Work has started";
|
|
58
|
+
time = 20000;
|
|
59
|
+
const first = await view().list({ slug: post.slug });
|
|
60
|
+
expect(first.items).toMatchObject([
|
|
61
|
+
{ kind: "status", status: "In Progress", observedAt: new Date(20000) },
|
|
62
|
+
{ kind: "update", body: "Work has started", observedAt: new Date(20000) },
|
|
63
|
+
]);
|
|
64
|
+
expect((await view().list({ slug: post.slug })).items).toEqual(first.items);
|
|
65
|
+
expect(JSON.stringify(first)).not.toMatch(
|
|
66
|
+
/Private|private-provider-id|private@example|actor|subject/,
|
|
67
|
+
);
|
|
68
|
+
post.published = false;
|
|
69
|
+
post.teamResponse = "Private later discussion";
|
|
70
|
+
time = 30000;
|
|
71
|
+
expect((await view().list({ slug: post.slug })).items).toEqual([]);
|
|
72
|
+
post.status = "Shipped";
|
|
73
|
+
post.published = true;
|
|
74
|
+
time = 40000;
|
|
75
|
+
expect((await view().list({ slug: post.slug })).items).toEqual(first.items);
|
|
76
|
+
post.status = "Planned";
|
|
77
|
+
post.teamResponse = "Another approved update";
|
|
78
|
+
time = 50000;
|
|
79
|
+
await Promise.all([view().observe(post.slug), view().observe(post.slug)]);
|
|
80
|
+
expect((await view().list({ slug: post.slug })).items).toHaveLength(4);
|
|
81
|
+
post.status = "Shipped";
|
|
82
|
+
time = 60000;
|
|
83
|
+
await database.exec(
|
|
84
|
+
"CREATE TRIGGER fail_history BEFORE INSERT ON public_post_history BEGIN SELECT RAISE(FAIL,'History unavailable'); END;",
|
|
85
|
+
);
|
|
86
|
+
await expect(view().observe(post.slug)).rejects.toThrow();
|
|
87
|
+
await database.exec("DROP TRIGGER fail_history;");
|
|
88
|
+
expect((await view().list({ slug: post.slug })).items).toHaveLength(5);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("paginates a longer observed timeline without duplicates and rejects another Post's cursor", async () => {
|
|
92
|
+
runtime = new Miniflare({
|
|
93
|
+
modules: true,
|
|
94
|
+
script: "export default {}",
|
|
95
|
+
d1Databases: ["DB"],
|
|
96
|
+
compatibilityDate: "2026-07-30",
|
|
97
|
+
});
|
|
98
|
+
const database = await runtime.getD1Database("DB");
|
|
99
|
+
await database.exec(
|
|
100
|
+
await readFile(
|
|
101
|
+
existsSync("apps/portal/migrations/0009_observed_history.sql")
|
|
102
|
+
? "apps/portal/migrations/0009_observed_history.sql"
|
|
103
|
+
: "migrations/0009_observed_history.sql",
|
|
104
|
+
"utf8",
|
|
105
|
+
),
|
|
106
|
+
);
|
|
107
|
+
const post: Post = {
|
|
108
|
+
id: "source-id",
|
|
109
|
+
slug: "long-history",
|
|
110
|
+
title: "Long history",
|
|
111
|
+
description: "Public",
|
|
112
|
+
type: "General Feedback",
|
|
113
|
+
status: "Planned",
|
|
114
|
+
published: true,
|
|
115
|
+
createdAt: new Date(1000),
|
|
116
|
+
updatedAt: new Date(1000),
|
|
117
|
+
};
|
|
118
|
+
let time = 1000;
|
|
119
|
+
const view = () =>
|
|
120
|
+
createObservedHistoryModule({
|
|
121
|
+
database,
|
|
122
|
+
feedback: createFeedbackModule({ initialItems: [{ ...post }] }),
|
|
123
|
+
now: () => time,
|
|
124
|
+
});
|
|
125
|
+
await view().observe(post.slug);
|
|
126
|
+
for (let i = 0; i < 27; i++) {
|
|
127
|
+
time++;
|
|
128
|
+
post.status = i % 2 ? "Planned" : "In Progress";
|
|
129
|
+
await view().observe(post.slug);
|
|
130
|
+
}
|
|
131
|
+
const first = await view().list({ slug: post.slug });
|
|
132
|
+
expect(first.items).toHaveLength(25);
|
|
133
|
+
expect(first.items[0]).toMatchObject({
|
|
134
|
+
kind: "status",
|
|
135
|
+
status: "In Progress",
|
|
136
|
+
observedAt: new Date(1001),
|
|
137
|
+
});
|
|
138
|
+
const second = await view().list({
|
|
139
|
+
slug: post.slug,
|
|
140
|
+
cursor: first.nextCursor,
|
|
141
|
+
});
|
|
142
|
+
expect(second.items).toHaveLength(2);
|
|
143
|
+
expect(
|
|
144
|
+
new Set([...first.items, ...second.items].map((event) => event.id)).size,
|
|
145
|
+
).toBe(27);
|
|
146
|
+
post.slug = "another-post";
|
|
147
|
+
await expect(
|
|
148
|
+
view().list({ slug: post.slug, cursor: first.nextCursor }),
|
|
149
|
+
).rejects.toThrow("cursor");
|
|
150
|
+
});
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import type { D1Database } from "@cloudflare/workers-types";
|
|
3
|
+
import type { FeedbackModule, PostStatus } from "./index";
|
|
4
|
+
|
|
5
|
+
export type ObservedHistoryEvent = {
|
|
6
|
+
id: string;
|
|
7
|
+
observedAt: Date;
|
|
8
|
+
} & ({ kind: "status"; status: PostStatus } | { kind: "update"; body: string });
|
|
9
|
+
export interface ObservedHistoryPage {
|
|
10
|
+
available: boolean;
|
|
11
|
+
items: ObservedHistoryEvent[];
|
|
12
|
+
nextCursor?: string;
|
|
13
|
+
}
|
|
14
|
+
type Snapshot = { status: PostStatus; response: string | null };
|
|
15
|
+
type Observation = { revision: number; projection: string | null };
|
|
16
|
+
type EventRow = {
|
|
17
|
+
id: string;
|
|
18
|
+
revision: number;
|
|
19
|
+
kind: "status" | "update";
|
|
20
|
+
status: PostStatus | null;
|
|
21
|
+
message: string | null;
|
|
22
|
+
observed_at: number;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
// Callers supply the configured Feedback module, never provider payloads. The
|
|
26
|
+
// initial visible snapshot is a baseline, not reconstructed historical activity.
|
|
27
|
+
export function createObservedHistoryModule(options: {
|
|
28
|
+
database: D1Database;
|
|
29
|
+
feedback: FeedbackModule;
|
|
30
|
+
now?: () => number;
|
|
31
|
+
}) {
|
|
32
|
+
const { database, feedback } = options;
|
|
33
|
+
const now = options.now ?? Date.now;
|
|
34
|
+
const observe = async (slug: string) => {
|
|
35
|
+
const time = now();
|
|
36
|
+
const known = await database
|
|
37
|
+
.prepare("SELECT 1 FROM public_post_observations WHERE post_slug=?")
|
|
38
|
+
.bind(slug)
|
|
39
|
+
.first();
|
|
40
|
+
if (!known) {
|
|
41
|
+
const post = await feedback.getPublicPost(slug);
|
|
42
|
+
if (!post) return;
|
|
43
|
+
await database
|
|
44
|
+
.prepare(
|
|
45
|
+
"INSERT OR IGNORE INTO public_post_observations(post_slug,projection,checked_at) VALUES(?,?,?)",
|
|
46
|
+
)
|
|
47
|
+
.bind(
|
|
48
|
+
slug,
|
|
49
|
+
JSON.stringify({
|
|
50
|
+
status: post.status,
|
|
51
|
+
response: post.teamResponse ?? null,
|
|
52
|
+
}),
|
|
53
|
+
time,
|
|
54
|
+
)
|
|
55
|
+
.run();
|
|
56
|
+
return post;
|
|
57
|
+
}
|
|
58
|
+
const token = randomUUID();
|
|
59
|
+
const previous = await database
|
|
60
|
+
.prepare(
|
|
61
|
+
"UPDATE public_post_observations SET lease_token=?,lease_until=?,checked_at=? WHERE post_slug=? AND lease_until<=? RETURNING revision,projection",
|
|
62
|
+
)
|
|
63
|
+
.bind(token, time + 60000, time, slug, time)
|
|
64
|
+
.first<Observation>();
|
|
65
|
+
if (!previous) return feedback.getPublicPost(slug);
|
|
66
|
+
let providerReadSucceeded = false;
|
|
67
|
+
try {
|
|
68
|
+
const post = await feedback.getPublicPost(slug);
|
|
69
|
+
providerReadSucceeded = true;
|
|
70
|
+
const snapshot: Snapshot | null = post
|
|
71
|
+
? { status: post.status, response: post.teamResponse ?? null }
|
|
72
|
+
: null;
|
|
73
|
+
const encoded = snapshot ? JSON.stringify(snapshot) : null;
|
|
74
|
+
const before: Snapshot | null = previous.projection
|
|
75
|
+
? JSON.parse(previous.projection)
|
|
76
|
+
: null;
|
|
77
|
+
const revision = previous.revision + 1;
|
|
78
|
+
const statements = [];
|
|
79
|
+
if (before && snapshot) {
|
|
80
|
+
if (before.status !== snapshot.status)
|
|
81
|
+
statements.push(
|
|
82
|
+
database
|
|
83
|
+
.prepare(
|
|
84
|
+
"INSERT OR IGNORE INTO public_post_history(id,post_slug,revision,kind,status,observed_at) SELECT ?,?,?,'status',?,? WHERE EXISTS(SELECT 1 FROM public_post_observations WHERE post_slug=? AND lease_token=?)",
|
|
85
|
+
)
|
|
86
|
+
.bind(
|
|
87
|
+
randomUUID(),
|
|
88
|
+
slug,
|
|
89
|
+
revision,
|
|
90
|
+
snapshot.status,
|
|
91
|
+
time,
|
|
92
|
+
slug,
|
|
93
|
+
token,
|
|
94
|
+
),
|
|
95
|
+
);
|
|
96
|
+
if (snapshot.response && before.response !== snapshot.response)
|
|
97
|
+
statements.push(
|
|
98
|
+
database
|
|
99
|
+
.prepare(
|
|
100
|
+
"INSERT OR IGNORE INTO public_post_history(id,post_slug,revision,kind,message,observed_at) SELECT ?,?,?,'update',?,? WHERE EXISTS(SELECT 1 FROM public_post_observations WHERE post_slug=? AND lease_token=?)",
|
|
101
|
+
)
|
|
102
|
+
.bind(
|
|
103
|
+
randomUUID(),
|
|
104
|
+
slug,
|
|
105
|
+
revision,
|
|
106
|
+
snapshot.response,
|
|
107
|
+
time,
|
|
108
|
+
slug,
|
|
109
|
+
token,
|
|
110
|
+
),
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
statements.push(
|
|
114
|
+
database
|
|
115
|
+
.prepare(
|
|
116
|
+
"UPDATE public_post_observations SET revision=?,projection=?,lease_token=NULL,lease_until=0 WHERE post_slug=? AND lease_token=?",
|
|
117
|
+
)
|
|
118
|
+
.bind(revision, encoded, slug, token),
|
|
119
|
+
);
|
|
120
|
+
const committed = await database.batch(statements);
|
|
121
|
+
// A later observer may have revoked visibility after this lease expired.
|
|
122
|
+
// Its checkpoint wins; a fenced completion cannot authorize a public read.
|
|
123
|
+
return committed.at(-1)?.meta.changes === 1 ? post : undefined;
|
|
124
|
+
} catch (error) {
|
|
125
|
+
// Provider access loss breaks the public baseline. A storage failure
|
|
126
|
+
// preserves it so retry can durably record the same observed transition.
|
|
127
|
+
await database
|
|
128
|
+
.prepare(
|
|
129
|
+
"UPDATE public_post_observations SET projection=CASE WHEN ? THEN projection ELSE NULL END,lease_token=NULL,lease_until=0 WHERE post_slug=? AND lease_token=?",
|
|
130
|
+
)
|
|
131
|
+
.bind(providerReadSucceeded ? 1 : 0, slug, token)
|
|
132
|
+
.run();
|
|
133
|
+
throw error;
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
return {
|
|
137
|
+
observe,
|
|
138
|
+
async list(input: {
|
|
139
|
+
slug: string;
|
|
140
|
+
cursor?: string;
|
|
141
|
+
}): Promise<ObservedHistoryPage> {
|
|
142
|
+
if (!(await observe(input.slug))) return { available: false, items: [] };
|
|
143
|
+
let revision = -1;
|
|
144
|
+
let kind = "";
|
|
145
|
+
if (input.cursor) {
|
|
146
|
+
try {
|
|
147
|
+
const parsed = JSON.parse(
|
|
148
|
+
Buffer.from(input.cursor, "base64url").toString("utf8"),
|
|
149
|
+
);
|
|
150
|
+
if (
|
|
151
|
+
parsed.slug !== input.slug ||
|
|
152
|
+
!Number.isSafeInteger(parsed.revision) ||
|
|
153
|
+
parsed.revision < 0 ||
|
|
154
|
+
!["status", "update"].includes(parsed.kind)
|
|
155
|
+
)
|
|
156
|
+
throw Error();
|
|
157
|
+
revision = parsed.revision;
|
|
158
|
+
kind = parsed.kind;
|
|
159
|
+
} catch {
|
|
160
|
+
throw Error("Invalid observed-history cursor.");
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
const rows = (
|
|
164
|
+
await database
|
|
165
|
+
.prepare(
|
|
166
|
+
"SELECT id,revision,kind,status,message,observed_at FROM public_post_history WHERE post_slug=? AND (revision>? OR (revision=? AND kind>?)) ORDER BY revision,kind LIMIT 26",
|
|
167
|
+
)
|
|
168
|
+
.bind(input.slug, revision, revision, kind)
|
|
169
|
+
.all<EventRow>()
|
|
170
|
+
).results;
|
|
171
|
+
const selected = rows.slice(0, 25);
|
|
172
|
+
const last = selected.at(-1);
|
|
173
|
+
return {
|
|
174
|
+
available: true,
|
|
175
|
+
items: selected.map((row) => ({
|
|
176
|
+
id: row.id,
|
|
177
|
+
observedAt: new Date(row.observed_at),
|
|
178
|
+
...(row.kind === "status"
|
|
179
|
+
? { kind: "status" as const, status: row.status! }
|
|
180
|
+
: { kind: "update" as const, body: row.message! }),
|
|
181
|
+
})),
|
|
182
|
+
...(rows.length > 25 && last
|
|
183
|
+
? {
|
|
184
|
+
nextCursor: Buffer.from(
|
|
185
|
+
JSON.stringify({
|
|
186
|
+
slug: input.slug,
|
|
187
|
+
revision: last.revision,
|
|
188
|
+
kind: last.kind,
|
|
189
|
+
}),
|
|
190
|
+
).toString("base64url"),
|
|
191
|
+
}
|
|
192
|
+
: {}),
|
|
193
|
+
};
|
|
194
|
+
},
|
|
195
|
+
async synchronize(limit = 1) {
|
|
196
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 10)
|
|
197
|
+
throw Error("History work limit must be 1–10.");
|
|
198
|
+
const rows = (
|
|
199
|
+
await database
|
|
200
|
+
.prepare(
|
|
201
|
+
"SELECT post_slug FROM public_post_observations WHERE lease_until<=? ORDER BY checked_at,post_slug LIMIT ?",
|
|
202
|
+
)
|
|
203
|
+
.bind(now(), limit)
|
|
204
|
+
.all<{ post_slug: string }>()
|
|
205
|
+
).results;
|
|
206
|
+
let checked = 0,
|
|
207
|
+
failed = 0;
|
|
208
|
+
for (const row of rows) {
|
|
209
|
+
try {
|
|
210
|
+
await observe(row.post_slug);
|
|
211
|
+
checked++;
|
|
212
|
+
} catch {
|
|
213
|
+
failed++;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return { checked, failed };
|
|
217
|
+
},
|
|
218
|
+
};
|
|
219
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { BrowserCapabilityAuthorizationError } from "./index";
|
|
3
|
+
import type { D1Database } from "@cloudflare/workers-types";
|
|
4
|
+
import type {
|
|
5
|
+
FeedbackModule,
|
|
6
|
+
ParticipantPost,
|
|
7
|
+
Post,
|
|
8
|
+
PostType,
|
|
9
|
+
PublicPost,
|
|
10
|
+
} from "./index";
|
|
11
|
+
import type { VerifiedParticipant } from "./connected";
|
|
12
|
+
|
|
13
|
+
export interface PostOwnership {
|
|
14
|
+
owns(id: string): Promise<boolean>;
|
|
15
|
+
register(post: Post): Promise<void>;
|
|
16
|
+
withdrawn(id: string): Promise<void>;
|
|
17
|
+
}
|
|
18
|
+
export function createPostOwnership(
|
|
19
|
+
database: D1Database,
|
|
20
|
+
participant: VerifiedParticipant,
|
|
21
|
+
): PostOwnership {
|
|
22
|
+
return {
|
|
23
|
+
async owns(id) {
|
|
24
|
+
return Boolean(
|
|
25
|
+
await database
|
|
26
|
+
.prepare("SELECT 1 FROM post_ownership WHERE post_id=? AND subject=?")
|
|
27
|
+
.bind(id, participant.subject)
|
|
28
|
+
.first(),
|
|
29
|
+
);
|
|
30
|
+
},
|
|
31
|
+
async register(post) {
|
|
32
|
+
await database
|
|
33
|
+
.prepare(
|
|
34
|
+
"INSERT OR IGNORE INTO post_ownership(post_id,post_slug,subject,title,type,created_at) VALUES(?,?,?,?,?,?)",
|
|
35
|
+
)
|
|
36
|
+
.bind(
|
|
37
|
+
post.id,
|
|
38
|
+
post.slug,
|
|
39
|
+
participant.subject,
|
|
40
|
+
post.title,
|
|
41
|
+
post.type,
|
|
42
|
+
post.createdAt.getTime(),
|
|
43
|
+
)
|
|
44
|
+
.run();
|
|
45
|
+
if (!(await this.owns(post.id)))
|
|
46
|
+
throw new BrowserCapabilityAuthorizationError();
|
|
47
|
+
},
|
|
48
|
+
async withdrawn(id) {
|
|
49
|
+
await database
|
|
50
|
+
.prepare(
|
|
51
|
+
"UPDATE post_ownership SET withdrawn=1 WHERE post_id=? AND subject=?",
|
|
52
|
+
)
|
|
53
|
+
.bind(id, participant.subject)
|
|
54
|
+
.run();
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
export type MyFeedbackFilter = "submitted" | "supported" | "following";
|
|
59
|
+
export type MyFeedbackItem = Partial<ParticipantPost> &
|
|
60
|
+
Pick<PublicPost, "slug" | "title"> & {
|
|
61
|
+
unavailable?: boolean;
|
|
62
|
+
withdrawn?: boolean;
|
|
63
|
+
};
|
|
64
|
+
export interface MyFeedbackPage {
|
|
65
|
+
items: MyFeedbackItem[];
|
|
66
|
+
nextCursor?: string;
|
|
67
|
+
}
|
|
68
|
+
export function createMyFeedbackModule(options: {
|
|
69
|
+
database: D1Database;
|
|
70
|
+
feedback: FeedbackModule;
|
|
71
|
+
participant: VerifiedParticipant;
|
|
72
|
+
}) {
|
|
73
|
+
const { database, feedback, participant } = options;
|
|
74
|
+
return {
|
|
75
|
+
async list(input: {
|
|
76
|
+
filter: MyFeedbackFilter;
|
|
77
|
+
cursor?: string;
|
|
78
|
+
}): Promise<MyFeedbackPage> {
|
|
79
|
+
if (!["submitted", "supported", "following"].includes(input.filter))
|
|
80
|
+
throw new Error("Invalid My feedback filter.");
|
|
81
|
+
const fingerprint = createHash("sha256")
|
|
82
|
+
.update(JSON.stringify([participant.subject, input.filter]))
|
|
83
|
+
.digest("hex");
|
|
84
|
+
let cursor = { at: Number.MAX_SAFE_INTEGER, key: "" };
|
|
85
|
+
if (input.cursor) {
|
|
86
|
+
try {
|
|
87
|
+
if (input.cursor.length > 2048) throw new Error();
|
|
88
|
+
const value = JSON.parse(
|
|
89
|
+
Buffer.from(input.cursor, "base64url").toString(),
|
|
90
|
+
);
|
|
91
|
+
if (
|
|
92
|
+
value.view !== fingerprint ||
|
|
93
|
+
!Number.isSafeInteger(value.at) ||
|
|
94
|
+
value.at < 0 ||
|
|
95
|
+
typeof value.key !== "string" ||
|
|
96
|
+
value.key.length > 512
|
|
97
|
+
)
|
|
98
|
+
throw new Error();
|
|
99
|
+
cursor = value;
|
|
100
|
+
} catch {
|
|
101
|
+
throw new Error("Invalid My feedback cursor.");
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
const selection =
|
|
105
|
+
input.filter === "submitted"
|
|
106
|
+
? "SELECT post_id AS key,post_slug AS slug,created_at AS at,title,type,withdrawn FROM post_ownership WHERE subject=?"
|
|
107
|
+
: input.filter === "supported"
|
|
108
|
+
? "SELECT post_slug AS key,post_slug AS slug,0 AS at FROM participant_votes WHERE subject=?"
|
|
109
|
+
: "SELECT post_slug AS key,post_slug AS slug,followed_at AS at FROM post_follows WHERE subject=?";
|
|
110
|
+
const rows = (
|
|
111
|
+
await database
|
|
112
|
+
.prepare(
|
|
113
|
+
`SELECT * FROM (${selection}) WHERE at<? OR (at=? AND key>?) ORDER BY at DESC,key LIMIT 11`,
|
|
114
|
+
)
|
|
115
|
+
.bind(participant.subject, cursor.at, cursor.at, cursor.key)
|
|
116
|
+
.all<{
|
|
117
|
+
key: string;
|
|
118
|
+
slug: string;
|
|
119
|
+
at: number;
|
|
120
|
+
title?: string;
|
|
121
|
+
type?: PostType;
|
|
122
|
+
withdrawn?: number;
|
|
123
|
+
}>()
|
|
124
|
+
).results;
|
|
125
|
+
const items = await Promise.all(
|
|
126
|
+
rows.slice(0, 10).map(async (row): Promise<MyFeedbackItem> => {
|
|
127
|
+
if (input.filter === "submitted") {
|
|
128
|
+
if (!row.withdrawn) {
|
|
129
|
+
const post = await feedback.getParticipantPost(row.key);
|
|
130
|
+
if (post) return post;
|
|
131
|
+
}
|
|
132
|
+
return {
|
|
133
|
+
id: row.key,
|
|
134
|
+
slug: row.slug,
|
|
135
|
+
title: row.title ?? "Submission unavailable",
|
|
136
|
+
type: row.type,
|
|
137
|
+
editable: false,
|
|
138
|
+
unavailable: true,
|
|
139
|
+
...(row.withdrawn ? { withdrawn: true } : {}),
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
const post = await feedback.getPublicPost(row.slug);
|
|
143
|
+
return post
|
|
144
|
+
? { ...post, published: true, editable: false }
|
|
145
|
+
: {
|
|
146
|
+
slug: row.slug,
|
|
147
|
+
title: "Post unavailable",
|
|
148
|
+
unavailable: true,
|
|
149
|
+
editable: false,
|
|
150
|
+
};
|
|
151
|
+
}),
|
|
152
|
+
);
|
|
153
|
+
const last = rows[9];
|
|
154
|
+
return {
|
|
155
|
+
items,
|
|
156
|
+
...(rows.length > 10 && last
|
|
157
|
+
? {
|
|
158
|
+
nextCursor: Buffer.from(
|
|
159
|
+
JSON.stringify({
|
|
160
|
+
at: last.at,
|
|
161
|
+
key: last.key,
|
|
162
|
+
view: fingerprint,
|
|
163
|
+
}),
|
|
164
|
+
).toString("base64url"),
|
|
165
|
+
}
|
|
166
|
+
: {}),
|
|
167
|
+
};
|
|
168
|
+
},
|
|
169
|
+
};
|
|
170
|
+
}
|