convex-feedback 0.1.2 → 0.1.4

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 (42) hide show
  1. package/README.md +16 -0
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/client/api.d.ts +3 -1
  4. package/dist/client/api.d.ts.map +1 -1
  5. package/dist/client/index.d.ts +1 -1
  6. package/dist/client/index.d.ts.map +1 -1
  7. package/dist/client/index.js +4 -1
  8. package/dist/client/index.js.map +1 -1
  9. package/dist/component/_generated/component.d.ts +25 -0
  10. package/dist/component/_generated/component.d.ts.map +1 -1
  11. package/dist/component/comments.d.ts +10 -10
  12. package/dist/component/entries.d.ts +53 -28
  13. package/dist/component/entries.d.ts.map +1 -1
  14. package/dist/component/entries.js +7 -3
  15. package/dist/component/entries.js.map +1 -1
  16. package/dist/component/helpers.d.ts +2 -1
  17. package/dist/component/helpers.d.ts.map +1 -1
  18. package/dist/component/helpers.js +46 -1
  19. package/dist/component/helpers.js.map +1 -1
  20. package/dist/component/model.d.ts +70 -3
  21. package/dist/component/model.d.ts.map +1 -1
  22. package/dist/component/model.js +7 -0
  23. package/dist/component/model.js.map +1 -1
  24. package/dist/component/schema.d.ts +12 -1
  25. package/dist/component/schema.d.ts.map +1 -1
  26. package/dist/component/schema.js +2 -1
  27. package/dist/component/schema.js.map +1 -1
  28. package/dist/react/index.d.ts +28 -2
  29. package/dist/react/index.d.ts.map +1 -1
  30. package/dist/react/index.js +4 -1
  31. package/dist/react/index.js.map +1 -1
  32. package/dist/test.d.ts +12 -1
  33. package/dist/test.d.ts.map +1 -1
  34. package/package.json +1 -1
  35. package/src/client/api.ts +4 -0
  36. package/src/client/index.ts +6 -0
  37. package/src/component/_generated/component.ts +29 -1
  38. package/src/component/entries.ts +12 -1
  39. package/src/component/helpers.ts +70 -0
  40. package/src/component/model.ts +30 -0
  41. package/src/component/schema.ts +6 -1
  42. package/src/react/index.ts +26 -4
@@ -14,12 +14,14 @@ import {
14
14
  normalizeRequiredText,
15
15
  normalizeTitle,
16
16
  serializeEntry,
17
+ validateFeedbackMetadata,
17
18
  } from "./helpers.js";
18
19
  import {
19
20
  actorValidator,
20
21
  entryKindValidator,
21
22
  entrySortValidator,
22
23
  entryStatusValidator,
24
+ feedbackMetadataValidator,
23
25
  publicEntryValidator,
24
26
  similarEntriesValidator,
25
27
  type EntryKind,
@@ -200,13 +202,19 @@ export const get = query({
200
202
  args: {
201
203
  entryId: v.id("entries"),
202
204
  viewerActorId: v.optional(v.string()),
205
+ viewerIsModerator: v.optional(v.boolean()),
203
206
  },
204
207
  returns: v.union(publicEntryValidator, v.null()),
205
208
  handler: async (ctx, args) => {
206
209
  const entry = await ctx.db.get("entries", args.entryId);
207
210
  return entry === null
208
211
  ? null
209
- : serializeEntry(ctx, entry, args.viewerActorId);
212
+ : serializeEntry(
213
+ ctx,
214
+ entry,
215
+ args.viewerActorId,
216
+ args.viewerIsModerator === true,
217
+ );
210
218
  },
211
219
  });
212
220
 
@@ -424,6 +432,7 @@ export const create = mutation({
424
432
  enabledKinds: v.array(entryKindValidator),
425
433
  maxTitleLength: v.number(),
426
434
  maxBodyLength: v.number(),
435
+ metadata: v.optional(feedbackMetadataValidator),
427
436
  },
428
437
  returns: v.id("entries"),
429
438
  handler: async (ctx, args) => {
@@ -438,6 +447,7 @@ export const create = mutation({
438
447
  args.maxTitleLength,
439
448
  );
440
449
  const body = normalizeRequiredText(args.body, "Body", args.maxBodyLength);
450
+ validateFeedbackMetadata(args.metadata);
441
451
 
442
452
  const entry = await ctx.db.insert("entries", {
443
453
  actorId: args.actorId,
@@ -449,6 +459,7 @@ export const create = mutation({
449
459
  searchText: `${title}\n${body}`,
450
460
  upvoteCount: 1,
451
461
  commentCount: 0,
462
+ ...(args.metadata === undefined ? {} : { metadata: args.metadata }),
452
463
  });
453
464
 
454
465
  await ctx.db.insert("reactions", {
@@ -4,6 +4,72 @@ import type { DataModel } from "./_generated/dataModel.js";
4
4
  import type { QueryCtx } from "./types.js";
5
5
  import type { FeedbackComment, FeedbackEntry } from "./model.js";
6
6
 
7
+ const metadataMaximumKeysPerSection = 32;
8
+ const metadataMaximumKeyLength = 64;
9
+ const metadataMaximumStringLength = 1_024;
10
+ const metadataMaximumEncodedBytes = 16 * 1_024;
11
+ const forbiddenMetadataKeys = new Set([
12
+ "__proto__",
13
+ "constructor",
14
+ "prototype",
15
+ ]);
16
+
17
+ export function validateFeedbackMetadata(
18
+ metadata: DataModel["entries"]["document"]["metadata"],
19
+ ): void {
20
+ if (metadata === undefined) return;
21
+
22
+ for (const [sectionName, section] of Object.entries(metadata)) {
23
+ if (section === undefined) continue;
24
+ const entries = Object.entries(section);
25
+
26
+ if (entries.length > metadataMaximumKeysPerSection) {
27
+ throw new ConvexError(
28
+ `Metadata section '${sectionName}' must contain ${metadataMaximumKeysPerSection} keys or fewer.`,
29
+ );
30
+ }
31
+
32
+ for (const [key, value] of entries) {
33
+ if (key.length === 0) {
34
+ throw new ConvexError(
35
+ `Metadata section '${sectionName}' contains an empty key.`,
36
+ );
37
+ }
38
+ if (key.length > metadataMaximumKeyLength) {
39
+ throw new ConvexError(
40
+ `Metadata key '${key}' in section '${sectionName}' must be ${metadataMaximumKeyLength} characters or fewer.`,
41
+ );
42
+ }
43
+ if (
44
+ key.startsWith("_") ||
45
+ key.startsWith("$") ||
46
+ forbiddenMetadataKeys.has(key)
47
+ ) {
48
+ throw new ConvexError(
49
+ `Metadata key '${key}' in section '${sectionName}' is reserved and cannot be used.`,
50
+ );
51
+ }
52
+ if (
53
+ typeof value === "string" &&
54
+ value.length > metadataMaximumStringLength
55
+ ) {
56
+ throw new ConvexError(
57
+ `Metadata value for '${sectionName}.${key}' must be ${metadataMaximumStringLength} characters or fewer.`,
58
+ );
59
+ }
60
+ }
61
+ }
62
+
63
+ const encodedBytes = new TextEncoder().encode(
64
+ JSON.stringify(metadata),
65
+ ).length;
66
+ if (encodedBytes > metadataMaximumEncodedBytes) {
67
+ throw new ConvexError(
68
+ `Metadata must be ${metadataMaximumEncodedBytes} UTF-8 bytes or fewer; received ${encodedBytes} bytes.`,
69
+ );
70
+ }
71
+ }
72
+
7
73
  export function normalizeTitle(value: string): string {
8
74
  return value.trim().replace(/\s+/g, " ").toLocaleLowerCase("en-US");
9
75
  }
@@ -41,6 +107,7 @@ export async function serializeEntry(
41
107
  ctx: QueryCtx,
42
108
  entry: DataModel["entries"]["document"],
43
109
  viewerActorId: string | undefined,
110
+ includeMetadata = false,
44
111
  ): Promise<FeedbackEntry> {
45
112
  const reaction =
46
113
  viewerActorId !== undefined
@@ -64,6 +131,9 @@ export async function serializeEntry(
64
131
  commentCount: entry.commentCount,
65
132
  ...(entry.updatedAt === undefined ? {} : { updatedAt: entry.updatedAt }),
66
133
  viewerHasUpvoted: reaction !== null,
134
+ ...(includeMetadata && entry.metadata !== undefined
135
+ ? { metadata: entry.metadata }
136
+ : {}),
67
137
  };
68
138
  }
69
139
 
@@ -32,6 +32,22 @@ export const actorValidator = v.object({
32
32
  isModerator: v.boolean(),
33
33
  });
34
34
 
35
+ export const feedbackMetadataValueValidator = v.union(
36
+ v.string(),
37
+ v.number(),
38
+ v.boolean(),
39
+ );
40
+
41
+ export const feedbackMetadataRecordValidator = v.record(
42
+ v.string(),
43
+ feedbackMetadataValueValidator,
44
+ );
45
+
46
+ export const feedbackMetadataValidator = v.object({
47
+ standard: v.optional(feedbackMetadataRecordValidator),
48
+ additional: v.optional(feedbackMetadataRecordValidator),
49
+ });
50
+
35
51
  export const publicEntryValidator = v.object({
36
52
  id: v.string(),
37
53
  creationTime: v.number(),
@@ -44,6 +60,7 @@ export const publicEntryValidator = v.object({
44
60
  commentCount: v.number(),
45
61
  updatedAt: v.optional(v.number()),
46
62
  viewerHasUpvoted: v.boolean(),
63
+ metadata: v.optional(feedbackMetadataValidator),
47
64
  });
48
65
 
49
66
  export const publicCommentValidator = v.object({
@@ -119,6 +136,14 @@ export type CommentSort = Infer<typeof commentSortValidator>;
119
136
  */
120
137
  export type FeedbackActor = Infer<typeof actorValidator>;
121
138
 
139
+ /** Scalar value accepted in entry diagnostic metadata. */
140
+ export type FeedbackMetadataValue = Infer<
141
+ typeof feedbackMetadataValueValidator
142
+ >;
143
+
144
+ /** Flat metadata values grouped by their source. */
145
+ export type FeedbackMetadata = Infer<typeof feedbackMetadataValidator>;
146
+
122
147
  /**
123
148
  * Public representation of a feedback, feature-request, or bug-report entry.
124
149
  *
@@ -157,6 +182,11 @@ export type FeedbackActor = Infer<typeof actorValidator>;
157
182
  * @property viewerHasUpvoted
158
183
  * Whether the actor associated with the current query has upvoted the entry.
159
184
  * `false` when no viewer actor is available.
185
+ *
186
+ * @property metadata
187
+ * Creation-time diagnostic metadata. Present only when `getEntry` is queried
188
+ * by a moderator. Ordinary entry lists, searches, and non-moderator reads omit
189
+ * this property.
160
190
  */
161
191
  export type FeedbackEntry = Infer<typeof publicEntryValidator>;
162
192
 
@@ -1,7 +1,11 @@
1
1
  import { defineSchema, defineTable } from "convex/server";
2
2
  import { v } from "convex/values";
3
3
 
4
- import { entryKindValidator, entryStatusValidator } from "./model.js";
4
+ import {
5
+ entryKindValidator,
6
+ entryStatusValidator,
7
+ feedbackMetadataValidator,
8
+ } from "./model.js";
5
9
 
6
10
  const schema = defineSchema({
7
11
  entries: defineTable({
@@ -15,6 +19,7 @@ const schema = defineSchema({
15
19
  upvoteCount: v.number(),
16
20
  commentCount: v.number(),
17
21
  updatedAt: v.optional(v.number()),
22
+ metadata: v.optional(feedbackMetadataValidator),
18
23
  })
19
24
  .index("by_kind", ["kind"])
20
25
  .index("by_status", ["status"])
@@ -186,7 +186,7 @@ function positivePageSize(value: number | undefined, fallback: number): number {
186
186
  * @typeParam RateLimitResult The validated non-throwing rate-limit rejection
187
187
  * type returned by mutation hooks. It is inferred from the supplied API.
188
188
  */
189
- export function createFeedbackHooks<RateLimitResult = never>(
189
+ function createFeedbackHooksImplementation<RateLimitResult>(
190
190
  api: FeedbackPublicApi<string | undefined, RateLimitResult>,
191
191
  options: FeedbackHooksOptions = {},
192
192
  ) {
@@ -334,6 +334,29 @@ export function createFeedbackHooks<RateLimitResult = never>(
334
334
  };
335
335
  }
336
336
 
337
+ type CreatedFeedbackHooks<RateLimitResult> = ReturnType<
338
+ typeof createFeedbackHooksImplementation<RateLimitResult>
339
+ >;
340
+
341
+ /** Creates hooks for a feedback API whose rate limiters reject by throwing. */
342
+ export function createFeedbackHooks(
343
+ api: FeedbackPublicApi<string | undefined, never>,
344
+ options?: FeedbackHooksOptions,
345
+ ): CreatedFeedbackHooks<never>;
346
+
347
+ /** Creates hooks carrying a validated non-throwing rate-limit result. */
348
+ export function createFeedbackHooks<RateLimitResult>(
349
+ api: FeedbackPublicApi<string | undefined, RateLimitResult>,
350
+ options?: FeedbackHooksOptions,
351
+ ): CreatedFeedbackHooks<RateLimitResult>;
352
+
353
+ export function createFeedbackHooks<RateLimitResult>(
354
+ api: FeedbackPublicApi<string | undefined, RateLimitResult>,
355
+ options: FeedbackHooksOptions = {},
356
+ ): CreatedFeedbackHooks<RateLimitResult> {
357
+ return createFeedbackHooksImplementation(api, options);
358
+ }
359
+
337
360
  /**
338
361
  * Hook collection returned by `createFeedbackHooks`.
339
362
  *
@@ -343,6 +366,5 @@ export function createFeedbackHooks<RateLimitResult = never>(
343
366
  * @typeParam RateLimitResult The validated non-throwing rate-limit rejection
344
367
  * type returned by mutation hooks.
345
368
  */
346
- export type FeedbackHooks<RateLimitResult = never> = ReturnType<
347
- typeof createFeedbackHooks<RateLimitResult>
348
- >;
369
+ export type FeedbackHooks<RateLimitResult = never> =
370
+ CreatedFeedbackHooks<RateLimitResult>;