create-feedbax 0.2.1 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/README.md +17 -5
  2. package/dist/cli.js +502 -209
  3. package/dist/index.js +251 -68
  4. package/package.json +7 -7
  5. package/template/.feedbax-template.sha256 +1 -1
  6. package/template/.gitignore.template +1 -0
  7. package/template/.npmignore.template +1 -0
  8. package/template/.nvmrc +1 -0
  9. package/template/AGENTS.md +1 -1
  10. package/template/CONNECTED.md +16 -2
  11. package/template/README.md +52 -13
  12. package/template/feedbax-vite.ts +79 -0
  13. package/template/migrations/0005_github.sql +4 -0
  14. package/template/migrations/0006_integration.sql +3 -0
  15. package/template/migrations/0007_ownership.sql +2 -0
  16. package/template/migrations/0008_integration_recovery.sql +5 -0
  17. package/template/migrations/0009_observed_history.sql +3 -0
  18. package/template/package.json +34 -33
  19. package/template/src/changelog-runtime.ts +2 -1
  20. package/template/src/cloudflare-workers.d.ts +3 -0
  21. package/template/src/comment-creation.test.tsx +53 -6
  22. package/template/src/components/connected-identity-provider.tsx +44 -17
  23. package/template/src/components/follow-post-button.tsx +2 -13
  24. package/template/src/components/post-history.tsx +101 -0
  25. package/template/src/components/profile-menu.tsx +7 -0
  26. package/template/src/configured-feedback.test.ts +82 -0
  27. package/template/src/configured-feedback.ts +61 -0
  28. package/template/src/connected-identity.test.ts +7 -3
  29. package/template/src/connected-identity.ts +6 -1
  30. package/template/src/connected-runtime.test.ts +5 -2
  31. package/template/src/feedback-cache-invalidation.ts +2 -0
  32. package/template/src/feedback-runtime.ts +61 -18
  33. package/template/src/feedbax/changelog/index.test.ts +35 -0
  34. package/template/src/feedbax/changelog/index.ts +5 -1
  35. package/template/src/feedbax/changelog/notion-changelog.test.ts +1 -1
  36. package/template/src/feedbax/changelog/notion-changelog.ts +1 -1
  37. package/template/src/feedbax/config/index.test.ts +45 -1
  38. package/template/src/feedbax/config/index.ts +112 -9
  39. package/template/src/feedbax/feedback/comment-storage.ts +38 -0
  40. package/template/src/feedbax/feedback/connected-feedback.ts +41 -12
  41. package/template/src/feedbax/feedback/connected.ts +15 -5
  42. package/template/src/feedbax/feedback/feedback-storage.ts +1 -0
  43. package/template/src/feedbax/feedback/github-comments.ts +390 -0
  44. package/template/src/feedbax/feedback/github-feedback.test.ts +1138 -0
  45. package/template/src/feedbax/feedback/github-feedback.ts +581 -0
  46. package/template/src/feedbax/feedback/github-request.ts +120 -0
  47. package/template/src/feedbax/feedback/github-service-client.ts +56 -0
  48. package/template/src/feedbax/feedback/image-feedback.ts +23 -10
  49. package/template/src/feedbax/feedback/images.ts +26 -0
  50. package/template/src/feedbax/feedback/index.ts +172 -16
  51. package/template/src/feedbax/feedback/integration-auth.ts +138 -0
  52. package/template/src/feedbax/feedback/notion-comments.ts +2 -35
  53. package/template/src/feedbax/feedback/observed-history.test.ts +150 -0
  54. package/template/src/feedbax/feedback/observed-history.ts +219 -0
  55. package/template/src/feedbax/feedback/ownership.ts +170 -0
  56. package/template/src/history-runtime.test.ts +80 -0
  57. package/template/src/history-runtime.ts +37 -0
  58. package/template/src/index.css +6 -0
  59. package/template/src/integration-runtime.test.ts +365 -0
  60. package/template/src/integration-runtime.ts +289 -0
  61. package/template/src/my-feedback-page.test.tsx +152 -0
  62. package/template/src/my-feedback-page.tsx +398 -0
  63. package/template/src/my-feedback-runtime.test.ts +93 -0
  64. package/template/src/my-feedback-runtime.ts +154 -0
  65. package/template/src/participation-runtime.ts +3 -3
  66. package/template/src/portal-comments.ts +11 -0
  67. package/template/src/portal-feedback-form.tsx +11 -3
  68. package/template/src/portal-feedback-submission.ts +9 -0
  69. package/template/src/portal-server-entry.ts +9 -0
  70. package/template/src/public-portal-error.tsx +1 -1
  71. package/template/src/public-post-detail.tsx +332 -256
  72. package/template/src/public-release-links.test.ts +120 -0
  73. package/template/src/public-release-links.ts +130 -0
  74. package/template/src/routeTree.gen.ts +21 -0
  75. package/template/src/routes/my-feedback.tsx +16 -0
  76. package/template/src/server.ts +46 -1
  77. package/template/src/submission-intent.ts +26 -0
  78. package/template/vite.config.ts +2 -0
@@ -0,0 +1,154 @@
1
+ import {
2
+ boundedBody,
3
+ BrowserCapabilityAuthorizationError,
4
+ createMyFeedbackModule,
5
+ type ConnectedDatabase,
6
+ type FeedbackModule,
7
+ type VerifiedParticipant,
8
+ } from "@feedbax/feedback";
9
+ import { sameOrigin } from "./connected-identity";
10
+ import { createPortalDraftManagement } from "./portal-feedback-submission";
11
+
12
+ export function createMyFeedbackRequestHandler(options: {
13
+ enabled: boolean;
14
+ siteUrl: string;
15
+ database: ConnectedDatabase;
16
+ getParticipant(request: Request): Promise<VerifiedParticipant | undefined>;
17
+ getFeedback(participant: VerifiedParticipant): FeedbackModule;
18
+ rateLimit(request: Request): Promise<boolean>;
19
+ limits?: { maxTitleLength: number; maxDescriptionLength: number };
20
+ }) {
21
+ const json = (body: unknown, status = 200) =>
22
+ Response.json(body, {
23
+ status,
24
+ headers: {
25
+ "Cache-Control": "no-store",
26
+ "Referrer-Policy": "no-referrer",
27
+ Vary: "Cookie",
28
+ },
29
+ });
30
+ return async (request: Request): Promise<Response | undefined> => {
31
+ const url = new URL(request.url);
32
+ if (!url.pathname.startsWith("/internal/my-feedback")) return;
33
+ if (!options.enabled) return json({ error: "Not found." }, 404);
34
+ if (request.method !== "GET" && !sameOrigin(request, options.siteUrl))
35
+ return json({ error: "Request origin is invalid." }, 403);
36
+ try {
37
+ const participant = await options.getParticipant(request);
38
+ if (!participant)
39
+ return json(
40
+ { error: "Sign in through the product to view My feedback." },
41
+ 401,
42
+ );
43
+ if (!(await options.rateLimit(request)))
44
+ return json({ error: "Too many requests. Try again shortly." }, 429);
45
+ const feedback = options.getFeedback(participant);
46
+ if (
47
+ url.pathname === "/internal/my-feedback" &&
48
+ request.method === "GET"
49
+ ) {
50
+ const filter = url.searchParams.get("filter") ?? "submitted";
51
+ if (
52
+ filter !== "submitted" &&
53
+ filter !== "supported" &&
54
+ filter !== "following"
55
+ )
56
+ return json({ error: "Invalid My feedback filter." }, 400);
57
+ return json(
58
+ await createMyFeedbackModule({
59
+ database: options.database,
60
+ feedback,
61
+ participant,
62
+ }).list({
63
+ filter,
64
+ cursor: url.searchParams.get("cursor") ?? undefined,
65
+ }),
66
+ );
67
+ }
68
+ if (
69
+ url.pathname !== "/internal/my-feedback/post" ||
70
+ request.method !== "POST"
71
+ )
72
+ return json({ error: "Not found." }, 404);
73
+ const value = JSON.parse(await boundedBody(request, 16_384)) as Record<
74
+ string,
75
+ unknown
76
+ >;
77
+ if (
78
+ !value ||
79
+ typeof value !== "object" ||
80
+ typeof value.id !== "string" ||
81
+ value.id.length > 128
82
+ )
83
+ return json({ error: "Invalid submission request." }, 400);
84
+ if (value.action === "claim") {
85
+ if (
86
+ typeof value.browserCapability !== "string" ||
87
+ value.browserCapability.length > 512
88
+ )
89
+ return json(
90
+ {
91
+ error:
92
+ "A valid Browser Capability is required to link this draft.",
93
+ },
94
+ 400,
95
+ );
96
+ const draft = await feedback.claimDraftPost({
97
+ id: value.id,
98
+ browserCapability:
99
+ value.browserCapability as import("@feedbax/feedback").BrowserCapability,
100
+ });
101
+ return draft
102
+ ? json(draft)
103
+ : json({ error: "Submission unavailable." }, 404);
104
+ }
105
+ const post = await feedback.getParticipantPost(value.id);
106
+ if (!post) return json({ error: "Submission unavailable." }, 404);
107
+ if (value.action === "read") return json(post);
108
+ if (!post.editable)
109
+ return json(
110
+ { error: "Only New, unpublished drafts may be changed." },
111
+ 409,
112
+ );
113
+ const drafts = createPortalDraftManagement({
114
+ feedback,
115
+ limits: options.limits ?? {
116
+ maxTitleLength: 160,
117
+ maxDescriptionLength: 5000,
118
+ },
119
+ });
120
+ if (value.action === "edit") {
121
+ const updated = await drafts.edit({
122
+ ...value,
123
+ browserCapability: "participant-owner",
124
+ });
125
+ return json({
126
+ id: updated.id,
127
+ slug: updated.slug,
128
+ title: updated.title,
129
+ description: updated.description,
130
+ type: updated.type,
131
+ status: updated.status,
132
+ published: updated.published,
133
+ createdAt: updated.createdAt,
134
+ updatedAt: updated.updatedAt,
135
+ });
136
+ }
137
+ if (value.action === "withdraw") {
138
+ await drafts.withdraw({
139
+ id: value.id,
140
+ browserCapability: "participant-owner",
141
+ });
142
+ return json({ withdrawn: true });
143
+ }
144
+ return json({ error: "Invalid submission action." }, 400);
145
+ } catch (error) {
146
+ if (error instanceof BrowserCapabilityAuthorizationError)
147
+ return json({ error: "Submission unavailable." }, 404);
148
+ return json(
149
+ { error: "My feedback is unavailable. Refresh and try again." },
150
+ 503,
151
+ );
152
+ }
153
+ };
154
+ }
@@ -7,7 +7,7 @@ import { env } from "cloudflare:workers";
7
7
  import feedbax from "./feedbax";
8
8
  import {
9
9
  configuredImages,
10
- createConfiguredNotionFeedbackModule,
10
+ createConfiguredContentFeedbackModule,
11
11
  getConnectedStore,
12
12
  } from "./feedback-runtime";
13
13
  import {
@@ -175,7 +175,7 @@ export function createParticipationRequestHandler(options: {
175
175
 
176
176
  export function configuredParticipation(participant?: VerifiedParticipant) {
177
177
  return createConfiguredParticipation(
178
- createConfiguredNotionFeedbackModule(),
178
+ createConfiguredContentFeedbackModule(),
179
179
  participant,
180
180
  );
181
181
  }
@@ -241,7 +241,7 @@ export const handleParticipationRequest = createParticipationRequestHandler({
241
241
  });
242
242
  export async function synchronizeConnectedUpdates() {
243
243
  if (!feedbax.connected) return;
244
- const content = createConfiguredNotionFeedbackModule();
244
+ const content = createConfiguredContentFeedbackModule();
245
245
  const module = createConfiguredParticipation(content);
246
246
  const observation = await module.synchronize();
247
247
  const email = await module.deliverEmails();
@@ -26,6 +26,7 @@ export interface PortalCommentResult {
26
26
  const commentCapabilityLifetime = 15 * 60 * 1_000;
27
27
 
28
28
  export interface PortalCommentRequest {
29
+ submissionKey?: string;
29
30
  imageIds?: string[];
30
31
  slug: string;
31
32
  body: string;
@@ -92,6 +93,7 @@ export function createPortalCommentHandler(options: {
92
93
 
93
94
  const command = {
94
95
  ...(value.imageIds ? { imageIds: value.imageIds } : {}),
96
+ ...(value.submissionKey ? { submissionKey: value.submissionKey } : {}),
95
97
  slug: value.slug,
96
98
  body: value.body,
97
99
  displayName: value.displayName,
@@ -377,7 +379,16 @@ function validateCommentInput(
377
379
  ))
378
380
  )
379
381
  throw invalidCommentRequest();
382
+ if (
383
+ value.submissionKey !== undefined &&
384
+ (typeof value.submissionKey !== "string" ||
385
+ !/^[A-Za-z0-9_-]{43}$/.test(value.submissionKey))
386
+ )
387
+ throw invalidCommentRequest();
380
388
  return {
389
+ ...(typeof value.submissionKey === "string"
390
+ ? { submissionKey: value.submissionKey }
391
+ : {}),
381
392
  ...(value.imageIds ? { imageIds: value.imageIds as string[] } : {}),
382
393
  slug: value.slug.trim(),
383
394
  body: value.body.trim(),
@@ -1,3 +1,4 @@
1
+ import { submissionIntent } from "./submission-intent";
1
2
  import { ImageAttachments } from "./components/image-attachments";
2
3
  import { configuredTurnstileSiteKey } from "./turnstile-configuration";
3
4
  import type { Post, PostType, SubmittedPost } from "@feedbax/feedback";
@@ -163,9 +164,15 @@ export function PortalFeedbackForm({
163
164
  setPending(true);
164
165
  setMessage(undefined);
165
166
  try {
167
+ const intent = await submissionIntent(window.sessionStorage, {
168
+ ...values,
169
+ imageIds: images.map((image) => image.id),
170
+ submitter: readDeviceProfile(window.localStorage),
171
+ });
166
172
  const result = await mutations.submitPost({
167
173
  data: {
168
174
  ...values,
175
+ submissionKey: intent.key,
169
176
  ...(images.length
170
177
  ? { imageIds: images.map((image) => image.id) }
171
178
  : {}),
@@ -178,9 +185,9 @@ export function PortalFeedbackForm({
178
185
  const storedDraft: StoredDraft = {
179
186
  id: result.id,
180
187
  browserCapability: result.browserCapability,
181
- title: values.title,
182
- description: values.description,
183
- type: values.type,
188
+ title: result.title,
189
+ description: result.description,
190
+ type: result.type,
184
191
  };
185
192
  setDraft(storedDraft);
186
193
  try {
@@ -193,6 +200,7 @@ export function PortalFeedbackForm({
193
200
  draftStorageKey,
194
201
  JSON.stringify(storedDraft),
195
202
  );
203
+ intent.complete();
196
204
  } catch {
197
205
  setMessage(
198
206
  "Draft submitted, but this browser could not save access. Keep this page open to manage it.",
@@ -157,7 +157,16 @@ function validateInput(
157
157
  )
158
158
  throw invalidSubmission();
159
159
 
160
+ if (
161
+ value.submissionKey !== undefined &&
162
+ (typeof value.submissionKey !== "string" ||
163
+ !/^[A-Za-z0-9_-]{43}$/.test(value.submissionKey))
164
+ )
165
+ throw invalidSubmission();
160
166
  return {
167
+ ...(value.submissionKey
168
+ ? { submissionKey: value.submissionKey as string }
169
+ : {}),
161
170
  ...(value.imageIds ? { imageIds: value.imageIds as string[] } : {}),
162
171
  title: value.title,
163
172
  description: value.description,
@@ -14,6 +14,7 @@ export function createPortalServerEntry({
14
14
  commentHandler,
15
15
  commentMutationHandler,
16
16
  applicationHandler = startHandler.fetch,
17
+ cachePublicReads = true,
17
18
  }: {
18
19
  connectedHandler?: (request: Request) => Promise<Response | undefined>;
19
20
  trustedFeedbackHandler: (request: Request) => Promise<Response>;
@@ -21,6 +22,7 @@ export function createPortalServerEntry({
21
22
  commentHandler?: (request: Request) => Promise<Response>;
22
23
  commentMutationHandler?: (request: Request) => Promise<Response>;
23
24
  applicationHandler?: typeof startHandler.fetch;
25
+ cachePublicReads?: boolean;
24
26
  }) {
25
27
  return createServerEntry({
26
28
  async fetch(request, options) {
@@ -62,6 +64,13 @@ export function createPortalServerEntry({
62
64
  return Response.redirect(canonicalRequest.url, 308);
63
65
  }
64
66
  const response = await applicationHandler(request, options);
67
+ if (!cachePublicReads) {
68
+ const privateResponse = new Response(response.body, response);
69
+ privateResponse.headers.set("Cache-Control", "no-store");
70
+ privateResponse.headers.delete("Cloudflare-CDN-Cache-Control");
71
+ privateResponse.headers.delete("Cache-Tag");
72
+ return privateResponse;
73
+ }
65
74
  return applyPublicCachePolicy(request, response);
66
75
  },
67
76
  });
@@ -3,7 +3,7 @@ import { Button } from "@feedbax/ui/components/button";
3
3
  export function PublicPortalError({
4
4
  reset,
5
5
  }: {
6
- error: Error;
6
+ error: unknown;
7
7
  reset: () => void;
8
8
  }) {
9
9
  return (