convex-feedback 0.1.4 → 0.2.0-beta.0

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 (79) hide show
  1. package/README.md +87 -27
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/client/api.d.ts +80 -1
  4. package/dist/client/api.d.ts.map +1 -1
  5. package/dist/client/config.d.ts +1 -1
  6. package/dist/client/index.d.ts +9 -5
  7. package/dist/client/index.d.ts.map +1 -1
  8. package/dist/client/index.js +261 -8
  9. package/dist/client/index.js.map +1 -1
  10. package/dist/component/_generated/api.d.ts +8 -0
  11. package/dist/component/_generated/api.d.ts.map +1 -1
  12. package/dist/component/_generated/api.js.map +1 -1
  13. package/dist/component/_generated/component.d.ts +295 -5
  14. package/dist/component/_generated/component.d.ts.map +1 -1
  15. package/dist/component/admin.d.ts +130 -0
  16. package/dist/component/admin.d.ts.map +1 -0
  17. package/dist/component/admin.js +125 -0
  18. package/dist/component/admin.js.map +1 -0
  19. package/dist/component/comments.d.ts +7 -5
  20. package/dist/component/comments.d.ts.map +1 -1
  21. package/dist/component/comments.js +4 -3
  22. package/dist/component/comments.js.map +1 -1
  23. package/dist/component/crons.d.ts +3 -0
  24. package/dist/component/crons.d.ts.map +1 -0
  25. package/dist/component/crons.js +8 -0
  26. package/dist/component/crons.js.map +1 -0
  27. package/dist/component/entries.d.ts +29 -16
  28. package/dist/component/entries.d.ts.map +1 -1
  29. package/dist/component/entries.js +136 -12
  30. package/dist/component/entries.js.map +1 -1
  31. package/dist/component/helpers.d.ts +3 -1
  32. package/dist/component/helpers.d.ts.map +1 -1
  33. package/dist/component/helpers.js +28 -0
  34. package/dist/component/helpers.js.map +1 -1
  35. package/dist/component/migrations.d.ts +11 -0
  36. package/dist/component/migrations.d.ts.map +1 -0
  37. package/dist/component/migrations.js +35 -0
  38. package/dist/component/migrations.js.map +1 -0
  39. package/dist/component/model.d.ts +144 -24
  40. package/dist/component/model.d.ts.map +1 -1
  41. package/dist/component/model.js +34 -1
  42. package/dist/component/model.js.map +1 -1
  43. package/dist/component/roadmap.d.ts +166 -0
  44. package/dist/component/roadmap.d.ts.map +1 -0
  45. package/dist/component/roadmap.js +534 -0
  46. package/dist/component/roadmap.js.map +1 -0
  47. package/dist/component/schema.d.ts +67 -5
  48. package/dist/component/schema.d.ts.map +1 -1
  49. package/dist/component/schema.js +57 -2
  50. package/dist/component/schema.js.map +1 -1
  51. package/dist/component/tags.d.ts +58 -0
  52. package/dist/component/tags.d.ts.map +1 -0
  53. package/dist/component/tags.js +216 -0
  54. package/dist/component/tags.js.map +1 -0
  55. package/dist/react/index.d.ts +173 -12
  56. package/dist/react/index.d.ts.map +1 -1
  57. package/dist/react/index.js +78 -1
  58. package/dist/react/index.js.map +1 -1
  59. package/dist/test.d.ts +67 -5
  60. package/dist/test.d.ts.map +1 -1
  61. package/package.json +6 -2
  62. package/src/client/README.md +1 -1
  63. package/src/client/api.ts +183 -0
  64. package/src/client/config.ts +1 -1
  65. package/src/client/index.ts +340 -13
  66. package/src/component/README.md +3 -1
  67. package/src/component/_generated/api.ts +8 -0
  68. package/src/component/_generated/component.ts +373 -9
  69. package/src/component/admin.ts +180 -0
  70. package/src/component/comments.ts +5 -2
  71. package/src/component/crons.ts +16 -0
  72. package/src/component/entries.ts +175 -11
  73. package/src/component/helpers.ts +44 -1
  74. package/src/component/migrations.ts +44 -0
  75. package/src/component/model.ts +79 -4
  76. package/src/component/roadmap.ts +627 -0
  77. package/src/component/schema.ts +61 -1
  78. package/src/react/README.md +2 -2
  79. package/src/react/index.ts +152 -4
@@ -0,0 +1,627 @@
1
+ import {
2
+ paginationOptsValidator,
3
+ paginationResultValidator,
4
+ } from "convex/server";
5
+ import { ConvexError, v } from "convex/values";
6
+
7
+ import { internal } from "./_generated/api.js";
8
+ import type { Id } from "./_generated/dataModel.js";
9
+ import { internalMutation, mutation, query } from "./_generated/server.js";
10
+ import {
11
+ normalizeRequiredText,
12
+ serializeEntry,
13
+ serializeRoadmapItem,
14
+ } from "./helpers.js";
15
+ import type { MutationCtx, QueryCtx } from "./types.js";
16
+ import {
17
+ actorValidator,
18
+ actorIsAdmin,
19
+ publicEntryValidator,
20
+ roadmapItemValidator,
21
+ roadmapStatusValidator,
22
+ } from "./model.js";
23
+ import type { RoadmapStatus } from "./model.js";
24
+
25
+ const POSITION_STEP = 1_000_000;
26
+ const REBALANCE_GAP_THRESHOLD = 10;
27
+ const rebalanceBatchSize = 100;
28
+ const deletionBatchSize = 100;
29
+
30
+ type RebalanceCtx = MutationCtx | QueryCtx;
31
+
32
+ type RoadmapCreateArgs = {
33
+ title: string;
34
+ description?: string;
35
+ status: "planned" | "in_progress" | "shipped";
36
+ };
37
+
38
+ function assertAdmin(actor: {
39
+ id: string;
40
+ isAdmin?: boolean;
41
+ isModerator?: boolean;
42
+ }): void {
43
+ if (!actorIsAdmin(actor)) throw new ConvexError("Admin access is required.");
44
+ }
45
+
46
+ function optionalDescription(value: string | undefined): string | undefined {
47
+ const description = value?.trim();
48
+ if (description === undefined || description.length === 0) return undefined;
49
+ if (description.length > 2_000) {
50
+ throw new ConvexError("Description must be 2000 characters or fewer.");
51
+ }
52
+ return description;
53
+ }
54
+
55
+ async function getRebalanceState(ctx: RebalanceCtx, status: RoadmapStatus) {
56
+ return await ctx.db
57
+ .query("roadmapRebalances")
58
+ .withIndex("by_status", (q) => q.eq("status", status))
59
+ .first();
60
+ }
61
+
62
+ async function resetRebalance(
63
+ ctx: MutationCtx,
64
+ statuses: RoadmapStatus[],
65
+ ): Promise<void> {
66
+ for (const status of new Set(statuses)) {
67
+ const state = await getRebalanceState(ctx, status);
68
+ if (
69
+ state !== null &&
70
+ (state.activeGeneration !== undefined ||
71
+ state.visibleGeneration !== undefined)
72
+ ) {
73
+ await ctx.db.patch("roadmapRebalances", state._id, {
74
+ activeGeneration: undefined,
75
+ visibleGeneration: undefined,
76
+ });
77
+ }
78
+ }
79
+ }
80
+
81
+ async function assertNoRebalancePromotion(
82
+ ctx: MutationCtx,
83
+ statuses: RoadmapStatus[],
84
+ ): Promise<void> {
85
+ for (const status of new Set(statuses)) {
86
+ const state = await getRebalanceState(ctx, status);
87
+ if (
88
+ state?.activeGeneration !== undefined &&
89
+ state.activeGeneration === state.visibleGeneration
90
+ ) {
91
+ throw new ConvexError("Roadmap is being rebalanced. Please retry.");
92
+ }
93
+ }
94
+ }
95
+
96
+ async function scheduleRebalance(
97
+ ctx: MutationCtx,
98
+ status: RoadmapStatus,
99
+ ): Promise<void> {
100
+ const state = await getRebalanceState(ctx, status);
101
+ const generationNumber = (state?.nextGeneration ?? 0) + 1;
102
+ const rebalanceId = String(generationNumber);
103
+ if (state === null) {
104
+ await ctx.db.insert("roadmapRebalances", {
105
+ status,
106
+ nextGeneration: generationNumber,
107
+ activeGeneration: rebalanceId,
108
+ });
109
+ } else {
110
+ await ctx.db.patch("roadmapRebalances", state._id, {
111
+ nextGeneration: generationNumber,
112
+ activeGeneration: rebalanceId,
113
+ });
114
+ }
115
+ await ctx.scheduler.runAfter(0, internal.roadmap.rebalanceBatch, {
116
+ status,
117
+ rebalanceId,
118
+ phase: "mark",
119
+ paginationOpts: { cursor: null, numItems: rebalanceBatchSize },
120
+ offset: 0,
121
+ });
122
+ }
123
+
124
+ export const list = query({
125
+ args: {
126
+ paginationOpts: paginationOptsValidator,
127
+ status: v.optional(roadmapStatusValidator),
128
+ },
129
+ returns: paginationResultValidator(roadmapItemValidator),
130
+ handler: async (ctx, args) => {
131
+ const status = args.status;
132
+ const state =
133
+ status === undefined ? null : await getRebalanceState(ctx, status);
134
+ const visibleGeneration = state?.visibleGeneration;
135
+ const roadmapQuery =
136
+ status !== undefined && visibleGeneration !== undefined
137
+ ? ctx.db
138
+ .query("roadmap")
139
+ .withIndex("by_status_deleting_at_rebalance_id_position", (q) =>
140
+ q
141
+ .eq("status", status)
142
+ .eq("deletingAt", undefined)
143
+ .eq("rebalanceId", visibleGeneration),
144
+ )
145
+ : status === undefined
146
+ ? ctx.db
147
+ .query("roadmap")
148
+ .withIndex("by_deleting_at_and_position", (q) =>
149
+ q.eq("deletingAt", undefined),
150
+ )
151
+ : ctx.db
152
+ .query("roadmap")
153
+ .withIndex("by_status_deleting_at_position", (q) =>
154
+ q.eq("status", status).eq("deletingAt", undefined),
155
+ );
156
+ const result = await roadmapQuery.paginate(args.paginationOpts);
157
+ return {
158
+ ...result,
159
+ page: result.page.map((item) =>
160
+ serializeRoadmapItem(
161
+ item,
162
+ visibleGeneration === undefined ? undefined : item.rebalancePosition,
163
+ ),
164
+ ),
165
+ };
166
+ },
167
+ });
168
+
169
+ export const search = query({
170
+ args: { searchQuery: v.string(), limit: v.number() },
171
+ returns: v.array(roadmapItemValidator),
172
+ handler: async (ctx, args) => {
173
+ const searchQuery = args.searchQuery.trim();
174
+ if (searchQuery.length === 0 || args.limit <= 0) return [];
175
+ const items = await ctx.db
176
+ .query("roadmap")
177
+ .withSearchIndex("search_title", (q) =>
178
+ q.search("title", searchQuery).eq("deletingAt", undefined),
179
+ )
180
+ .take(Math.min(Math.floor(args.limit), 50));
181
+ return items.map(serializeRoadmapItem);
182
+ },
183
+ });
184
+
185
+ async function createRoadmapRecord(ctx: MutationCtx, args: RoadmapCreateArgs) {
186
+ await assertNoRebalancePromotion(ctx, [args.status]);
187
+ await resetRebalance(ctx, [args.status]);
188
+ const last = await ctx.db
189
+ .query("roadmap")
190
+ .withIndex("by_status_and_position", (q) => q.eq("status", args.status))
191
+ .order("desc")
192
+ .first();
193
+ const description = optionalDescription(args.description);
194
+ const now = Date.now();
195
+ return await ctx.db.insert("roadmap", {
196
+ title: normalizeRequiredText(args.title, "Roadmap title", 160),
197
+ ...(description === undefined ? {} : { description }),
198
+ status: args.status,
199
+ position: (last?.position ?? 0) + POSITION_STEP,
200
+ feedbackCount: 0,
201
+ createdAt: now,
202
+ updatedAt: now,
203
+ });
204
+ }
205
+
206
+ export const create = mutation({
207
+ args: {
208
+ actor: actorValidator,
209
+ title: v.string(),
210
+ description: v.optional(v.string()),
211
+ status: roadmapStatusValidator,
212
+ },
213
+ returns: v.id("roadmap"),
214
+ handler: async (ctx, args) => {
215
+ assertAdmin(args.actor);
216
+ return await createRoadmapRecord(ctx, args);
217
+ },
218
+ });
219
+
220
+ export const update = mutation({
221
+ args: {
222
+ actor: actorValidator,
223
+ roadmapId: v.id("roadmap"),
224
+ title: v.string(),
225
+ description: v.optional(v.string()),
226
+ },
227
+ returns: v.null(),
228
+ handler: async (ctx, args) => {
229
+ assertAdmin(args.actor);
230
+ const item = await ctx.db.get("roadmap", args.roadmapId);
231
+ if (item === null) {
232
+ throw new ConvexError("Roadmap item not found.");
233
+ }
234
+ if (item.deletingAt !== undefined) {
235
+ throw new ConvexError("Roadmap item is being deleted.");
236
+ }
237
+ await ctx.db.patch("roadmap", args.roadmapId, {
238
+ title: normalizeRequiredText(args.title, "Roadmap title", 160),
239
+ description: optionalDescription(args.description),
240
+ updatedAt: Date.now(),
241
+ });
242
+ return null;
243
+ },
244
+ });
245
+
246
+ export const move = mutation({
247
+ args: {
248
+ actor: actorValidator,
249
+ roadmapId: v.id("roadmap"),
250
+ status: roadmapStatusValidator,
251
+ previousItemId: v.optional(v.id("roadmap")),
252
+ nextItemId: v.optional(v.id("roadmap")),
253
+ },
254
+ returns: v.number(),
255
+ handler: async (ctx, args) => {
256
+ assertAdmin(args.actor);
257
+ const [item, previous, next] = await Promise.all([
258
+ ctx.db.get("roadmap", args.roadmapId),
259
+ args.previousItemId === undefined
260
+ ? null
261
+ : ctx.db.get("roadmap", args.previousItemId),
262
+ args.nextItemId === undefined
263
+ ? null
264
+ : ctx.db.get("roadmap", args.nextItemId),
265
+ ]);
266
+ if (item === null) throw new ConvexError("Roadmap item not found.");
267
+ if (
268
+ args.previousItemId === args.roadmapId ||
269
+ args.nextItemId === args.roadmapId
270
+ ) {
271
+ throw new ConvexError("Roadmap item cannot be its own neighbor.");
272
+ }
273
+ if (args.previousItemId !== undefined && previous === null) {
274
+ throw new ConvexError("Previous roadmap item not found.");
275
+ }
276
+ if (args.nextItemId !== undefined && next === null) {
277
+ throw new ConvexError("Next roadmap item not found.");
278
+ }
279
+ if (item.deletingAt !== undefined) {
280
+ throw new ConvexError("Roadmap item is being deleted.");
281
+ }
282
+ if (previous !== null && previous.status !== args.status) {
283
+ throw new ConvexError("Previous item is not in the target stage.");
284
+ }
285
+ if (next !== null && next.status !== args.status) {
286
+ throw new ConvexError("Next item is not in the target stage.");
287
+ }
288
+ if (
289
+ previous !== null &&
290
+ next !== null &&
291
+ previous.position >= next.position
292
+ ) {
293
+ throw new ConvexError("Roadmap neighbors are out of order.");
294
+ }
295
+
296
+ await assertNoRebalancePromotion(ctx, [item.status, args.status]);
297
+ await resetRebalance(ctx, [item.status, args.status]);
298
+
299
+ const previousPosition = previous?.position;
300
+ const nextPosition = next?.position;
301
+
302
+ const shouldScheduleRebalance =
303
+ previous !== null &&
304
+ next !== null &&
305
+ next.position - previous.position <= REBALANCE_GAP_THRESHOLD;
306
+
307
+ let position: number;
308
+ if (previousPosition !== undefined && nextPosition !== undefined) {
309
+ position = previousPosition + (nextPosition - previousPosition) / 2;
310
+ } else if (previousPosition !== undefined) {
311
+ position = previousPosition + POSITION_STEP;
312
+ } else if (nextPosition !== undefined) {
313
+ position = nextPosition - POSITION_STEP;
314
+ } else {
315
+ const last = await ctx.db
316
+ .query("roadmap")
317
+ .withIndex("by_status_and_position", (q) => q.eq("status", args.status))
318
+ .order("desc")
319
+ .first();
320
+ position = (last?.position ?? 0) + POSITION_STEP;
321
+ }
322
+ await ctx.db.patch("roadmap", args.roadmapId, {
323
+ status: args.status,
324
+ position,
325
+ rebalanceId: undefined,
326
+ rebalanceRank: undefined,
327
+ rebalancePosition: undefined,
328
+ updatedAt: Date.now(),
329
+ });
330
+ if (shouldScheduleRebalance) {
331
+ await scheduleRebalance(ctx, args.status);
332
+ }
333
+ return position;
334
+ },
335
+ });
336
+
337
+ export const rebalanceBatch = internalMutation({
338
+ args: {
339
+ status: roadmapStatusValidator,
340
+ rebalanceId: v.string(),
341
+ phase: v.union(
342
+ v.literal("mark"),
343
+ v.literal("rewrite"),
344
+ v.literal("promote"),
345
+ ),
346
+ paginationOpts: paginationOptsValidator,
347
+ offset: v.number(),
348
+ },
349
+ returns: v.null(),
350
+ handler: async (ctx, args) => {
351
+ const state = await getRebalanceState(ctx, args.status);
352
+ if (state?.activeGeneration !== args.rebalanceId) return null;
353
+
354
+ if (args.phase === "mark") {
355
+ const result = await ctx.db
356
+ .query("roadmap")
357
+ .withIndex("by_status_deleting_at_position", (q) =>
358
+ q.eq("status", args.status).eq("deletingAt", undefined),
359
+ )
360
+ .order("asc")
361
+ .paginate(args.paginationOpts);
362
+
363
+ for (const [index, item] of result.page.entries()) {
364
+ await ctx.db.patch("roadmap", item._id, {
365
+ rebalanceId: args.rebalanceId,
366
+ rebalanceRank: args.offset + index,
367
+ rebalancePosition: undefined,
368
+ });
369
+ }
370
+
371
+ if (result.isDone) {
372
+ await ctx.scheduler.runAfter(0, internal.roadmap.rebalanceBatch, {
373
+ status: args.status,
374
+ rebalanceId: args.rebalanceId,
375
+ phase: "rewrite",
376
+ paginationOpts: { cursor: null, numItems: rebalanceBatchSize },
377
+ offset: 0,
378
+ });
379
+ } else {
380
+ await ctx.scheduler.runAfter(0, internal.roadmap.rebalanceBatch, {
381
+ status: args.status,
382
+ rebalanceId: args.rebalanceId,
383
+ phase: "mark",
384
+ paginationOpts: {
385
+ cursor: result.continueCursor,
386
+ numItems: rebalanceBatchSize,
387
+ },
388
+ offset: args.offset + result.page.length,
389
+ });
390
+ }
391
+ return null;
392
+ }
393
+
394
+ const result = await ctx.db
395
+ .query("roadmap")
396
+ .withIndex("by_status_rebalance_rank", (q) =>
397
+ q.eq("status", args.status).eq("rebalanceId", args.rebalanceId),
398
+ )
399
+ .order("asc")
400
+ .paginate(args.paginationOpts);
401
+
402
+ for (const [index, item] of result.page.entries()) {
403
+ const position = (args.offset + index + 1) * POSITION_STEP;
404
+ if (args.phase === "rewrite") {
405
+ await ctx.db.patch("roadmap", item._id, {
406
+ rebalancePosition: position,
407
+ });
408
+ } else {
409
+ if (item.rebalancePosition === undefined) {
410
+ throw new ConvexError("Roadmap rebalance is incomplete.");
411
+ }
412
+ await ctx.db.patch("roadmap", item._id, {
413
+ position: item.rebalancePosition,
414
+ });
415
+ }
416
+ }
417
+
418
+ if (!result.isDone) {
419
+ await ctx.scheduler.runAfter(0, internal.roadmap.rebalanceBatch, {
420
+ status: args.status,
421
+ rebalanceId: args.rebalanceId,
422
+ phase: args.phase,
423
+ paginationOpts: {
424
+ cursor: result.continueCursor,
425
+ numItems: rebalanceBatchSize,
426
+ },
427
+ offset: args.offset + result.page.length,
428
+ });
429
+ } else if (args.phase === "rewrite") {
430
+ await ctx.db.patch("roadmapRebalances", state._id, {
431
+ visibleGeneration: args.rebalanceId,
432
+ });
433
+ await ctx.scheduler.runAfter(0, internal.roadmap.rebalanceBatch, {
434
+ status: args.status,
435
+ rebalanceId: args.rebalanceId,
436
+ phase: "promote",
437
+ paginationOpts: { cursor: null, numItems: rebalanceBatchSize },
438
+ offset: 0,
439
+ });
440
+ } else {
441
+ await ctx.db.patch("roadmapRebalances", state._id, {
442
+ activeGeneration: undefined,
443
+ visibleGeneration: undefined,
444
+ });
445
+ }
446
+ return null;
447
+ },
448
+ });
449
+
450
+ export const remove = mutation({
451
+ args: { actor: actorValidator, roadmapId: v.id("roadmap") },
452
+ returns: v.null(),
453
+ handler: async (ctx, args) => {
454
+ assertAdmin(args.actor);
455
+ const item = await ctx.db.get("roadmap", args.roadmapId);
456
+ if (item === null || item.deletingAt !== undefined) return null;
457
+
458
+ await assertNoRebalancePromotion(ctx, [item.status]);
459
+ await resetRebalance(ctx, [item.status]);
460
+ await ctx.db.patch("roadmap", args.roadmapId, { deletingAt: Date.now() });
461
+ await ctx.scheduler.runAfter(0, internal.roadmap.removeBatch, {
462
+ roadmapId: args.roadmapId,
463
+ });
464
+ return null;
465
+ },
466
+ });
467
+
468
+ export const removeBatch = internalMutation({
469
+ args: { roadmapId: v.id("roadmap") },
470
+ returns: v.object({ processed: v.number(), hasMore: v.boolean() }),
471
+ handler: async (ctx, args) => {
472
+ const item = await ctx.db.get("roadmap", args.roadmapId);
473
+ if (item === null || item.deletingAt === undefined) {
474
+ return { processed: 0, hasMore: false };
475
+ }
476
+
477
+ const entries = await ctx.db
478
+ .query("entries")
479
+ .withIndex("by_roadmap_id", (q) => q.eq("roadmapId", args.roadmapId))
480
+ .take(deletionBatchSize);
481
+ const now = Date.now();
482
+ for (const entry of entries) {
483
+ await ctx.db.patch("entries", entry._id, {
484
+ roadmapId: undefined,
485
+ updatedAt: now,
486
+ });
487
+ }
488
+
489
+ const hasMore = entries.length === deletionBatchSize;
490
+ if (hasMore) {
491
+ await ctx.scheduler.runAfter(0, internal.roadmap.removeBatch, {
492
+ roadmapId: args.roadmapId,
493
+ });
494
+ } else {
495
+ await ctx.scheduler.runAfter(0, internal.roadmap.finalizeRemoval, {
496
+ roadmapId: args.roadmapId,
497
+ });
498
+ }
499
+
500
+ return { processed: entries.length, hasMore };
501
+ },
502
+ });
503
+
504
+ export const finalizeRemoval = internalMutation({
505
+ args: { roadmapId: v.id("roadmap") },
506
+ returns: v.null(),
507
+ handler: async (ctx, args) => {
508
+ const item = await ctx.db.get("roadmap", args.roadmapId);
509
+ if (item === null || item.deletingAt === undefined) return null;
510
+ await ctx.db.delete("roadmap", args.roadmapId);
511
+ return null;
512
+ },
513
+ });
514
+
515
+ async function attachFeedbackRecord(
516
+ ctx: MutationCtx,
517
+ roadmapId: Id<"roadmap">,
518
+ entryId: Id<"entries">,
519
+ ): Promise<void> {
520
+ const [item, entry] = await Promise.all([
521
+ ctx.db.get("roadmap", roadmapId),
522
+ ctx.db.get("entries", entryId),
523
+ ]);
524
+ if (item === null) throw new ConvexError("Roadmap item not found.");
525
+ if (item.deletingAt !== undefined) {
526
+ throw new ConvexError("Roadmap item is being deleted.");
527
+ }
528
+ if (entry === null) throw new ConvexError("Entry not found.");
529
+ if (entry.roadmapId === roadmapId) return;
530
+
531
+ const now = Date.now();
532
+ if (entry.roadmapId !== undefined) {
533
+ const previous = await ctx.db.get("roadmap", entry.roadmapId);
534
+ if (previous !== null) {
535
+ await ctx.db.patch("roadmap", previous._id, {
536
+ feedbackCount: Math.max(0, previous.feedbackCount - 1),
537
+ updatedAt: now,
538
+ });
539
+ }
540
+ }
541
+ await ctx.db.patch("entries", entryId, {
542
+ roadmapId,
543
+ updatedAt: now,
544
+ });
545
+ await ctx.db.patch("roadmap", roadmapId, {
546
+ feedbackCount: item.feedbackCount + 1,
547
+ updatedAt: now,
548
+ });
549
+ }
550
+
551
+ export const attachFeedback = mutation({
552
+ args: {
553
+ actor: actorValidator,
554
+ roadmapId: v.id("roadmap"),
555
+ entryId: v.id("entries"),
556
+ },
557
+ returns: v.null(),
558
+ handler: async (ctx, args) => {
559
+ assertAdmin(args.actor);
560
+ await attachFeedbackRecord(ctx, args.roadmapId, args.entryId);
561
+ return null;
562
+ },
563
+ });
564
+
565
+ export const createForEntry = mutation({
566
+ args: {
567
+ actor: actorValidator,
568
+ title: v.string(),
569
+ description: v.optional(v.string()),
570
+ status: roadmapStatusValidator,
571
+ entryId: v.id("entries"),
572
+ },
573
+ returns: v.id("roadmap"),
574
+ handler: async (ctx, args) => {
575
+ assertAdmin(args.actor);
576
+ const roadmapId = await createRoadmapRecord(ctx, args);
577
+ await attachFeedbackRecord(ctx, roadmapId, args.entryId);
578
+ return roadmapId;
579
+ },
580
+ });
581
+
582
+ export const detachFeedback = mutation({
583
+ args: { actor: actorValidator, entryId: v.id("entries") },
584
+ returns: v.null(),
585
+ handler: async (ctx, args) => {
586
+ assertAdmin(args.actor);
587
+ const entry = await ctx.db.get("entries", args.entryId);
588
+ if (entry === null) throw new ConvexError("Entry not found.");
589
+ if (entry.roadmapId === undefined) return null;
590
+ const item = await ctx.db.get("roadmap", entry.roadmapId);
591
+ await ctx.db.patch("entries", args.entryId, {
592
+ roadmapId: undefined,
593
+ updatedAt: Date.now(),
594
+ });
595
+ if (item !== null) {
596
+ await ctx.db.patch("roadmap", item._id, {
597
+ feedbackCount: Math.max(0, item.feedbackCount - 1),
598
+ updatedAt: Date.now(),
599
+ });
600
+ }
601
+ return null;
602
+ },
603
+ });
604
+
605
+ export const listFeedback = query({
606
+ args: {
607
+ paginationOpts: paginationOptsValidator,
608
+ roadmapId: v.id("roadmap"),
609
+ viewerActorId: v.optional(v.string()),
610
+ },
611
+ returns: paginationResultValidator(publicEntryValidator),
612
+ handler: async (ctx, args) => {
613
+ const result = await ctx.db
614
+ .query("entries")
615
+ .withIndex("by_roadmap_id", (q) => q.eq("roadmapId", args.roadmapId))
616
+ .order("desc")
617
+ .paginate(args.paginationOpts);
618
+ return {
619
+ ...result,
620
+ page: await Promise.all(
621
+ result.page.map((entry) =>
622
+ serializeEntry(ctx, entry, args.viewerActorId),
623
+ ),
624
+ ),
625
+ };
626
+ },
627
+ });