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.
- package/README.md +398 -7
- package/dist/.tsbuildinfo +1 -1
- package/dist/client/api.d.ts +49 -9
- package/dist/client/api.d.ts.map +1 -1
- package/dist/client/config.d.ts +1 -3
- package/dist/client/config.d.ts.map +1 -1
- package/dist/client/config.js.map +1 -1
- package/dist/client/index.d.ts +707 -15
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +243 -12
- package/dist/client/index.js.map +1 -1
- package/dist/component/_generated/api.d.ts +2 -0
- package/dist/component/_generated/api.d.ts.map +1 -1
- package/dist/component/_generated/api.js.map +1 -1
- package/dist/component/_generated/component.d.ts +222 -4
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/admin.d.ts +5 -90
- package/dist/component/admin.d.ts.map +1 -1
- package/dist/component/admin.js +59 -29
- package/dist/component/admin.js.map +1 -1
- package/dist/component/comments.d.ts +107 -15
- package/dist/component/comments.d.ts.map +1 -1
- package/dist/component/comments.js +342 -28
- package/dist/component/comments.js.map +1 -1
- package/dist/component/crons.d.ts.map +1 -1
- package/dist/component/crons.js +4 -0
- package/dist/component/crons.js.map +1 -1
- package/dist/component/entries.d.ts +119 -91
- package/dist/component/entries.d.ts.map +1 -1
- package/dist/component/entries.js +348 -29
- package/dist/component/entries.js.map +1 -1
- package/dist/component/helpers.d.ts +32 -1
- package/dist/component/helpers.d.ts.map +1 -1
- package/dist/component/helpers.js +121 -4
- package/dist/component/helpers.js.map +1 -1
- package/dist/component/migrations.d.ts +24 -0
- package/dist/component/migrations.d.ts.map +1 -1
- package/dist/component/migrations.js +318 -0
- package/dist/component/migrations.js.map +1 -1
- package/dist/component/model.d.ts +455 -120
- package/dist/component/model.d.ts.map +1 -1
- package/dist/component/model.js +70 -2
- package/dist/component/model.js.map +1 -1
- package/dist/component/reactions.d.ts +37 -0
- package/dist/component/reactions.d.ts.map +1 -0
- package/dist/component/reactions.js +52 -0
- package/dist/component/reactions.js.map +1 -0
- package/dist/component/roadmap.d.ts +10 -46
- package/dist/component/roadmap.d.ts.map +1 -1
- package/dist/component/roadmap.js +239 -21
- package/dist/component/roadmap.js.map +1 -1
- package/dist/component/schema.d.ts +33 -5
- package/dist/component/schema.d.ts.map +1 -1
- package/dist/component/schema.js +40 -4
- package/dist/component/schema.js.map +1 -1
- package/dist/react/index.d.ts +26 -238
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js +65 -2
- package/dist/react/index.js.map +1 -1
- package/dist/test.d.ts +23 -5
- package/dist/test.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/client/README.md +6 -1
- package/src/client/api.ts +89 -7
- package/src/client/config.ts +1 -3
- package/src/client/index.ts +1237 -34
- package/src/component/README.md +32 -4
- package/src/component/_generated/api.ts +2 -0
- package/src/component/_generated/component.ts +290 -8
- package/src/component/admin.ts +88 -30
- package/src/component/comments.ts +405 -26
- package/src/component/crons.ts +10 -0
- package/src/component/entries.ts +388 -27
- package/src/component/helpers.ts +163 -4
- package/src/component/migrations.ts +415 -0
- package/src/component/model.ts +381 -117
- package/src/component/reactions.ts +60 -0
- package/src/component/roadmap.ts +357 -35
- package/src/component/schema.ts +40 -4
- package/src/react/README.md +5 -0
- package/src/react/index.ts +90 -3
package/src/component/entries.ts
CHANGED
|
@@ -8,15 +8,18 @@ import { ConvexError, v } from "convex/values";
|
|
|
8
8
|
|
|
9
9
|
import { mergedStream, stream } from "convex-helpers/server/stream";
|
|
10
10
|
import type { Doc } from "./_generated/dataModel.js";
|
|
11
|
-
import {
|
|
11
|
+
import { internal } from "./_generated/api.js";
|
|
12
|
+
import { internalMutation, mutation, query } from "./_generated/server.js";
|
|
12
13
|
import {
|
|
13
14
|
assertActorId,
|
|
14
15
|
normalizeRequiredText,
|
|
15
16
|
normalizeTitle,
|
|
17
|
+
serializeActivityEntry,
|
|
16
18
|
serializeEntry,
|
|
17
19
|
validateFeedbackMetadata,
|
|
18
20
|
} from "./helpers.js";
|
|
19
21
|
import {
|
|
22
|
+
activityEntryWithContextValidator,
|
|
20
23
|
actorValidator,
|
|
21
24
|
actorIsAdmin,
|
|
22
25
|
entryKindValidator,
|
|
@@ -31,6 +34,7 @@ import {
|
|
|
31
34
|
type EntryKind,
|
|
32
35
|
} from "./model.js";
|
|
33
36
|
import schema from "./schema.js";
|
|
37
|
+
import type { MutationCtx } from "./types.js";
|
|
34
38
|
|
|
35
39
|
const allEntryKinds: readonly EntryKind[] = [
|
|
36
40
|
"feedback",
|
|
@@ -38,6 +42,26 @@ const allEntryKinds: readonly EntryKind[] = [
|
|
|
38
42
|
"bug_report",
|
|
39
43
|
];
|
|
40
44
|
|
|
45
|
+
/**
|
|
46
|
+
* Cleanup deliberately uses small bounded batches. A comment may have more
|
|
47
|
+
* reactions than this limit, so its reactions are drained over multiple
|
|
48
|
+
* scheduled transactions before the comment itself is removed.
|
|
49
|
+
*/
|
|
50
|
+
const deletionBatchSize = 100;
|
|
51
|
+
const commentDeletionBatchSize = 25;
|
|
52
|
+
const reactionDeletionBatchSize = 100;
|
|
53
|
+
|
|
54
|
+
function entryIsLive(entry: Doc<"entries">): Promise<boolean> {
|
|
55
|
+
return Promise.resolve(entry.deletingAt === undefined);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function scheduleEntryCleanup(
|
|
59
|
+
ctx: MutationCtx,
|
|
60
|
+
entryId: Doc<"entries">["_id"],
|
|
61
|
+
): Promise<void> {
|
|
62
|
+
await ctx.scheduler.runAfter(0, internal.entries.removeBatch, { entryId });
|
|
63
|
+
}
|
|
64
|
+
|
|
41
65
|
function normalizeKindFilter(
|
|
42
66
|
kinds: readonly EntryKind[] | undefined,
|
|
43
67
|
): EntryKind[] | undefined {
|
|
@@ -95,6 +119,7 @@ export const list = query({
|
|
|
95
119
|
q.eq("statusFilter", statusFilter),
|
|
96
120
|
)
|
|
97
121
|
.order("desc")
|
|
122
|
+
.filterWith(entryIsLive)
|
|
98
123
|
.paginate(args.paginationOpts)
|
|
99
124
|
: await db
|
|
100
125
|
.query("entries")
|
|
@@ -102,6 +127,7 @@ export const list = query({
|
|
|
102
127
|
q.eq("statusFilter", statusFilter),
|
|
103
128
|
)
|
|
104
129
|
.order("desc")
|
|
130
|
+
.filterWith(entryIsLive)
|
|
105
131
|
.paginate(args.paginationOpts);
|
|
106
132
|
} else if (kinds.length === 1) {
|
|
107
133
|
const kind = kinds[0];
|
|
@@ -118,6 +144,7 @@ export const list = query({
|
|
|
118
144
|
q.eq("kind", kind).eq("statusFilter", statusFilter),
|
|
119
145
|
)
|
|
120
146
|
.order("desc")
|
|
147
|
+
.filterWith(entryIsLive)
|
|
121
148
|
.paginate(args.paginationOpts)
|
|
122
149
|
: await db
|
|
123
150
|
.query("entries")
|
|
@@ -125,6 +152,7 @@ export const list = query({
|
|
|
125
152
|
q.eq("kind", kind).eq("statusFilter", statusFilter),
|
|
126
153
|
)
|
|
127
154
|
.order("desc")
|
|
155
|
+
.filterWith(entryIsLive)
|
|
128
156
|
.paginate(args.paginationOpts);
|
|
129
157
|
} else {
|
|
130
158
|
const streams = kinds.map((kind) =>
|
|
@@ -135,12 +163,14 @@ export const list = query({
|
|
|
135
163
|
q.eq("kind", kind).eq("statusFilter", statusFilter),
|
|
136
164
|
)
|
|
137
165
|
.order("desc")
|
|
166
|
+
.filterWith(entryIsLive)
|
|
138
167
|
: stream(ctx.db, schema)
|
|
139
168
|
.query("entries")
|
|
140
169
|
.withIndex("by_kind_status_filter", (q) =>
|
|
141
170
|
q.eq("kind", kind).eq("statusFilter", statusFilter),
|
|
142
171
|
)
|
|
143
|
-
.order("desc")
|
|
172
|
+
.order("desc")
|
|
173
|
+
.filterWith(entryIsLive),
|
|
144
174
|
);
|
|
145
175
|
|
|
146
176
|
result = await mergedStream(
|
|
@@ -158,21 +188,25 @@ export const list = query({
|
|
|
158
188
|
.query("entries")
|
|
159
189
|
.withIndex("by_upvotes")
|
|
160
190
|
.order("desc")
|
|
191
|
+
.filterWith(entryIsLive)
|
|
161
192
|
.paginate(args.paginationOpts)
|
|
162
193
|
: await db
|
|
163
194
|
.query("entries")
|
|
164
195
|
.withIndex("by_status_upvotes", (q) => q.eq("status", status))
|
|
165
196
|
.order("desc")
|
|
197
|
+
.filterWith(entryIsLive)
|
|
166
198
|
.paginate(args.paginationOpts)
|
|
167
199
|
: status === undefined
|
|
168
200
|
? await db
|
|
169
201
|
.query("entries")
|
|
170
202
|
.order("desc")
|
|
203
|
+
.filterWith(entryIsLive)
|
|
171
204
|
.paginate(args.paginationOpts)
|
|
172
205
|
: await db
|
|
173
206
|
.query("entries")
|
|
174
207
|
.withIndex("by_status", (q) => q.eq("status", status))
|
|
175
208
|
.order("desc")
|
|
209
|
+
.filterWith(entryIsLive)
|
|
176
210
|
.paginate(args.paginationOpts);
|
|
177
211
|
} else if (kinds.length === 1) {
|
|
178
212
|
const kind = kinds[0];
|
|
@@ -188,6 +222,7 @@ export const list = query({
|
|
|
188
222
|
.query("entries")
|
|
189
223
|
.withIndex("by_kind_upvotes", (q) => q.eq("kind", kind))
|
|
190
224
|
.order("desc")
|
|
225
|
+
.filterWith(entryIsLive)
|
|
191
226
|
.paginate(args.paginationOpts)
|
|
192
227
|
: await db
|
|
193
228
|
.query("entries")
|
|
@@ -195,12 +230,14 @@ export const list = query({
|
|
|
195
230
|
q.eq("kind", kind).eq("status", status),
|
|
196
231
|
)
|
|
197
232
|
.order("desc")
|
|
233
|
+
.filterWith(entryIsLive)
|
|
198
234
|
.paginate(args.paginationOpts)
|
|
199
235
|
: status === undefined
|
|
200
236
|
? await db
|
|
201
237
|
.query("entries")
|
|
202
238
|
.withIndex("by_kind", (q) => q.eq("kind", kind))
|
|
203
239
|
.order("desc")
|
|
240
|
+
.filterWith(entryIsLive)
|
|
204
241
|
.paginate(args.paginationOpts)
|
|
205
242
|
: await db
|
|
206
243
|
.query("entries")
|
|
@@ -208,6 +245,7 @@ export const list = query({
|
|
|
208
245
|
q.eq("kind", kind).eq("status", status),
|
|
209
246
|
)
|
|
210
247
|
.order("desc")
|
|
248
|
+
.filterWith(entryIsLive)
|
|
211
249
|
.paginate(args.paginationOpts);
|
|
212
250
|
} else if (args.sort === "top") {
|
|
213
251
|
if (status === undefined) {
|
|
@@ -215,7 +253,8 @@ export const list = query({
|
|
|
215
253
|
stream(ctx.db, schema)
|
|
216
254
|
.query("entries")
|
|
217
255
|
.withIndex("by_kind_upvotes", (q) => q.eq("kind", kind))
|
|
218
|
-
.order("desc")
|
|
256
|
+
.order("desc")
|
|
257
|
+
.filterWith(entryIsLive),
|
|
219
258
|
);
|
|
220
259
|
|
|
221
260
|
result = await mergedStream(streams, [
|
|
@@ -229,7 +268,8 @@ export const list = query({
|
|
|
229
268
|
.withIndex("by_kind_status_upvotes", (q) =>
|
|
230
269
|
q.eq("kind", kind).eq("status", status),
|
|
231
270
|
)
|
|
232
|
-
.order("desc")
|
|
271
|
+
.order("desc")
|
|
272
|
+
.filterWith(entryIsLive),
|
|
233
273
|
);
|
|
234
274
|
|
|
235
275
|
result = await mergedStream(streams, [
|
|
@@ -242,7 +282,8 @@ export const list = query({
|
|
|
242
282
|
stream(ctx.db, schema)
|
|
243
283
|
.query("entries")
|
|
244
284
|
.withIndex("by_kind", (q) => q.eq("kind", kind))
|
|
245
|
-
.order("desc")
|
|
285
|
+
.order("desc")
|
|
286
|
+
.filterWith(entryIsLive),
|
|
246
287
|
);
|
|
247
288
|
|
|
248
289
|
result = await mergedStream(streams, ["_creationTime"]).paginate(
|
|
@@ -255,7 +296,8 @@ export const list = query({
|
|
|
255
296
|
.withIndex("by_kind_status", (q) =>
|
|
256
297
|
q.eq("kind", kind).eq("status", status),
|
|
257
298
|
)
|
|
258
|
-
.order("desc")
|
|
299
|
+
.order("desc")
|
|
300
|
+
.filterWith(entryIsLive),
|
|
259
301
|
);
|
|
260
302
|
|
|
261
303
|
result = await mergedStream(streams, ["_creationTime"]).paginate(
|
|
@@ -274,6 +316,43 @@ export const list = query({
|
|
|
274
316
|
},
|
|
275
317
|
});
|
|
276
318
|
|
|
319
|
+
/**
|
|
320
|
+
* List entries created by a known actor. This component-level query remains
|
|
321
|
+
* actor-parameterized so trusted host/server consumers can use it without a
|
|
322
|
+
* request authentication context.
|
|
323
|
+
*
|
|
324
|
+
* `includeAdminContext` is intentionally unavailable on the host-facing
|
|
325
|
+
* `listUserEntries` wrapper. Trusted server consumers such as exports may opt
|
|
326
|
+
* into retained metadata and triage context when needed.
|
|
327
|
+
*/
|
|
328
|
+
export const listByActor = query({
|
|
329
|
+
args: {
|
|
330
|
+
paginationOpts: paginationOptsValidator,
|
|
331
|
+
actorId: v.string(),
|
|
332
|
+
includeAdminContext: v.optional(v.boolean()),
|
|
333
|
+
},
|
|
334
|
+
returns: paginationResultValidator(activityEntryWithContextValidator),
|
|
335
|
+
handler: async (ctx, args) => {
|
|
336
|
+
assertActorId(args.actorId);
|
|
337
|
+
|
|
338
|
+
const result = await stream(ctx.db, schema)
|
|
339
|
+
.query("entries")
|
|
340
|
+
.withIndex("by_actor", (q) => q.eq("actorId", args.actorId))
|
|
341
|
+
.order("desc")
|
|
342
|
+
.filterWith(entryIsLive)
|
|
343
|
+
.paginate(args.paginationOpts);
|
|
344
|
+
|
|
345
|
+
return {
|
|
346
|
+
...result,
|
|
347
|
+
page: await Promise.all(
|
|
348
|
+
result.page.map((entry) =>
|
|
349
|
+
serializeActivityEntry(ctx, entry, args.includeAdminContext === true),
|
|
350
|
+
),
|
|
351
|
+
),
|
|
352
|
+
};
|
|
353
|
+
},
|
|
354
|
+
});
|
|
355
|
+
|
|
277
356
|
export const get = query({
|
|
278
357
|
args: {
|
|
279
358
|
entryId: v.id("entries"),
|
|
@@ -283,7 +362,7 @@ export const get = query({
|
|
|
283
362
|
returns: v.union(publicEntryValidator, v.null()),
|
|
284
363
|
handler: async (ctx, args) => {
|
|
285
364
|
const entry = await ctx.db.get("entries", args.entryId);
|
|
286
|
-
return entry === null
|
|
365
|
+
return entry === null || entry.deletingAt !== undefined
|
|
287
366
|
? null
|
|
288
367
|
: serializeEntry(
|
|
289
368
|
ctx,
|
|
@@ -329,7 +408,8 @@ export const search = query({
|
|
|
329
408
|
.withSearchIndex("search", (q) =>
|
|
330
409
|
q
|
|
331
410
|
.search("searchText", searchQuery)
|
|
332
|
-
.eq("statusFilter", statusFilter)
|
|
411
|
+
.eq("statusFilter", statusFilter)
|
|
412
|
+
.eq("deletingAt", undefined),
|
|
333
413
|
)
|
|
334
414
|
.take(args.limit);
|
|
335
415
|
} else if (kinds.length === 1) {
|
|
@@ -345,7 +425,8 @@ export const search = query({
|
|
|
345
425
|
q
|
|
346
426
|
.search("searchText", searchQuery)
|
|
347
427
|
.eq("kind", kind)
|
|
348
|
-
.eq("statusFilter", statusFilter)
|
|
428
|
+
.eq("statusFilter", statusFilter)
|
|
429
|
+
.eq("deletingAt", undefined),
|
|
349
430
|
)
|
|
350
431
|
.take(args.limit);
|
|
351
432
|
} else {
|
|
@@ -361,7 +442,8 @@ export const search = query({
|
|
|
361
442
|
.withSearchIndex("search", (q) =>
|
|
362
443
|
q
|
|
363
444
|
.search("searchText", searchQuery)
|
|
364
|
-
.eq("statusFilter", statusFilter)
|
|
445
|
+
.eq("statusFilter", statusFilter)
|
|
446
|
+
.eq("deletingAt", undefined),
|
|
365
447
|
)
|
|
366
448
|
// eslint-disable-next-line @convex-dev/no-filter-in-query
|
|
367
449
|
.filter((q) =>
|
|
@@ -378,13 +460,16 @@ export const search = query({
|
|
|
378
460
|
? await ctx.db
|
|
379
461
|
.query("entries")
|
|
380
462
|
.withSearchIndex("search", (q) =>
|
|
381
|
-
q.search("searchText", searchQuery),
|
|
463
|
+
q.search("searchText", searchQuery).eq("deletingAt", undefined),
|
|
382
464
|
)
|
|
383
465
|
.take(args.limit)
|
|
384
466
|
: await ctx.db
|
|
385
467
|
.query("entries")
|
|
386
468
|
.withSearchIndex("search", (q) =>
|
|
387
|
-
q
|
|
469
|
+
q
|
|
470
|
+
.search("searchText", searchQuery)
|
|
471
|
+
.eq("status", status)
|
|
472
|
+
.eq("deletingAt", undefined),
|
|
388
473
|
)
|
|
389
474
|
.take(args.limit);
|
|
390
475
|
} else if (kinds.length === 1) {
|
|
@@ -399,7 +484,10 @@ export const search = query({
|
|
|
399
484
|
? await ctx.db
|
|
400
485
|
.query("entries")
|
|
401
486
|
.withSearchIndex("search", (q) =>
|
|
402
|
-
q
|
|
487
|
+
q
|
|
488
|
+
.search("searchText", searchQuery)
|
|
489
|
+
.eq("kind", kind)
|
|
490
|
+
.eq("deletingAt", undefined),
|
|
403
491
|
)
|
|
404
492
|
.take(args.limit)
|
|
405
493
|
: await ctx.db
|
|
@@ -408,7 +496,8 @@ export const search = query({
|
|
|
408
496
|
q
|
|
409
497
|
.search("searchText", searchQuery)
|
|
410
498
|
.eq("kind", kind)
|
|
411
|
-
.eq("status", status)
|
|
499
|
+
.eq("status", status)
|
|
500
|
+
.eq("deletingAt", undefined),
|
|
412
501
|
)
|
|
413
502
|
.take(args.limit);
|
|
414
503
|
} else {
|
|
@@ -424,12 +513,15 @@ export const search = query({
|
|
|
424
513
|
? ctx.db
|
|
425
514
|
.query("entries")
|
|
426
515
|
.withSearchIndex("search", (q) =>
|
|
427
|
-
q.search("searchText", searchQuery),
|
|
516
|
+
q.search("searchText", searchQuery).eq("deletingAt", undefined),
|
|
428
517
|
)
|
|
429
518
|
: ctx.db
|
|
430
519
|
.query("entries")
|
|
431
520
|
.withSearchIndex("search", (q) =>
|
|
432
|
-
q
|
|
521
|
+
q
|
|
522
|
+
.search("searchText", searchQuery)
|
|
523
|
+
.eq("status", status)
|
|
524
|
+
.eq("deletingAt", undefined),
|
|
433
525
|
);
|
|
434
526
|
|
|
435
527
|
entries = await searchResults
|
|
@@ -444,7 +536,9 @@ export const search = query({
|
|
|
444
536
|
}
|
|
445
537
|
|
|
446
538
|
return await Promise.all(
|
|
447
|
-
entries
|
|
539
|
+
entries
|
|
540
|
+
.filter((entry) => entry.deletingAt === undefined)
|
|
541
|
+
.map((entry) => serializeEntry(ctx, entry, args.viewerActorId)),
|
|
448
542
|
);
|
|
449
543
|
},
|
|
450
544
|
});
|
|
@@ -489,12 +583,18 @@ export const similar = query({
|
|
|
489
583
|
.withIndex("by_normalized_title", (q) =>
|
|
490
584
|
q.eq("normalizedTitle", normalizedTitle),
|
|
491
585
|
)
|
|
586
|
+
// The deletion flag is not part of this exact-match index.
|
|
587
|
+
// Filter before take so pending entries cannot consume a slot.
|
|
588
|
+
// eslint-disable-next-line @convex-dev/no-filter-in-query
|
|
589
|
+
.filter((q) => q.eq(q.field("deletingAt"), undefined))
|
|
492
590
|
.take(args.limit)
|
|
493
591
|
: await ctx.db
|
|
494
592
|
.query("entries")
|
|
495
593
|
.withIndex("by_kind_normalized_title", (q) =>
|
|
496
594
|
q.eq("kind", kind).eq("normalizedTitle", normalizedTitle),
|
|
497
595
|
)
|
|
596
|
+
// eslint-disable-next-line @convex-dev/no-filter-in-query
|
|
597
|
+
.filter((q) => q.eq(q.field("deletingAt"), undefined))
|
|
498
598
|
.take(args.limit);
|
|
499
599
|
|
|
500
600
|
const exact = await Promise.all(
|
|
@@ -530,13 +630,16 @@ export const similar = query({
|
|
|
530
630
|
? await ctx.db
|
|
531
631
|
.query("entries")
|
|
532
632
|
.withSearchIndex("search", (q) =>
|
|
533
|
-
q.search("searchText", searchText),
|
|
633
|
+
q.search("searchText", searchText).eq("deletingAt", undefined),
|
|
534
634
|
)
|
|
535
635
|
.take(candidateLimit)
|
|
536
636
|
: await ctx.db
|
|
537
637
|
.query("entries")
|
|
538
638
|
.withSearchIndex("search", (q) =>
|
|
539
|
-
q
|
|
639
|
+
q
|
|
640
|
+
.search("searchText", searchText)
|
|
641
|
+
.eq("kind", kind)
|
|
642
|
+
.eq("deletingAt", undefined),
|
|
540
643
|
)
|
|
541
644
|
.take(candidateLimit);
|
|
542
645
|
|
|
@@ -566,8 +669,25 @@ export const create = mutation({
|
|
|
566
669
|
maxTitleLength: v.number(),
|
|
567
670
|
maxBodyLength: v.number(),
|
|
568
671
|
metadata: v.optional(feedbackMetadataValidator),
|
|
672
|
+
includeCallbackContext: v.optional(v.boolean()),
|
|
569
673
|
},
|
|
570
|
-
returns: v.
|
|
674
|
+
returns: v.union(
|
|
675
|
+
v.object({ id: v.id("entries") }),
|
|
676
|
+
v.object({
|
|
677
|
+
id: v.id("entries"),
|
|
678
|
+
entry: v.object({
|
|
679
|
+
id: v.id("entries"),
|
|
680
|
+
actorId: v.string(),
|
|
681
|
+
kind: entryKindValidator,
|
|
682
|
+
status: entryStatusValidator,
|
|
683
|
+
title: v.string(),
|
|
684
|
+
body: v.string(),
|
|
685
|
+
metadata: v.optional(feedbackMetadataValidator),
|
|
686
|
+
upvoteCount: v.number(),
|
|
687
|
+
commentCount: v.number(),
|
|
688
|
+
}),
|
|
689
|
+
}),
|
|
690
|
+
),
|
|
571
691
|
handler: async (ctx, args) => {
|
|
572
692
|
assertActorId(args.actorId);
|
|
573
693
|
if (!args.enabledKinds.includes(args.kind)) {
|
|
@@ -601,7 +721,22 @@ export const create = mutation({
|
|
|
601
721
|
entryId: entry,
|
|
602
722
|
});
|
|
603
723
|
|
|
604
|
-
return entry;
|
|
724
|
+
if (!args.includeCallbackContext) return { id: entry };
|
|
725
|
+
|
|
726
|
+
return {
|
|
727
|
+
id: entry,
|
|
728
|
+
entry: {
|
|
729
|
+
id: entry,
|
|
730
|
+
actorId: args.actorId,
|
|
731
|
+
kind: args.kind,
|
|
732
|
+
status: args.defaultStatus,
|
|
733
|
+
title,
|
|
734
|
+
body,
|
|
735
|
+
...(args.metadata === undefined ? {} : { metadata: args.metadata }),
|
|
736
|
+
upvoteCount: 1,
|
|
737
|
+
commentCount: 0,
|
|
738
|
+
},
|
|
739
|
+
};
|
|
605
740
|
},
|
|
606
741
|
});
|
|
607
742
|
|
|
@@ -621,6 +756,9 @@ export const update = mutation({
|
|
|
621
756
|
assertActorId(args.actor.id);
|
|
622
757
|
const entry = await ctx.db.get("entries", args.entryId);
|
|
623
758
|
if (entry === null) throw new ConvexError("Entry not found.");
|
|
759
|
+
if (entry.deletingAt !== undefined) {
|
|
760
|
+
throw new ConvexError("Entry is being deleted.");
|
|
761
|
+
}
|
|
624
762
|
|
|
625
763
|
const canEdit =
|
|
626
764
|
actorIsAdmin(args.actor) ||
|
|
@@ -652,6 +790,150 @@ export const update = mutation({
|
|
|
652
790
|
},
|
|
653
791
|
});
|
|
654
792
|
|
|
793
|
+
export const remove = mutation({
|
|
794
|
+
args: {
|
|
795
|
+
actor: actorValidator,
|
|
796
|
+
entryId: v.id("entries"),
|
|
797
|
+
},
|
|
798
|
+
returns: v.null(),
|
|
799
|
+
handler: async (ctx, args) => {
|
|
800
|
+
assertActorId(args.actor.id);
|
|
801
|
+
if (!actorIsAdmin(args.actor)) {
|
|
802
|
+
throw new ConvexError("Admin access is required to delete an entry.");
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
const entry = await ctx.db.get("entries", args.entryId);
|
|
806
|
+
if (entry === null) return null;
|
|
807
|
+
|
|
808
|
+
// Mark and detach in one transaction so every committed read can
|
|
809
|
+
// hide/reject the entry before scheduled cleanup removes dependents.
|
|
810
|
+
// Repeated requests are safe: they only enqueue another idempotent batch.
|
|
811
|
+
if (entry.deletingAt !== undefined) {
|
|
812
|
+
await scheduleEntryCleanup(ctx, args.entryId);
|
|
813
|
+
return null;
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
const now = Date.now();
|
|
817
|
+
if (entry.roadmapId !== undefined) {
|
|
818
|
+
const roadmap = await ctx.db.get("roadmap", entry.roadmapId);
|
|
819
|
+
if (roadmap !== null) {
|
|
820
|
+
await ctx.db.patch("roadmap", roadmap._id, {
|
|
821
|
+
feedbackCount: Math.max(0, roadmap.feedbackCount - 1),
|
|
822
|
+
updatedAt: now,
|
|
823
|
+
});
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
await ctx.db.patch("entries", args.entryId, {
|
|
828
|
+
roadmapId: undefined,
|
|
829
|
+
deletingAt: now,
|
|
830
|
+
updatedAt: now,
|
|
831
|
+
});
|
|
832
|
+
await scheduleEntryCleanup(ctx, args.entryId);
|
|
833
|
+
return null;
|
|
834
|
+
},
|
|
835
|
+
});
|
|
836
|
+
|
|
837
|
+
/**
|
|
838
|
+
* Remove one bounded slice of an entry's dependent documents. Direct entry
|
|
839
|
+
* reactions are drained first, then comments are processed in index order.
|
|
840
|
+
* Each comment's reactions are drained before the comment is deleted, which
|
|
841
|
+
* keeps this safe for comments with arbitrarily many likes.
|
|
842
|
+
*/
|
|
843
|
+
export const removeBatch = internalMutation({
|
|
844
|
+
args: { entryId: v.id("entries") },
|
|
845
|
+
returns: v.object({ processed: v.number(), hasMore: v.boolean() }),
|
|
846
|
+
handler: async (ctx, args) => {
|
|
847
|
+
const entry = await ctx.db.get("entries", args.entryId);
|
|
848
|
+
if (entry === null || entry.deletingAt === undefined) {
|
|
849
|
+
return { processed: 0, hasMore: false };
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
const directReactions = await ctx.db
|
|
853
|
+
.query("reactions")
|
|
854
|
+
.withIndex("by_entry_actor", (q) => q.eq("entryId", args.entryId))
|
|
855
|
+
.take(deletionBatchSize);
|
|
856
|
+
for (const reaction of directReactions) {
|
|
857
|
+
await ctx.db.delete("reactions", reaction._id);
|
|
858
|
+
}
|
|
859
|
+
if (directReactions.length === deletionBatchSize) {
|
|
860
|
+
await scheduleEntryCleanup(ctx, args.entryId);
|
|
861
|
+
return { processed: directReactions.length, hasMore: true };
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
const comments = await ctx.db
|
|
865
|
+
.query("comments")
|
|
866
|
+
.withIndex("by_entry_parent", (q) => q.eq("entryId", args.entryId))
|
|
867
|
+
.take(commentDeletionBatchSize);
|
|
868
|
+
let processed = directReactions.length;
|
|
869
|
+
|
|
870
|
+
for (const comment of comments) {
|
|
871
|
+
const commentReactions = await ctx.db
|
|
872
|
+
.query("reactions")
|
|
873
|
+
.withIndex("by_comment_actor", (q) => q.eq("commentId", comment._id))
|
|
874
|
+
.take(reactionDeletionBatchSize + 1);
|
|
875
|
+
|
|
876
|
+
const reactionsToDelete = commentReactions.slice(
|
|
877
|
+
0,
|
|
878
|
+
reactionDeletionBatchSize,
|
|
879
|
+
);
|
|
880
|
+
for (const reaction of reactionsToDelete) {
|
|
881
|
+
await ctx.db.delete("reactions", reaction._id);
|
|
882
|
+
processed += 1;
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
// Leave this comment in place until its complete reaction set has been
|
|
886
|
+
// drained. It will be the first item in the next indexed batch.
|
|
887
|
+
if (commentReactions.length > reactionDeletionBatchSize) {
|
|
888
|
+
await scheduleEntryCleanup(ctx, args.entryId);
|
|
889
|
+
return { processed, hasMore: true };
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
await ctx.db.delete("comments", comment._id);
|
|
893
|
+
processed += 1;
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
if (comments.length === commentDeletionBatchSize) {
|
|
897
|
+
await scheduleEntryCleanup(ctx, args.entryId);
|
|
898
|
+
return { processed, hasMore: true };
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
await ctx.scheduler.runAfter(0, internal.entries.finalizeRemoval, {
|
|
902
|
+
entryId: args.entryId,
|
|
903
|
+
});
|
|
904
|
+
return { processed, hasMore: false };
|
|
905
|
+
},
|
|
906
|
+
});
|
|
907
|
+
|
|
908
|
+
/** Hard-delete only after a fresh bounded dependency check. */
|
|
909
|
+
export const finalizeRemoval = internalMutation({
|
|
910
|
+
args: { entryId: v.id("entries") },
|
|
911
|
+
returns: v.null(),
|
|
912
|
+
handler: async (ctx, args) => {
|
|
913
|
+
const entry = await ctx.db.get("entries", args.entryId);
|
|
914
|
+
if (entry === null || entry.deletingAt === undefined) return null;
|
|
915
|
+
|
|
916
|
+
const [directReaction, comment] = await Promise.all([
|
|
917
|
+
ctx.db
|
|
918
|
+
.query("reactions")
|
|
919
|
+
.withIndex("by_entry_actor", (q) => q.eq("entryId", args.entryId))
|
|
920
|
+
.first(),
|
|
921
|
+
ctx.db
|
|
922
|
+
.query("comments")
|
|
923
|
+
.withIndex("by_entry_parent", (q) => q.eq("entryId", args.entryId))
|
|
924
|
+
.first(),
|
|
925
|
+
]);
|
|
926
|
+
|
|
927
|
+
if (directReaction !== null || comment !== null) {
|
|
928
|
+
await scheduleEntryCleanup(ctx, args.entryId);
|
|
929
|
+
return null;
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
await ctx.db.delete("entries", args.entryId);
|
|
933
|
+
return null;
|
|
934
|
+
},
|
|
935
|
+
});
|
|
936
|
+
|
|
655
937
|
export const setStatus = mutation({
|
|
656
938
|
args: {
|
|
657
939
|
actor: actorValidator,
|
|
@@ -663,9 +945,13 @@ export const setStatus = mutation({
|
|
|
663
945
|
if (!actorIsAdmin(args.actor)) {
|
|
664
946
|
throw new ConvexError("Admin access is required to change status.");
|
|
665
947
|
}
|
|
666
|
-
|
|
948
|
+
const entry = await ctx.db.get("entries", args.entryId);
|
|
949
|
+
if (entry === null) {
|
|
667
950
|
throw new ConvexError("Entry not found.");
|
|
668
951
|
}
|
|
952
|
+
if (entry.deletingAt !== undefined) {
|
|
953
|
+
throw new ConvexError("Entry is being deleted.");
|
|
954
|
+
}
|
|
669
955
|
await ctx.db.patch("entries", args.entryId, {
|
|
670
956
|
status: args.status,
|
|
671
957
|
statusFilter: entryStatusFilterForStatus(args.status),
|
|
@@ -686,9 +972,13 @@ export const setPriority = mutation({
|
|
|
686
972
|
if (!actorIsAdmin(args.actor)) {
|
|
687
973
|
throw new ConvexError("Admin access is required to change priority.");
|
|
688
974
|
}
|
|
689
|
-
|
|
975
|
+
const entry = await ctx.db.get("entries", args.entryId);
|
|
976
|
+
if (entry === null) {
|
|
690
977
|
throw new ConvexError("Entry not found.");
|
|
691
978
|
}
|
|
979
|
+
if (entry.deletingAt !== undefined) {
|
|
980
|
+
throw new ConvexError("Entry is being deleted.");
|
|
981
|
+
}
|
|
692
982
|
await ctx.db.patch("entries", args.entryId, {
|
|
693
983
|
priority: args.priority === null ? undefined : args.priority,
|
|
694
984
|
updatedAt: Date.now(),
|
|
@@ -702,12 +992,42 @@ export const setUpvote = mutation({
|
|
|
702
992
|
actorId: v.string(),
|
|
703
993
|
entryId: v.id("entries"),
|
|
704
994
|
desiredState: v.boolean(),
|
|
995
|
+
includeCallbackContext: v.optional(v.boolean()),
|
|
705
996
|
},
|
|
706
|
-
returns: v.
|
|
997
|
+
returns: v.union(
|
|
998
|
+
v.object({
|
|
999
|
+
active: v.boolean(),
|
|
1000
|
+
upvoteCount: v.number(),
|
|
1001
|
+
}),
|
|
1002
|
+
v.object({
|
|
1003
|
+
changed: v.literal(false),
|
|
1004
|
+
active: v.boolean(),
|
|
1005
|
+
transition: v.null(),
|
|
1006
|
+
previousCount: v.number(),
|
|
1007
|
+
count: v.number(),
|
|
1008
|
+
}),
|
|
1009
|
+
v.object({
|
|
1010
|
+
changed: v.literal(true),
|
|
1011
|
+
active: v.boolean(),
|
|
1012
|
+
transition: v.union(v.literal("added"), v.literal("removed")),
|
|
1013
|
+
previousCount: v.number(),
|
|
1014
|
+
count: v.number(),
|
|
1015
|
+
entry: v.object({
|
|
1016
|
+
id: v.id("entries"),
|
|
1017
|
+
actorId: v.string(),
|
|
1018
|
+
kind: entryKindValidator,
|
|
1019
|
+
status: entryStatusValidator,
|
|
1020
|
+
title: v.string(),
|
|
1021
|
+
}),
|
|
1022
|
+
}),
|
|
1023
|
+
),
|
|
707
1024
|
handler: async (ctx, args) => {
|
|
708
1025
|
assertActorId(args.actorId);
|
|
709
1026
|
const entry = await ctx.db.get("entries", args.entryId);
|
|
710
1027
|
if (entry === null) throw new ConvexError("Entry not found.");
|
|
1028
|
+
if (entry.deletingAt !== undefined) {
|
|
1029
|
+
throw new ConvexError("Entry is being deleted.");
|
|
1030
|
+
}
|
|
711
1031
|
|
|
712
1032
|
const existing = await ctx.db
|
|
713
1033
|
.query("reactions")
|
|
@@ -726,7 +1046,23 @@ export const setUpvote = mutation({
|
|
|
726
1046
|
upvoteCount,
|
|
727
1047
|
statusFilter: entryStatusFilterForStatus(entry.status),
|
|
728
1048
|
});
|
|
729
|
-
|
|
1049
|
+
if (!args.includeCallbackContext) {
|
|
1050
|
+
return { active: true, upvoteCount };
|
|
1051
|
+
}
|
|
1052
|
+
return {
|
|
1053
|
+
changed: true as const,
|
|
1054
|
+
active: true,
|
|
1055
|
+
transition: "added" as const,
|
|
1056
|
+
previousCount: entry.upvoteCount,
|
|
1057
|
+
count: upvoteCount,
|
|
1058
|
+
entry: {
|
|
1059
|
+
id: entry._id,
|
|
1060
|
+
actorId: entry.actorId,
|
|
1061
|
+
kind: entry.kind,
|
|
1062
|
+
status: entry.status,
|
|
1063
|
+
title: entry.title,
|
|
1064
|
+
},
|
|
1065
|
+
};
|
|
730
1066
|
}
|
|
731
1067
|
|
|
732
1068
|
if (!args.desiredState && existing !== null) {
|
|
@@ -736,9 +1072,34 @@ export const setUpvote = mutation({
|
|
|
736
1072
|
upvoteCount,
|
|
737
1073
|
statusFilter: entryStatusFilterForStatus(entry.status),
|
|
738
1074
|
});
|
|
739
|
-
|
|
1075
|
+
if (!args.includeCallbackContext) {
|
|
1076
|
+
return { active: false, upvoteCount };
|
|
1077
|
+
}
|
|
1078
|
+
return {
|
|
1079
|
+
changed: true as const,
|
|
1080
|
+
active: false,
|
|
1081
|
+
transition: "removed" as const,
|
|
1082
|
+
previousCount: entry.upvoteCount,
|
|
1083
|
+
count: upvoteCount,
|
|
1084
|
+
entry: {
|
|
1085
|
+
id: entry._id,
|
|
1086
|
+
actorId: entry.actorId,
|
|
1087
|
+
kind: entry.kind,
|
|
1088
|
+
status: entry.status,
|
|
1089
|
+
title: entry.title,
|
|
1090
|
+
},
|
|
1091
|
+
};
|
|
740
1092
|
}
|
|
741
1093
|
|
|
742
|
-
|
|
1094
|
+
if (!args.includeCallbackContext) {
|
|
1095
|
+
return { active: args.desiredState, upvoteCount: entry.upvoteCount };
|
|
1096
|
+
}
|
|
1097
|
+
return {
|
|
1098
|
+
changed: false as const,
|
|
1099
|
+
active: args.desiredState,
|
|
1100
|
+
transition: null,
|
|
1101
|
+
previousCount: entry.upvoteCount,
|
|
1102
|
+
count: entry.upvoteCount,
|
|
1103
|
+
};
|
|
743
1104
|
},
|
|
744
1105
|
});
|