convex-feedback 0.2.0-beta.8 → 0.2.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 (81) hide show
  1. package/README.md +398 -7
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/client/api.d.ts +49 -9
  4. package/dist/client/api.d.ts.map +1 -1
  5. package/dist/client/config.d.ts +1 -3
  6. package/dist/client/config.d.ts.map +1 -1
  7. package/dist/client/config.js.map +1 -1
  8. package/dist/client/index.d.ts +707 -15
  9. package/dist/client/index.d.ts.map +1 -1
  10. package/dist/client/index.js +243 -12
  11. package/dist/client/index.js.map +1 -1
  12. package/dist/component/_generated/api.d.ts +2 -0
  13. package/dist/component/_generated/api.d.ts.map +1 -1
  14. package/dist/component/_generated/api.js.map +1 -1
  15. package/dist/component/_generated/component.d.ts +222 -4
  16. package/dist/component/_generated/component.d.ts.map +1 -1
  17. package/dist/component/admin.d.ts +5 -90
  18. package/dist/component/admin.d.ts.map +1 -1
  19. package/dist/component/admin.js +59 -29
  20. package/dist/component/admin.js.map +1 -1
  21. package/dist/component/comments.d.ts +107 -15
  22. package/dist/component/comments.d.ts.map +1 -1
  23. package/dist/component/comments.js +342 -28
  24. package/dist/component/comments.js.map +1 -1
  25. package/dist/component/crons.d.ts.map +1 -1
  26. package/dist/component/crons.js +4 -0
  27. package/dist/component/crons.js.map +1 -1
  28. package/dist/component/entries.d.ts +119 -91
  29. package/dist/component/entries.d.ts.map +1 -1
  30. package/dist/component/entries.js +348 -29
  31. package/dist/component/entries.js.map +1 -1
  32. package/dist/component/helpers.d.ts +32 -1
  33. package/dist/component/helpers.d.ts.map +1 -1
  34. package/dist/component/helpers.js +121 -4
  35. package/dist/component/helpers.js.map +1 -1
  36. package/dist/component/migrations.d.ts +24 -0
  37. package/dist/component/migrations.d.ts.map +1 -1
  38. package/dist/component/migrations.js +318 -0
  39. package/dist/component/migrations.js.map +1 -1
  40. package/dist/component/model.d.ts +455 -120
  41. package/dist/component/model.d.ts.map +1 -1
  42. package/dist/component/model.js +70 -2
  43. package/dist/component/model.js.map +1 -1
  44. package/dist/component/reactions.d.ts +37 -0
  45. package/dist/component/reactions.d.ts.map +1 -0
  46. package/dist/component/reactions.js +52 -0
  47. package/dist/component/reactions.js.map +1 -0
  48. package/dist/component/roadmap.d.ts +10 -46
  49. package/dist/component/roadmap.d.ts.map +1 -1
  50. package/dist/component/roadmap.js +239 -21
  51. package/dist/component/roadmap.js.map +1 -1
  52. package/dist/component/schema.d.ts +33 -5
  53. package/dist/component/schema.d.ts.map +1 -1
  54. package/dist/component/schema.js +40 -4
  55. package/dist/component/schema.js.map +1 -1
  56. package/dist/react/index.d.ts +26 -238
  57. package/dist/react/index.d.ts.map +1 -1
  58. package/dist/react/index.js +65 -2
  59. package/dist/react/index.js.map +1 -1
  60. package/dist/test.d.ts +23 -5
  61. package/dist/test.d.ts.map +1 -1
  62. package/package.json +1 -1
  63. package/src/client/README.md +6 -1
  64. package/src/client/api.ts +89 -7
  65. package/src/client/config.ts +1 -3
  66. package/src/client/index.ts +1237 -34
  67. package/src/component/README.md +32 -4
  68. package/src/component/_generated/api.ts +2 -0
  69. package/src/component/_generated/component.ts +290 -8
  70. package/src/component/admin.ts +88 -30
  71. package/src/component/comments.ts +405 -26
  72. package/src/component/crons.ts +10 -0
  73. package/src/component/entries.ts +388 -27
  74. package/src/component/helpers.ts +163 -4
  75. package/src/component/migrations.ts +415 -0
  76. package/src/component/model.ts +381 -117
  77. package/src/component/reactions.ts +60 -0
  78. package/src/component/roadmap.ts +357 -35
  79. package/src/component/schema.ts +40 -4
  80. package/src/react/README.md +5 -0
  81. package/src/react/index.ts +90 -3
@@ -2,15 +2,30 @@ import { paginator } from "convex-helpers/server/pagination";
2
2
  import { paginationOptsValidator, paginationResultValidator, } from "convex/server";
3
3
  import { ConvexError, v } from "convex/values";
4
4
  import { mergedStream, stream } from "convex-helpers/server/stream";
5
- import { mutation, query } from "./_generated/server.js";
6
- import { assertActorId, normalizeRequiredText, normalizeTitle, serializeEntry, validateFeedbackMetadata, } from "./helpers.js";
7
- import { actorValidator, actorIsAdmin, entryKindValidator, entryPriorityValidator, entrySortValidator, entryStatusFilterForStatus, entryStatusFilterValidator, entryStatusValidator, feedbackMetadataValidator, publicEntryValidator, similarEntriesValidator, } from "./model.js";
5
+ import { internal } from "./_generated/api.js";
6
+ import { internalMutation, mutation, query } from "./_generated/server.js";
7
+ import { assertActorId, normalizeRequiredText, normalizeTitle, serializeActivityEntry, serializeEntry, validateFeedbackMetadata, } from "./helpers.js";
8
+ import { activityEntryWithContextValidator, actorValidator, actorIsAdmin, entryKindValidator, entryPriorityValidator, entrySortValidator, entryStatusFilterForStatus, entryStatusFilterValidator, entryStatusValidator, feedbackMetadataValidator, publicEntryValidator, similarEntriesValidator, } from "./model.js";
8
9
  import schema from "./schema.js";
9
10
  const allEntryKinds = [
10
11
  "feedback",
11
12
  "feature_request",
12
13
  "bug_report",
13
14
  ];
15
+ /**
16
+ * Cleanup deliberately uses small bounded batches. A comment may have more
17
+ * reactions than this limit, so its reactions are drained over multiple
18
+ * scheduled transactions before the comment itself is removed.
19
+ */
20
+ const deletionBatchSize = 100;
21
+ const commentDeletionBatchSize = 25;
22
+ const reactionDeletionBatchSize = 100;
23
+ function entryIsLive(entry) {
24
+ return Promise.resolve(entry.deletingAt === undefined);
25
+ }
26
+ async function scheduleEntryCleanup(ctx, entryId) {
27
+ await ctx.scheduler.runAfter(0, internal.entries.removeBatch, { entryId });
28
+ }
14
29
  function normalizeKindFilter(kinds) {
15
30
  if (kinds === undefined) {
16
31
  return undefined;
@@ -52,11 +67,13 @@ export const list = query({
52
67
  .query("entries")
53
68
  .withIndex("by_status_filter_upvotes", (q) => q.eq("statusFilter", statusFilter))
54
69
  .order("desc")
70
+ .filterWith(entryIsLive)
55
71
  .paginate(args.paginationOpts)
56
72
  : await db
57
73
  .query("entries")
58
74
  .withIndex("by_status_filter", (q) => q.eq("statusFilter", statusFilter))
59
75
  .order("desc")
76
+ .filterWith(entryIsLive)
60
77
  .paginate(args.paginationOpts);
61
78
  }
62
79
  else if (kinds.length === 1) {
@@ -70,11 +87,13 @@ export const list = query({
70
87
  .query("entries")
71
88
  .withIndex("by_kind_status_filter_upvotes", (q) => q.eq("kind", kind).eq("statusFilter", statusFilter))
72
89
  .order("desc")
90
+ .filterWith(entryIsLive)
73
91
  .paginate(args.paginationOpts)
74
92
  : await db
75
93
  .query("entries")
76
94
  .withIndex("by_kind_status_filter", (q) => q.eq("kind", kind).eq("statusFilter", statusFilter))
77
95
  .order("desc")
96
+ .filterWith(entryIsLive)
78
97
  .paginate(args.paginationOpts);
79
98
  }
80
99
  else {
@@ -83,10 +102,12 @@ export const list = query({
83
102
  .query("entries")
84
103
  .withIndex("by_kind_status_filter_upvotes", (q) => q.eq("kind", kind).eq("statusFilter", statusFilter))
85
104
  .order("desc")
105
+ .filterWith(entryIsLive)
86
106
  : stream(ctx.db, schema)
87
107
  .query("entries")
88
108
  .withIndex("by_kind_status_filter", (q) => q.eq("kind", kind).eq("statusFilter", statusFilter))
89
- .order("desc"));
109
+ .order("desc")
110
+ .filterWith(entryIsLive));
90
111
  result = await mergedStream(streams, args.sort === "top"
91
112
  ? ["upvoteCount", "_creationTime"]
92
113
  : ["_creationTime"]).paginate(args.paginationOpts);
@@ -100,21 +121,25 @@ export const list = query({
100
121
  .query("entries")
101
122
  .withIndex("by_upvotes")
102
123
  .order("desc")
124
+ .filterWith(entryIsLive)
103
125
  .paginate(args.paginationOpts)
104
126
  : await db
105
127
  .query("entries")
106
128
  .withIndex("by_status_upvotes", (q) => q.eq("status", status))
107
129
  .order("desc")
130
+ .filterWith(entryIsLive)
108
131
  .paginate(args.paginationOpts)
109
132
  : status === undefined
110
133
  ? await db
111
134
  .query("entries")
112
135
  .order("desc")
136
+ .filterWith(entryIsLive)
113
137
  .paginate(args.paginationOpts)
114
138
  : await db
115
139
  .query("entries")
116
140
  .withIndex("by_status", (q) => q.eq("status", status))
117
141
  .order("desc")
142
+ .filterWith(entryIsLive)
118
143
  .paginate(args.paginationOpts);
119
144
  }
120
145
  else if (kinds.length === 1) {
@@ -129,22 +154,26 @@ export const list = query({
129
154
  .query("entries")
130
155
  .withIndex("by_kind_upvotes", (q) => q.eq("kind", kind))
131
156
  .order("desc")
157
+ .filterWith(entryIsLive)
132
158
  .paginate(args.paginationOpts)
133
159
  : await db
134
160
  .query("entries")
135
161
  .withIndex("by_kind_status_upvotes", (q) => q.eq("kind", kind).eq("status", status))
136
162
  .order("desc")
163
+ .filterWith(entryIsLive)
137
164
  .paginate(args.paginationOpts)
138
165
  : status === undefined
139
166
  ? await db
140
167
  .query("entries")
141
168
  .withIndex("by_kind", (q) => q.eq("kind", kind))
142
169
  .order("desc")
170
+ .filterWith(entryIsLive)
143
171
  .paginate(args.paginationOpts)
144
172
  : await db
145
173
  .query("entries")
146
174
  .withIndex("by_kind_status", (q) => q.eq("kind", kind).eq("status", status))
147
175
  .order("desc")
176
+ .filterWith(entryIsLive)
148
177
  .paginate(args.paginationOpts);
149
178
  }
150
179
  else if (args.sort === "top") {
@@ -152,7 +181,8 @@ export const list = query({
152
181
  const streams = kinds.map((kind) => stream(ctx.db, schema)
153
182
  .query("entries")
154
183
  .withIndex("by_kind_upvotes", (q) => q.eq("kind", kind))
155
- .order("desc"));
184
+ .order("desc")
185
+ .filterWith(entryIsLive));
156
186
  result = await mergedStream(streams, [
157
187
  "upvoteCount",
158
188
  "_creationTime",
@@ -162,7 +192,8 @@ export const list = query({
162
192
  const streams = kinds.map((kind) => stream(ctx.db, schema)
163
193
  .query("entries")
164
194
  .withIndex("by_kind_status_upvotes", (q) => q.eq("kind", kind).eq("status", status))
165
- .order("desc"));
195
+ .order("desc")
196
+ .filterWith(entryIsLive));
166
197
  result = await mergedStream(streams, [
167
198
  "upvoteCount",
168
199
  "_creationTime",
@@ -173,14 +204,16 @@ export const list = query({
173
204
  const streams = kinds.map((kind) => stream(ctx.db, schema)
174
205
  .query("entries")
175
206
  .withIndex("by_kind", (q) => q.eq("kind", kind))
176
- .order("desc"));
207
+ .order("desc")
208
+ .filterWith(entryIsLive));
177
209
  result = await mergedStream(streams, ["_creationTime"]).paginate(args.paginationOpts);
178
210
  }
179
211
  else {
180
212
  const streams = kinds.map((kind) => stream(ctx.db, schema)
181
213
  .query("entries")
182
214
  .withIndex("by_kind_status", (q) => q.eq("kind", kind).eq("status", status))
183
- .order("desc"));
215
+ .order("desc")
216
+ .filterWith(entryIsLive));
184
217
  result = await mergedStream(streams, ["_creationTime"]).paginate(args.paginationOpts);
185
218
  }
186
219
  return {
@@ -189,6 +222,36 @@ export const list = query({
189
222
  };
190
223
  },
191
224
  });
225
+ /**
226
+ * List entries created by a known actor. This component-level query remains
227
+ * actor-parameterized so trusted host/server consumers can use it without a
228
+ * request authentication context.
229
+ *
230
+ * `includeAdminContext` is intentionally unavailable on the host-facing
231
+ * `listUserEntries` wrapper. Trusted server consumers such as exports may opt
232
+ * into retained metadata and triage context when needed.
233
+ */
234
+ export const listByActor = query({
235
+ args: {
236
+ paginationOpts: paginationOptsValidator,
237
+ actorId: v.string(),
238
+ includeAdminContext: v.optional(v.boolean()),
239
+ },
240
+ returns: paginationResultValidator(activityEntryWithContextValidator),
241
+ handler: async (ctx, args) => {
242
+ assertActorId(args.actorId);
243
+ const result = await stream(ctx.db, schema)
244
+ .query("entries")
245
+ .withIndex("by_actor", (q) => q.eq("actorId", args.actorId))
246
+ .order("desc")
247
+ .filterWith(entryIsLive)
248
+ .paginate(args.paginationOpts);
249
+ return {
250
+ ...result,
251
+ page: await Promise.all(result.page.map((entry) => serializeActivityEntry(ctx, entry, args.includeAdminContext === true))),
252
+ };
253
+ },
254
+ });
192
255
  export const get = query({
193
256
  args: {
194
257
  entryId: v.id("entries"),
@@ -198,7 +261,7 @@ export const get = query({
198
261
  returns: v.union(publicEntryValidator, v.null()),
199
262
  handler: async (ctx, args) => {
200
263
  const entry = await ctx.db.get("entries", args.entryId);
201
- return entry === null
264
+ return entry === null || entry.deletingAt !== undefined
202
265
  ? null
203
266
  : serializeEntry(ctx, entry, args.viewerActorId, args.viewerIsAdmin === true);
204
267
  },
@@ -230,7 +293,8 @@ export const search = query({
230
293
  .query("entries")
231
294
  .withSearchIndex("search", (q) => q
232
295
  .search("searchText", searchQuery)
233
- .eq("statusFilter", statusFilter))
296
+ .eq("statusFilter", statusFilter)
297
+ .eq("deletingAt", undefined))
234
298
  .take(args.limit);
235
299
  }
236
300
  else if (kinds.length === 1) {
@@ -243,7 +307,8 @@ export const search = query({
243
307
  .withSearchIndex("search", (q) => q
244
308
  .search("searchText", searchQuery)
245
309
  .eq("kind", kind)
246
- .eq("statusFilter", statusFilter))
310
+ .eq("statusFilter", statusFilter)
311
+ .eq("deletingAt", undefined))
247
312
  .take(args.limit);
248
313
  }
249
314
  else {
@@ -256,7 +321,8 @@ export const search = query({
256
321
  .query("entries")
257
322
  .withSearchIndex("search", (q) => q
258
323
  .search("searchText", searchQuery)
259
- .eq("statusFilter", statusFilter))
324
+ .eq("statusFilter", statusFilter)
325
+ .eq("deletingAt", undefined))
260
326
  // eslint-disable-next-line @convex-dev/no-filter-in-query
261
327
  .filter((q) => q.or(q.eq(q.field("kind"), firstKind), q.eq(q.field("kind"), secondKind)))
262
328
  .take(args.limit);
@@ -267,11 +333,14 @@ export const search = query({
267
333
  status === undefined
268
334
  ? await ctx.db
269
335
  .query("entries")
270
- .withSearchIndex("search", (q) => q.search("searchText", searchQuery))
336
+ .withSearchIndex("search", (q) => q.search("searchText", searchQuery).eq("deletingAt", undefined))
271
337
  .take(args.limit)
272
338
  : await ctx.db
273
339
  .query("entries")
274
- .withSearchIndex("search", (q) => q.search("searchText", searchQuery).eq("status", status))
340
+ .withSearchIndex("search", (q) => q
341
+ .search("searchText", searchQuery)
342
+ .eq("status", status)
343
+ .eq("deletingAt", undefined))
275
344
  .take(args.limit);
276
345
  }
277
346
  else if (kinds.length === 1) {
@@ -283,14 +352,18 @@ export const search = query({
283
352
  status === undefined
284
353
  ? await ctx.db
285
354
  .query("entries")
286
- .withSearchIndex("search", (q) => q.search("searchText", searchQuery).eq("kind", kind))
355
+ .withSearchIndex("search", (q) => q
356
+ .search("searchText", searchQuery)
357
+ .eq("kind", kind)
358
+ .eq("deletingAt", undefined))
287
359
  .take(args.limit)
288
360
  : await ctx.db
289
361
  .query("entries")
290
362
  .withSearchIndex("search", (q) => q
291
363
  .search("searchText", searchQuery)
292
364
  .eq("kind", kind)
293
- .eq("status", status))
365
+ .eq("status", status)
366
+ .eq("deletingAt", undefined))
294
367
  .take(args.limit);
295
368
  }
296
369
  else {
@@ -302,16 +375,21 @@ export const search = query({
302
375
  const searchResults = status === undefined
303
376
  ? ctx.db
304
377
  .query("entries")
305
- .withSearchIndex("search", (q) => q.search("searchText", searchQuery))
378
+ .withSearchIndex("search", (q) => q.search("searchText", searchQuery).eq("deletingAt", undefined))
306
379
  : ctx.db
307
380
  .query("entries")
308
- .withSearchIndex("search", (q) => q.search("searchText", searchQuery).eq("status", status));
381
+ .withSearchIndex("search", (q) => q
382
+ .search("searchText", searchQuery)
383
+ .eq("status", status)
384
+ .eq("deletingAt", undefined));
309
385
  entries = await searchResults
310
386
  // eslint-disable-next-line @convex-dev/no-filter-in-query
311
387
  .filter((q) => q.or(q.eq(q.field("kind"), firstKind), q.eq(q.field("kind"), secondKind)))
312
388
  .take(args.limit);
313
389
  }
314
- return await Promise.all(entries.map((entry) => serializeEntry(ctx, entry, args.viewerActorId)));
390
+ return await Promise.all(entries
391
+ .filter((entry) => entry.deletingAt === undefined)
392
+ .map((entry) => serializeEntry(ctx, entry, args.viewerActorId)));
315
393
  },
316
394
  });
317
395
  export const similar = query({
@@ -347,10 +425,16 @@ export const similar = query({
347
425
  ? await ctx.db
348
426
  .query("entries")
349
427
  .withIndex("by_normalized_title", (q) => q.eq("normalizedTitle", normalizedTitle))
428
+ // The deletion flag is not part of this exact-match index.
429
+ // Filter before take so pending entries cannot consume a slot.
430
+ // eslint-disable-next-line @convex-dev/no-filter-in-query
431
+ .filter((q) => q.eq(q.field("deletingAt"), undefined))
350
432
  .take(args.limit)
351
433
  : await ctx.db
352
434
  .query("entries")
353
435
  .withIndex("by_kind_normalized_title", (q) => q.eq("kind", kind).eq("normalizedTitle", normalizedTitle))
436
+ // eslint-disable-next-line @convex-dev/no-filter-in-query
437
+ .filter((q) => q.eq(q.field("deletingAt"), undefined))
354
438
  .take(args.limit);
355
439
  const exact = await Promise.all(exactDocs.map((entry) => serializeEntry(ctx, entry, args.viewerActorId)));
356
440
  const remainingLimit = args.limit - exactDocs.length;
@@ -374,11 +458,14 @@ export const similar = query({
374
458
  const similarDocs = kind === undefined
375
459
  ? await ctx.db
376
460
  .query("entries")
377
- .withSearchIndex("search", (q) => q.search("searchText", searchText))
461
+ .withSearchIndex("search", (q) => q.search("searchText", searchText).eq("deletingAt", undefined))
378
462
  .take(candidateLimit)
379
463
  : await ctx.db
380
464
  .query("entries")
381
- .withSearchIndex("search", (q) => q.search("searchText", searchText).eq("kind", kind))
465
+ .withSearchIndex("search", (q) => q
466
+ .search("searchText", searchText)
467
+ .eq("kind", kind)
468
+ .eq("deletingAt", undefined))
382
469
  .take(candidateLimit);
383
470
  const similarDocsWithoutExactMatches = similarDocs
384
471
  .filter((entry) => !exactIds.has(entry._id))
@@ -400,8 +487,22 @@ export const create = mutation({
400
487
  maxTitleLength: v.number(),
401
488
  maxBodyLength: v.number(),
402
489
  metadata: v.optional(feedbackMetadataValidator),
490
+ includeCallbackContext: v.optional(v.boolean()),
403
491
  },
404
- returns: v.id("entries"),
492
+ returns: v.union(v.object({ id: v.id("entries") }), v.object({
493
+ id: v.id("entries"),
494
+ entry: v.object({
495
+ id: v.id("entries"),
496
+ actorId: v.string(),
497
+ kind: entryKindValidator,
498
+ status: entryStatusValidator,
499
+ title: v.string(),
500
+ body: v.string(),
501
+ metadata: v.optional(feedbackMetadataValidator),
502
+ upvoteCount: v.number(),
503
+ commentCount: v.number(),
504
+ }),
505
+ })),
405
506
  handler: async (ctx, args) => {
406
507
  assertActorId(args.actorId);
407
508
  if (!args.enabledKinds.includes(args.kind)) {
@@ -427,7 +528,22 @@ export const create = mutation({
427
528
  actorId: args.actorId,
428
529
  entryId: entry,
429
530
  });
430
- return entry;
531
+ if (!args.includeCallbackContext)
532
+ return { id: entry };
533
+ return {
534
+ id: entry,
535
+ entry: {
536
+ id: entry,
537
+ actorId: args.actorId,
538
+ kind: args.kind,
539
+ status: args.defaultStatus,
540
+ title,
541
+ body,
542
+ ...(args.metadata === undefined ? {} : { metadata: args.metadata }),
543
+ upvoteCount: 1,
544
+ commentCount: 0,
545
+ },
546
+ };
431
547
  },
432
548
  });
433
549
  export const update = mutation({
@@ -447,6 +563,9 @@ export const update = mutation({
447
563
  const entry = await ctx.db.get("entries", args.entryId);
448
564
  if (entry === null)
449
565
  throw new ConvexError("Entry not found.");
566
+ if (entry.deletingAt !== undefined) {
567
+ throw new ConvexError("Entry is being deleted.");
568
+ }
450
569
  const canEdit = actorIsAdmin(args.actor) ||
451
570
  (args.editableByAuthor && entry.actorId === args.actor.id);
452
571
  if (!canEdit)
@@ -470,6 +589,131 @@ export const update = mutation({
470
589
  return null;
471
590
  },
472
591
  });
592
+ export const remove = mutation({
593
+ args: {
594
+ actor: actorValidator,
595
+ entryId: v.id("entries"),
596
+ },
597
+ returns: v.null(),
598
+ handler: async (ctx, args) => {
599
+ assertActorId(args.actor.id);
600
+ if (!actorIsAdmin(args.actor)) {
601
+ throw new ConvexError("Admin access is required to delete an entry.");
602
+ }
603
+ const entry = await ctx.db.get("entries", args.entryId);
604
+ if (entry === null)
605
+ return null;
606
+ // Mark and detach in one transaction so every committed read can
607
+ // hide/reject the entry before scheduled cleanup removes dependents.
608
+ // Repeated requests are safe: they only enqueue another idempotent batch.
609
+ if (entry.deletingAt !== undefined) {
610
+ await scheduleEntryCleanup(ctx, args.entryId);
611
+ return null;
612
+ }
613
+ const now = Date.now();
614
+ if (entry.roadmapId !== undefined) {
615
+ const roadmap = await ctx.db.get("roadmap", entry.roadmapId);
616
+ if (roadmap !== null) {
617
+ await ctx.db.patch("roadmap", roadmap._id, {
618
+ feedbackCount: Math.max(0, roadmap.feedbackCount - 1),
619
+ updatedAt: now,
620
+ });
621
+ }
622
+ }
623
+ await ctx.db.patch("entries", args.entryId, {
624
+ roadmapId: undefined,
625
+ deletingAt: now,
626
+ updatedAt: now,
627
+ });
628
+ await scheduleEntryCleanup(ctx, args.entryId);
629
+ return null;
630
+ },
631
+ });
632
+ /**
633
+ * Remove one bounded slice of an entry's dependent documents. Direct entry
634
+ * reactions are drained first, then comments are processed in index order.
635
+ * Each comment's reactions are drained before the comment is deleted, which
636
+ * keeps this safe for comments with arbitrarily many likes.
637
+ */
638
+ export const removeBatch = internalMutation({
639
+ args: { entryId: v.id("entries") },
640
+ returns: v.object({ processed: v.number(), hasMore: v.boolean() }),
641
+ handler: async (ctx, args) => {
642
+ const entry = await ctx.db.get("entries", args.entryId);
643
+ if (entry === null || entry.deletingAt === undefined) {
644
+ return { processed: 0, hasMore: false };
645
+ }
646
+ const directReactions = await ctx.db
647
+ .query("reactions")
648
+ .withIndex("by_entry_actor", (q) => q.eq("entryId", args.entryId))
649
+ .take(deletionBatchSize);
650
+ for (const reaction of directReactions) {
651
+ await ctx.db.delete("reactions", reaction._id);
652
+ }
653
+ if (directReactions.length === deletionBatchSize) {
654
+ await scheduleEntryCleanup(ctx, args.entryId);
655
+ return { processed: directReactions.length, hasMore: true };
656
+ }
657
+ const comments = await ctx.db
658
+ .query("comments")
659
+ .withIndex("by_entry_parent", (q) => q.eq("entryId", args.entryId))
660
+ .take(commentDeletionBatchSize);
661
+ let processed = directReactions.length;
662
+ for (const comment of comments) {
663
+ const commentReactions = await ctx.db
664
+ .query("reactions")
665
+ .withIndex("by_comment_actor", (q) => q.eq("commentId", comment._id))
666
+ .take(reactionDeletionBatchSize + 1);
667
+ const reactionsToDelete = commentReactions.slice(0, reactionDeletionBatchSize);
668
+ for (const reaction of reactionsToDelete) {
669
+ await ctx.db.delete("reactions", reaction._id);
670
+ processed += 1;
671
+ }
672
+ // Leave this comment in place until its complete reaction set has been
673
+ // drained. It will be the first item in the next indexed batch.
674
+ if (commentReactions.length > reactionDeletionBatchSize) {
675
+ await scheduleEntryCleanup(ctx, args.entryId);
676
+ return { processed, hasMore: true };
677
+ }
678
+ await ctx.db.delete("comments", comment._id);
679
+ processed += 1;
680
+ }
681
+ if (comments.length === commentDeletionBatchSize) {
682
+ await scheduleEntryCleanup(ctx, args.entryId);
683
+ return { processed, hasMore: true };
684
+ }
685
+ await ctx.scheduler.runAfter(0, internal.entries.finalizeRemoval, {
686
+ entryId: args.entryId,
687
+ });
688
+ return { processed, hasMore: false };
689
+ },
690
+ });
691
+ /** Hard-delete only after a fresh bounded dependency check. */
692
+ export const finalizeRemoval = internalMutation({
693
+ args: { entryId: v.id("entries") },
694
+ returns: v.null(),
695
+ handler: async (ctx, args) => {
696
+ const entry = await ctx.db.get("entries", args.entryId);
697
+ if (entry === null || entry.deletingAt === undefined)
698
+ return null;
699
+ const [directReaction, comment] = await Promise.all([
700
+ ctx.db
701
+ .query("reactions")
702
+ .withIndex("by_entry_actor", (q) => q.eq("entryId", args.entryId))
703
+ .first(),
704
+ ctx.db
705
+ .query("comments")
706
+ .withIndex("by_entry_parent", (q) => q.eq("entryId", args.entryId))
707
+ .first(),
708
+ ]);
709
+ if (directReaction !== null || comment !== null) {
710
+ await scheduleEntryCleanup(ctx, args.entryId);
711
+ return null;
712
+ }
713
+ await ctx.db.delete("entries", args.entryId);
714
+ return null;
715
+ },
716
+ });
473
717
  export const setStatus = mutation({
474
718
  args: {
475
719
  actor: actorValidator,
@@ -481,9 +725,13 @@ export const setStatus = mutation({
481
725
  if (!actorIsAdmin(args.actor)) {
482
726
  throw new ConvexError("Admin access is required to change status.");
483
727
  }
484
- if ((await ctx.db.get("entries", args.entryId)) === null) {
728
+ const entry = await ctx.db.get("entries", args.entryId);
729
+ if (entry === null) {
485
730
  throw new ConvexError("Entry not found.");
486
731
  }
732
+ if (entry.deletingAt !== undefined) {
733
+ throw new ConvexError("Entry is being deleted.");
734
+ }
487
735
  await ctx.db.patch("entries", args.entryId, {
488
736
  status: args.status,
489
737
  statusFilter: entryStatusFilterForStatus(args.status),
@@ -503,9 +751,13 @@ export const setPriority = mutation({
503
751
  if (!actorIsAdmin(args.actor)) {
504
752
  throw new ConvexError("Admin access is required to change priority.");
505
753
  }
506
- if ((await ctx.db.get("entries", args.entryId)) === null) {
754
+ const entry = await ctx.db.get("entries", args.entryId);
755
+ if (entry === null) {
507
756
  throw new ConvexError("Entry not found.");
508
757
  }
758
+ if (entry.deletingAt !== undefined) {
759
+ throw new ConvexError("Entry is being deleted.");
760
+ }
509
761
  await ctx.db.patch("entries", args.entryId, {
510
762
  priority: args.priority === null ? undefined : args.priority,
511
763
  updatedAt: Date.now(),
@@ -518,13 +770,39 @@ export const setUpvote = mutation({
518
770
  actorId: v.string(),
519
771
  entryId: v.id("entries"),
520
772
  desiredState: v.boolean(),
773
+ includeCallbackContext: v.optional(v.boolean()),
521
774
  },
522
- returns: v.object({ active: v.boolean(), upvoteCount: v.number() }),
775
+ returns: v.union(v.object({
776
+ active: v.boolean(),
777
+ upvoteCount: v.number(),
778
+ }), v.object({
779
+ changed: v.literal(false),
780
+ active: v.boolean(),
781
+ transition: v.null(),
782
+ previousCount: v.number(),
783
+ count: v.number(),
784
+ }), v.object({
785
+ changed: v.literal(true),
786
+ active: v.boolean(),
787
+ transition: v.union(v.literal("added"), v.literal("removed")),
788
+ previousCount: v.number(),
789
+ count: v.number(),
790
+ entry: v.object({
791
+ id: v.id("entries"),
792
+ actorId: v.string(),
793
+ kind: entryKindValidator,
794
+ status: entryStatusValidator,
795
+ title: v.string(),
796
+ }),
797
+ })),
523
798
  handler: async (ctx, args) => {
524
799
  assertActorId(args.actorId);
525
800
  const entry = await ctx.db.get("entries", args.entryId);
526
801
  if (entry === null)
527
802
  throw new ConvexError("Entry not found.");
803
+ if (entry.deletingAt !== undefined) {
804
+ throw new ConvexError("Entry is being deleted.");
805
+ }
528
806
  const existing = await ctx.db
529
807
  .query("reactions")
530
808
  .withIndex("by_entry_actor", (q) => q.eq("entryId", args.entryId).eq("actorId", args.actorId))
@@ -539,7 +817,23 @@ export const setUpvote = mutation({
539
817
  upvoteCount,
540
818
  statusFilter: entryStatusFilterForStatus(entry.status),
541
819
  });
542
- return { active: true, upvoteCount };
820
+ if (!args.includeCallbackContext) {
821
+ return { active: true, upvoteCount };
822
+ }
823
+ return {
824
+ changed: true,
825
+ active: true,
826
+ transition: "added",
827
+ previousCount: entry.upvoteCount,
828
+ count: upvoteCount,
829
+ entry: {
830
+ id: entry._id,
831
+ actorId: entry.actorId,
832
+ kind: entry.kind,
833
+ status: entry.status,
834
+ title: entry.title,
835
+ },
836
+ };
543
837
  }
544
838
  if (!args.desiredState && existing !== null) {
545
839
  await ctx.db.delete("reactions", existing._id);
@@ -548,9 +842,34 @@ export const setUpvote = mutation({
548
842
  upvoteCount,
549
843
  statusFilter: entryStatusFilterForStatus(entry.status),
550
844
  });
551
- return { active: false, upvoteCount };
845
+ if (!args.includeCallbackContext) {
846
+ return { active: false, upvoteCount };
847
+ }
848
+ return {
849
+ changed: true,
850
+ active: false,
851
+ transition: "removed",
852
+ previousCount: entry.upvoteCount,
853
+ count: upvoteCount,
854
+ entry: {
855
+ id: entry._id,
856
+ actorId: entry.actorId,
857
+ kind: entry.kind,
858
+ status: entry.status,
859
+ title: entry.title,
860
+ },
861
+ };
862
+ }
863
+ if (!args.includeCallbackContext) {
864
+ return { active: args.desiredState, upvoteCount: entry.upvoteCount };
552
865
  }
553
- return { active: args.desiredState, upvoteCount: entry.upvoteCount };
866
+ return {
867
+ changed: false,
868
+ active: args.desiredState,
869
+ transition: null,
870
+ previousCount: entry.upvoteCount,
871
+ count: entry.upvoteCount,
872
+ };
554
873
  },
555
874
  });
556
875
  //# sourceMappingURL=entries.js.map