convex-feedback 0.1.3 → 0.1.5

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 (61) hide show
  1. package/README.md +33 -0
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/client/api.d.ts +7 -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 +12 -1
  8. package/dist/client/index.js.map +1 -1
  9. package/dist/component/_generated/api.d.ts +4 -0
  10. package/dist/component/_generated/api.d.ts.map +1 -1
  11. package/dist/component/_generated/api.js.map +1 -1
  12. package/dist/component/_generated/component.d.ts +27 -0
  13. package/dist/component/_generated/component.d.ts.map +1 -1
  14. package/dist/component/comments.d.ts +8 -8
  15. package/dist/component/comments.d.ts.map +1 -1
  16. package/dist/component/comments.js +2 -1
  17. package/dist/component/comments.js.map +1 -1
  18. package/dist/component/crons.d.ts +3 -0
  19. package/dist/component/crons.d.ts.map +1 -0
  20. package/dist/component/crons.js +8 -0
  21. package/dist/component/crons.js.map +1 -0
  22. package/dist/component/entries.d.ts +53 -26
  23. package/dist/component/entries.d.ts.map +1 -1
  24. package/dist/component/entries.js +116 -9
  25. package/dist/component/entries.js.map +1 -1
  26. package/dist/component/helpers.d.ts +2 -1
  27. package/dist/component/helpers.d.ts.map +1 -1
  28. package/dist/component/helpers.js +46 -1
  29. package/dist/component/helpers.js.map +1 -1
  30. package/dist/component/migrations.d.ts +11 -0
  31. package/dist/component/migrations.d.ts.map +1 -0
  32. package/dist/component/migrations.js +35 -0
  33. package/dist/component/migrations.js.map +1 -0
  34. package/dist/component/model.d.ts +74 -3
  35. package/dist/component/model.d.ts.map +1 -1
  36. package/dist/component/model.js +11 -0
  37. package/dist/component/model.js.map +1 -1
  38. package/dist/component/schema.d.ts +19 -2
  39. package/dist/component/schema.d.ts.map +1 -1
  40. package/dist/component/schema.js +12 -2
  41. package/dist/component/schema.js.map +1 -1
  42. package/dist/react/index.d.ts +27 -3
  43. package/dist/react/index.d.ts.map +1 -1
  44. package/dist/react/index.js +6 -0
  45. package/dist/react/index.js.map +1 -1
  46. package/dist/test.d.ts +19 -2
  47. package/dist/test.d.ts.map +1 -1
  48. package/package.json +6 -2
  49. package/src/client/api.ts +11 -0
  50. package/src/client/index.ts +16 -0
  51. package/src/component/README.md +2 -0
  52. package/src/component/_generated/api.ts +4 -0
  53. package/src/component/_generated/component.ts +31 -1
  54. package/src/component/comments.ts +2 -0
  55. package/src/component/crons.ts +16 -0
  56. package/src/component/entries.ts +158 -7
  57. package/src/component/helpers.ts +70 -0
  58. package/src/component/migrations.ts +44 -0
  59. package/src/component/model.ts +44 -0
  60. package/src/component/schema.ts +17 -2
  61. package/src/react/index.ts +13 -0
package/src/client/api.ts CHANGED
@@ -9,8 +9,10 @@ import type {
9
9
  EntryKind,
10
10
  EntrySort,
11
11
  EntryStatus,
12
+ EntryStatusFilter,
12
13
  FeedbackComment,
13
14
  FeedbackEntry,
15
+ FeedbackMetadata,
14
16
  SimilarEntriesResult,
15
17
  } from "../component/model.js";
16
18
 
@@ -39,6 +41,9 @@ export type ListEntriesArgs = {
39
41
  /** Restricts entries to this workflow status. */
40
42
  status?: EntryStatus;
41
43
 
44
+ /** Restricts entries to the indexed open or closed board bucket. */
45
+ statusFilter?: EntryStatusFilter;
46
+
42
47
  /**
43
48
  * Server-side ordering strategy.
44
49
  *
@@ -73,6 +78,9 @@ export type SearchEntriesArgs = {
73
78
  /** Optional workflow-status restriction. */
74
79
  status?: EntryStatus;
75
80
 
81
+ /** Optional indexed open or closed board bucket. */
82
+ statusFilter?: EntryStatusFilter;
83
+
76
84
  /**
77
85
  * Maximum number of results to return.
78
86
  *
@@ -134,6 +142,9 @@ export type CreateEntryArgs = {
134
142
 
135
143
  /** Entry description/body. */
136
144
  body: string;
145
+
146
+ /** Optional creation-time diagnostic metadata. */
147
+ metadata?: FeedbackMetadata;
137
148
  };
138
149
 
139
150
  /**
@@ -26,7 +26,9 @@ import {
26
26
  commentSortValidator,
27
27
  entryKindValidator,
28
28
  entrySortValidator,
29
+ entryStatusFilterValidator,
29
30
  entryStatusValidator,
31
+ feedbackMetadataValidator,
30
32
  publicCommentValidator,
31
33
  publicEntryValidator,
32
34
  similarEntriesValidator,
@@ -43,9 +45,12 @@ export type {
43
45
  EntryKind,
44
46
  EntrySort,
45
47
  EntryStatus,
48
+ EntryStatusFilter,
46
49
  FeedbackActor,
47
50
  FeedbackComment,
48
51
  FeedbackEntry,
52
+ FeedbackMetadata,
53
+ FeedbackMetadataValue,
49
54
  SimilarEntriesResult,
50
55
  } from "../component/model.js";
51
56
  export type { FeedbackPublicApi } from "./api.js";
@@ -425,6 +430,7 @@ function buildFeedbackApi<
425
430
  paginationOpts: paginationOptsValidator,
426
431
  kinds: v.optional(v.array(entryKindValidator)),
427
432
  status: v.optional(entryStatusValidator),
433
+ statusFilter: v.optional(entryStatusFilterValidator),
428
434
  sort: v.optional(entrySortValidator),
429
435
  },
430
436
  returns: paginationResultValidator(publicEntryValidator),
@@ -438,6 +444,9 @@ function buildFeedbackApi<
438
444
  ),
439
445
  ...(args.kinds === undefined ? {} : { kinds: args.kinds }),
440
446
  ...(args.status === undefined ? {} : { status: args.status }),
447
+ ...(args.statusFilter === undefined
448
+ ? {}
449
+ : { statusFilter: args.statusFilter }),
441
450
  sort: args.sort ?? config.entries.defaultSort,
442
451
  ...actorIdFields(actor),
443
452
  });
@@ -452,6 +461,7 @@ function buildFeedbackApi<
452
461
  return await ctx.runQuery(component.entries.get, {
453
462
  entryId: args.entryId,
454
463
  ...actorIdFields(actor),
464
+ ...(actor?.isModerator === true ? { viewerIsModerator: true } : {}),
455
465
  });
456
466
  },
457
467
  }),
@@ -461,6 +471,7 @@ function buildFeedbackApi<
461
471
  searchQuery: v.string(),
462
472
  kinds: v.optional(v.array(entryKindValidator)),
463
473
  status: v.optional(entryStatusValidator),
474
+ statusFilter: v.optional(entryStatusFilterValidator),
464
475
  limit: v.optional(v.number()),
465
476
  },
466
477
  returns: v.array(publicEntryValidator),
@@ -471,6 +482,9 @@ function buildFeedbackApi<
471
482
  searchQuery: args.searchQuery,
472
483
  ...(args.kinds === undefined ? {} : { kinds: args.kinds }),
473
484
  ...(args.status === undefined ? {} : { status: args.status }),
485
+ ...(args.statusFilter === undefined
486
+ ? {}
487
+ : { statusFilter: args.statusFilter }),
474
488
  limit: clampPositive(
475
489
  args.limit,
476
490
  config.search.defaultLimit,
@@ -515,6 +529,7 @@ function buildFeedbackApi<
515
529
  kind: entryKindValidator,
516
530
  title: v.string(),
517
531
  body: v.string(),
532
+ metadata: v.optional(feedbackMetadataValidator),
518
533
  },
519
534
  returns: idReturns,
520
535
  handler: async (ctx, args) => {
@@ -535,6 +550,7 @@ function buildFeedbackApi<
535
550
  enabledKinds: [...config.entries.enabledKinds],
536
551
  maxTitleLength: config.limits.titleLength,
537
552
  maxBodyLength: config.limits.bodyLength,
553
+ ...(args.metadata === undefined ? {} : { metadata: args.metadata }),
538
554
  });
539
555
  },
540
556
  }),
@@ -5,6 +5,8 @@ This directory is the isolated Convex component backend.
5
5
  - `schema.ts`: three tables and query-driven indexes.
6
6
  - `entries.ts`: entry CRUD, full-text search, duplicate suggestions, entry voting, indexed pagination.
7
7
  - `comments.ts`: direct-child pagination, recursive write validation, soft deletion, comment likes.
8
+ - `migrations.ts`: bounded, idempotent legacy-entry backfills.
9
+ - `crons.ts`: immediate upgrade migration and temporary 30-day self-healing schedule.
8
10
  - `model.ts`: reusable validators and public model types.
9
11
  - `helpers.ts`: normalization, validation, and serializers.
10
12
  - `convex.config.ts`: packaged component definition.
@@ -9,8 +9,10 @@
9
9
  */
10
10
 
11
11
  import type * as comments from "../comments.js";
12
+ import type * as crons from "../crons.js";
12
13
  import type * as entries from "../entries.js";
13
14
  import type * as helpers from "../helpers.js";
15
+ import type * as migrations from "../migrations.js";
14
16
  import type * as model from "../model.js";
15
17
  import type * as types from "../types.js";
16
18
 
@@ -23,8 +25,10 @@ import { anyApi, componentsGeneric } from "convex/server";
23
25
 
24
26
  const fullApi: ApiFromModules<{
25
27
  comments: typeof comments;
28
+ crons: typeof crons;
26
29
  entries: typeof entries;
27
30
  helpers: typeof helpers;
31
+ migrations: typeof migrations;
28
32
  model: typeof model;
29
33
  types: typeof types;
30
34
  }> = anyApi as any;
@@ -127,6 +127,10 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
127
127
  kind: "feedback" | "feature_request" | "bug_report";
128
128
  maxBodyLength: number;
129
129
  maxTitleLength: number;
130
+ metadata?: {
131
+ additional?: Record<string, string | number | boolean>;
132
+ standard?: Record<string, string | number | boolean>;
133
+ };
130
134
  title: string;
131
135
  },
132
136
  string,
@@ -135,7 +139,11 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
135
139
  get: FunctionReference<
136
140
  "query",
137
141
  "internal",
138
- { entryId: string; viewerActorId?: string },
142
+ {
143
+ entryId: string;
144
+ viewerActorId?: string;
145
+ viewerIsModerator?: boolean;
146
+ },
139
147
  {
140
148
  actorId: string;
141
149
  body: string;
@@ -143,6 +151,10 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
143
151
  creationTime: number;
144
152
  id: string;
145
153
  kind: "feedback" | "feature_request" | "bug_report";
154
+ metadata?: {
155
+ additional?: Record<string, string | number | boolean>;
156
+ standard?: Record<string, string | number | boolean>;
157
+ };
146
158
  status:
147
159
  | "open"
148
160
  | "under_review"
@@ -178,6 +190,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
178
190
  | "in_progress"
179
191
  | "completed"
180
192
  | "closed";
193
+ statusFilter?: "open" | "closed";
181
194
  viewerActorId?: string;
182
195
  },
183
196
  {
@@ -190,6 +203,10 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
190
203
  creationTime: number;
191
204
  id: string;
192
205
  kind: "feedback" | "feature_request" | "bug_report";
206
+ metadata?: {
207
+ additional?: Record<string, string | number | boolean>;
208
+ standard?: Record<string, string | number | boolean>;
209
+ };
193
210
  status:
194
211
  | "open"
195
212
  | "under_review"
@@ -221,6 +238,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
221
238
  | "in_progress"
222
239
  | "completed"
223
240
  | "closed";
241
+ statusFilter?: "open" | "closed";
224
242
  viewerActorId?: string;
225
243
  },
226
244
  Array<{
@@ -230,6 +248,10 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
230
248
  creationTime: number;
231
249
  id: string;
232
250
  kind: "feedback" | "feature_request" | "bug_report";
251
+ metadata?: {
252
+ additional?: Record<string, string | number | boolean>;
253
+ standard?: Record<string, string | number | boolean>;
254
+ };
233
255
  status:
234
256
  | "open"
235
257
  | "under_review"
@@ -286,6 +308,10 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
286
308
  creationTime: number;
287
309
  id: string;
288
310
  kind: "feedback" | "feature_request" | "bug_report";
311
+ metadata?: {
312
+ additional?: Record<string, string | number | boolean>;
313
+ standard?: Record<string, string | number | boolean>;
314
+ };
289
315
  status:
290
316
  | "open"
291
317
  | "under_review"
@@ -305,6 +331,10 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
305
331
  creationTime: number;
306
332
  id: string;
307
333
  kind: "feedback" | "feature_request" | "bug_report";
334
+ metadata?: {
335
+ additional?: Record<string, string | number | boolean>;
336
+ standard?: Record<string, string | number | boolean>;
337
+ };
308
338
  status:
309
339
  | "open"
310
340
  | "under_review"
@@ -15,6 +15,7 @@ import {
15
15
  import {
16
16
  actorValidator,
17
17
  commentSortValidator,
18
+ entryStatusFilterForStatus,
18
19
  publicCommentValidator,
19
20
  } from "./model.js";
20
21
  import schema from "./schema.js";
@@ -121,6 +122,7 @@ export const create = mutation({
121
122
 
122
123
  await ctx.db.patch("entries", args.entryId, {
123
124
  commentCount: entry.commentCount + 1,
125
+ statusFilter: entryStatusFilterForStatus(entry.status),
124
126
  });
125
127
 
126
128
  if (args.parentCommentId !== undefined) {
@@ -0,0 +1,16 @@
1
+ import { cronJobs } from "convex/server";
2
+
3
+ import { internal } from "./_generated/api.js";
4
+
5
+ const crons = cronJobs();
6
+
7
+ // Interval jobs run as soon as they are first deployed. The 30-day repeat is a
8
+ // temporary, low-frequency self-healing safeguard for missed legacy records.
9
+ crons.interval(
10
+ "self-heal entry status filters",
11
+ { hours: 24 * 30 },
12
+ internal.migrations.backfillStatusFilter,
13
+ {},
14
+ );
15
+
16
+ export default crons;
@@ -14,12 +14,16 @@ 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,
23
+ entryStatusFilterForStatus,
24
+ entryStatusFilterValidator,
22
25
  entryStatusValidator,
26
+ feedbackMetadataValidator,
23
27
  publicEntryValidator,
24
28
  similarEntriesValidator,
25
29
  type EntryKind,
@@ -61,6 +65,7 @@ export const list = query({
61
65
  paginationOpts: paginationOptsValidator,
62
66
  kinds: v.optional(v.array(entryKindValidator)),
63
67
  status: v.optional(entryStatusValidator),
68
+ statusFilter: v.optional(entryStatusFilterValidator),
64
69
  sort: entrySortValidator,
65
70
  viewerActorId: v.optional(v.string()),
66
71
  },
@@ -68,11 +73,82 @@ export const list = query({
68
73
  handler: async (ctx, args) => {
69
74
  const db = paginator(ctx.db, schema);
70
75
  const kinds = normalizeKindFilter(args.kinds);
71
- const { status } = args;
76
+ const { status, statusFilter } = args;
77
+
78
+ if (status !== undefined && statusFilter !== undefined) {
79
+ throw new ConvexError(
80
+ "`status` and `statusFilter` cannot be used together.",
81
+ );
82
+ }
72
83
 
73
84
  let result: PaginationResult<Doc<"entries">>;
74
85
 
75
- if (kinds === undefined) {
86
+ if (statusFilter !== undefined) {
87
+ if (kinds === undefined) {
88
+ result =
89
+ args.sort === "top"
90
+ ? await db
91
+ .query("entries")
92
+ .withIndex("by_status_filter_upvotes", (q) =>
93
+ q.eq("statusFilter", statusFilter),
94
+ )
95
+ .order("desc")
96
+ .paginate(args.paginationOpts)
97
+ : await db
98
+ .query("entries")
99
+ .withIndex("by_status_filter", (q) =>
100
+ q.eq("statusFilter", statusFilter),
101
+ )
102
+ .order("desc")
103
+ .paginate(args.paginationOpts);
104
+ } else if (kinds.length === 1) {
105
+ const kind = kinds[0];
106
+
107
+ if (kind === undefined) {
108
+ throw new ConvexError("Invalid kind filter.");
109
+ }
110
+
111
+ result =
112
+ args.sort === "top"
113
+ ? await db
114
+ .query("entries")
115
+ .withIndex("by_kind_status_filter_upvotes", (q) =>
116
+ q.eq("kind", kind).eq("statusFilter", statusFilter),
117
+ )
118
+ .order("desc")
119
+ .paginate(args.paginationOpts)
120
+ : await db
121
+ .query("entries")
122
+ .withIndex("by_kind_status_filter", (q) =>
123
+ q.eq("kind", kind).eq("statusFilter", statusFilter),
124
+ )
125
+ .order("desc")
126
+ .paginate(args.paginationOpts);
127
+ } else {
128
+ const streams = kinds.map((kind) =>
129
+ args.sort === "top"
130
+ ? stream(ctx.db, schema)
131
+ .query("entries")
132
+ .withIndex("by_kind_status_filter_upvotes", (q) =>
133
+ q.eq("kind", kind).eq("statusFilter", statusFilter),
134
+ )
135
+ .order("desc")
136
+ : stream(ctx.db, schema)
137
+ .query("entries")
138
+ .withIndex("by_kind_status_filter", (q) =>
139
+ q.eq("kind", kind).eq("statusFilter", statusFilter),
140
+ )
141
+ .order("desc"),
142
+ );
143
+
144
+ result = await mergedStream(
145
+ streams,
146
+ args.sort === "top"
147
+ ? ["upvoteCount", "_creationTime"]
148
+ : ["_creationTime"],
149
+ ).paginate(args.paginationOpts);
150
+ }
151
+ } else if (kinds === undefined) {
76
152
  result =
77
153
  args.sort === "top"
78
154
  ? status === undefined
@@ -200,13 +276,19 @@ export const get = query({
200
276
  args: {
201
277
  entryId: v.id("entries"),
202
278
  viewerActorId: v.optional(v.string()),
279
+ viewerIsModerator: v.optional(v.boolean()),
203
280
  },
204
281
  returns: v.union(publicEntryValidator, v.null()),
205
282
  handler: async (ctx, args) => {
206
283
  const entry = await ctx.db.get("entries", args.entryId);
207
284
  return entry === null
208
285
  ? null
209
- : serializeEntry(ctx, entry, args.viewerActorId);
286
+ : serializeEntry(
287
+ ctx,
288
+ entry,
289
+ args.viewerActorId,
290
+ args.viewerIsModerator === true,
291
+ );
210
292
  },
211
293
  });
212
294
 
@@ -215,6 +297,7 @@ export const search = query({
215
297
  searchQuery: v.string(),
216
298
  kinds: v.optional(v.array(entryKindValidator)),
217
299
  status: v.optional(entryStatusValidator),
300
+ statusFilter: v.optional(entryStatusFilterValidator),
218
301
  limit: v.number(),
219
302
  viewerActorId: v.optional(v.string()),
220
303
  },
@@ -227,11 +310,67 @@ export const search = query({
227
310
  }
228
311
 
229
312
  const kinds = normalizeKindFilter(args.kinds);
230
- const { status } = args;
313
+ const { status, statusFilter } = args;
314
+
315
+ if (status !== undefined && statusFilter !== undefined) {
316
+ throw new ConvexError(
317
+ "`status` and `statusFilter` cannot be used together.",
318
+ );
319
+ }
231
320
 
232
321
  let entries: Doc<"entries">[];
233
322
 
234
- if (kinds === undefined) {
323
+ if (statusFilter !== undefined) {
324
+ if (kinds === undefined) {
325
+ entries = await ctx.db
326
+ .query("entries")
327
+ .withSearchIndex("search", (q) =>
328
+ q
329
+ .search("searchText", searchQuery)
330
+ .eq("statusFilter", statusFilter),
331
+ )
332
+ .take(args.limit);
333
+ } else if (kinds.length === 1) {
334
+ const kind = kinds[0];
335
+
336
+ if (kind === undefined) {
337
+ throw new ConvexError("Invalid kind filter.");
338
+ }
339
+
340
+ entries = await ctx.db
341
+ .query("entries")
342
+ .withSearchIndex("search", (q) =>
343
+ q
344
+ .search("searchText", searchQuery)
345
+ .eq("kind", kind)
346
+ .eq("statusFilter", statusFilter),
347
+ )
348
+ .take(args.limit);
349
+ } else {
350
+ const firstKind = kinds[0];
351
+ const secondKind = kinds[1];
352
+
353
+ if (firstKind === undefined || secondKind === undefined) {
354
+ throw new ConvexError("Invalid kind filter.");
355
+ }
356
+
357
+ entries = await ctx.db
358
+ .query("entries")
359
+ .withSearchIndex("search", (q) =>
360
+ q
361
+ .search("searchText", searchQuery)
362
+ .eq("statusFilter", statusFilter),
363
+ )
364
+ // eslint-disable-next-line @convex-dev/no-filter-in-query
365
+ .filter((q) =>
366
+ q.or(
367
+ q.eq(q.field("kind"), firstKind),
368
+ q.eq(q.field("kind"), secondKind),
369
+ ),
370
+ )
371
+ .take(args.limit);
372
+ }
373
+ } else if (kinds === undefined) {
235
374
  entries =
236
375
  status === undefined
237
376
  ? await ctx.db
@@ -424,6 +563,7 @@ export const create = mutation({
424
563
  enabledKinds: v.array(entryKindValidator),
425
564
  maxTitleLength: v.number(),
426
565
  maxBodyLength: v.number(),
566
+ metadata: v.optional(feedbackMetadataValidator),
427
567
  },
428
568
  returns: v.id("entries"),
429
569
  handler: async (ctx, args) => {
@@ -438,17 +578,20 @@ export const create = mutation({
438
578
  args.maxTitleLength,
439
579
  );
440
580
  const body = normalizeRequiredText(args.body, "Body", args.maxBodyLength);
581
+ validateFeedbackMetadata(args.metadata);
441
582
 
442
583
  const entry = await ctx.db.insert("entries", {
443
584
  actorId: args.actorId,
444
585
  kind: args.kind,
445
586
  status: args.defaultStatus,
587
+ statusFilter: entryStatusFilterForStatus(args.defaultStatus),
446
588
  title,
447
589
  body,
448
590
  normalizedTitle: normalizeTitle(title),
449
591
  searchText: `${title}\n${body}`,
450
592
  upvoteCount: 1,
451
593
  commentCount: 0,
594
+ ...(args.metadata === undefined ? {} : { metadata: args.metadata }),
452
595
  });
453
596
 
454
597
  await ctx.db.insert("reactions", {
@@ -493,6 +636,7 @@ export const update = mutation({
493
636
  body,
494
637
  normalizedTitle: normalizeTitle(title),
495
638
  searchText: `${title}\n${body}`,
639
+ statusFilter: entryStatusFilterForStatus(entry.status),
496
640
  updatedAt: Date.now(),
497
641
  });
498
642
  return null;
@@ -515,6 +659,7 @@ export const setStatus = mutation({
515
659
  }
516
660
  await ctx.db.patch("entries", args.entryId, {
517
661
  status: args.status,
662
+ statusFilter: entryStatusFilterForStatus(args.status),
518
663
  updatedAt: Date.now(),
519
664
  });
520
665
  return null;
@@ -546,14 +691,20 @@ export const setUpvote = mutation({
546
691
  entryId: args.entryId,
547
692
  });
548
693
  const upvoteCount = entry.upvoteCount + 1;
549
- await ctx.db.patch("entries", args.entryId, { upvoteCount });
694
+ await ctx.db.patch("entries", args.entryId, {
695
+ upvoteCount,
696
+ statusFilter: entryStatusFilterForStatus(entry.status),
697
+ });
550
698
  return { active: true, upvoteCount };
551
699
  }
552
700
 
553
701
  if (!args.desiredState && existing !== null) {
554
702
  await ctx.db.delete("reactions", existing._id);
555
703
  const upvoteCount = Math.max(0, entry.upvoteCount - 1);
556
- await ctx.db.patch("entries", args.entryId, { upvoteCount });
704
+ await ctx.db.patch("entries", args.entryId, {
705
+ upvoteCount,
706
+ statusFilter: entryStatusFilterForStatus(entry.status),
707
+ });
557
708
  return { active: false, upvoteCount };
558
709
  }
559
710
 
@@ -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
 
@@ -0,0 +1,44 @@
1
+ import { v } from "convex/values";
2
+
3
+ import { internal } from "./_generated/api.js";
4
+ import { internalMutation } from "./_generated/server.js";
5
+ import { entryStatusFilterForStatus } from "./model.js";
6
+
7
+ const statusFilterMigrationBatchSize = 100;
8
+
9
+ /**
10
+ * Backfills one bounded batch of legacy entries and schedules the next batch.
11
+ *
12
+ * The indexed undefined lookup and deterministic status mapping make this
13
+ * safe to run concurrently, retry, or invoke again after completion.
14
+ */
15
+ export const backfillStatusFilter = internalMutation({
16
+ args: {},
17
+ returns: v.object({
18
+ updated: v.number(),
19
+ hasMore: v.boolean(),
20
+ }),
21
+ handler: async (ctx) => {
22
+ const entries = await ctx.db
23
+ .query("entries")
24
+ .withIndex("by_status_filter", (q) => q.eq("statusFilter", undefined))
25
+ .take(statusFilterMigrationBatchSize);
26
+
27
+ for (const entry of entries) {
28
+ await ctx.db.patch("entries", entry._id, {
29
+ statusFilter: entryStatusFilterForStatus(entry.status),
30
+ });
31
+ }
32
+
33
+ const hasMore = entries.length === statusFilterMigrationBatchSize;
34
+ if (hasMore) {
35
+ await ctx.scheduler.runAfter(
36
+ 0,
37
+ internal.migrations.backfillStatusFilter,
38
+ {},
39
+ );
40
+ }
41
+
42
+ return { updated: entries.length, hasMore };
43
+ },
44
+ });