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
|
@@ -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 {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|