convex-feedback 0.1.0 → 0.1.2

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 (36) hide show
  1. package/README.md +351 -92
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/client/api.d.ts +267 -68
  4. package/dist/client/api.d.ts.map +1 -1
  5. package/dist/client/config.d.ts +168 -29
  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 +167 -172
  9. package/dist/client/index.d.ts.map +1 -1
  10. package/dist/client/index.js +75 -14
  11. package/dist/client/index.js.map +1 -1
  12. package/dist/component/_generated/component.d.ts +2 -2
  13. package/dist/component/_generated/component.d.ts.map +1 -1
  14. package/dist/component/comments.d.ts +12 -12
  15. package/dist/component/comments.d.ts.map +1 -1
  16. package/dist/component/comments.js +43 -60
  17. package/dist/component/comments.js.map +1 -1
  18. package/dist/component/entries.d.ts +30 -30
  19. package/dist/component/entries.d.ts.map +1 -1
  20. package/dist/component/entries.js +216 -97
  21. package/dist/component/entries.js.map +1 -1
  22. package/dist/component/model.d.ts +151 -0
  23. package/dist/component/model.d.ts.map +1 -1
  24. package/dist/react/index.d.ts +178 -56
  25. package/dist/react/index.d.ts.map +1 -1
  26. package/dist/react/index.js +70 -4
  27. package/dist/react/index.js.map +1 -1
  28. package/package.json +1 -1
  29. package/src/client/api.ts +326 -36
  30. package/src/client/config.ts +191 -29
  31. package/src/client/index.ts +437 -16
  32. package/src/component/_generated/component.ts +2 -2
  33. package/src/component/comments.ts +43 -61
  34. package/src/component/entries.ts +300 -123
  35. package/src/component/model.ts +158 -0
  36. package/src/react/index.ts +217 -9
@@ -2,9 +2,12 @@ import { paginator } from "convex-helpers/server/pagination";
2
2
  import {
3
3
  paginationOptsValidator,
4
4
  paginationResultValidator,
5
+ type PaginationResult,
5
6
  } from "convex/server";
6
7
  import { ConvexError, v } from "convex/values";
7
8
 
9
+ import { mergedStream, stream } from "convex-helpers/server/stream";
10
+ import type { Doc } from "./_generated/dataModel.js";
8
11
  import { mutation, query } from "./_generated/server.js";
9
12
  import {
10
13
  assertActorId,
@@ -19,13 +22,44 @@ import {
19
22
  entryStatusValidator,
20
23
  publicEntryValidator,
21
24
  similarEntriesValidator,
25
+ type EntryKind,
22
26
  } from "./model.js";
23
27
  import schema from "./schema.js";
24
28
 
29
+ const allEntryKinds: readonly EntryKind[] = [
30
+ "feedback",
31
+ "feature_request",
32
+ "bug_report",
33
+ ];
34
+
35
+ function normalizeKindFilter(
36
+ kinds: readonly EntryKind[] | undefined,
37
+ ): EntryKind[] | undefined {
38
+ if (kinds === undefined) {
39
+ return undefined;
40
+ }
41
+
42
+ const uniqueKinds = [...new Set(kinds)];
43
+
44
+ if (uniqueKinds.length === 0) {
45
+ throw new ConvexError(
46
+ "`kinds` must contain at least one entry kind when provided.",
47
+ );
48
+ }
49
+
50
+ // All known kinds is equivalent to no kind filter and lets us use the
51
+ // simpler global indexes.
52
+ if (uniqueKinds.length === allEntryKinds.length) {
53
+ return undefined;
54
+ }
55
+
56
+ return uniqueKinds;
57
+ }
58
+
25
59
  export const list = query({
26
60
  args: {
27
61
  paginationOpts: paginationOptsValidator,
28
- kind: v.optional(entryKindValidator),
62
+ kinds: v.optional(v.array(entryKindValidator)),
29
63
  status: v.optional(entryStatusValidator),
30
64
  sort: entrySortValidator,
31
65
  viewerActorId: v.optional(v.string()),
@@ -33,59 +67,123 @@ export const list = query({
33
67
  returns: paginationResultValidator(publicEntryValidator),
34
68
  handler: async (ctx, args) => {
35
69
  const db = paginator(ctx.db, schema);
36
- const { kind, status } = args;
70
+ const kinds = normalizeKindFilter(args.kinds);
71
+ const { status } = args;
37
72
 
38
- const result =
39
- args.sort === "top"
40
- ? kind !== undefined && status !== undefined
41
- ? await db
42
- .query("entries")
43
- .withIndex("by_kind_status_upvotes", (q) =>
44
- q.eq("kind", kind).eq("status", status),
45
- )
46
- .order("desc")
47
- .paginate(args.paginationOpts)
48
- : kind !== undefined
73
+ let result: PaginationResult<Doc<"entries">>;
74
+
75
+ if (kinds === undefined) {
76
+ result =
77
+ args.sort === "top"
78
+ ? status === undefined
79
+ ? await db
80
+ .query("entries")
81
+ .withIndex("by_upvotes")
82
+ .order("desc")
83
+ .paginate(args.paginationOpts)
84
+ : await db
85
+ .query("entries")
86
+ .withIndex("by_status_upvotes", (q) => q.eq("status", status))
87
+ .order("desc")
88
+ .paginate(args.paginationOpts)
89
+ : status === undefined
90
+ ? await db
91
+ .query("entries")
92
+ .order("desc")
93
+ .paginate(args.paginationOpts)
94
+ : await db
95
+ .query("entries")
96
+ .withIndex("by_status", (q) => q.eq("status", status))
97
+ .order("desc")
98
+ .paginate(args.paginationOpts);
99
+ } else if (kinds.length === 1) {
100
+ const kind = kinds[0];
101
+
102
+ if (kind === undefined) {
103
+ throw new ConvexError("Invalid kind filter.");
104
+ }
105
+
106
+ result =
107
+ args.sort === "top"
108
+ ? status === undefined
49
109
  ? await db
50
110
  .query("entries")
51
111
  .withIndex("by_kind_upvotes", (q) => q.eq("kind", kind))
52
112
  .order("desc")
53
113
  .paginate(args.paginationOpts)
54
- : status !== undefined
55
- ? await db
56
- .query("entries")
57
- .withIndex("by_status_upvotes", (q) => q.eq("status", status))
58
- .order("desc")
59
- .paginate(args.paginationOpts)
60
- : await db
61
- .query("entries")
62
- .withIndex("by_upvotes")
63
- .order("desc")
64
- .paginate(args.paginationOpts)
65
- : kind !== undefined && status !== undefined
66
- ? await db
67
- .query("entries")
68
- .withIndex("by_kind_status", (q) =>
69
- q.eq("kind", kind).eq("status", status),
70
- )
71
- .order("desc")
72
- .paginate(args.paginationOpts)
73
- : kind !== undefined
114
+ : await db
115
+ .query("entries")
116
+ .withIndex("by_kind_status_upvotes", (q) =>
117
+ q.eq("kind", kind).eq("status", status),
118
+ )
119
+ .order("desc")
120
+ .paginate(args.paginationOpts)
121
+ : status === undefined
74
122
  ? await db
75
123
  .query("entries")
76
124
  .withIndex("by_kind", (q) => q.eq("kind", kind))
77
125
  .order("desc")
78
126
  .paginate(args.paginationOpts)
79
- : status !== undefined
80
- ? await db
81
- .query("entries")
82
- .withIndex("by_status", (q) => q.eq("status", status))
83
- .order("desc")
84
- .paginate(args.paginationOpts)
85
- : await db
86
- .query("entries")
87
- .order("desc")
88
- .paginate(args.paginationOpts);
127
+ : await db
128
+ .query("entries")
129
+ .withIndex("by_kind_status", (q) =>
130
+ q.eq("kind", kind).eq("status", status),
131
+ )
132
+ .order("desc")
133
+ .paginate(args.paginationOpts);
134
+ } else if (args.sort === "top") {
135
+ if (status === undefined) {
136
+ const streams = kinds.map((kind) =>
137
+ stream(ctx.db, schema)
138
+ .query("entries")
139
+ .withIndex("by_kind_upvotes", (q) => q.eq("kind", kind))
140
+ .order("desc"),
141
+ );
142
+
143
+ result = await mergedStream(streams, [
144
+ "upvoteCount",
145
+ "_creationTime",
146
+ ]).paginate(args.paginationOpts);
147
+ } else {
148
+ const streams = kinds.map((kind) =>
149
+ stream(ctx.db, schema)
150
+ .query("entries")
151
+ .withIndex("by_kind_status_upvotes", (q) =>
152
+ q.eq("kind", kind).eq("status", status),
153
+ )
154
+ .order("desc"),
155
+ );
156
+
157
+ result = await mergedStream(streams, [
158
+ "upvoteCount",
159
+ "_creationTime",
160
+ ]).paginate(args.paginationOpts);
161
+ }
162
+ } else if (status === undefined) {
163
+ const streams = kinds.map((kind) =>
164
+ stream(ctx.db, schema)
165
+ .query("entries")
166
+ .withIndex("by_kind", (q) => q.eq("kind", kind))
167
+ .order("desc"),
168
+ );
169
+
170
+ result = await mergedStream(streams, ["_creationTime"]).paginate(
171
+ args.paginationOpts,
172
+ );
173
+ } else {
174
+ const streams = kinds.map((kind) =>
175
+ stream(ctx.db, schema)
176
+ .query("entries")
177
+ .withIndex("by_kind_status", (q) =>
178
+ q.eq("kind", kind).eq("status", status),
179
+ )
180
+ .order("desc"),
181
+ );
182
+
183
+ result = await mergedStream(streams, ["_creationTime"]).paginate(
184
+ args.paginationOpts,
185
+ );
186
+ }
89
187
 
90
188
  return {
91
189
  ...result,
@@ -100,14 +198,12 @@ export const list = query({
100
198
 
101
199
  export const get = query({
102
200
  args: {
103
- entryId: v.string(),
201
+ entryId: v.id("entries"),
104
202
  viewerActorId: v.optional(v.string()),
105
203
  },
106
204
  returns: v.union(publicEntryValidator, v.null()),
107
205
  handler: async (ctx, args) => {
108
- const entryId = ctx.db.normalizeId("entries", args.entryId);
109
- if (entryId === null) return null;
110
- const entry = await ctx.db.get("entries", entryId);
206
+ const entry = await ctx.db.get("entries", args.entryId);
111
207
  return entry === null
112
208
  ? null
113
209
  : serializeEntry(ctx, entry, args.viewerActorId);
@@ -117,7 +213,7 @@ export const get = query({
117
213
  export const search = query({
118
214
  args: {
119
215
  searchQuery: v.string(),
120
- kind: v.optional(entryKindValidator),
216
+ kinds: v.optional(v.array(entryKindValidator)),
121
217
  status: v.optional(entryStatusValidator),
122
218
  limit: v.number(),
123
219
  viewerActorId: v.optional(v.string()),
@@ -125,42 +221,88 @@ export const search = query({
125
221
  returns: v.array(publicEntryValidator),
126
222
  handler: async (ctx, args) => {
127
223
  const searchQuery = args.searchQuery.trim();
128
- if (searchQuery.length === 0 || args.limit <= 0) return [];
129
- const { kind, status } = args;
130
224
 
131
- const entries =
132
- kind !== undefined && status !== undefined
133
- ? await ctx.db
134
- .query("entries")
135
- .withSearchIndex("search", (q) =>
136
- q
137
- .search("searchText", searchQuery)
138
- .eq("kind", kind)
139
- .eq("status", status),
140
- )
141
- .take(args.limit)
142
- : kind !== undefined
225
+ if (searchQuery.length === 0 || args.limit <= 0) {
226
+ return [];
227
+ }
228
+
229
+ const kinds = normalizeKindFilter(args.kinds);
230
+ const { status } = args;
231
+
232
+ let entries: Doc<"entries">[];
233
+
234
+ if (kinds === undefined) {
235
+ entries =
236
+ status === undefined
237
+ ? await ctx.db
238
+ .query("entries")
239
+ .withSearchIndex("search", (q) =>
240
+ q.search("searchText", searchQuery),
241
+ )
242
+ .take(args.limit)
243
+ : await ctx.db
244
+ .query("entries")
245
+ .withSearchIndex("search", (q) =>
246
+ q.search("searchText", searchQuery).eq("status", status),
247
+ )
248
+ .take(args.limit);
249
+ } else if (kinds.length === 1) {
250
+ const kind = kinds[0];
251
+
252
+ if (kind === undefined) {
253
+ throw new ConvexError("Invalid kind filter.");
254
+ }
255
+
256
+ entries =
257
+ status === undefined
143
258
  ? await ctx.db
144
259
  .query("entries")
145
260
  .withSearchIndex("search", (q) =>
146
261
  q.search("searchText", searchQuery).eq("kind", kind),
147
262
  )
148
263
  .take(args.limit)
149
- : status !== undefined
150
- ? await ctx.db
151
- .query("entries")
152
- .withSearchIndex("search", (q) =>
153
- q.search("searchText", searchQuery).eq("status", status),
154
- )
155
- .take(args.limit)
156
- : await ctx.db
157
- .query("entries")
158
- .withSearchIndex("search", (q) =>
159
- q.search("searchText", searchQuery),
160
- )
161
- .take(args.limit);
264
+ : await ctx.db
265
+ .query("entries")
266
+ .withSearchIndex("search", (q) =>
267
+ q
268
+ .search("searchText", searchQuery)
269
+ .eq("kind", kind)
270
+ .eq("status", status),
271
+ )
272
+ .take(args.limit);
273
+ } else {
274
+ const firstKind = kinds[0];
275
+ const secondKind = kinds[1];
276
+
277
+ if (firstKind === undefined || secondKind === undefined) {
278
+ throw new ConvexError("Invalid kind filter.");
279
+ }
280
+
281
+ const searchResults =
282
+ status === undefined
283
+ ? ctx.db
284
+ .query("entries")
285
+ .withSearchIndex("search", (q) =>
286
+ q.search("searchText", searchQuery),
287
+ )
288
+ : ctx.db
289
+ .query("entries")
290
+ .withSearchIndex("search", (q) =>
291
+ q.search("searchText", searchQuery).eq("status", status),
292
+ );
293
+
294
+ entries = await searchResults
295
+ // eslint-disable-next-line @convex-dev/no-filter-in-query
296
+ .filter((q) =>
297
+ q.or(
298
+ q.eq(q.field("kind"), firstKind),
299
+ q.eq(q.field("kind"), secondKind),
300
+ ),
301
+ )
302
+ .take(args.limit);
303
+ }
162
304
 
163
- return Promise.all(
305
+ return await Promise.all(
164
306
  entries.map((entry) => serializeEntry(ctx, entry, args.viewerActorId)),
165
307
  );
166
308
  },
@@ -176,16 +318,27 @@ export const similar = query({
176
318
  },
177
319
  returns: similarEntriesValidator,
178
320
  handler: async (ctx, args) => {
179
- if (args.limit <= 0) return { exact: [], similar: [] };
321
+ if (args.limit <= 0) {
322
+ return {
323
+ exact: [],
324
+ similar: [],
325
+ };
326
+ }
180
327
 
181
328
  const title = args.title.trim();
182
329
  const body = args.body.trim();
183
- const { kind } = args;
330
+ const kind = args.kind;
331
+
184
332
  if (title.length === 0 && body.length === 0) {
185
- return { exact: [], similar: [] };
333
+ return {
334
+ exact: [],
335
+ similar: [],
336
+ };
186
337
  }
187
338
 
188
339
  const normalizedTitle = normalizeTitle(title);
340
+
341
+ // Exact normalized-title matches always consume the available limit first.
189
342
  const exactDocs =
190
343
  title.length === 0
191
344
  ? []
@@ -203,38 +356,57 @@ export const similar = query({
203
356
  )
204
357
  .take(args.limit);
205
358
 
206
- const exactIds = new Set(exactDocs.map((entry) => entry._id));
359
+ const exact = await Promise.all(
360
+ exactDocs.map((entry) => serializeEntry(ctx, entry, args.viewerActorId)),
361
+ );
362
+
363
+ const remainingLimit = args.limit - exactDocs.length;
364
+
365
+ if (remainingLimit <= 0) {
366
+ return {
367
+ exact,
368
+ similar: [],
369
+ };
370
+ }
371
+
207
372
  const searchText = `${title}\n${body}`.trim();
208
373
 
374
+ if (searchText.length === 0) {
375
+ return {
376
+ exact,
377
+ similar: [],
378
+ };
379
+ }
380
+
381
+ const exactIds = new Set(exactDocs.map((entry) => entry._id));
382
+
383
+ // We fetch enough candidates to account for exact matches also appearing
384
+ // in the full-text search results. Those duplicates are removed below.
385
+ const candidateLimit = remainingLimit + exactIds.size;
386
+
209
387
  const similarDocs =
210
- searchText.length === 0
211
- ? []
212
- : kind === undefined
213
- ? await ctx.db
214
- .query("entries")
215
- .withSearchIndex("search", (q) =>
216
- q.search("searchText", searchText),
217
- )
218
- .take(args.limit + exactIds.size)
219
- : await ctx.db
220
- .query("entries")
221
- .withSearchIndex("search", (q) =>
222
- q.search("searchText", searchText).eq("kind", kind),
223
- )
224
- .take(args.limit + exactIds.size);
388
+ kind === undefined
389
+ ? await ctx.db
390
+ .query("entries")
391
+ .withSearchIndex("search", (q) =>
392
+ q.search("searchText", searchText),
393
+ )
394
+ .take(candidateLimit)
395
+ : await ctx.db
396
+ .query("entries")
397
+ .withSearchIndex("search", (q) =>
398
+ q.search("searchText", searchText).eq("kind", kind),
399
+ )
400
+ .take(candidateLimit);
225
401
 
226
- const similarFiltered = similarDocs
402
+ const similarDocsWithoutExactMatches = similarDocs
227
403
  .filter((entry) => !exactIds.has(entry._id))
228
- .slice(0, args.limit);
404
+ .slice(0, remainingLimit);
229
405
 
230
406
  return {
231
- exact: await Promise.all(
232
- exactDocs.map((entry) =>
233
- serializeEntry(ctx, entry, args.viewerActorId),
234
- ),
235
- ),
407
+ exact,
236
408
  similar: await Promise.all(
237
- similarFiltered.map((entry) =>
409
+ similarDocsWithoutExactMatches.map((entry) =>
238
410
  serializeEntry(ctx, entry, args.viewerActorId),
239
411
  ),
240
412
  ),
@@ -253,7 +425,7 @@ export const create = mutation({
253
425
  maxTitleLength: v.number(),
254
426
  maxBodyLength: v.number(),
255
427
  },
256
- returns: v.string(),
428
+ returns: v.id("entries"),
257
429
  handler: async (ctx, args) => {
258
430
  assertActorId(args.actorId);
259
431
  if (!args.enabledKinds.includes(args.kind)) {
@@ -267,7 +439,7 @@ export const create = mutation({
267
439
  );
268
440
  const body = normalizeRequiredText(args.body, "Body", args.maxBodyLength);
269
441
 
270
- return await ctx.db.insert("entries", {
442
+ const entry = await ctx.db.insert("entries", {
271
443
  actorId: args.actorId,
272
444
  kind: args.kind,
273
445
  status: args.defaultStatus,
@@ -275,16 +447,23 @@ export const create = mutation({
275
447
  body,
276
448
  normalizedTitle: normalizeTitle(title),
277
449
  searchText: `${title}\n${body}`,
278
- upvoteCount: 0,
450
+ upvoteCount: 1,
279
451
  commentCount: 0,
280
452
  });
453
+
454
+ await ctx.db.insert("reactions", {
455
+ actorId: args.actorId,
456
+ entryId: entry,
457
+ });
458
+
459
+ return entry;
281
460
  },
282
461
  });
283
462
 
284
463
  export const update = mutation({
285
464
  args: {
286
465
  actor: actorValidator,
287
- entryId: v.string(),
466
+ entryId: v.id("entries"),
288
467
  title: v.string(),
289
468
  body: v.string(),
290
469
  editableByAuthor: v.boolean(),
@@ -294,9 +473,7 @@ export const update = mutation({
294
473
  returns: v.null(),
295
474
  handler: async (ctx, args) => {
296
475
  assertActorId(args.actor.id);
297
- const entryId = ctx.db.normalizeId("entries", args.entryId);
298
- if (entryId === null) throw new ConvexError("Entry not found.");
299
- const entry = await ctx.db.get("entries", entryId);
476
+ const entry = await ctx.db.get("entries", args.entryId);
300
477
  if (entry === null) throw new ConvexError("Entry not found.");
301
478
 
302
479
  const canEdit =
@@ -311,7 +488,7 @@ export const update = mutation({
311
488
  );
312
489
  const body = normalizeRequiredText(args.body, "Body", args.maxBodyLength);
313
490
 
314
- await ctx.db.patch("entries", entryId, {
491
+ await ctx.db.patch("entries", args.entryId, {
315
492
  title,
316
493
  body,
317
494
  normalizedTitle: normalizeTitle(title),
@@ -325,7 +502,7 @@ export const update = mutation({
325
502
  export const setStatus = mutation({
326
503
  args: {
327
504
  actor: actorValidator,
328
- entryId: v.string(),
505
+ entryId: v.id("entries"),
329
506
  status: entryStatusValidator,
330
507
  },
331
508
  returns: v.null(),
@@ -333,11 +510,10 @@ export const setStatus = mutation({
333
510
  if (!args.actor.isModerator) {
334
511
  throw new ConvexError("Moderator access is required to change status.");
335
512
  }
336
- const entryId = ctx.db.normalizeId("entries", args.entryId);
337
- if (entryId === null || (await ctx.db.get("entries", entryId)) === null) {
513
+ if ((await ctx.db.get("entries", args.entryId)) === null) {
338
514
  throw new ConvexError("Entry not found.");
339
515
  }
340
- await ctx.db.patch("entries", entryId, {
516
+ await ctx.db.patch("entries", args.entryId, {
341
517
  status: args.status,
342
518
  updatedAt: Date.now(),
343
519
  });
@@ -348,35 +524,36 @@ export const setStatus = mutation({
348
524
  export const setUpvote = mutation({
349
525
  args: {
350
526
  actorId: v.string(),
351
- entryId: v.string(),
527
+ entryId: v.id("entries"),
352
528
  desiredState: v.boolean(),
353
529
  },
354
530
  returns: v.object({ active: v.boolean(), upvoteCount: v.number() }),
355
531
  handler: async (ctx, args) => {
356
532
  assertActorId(args.actorId);
357
- const entryId = ctx.db.normalizeId("entries", args.entryId);
358
- if (entryId === null) throw new ConvexError("Entry not found.");
359
- const entry = await ctx.db.get("entries", entryId);
533
+ const entry = await ctx.db.get("entries", args.entryId);
360
534
  if (entry === null) throw new ConvexError("Entry not found.");
361
535
 
362
536
  const existing = await ctx.db
363
537
  .query("reactions")
364
538
  .withIndex("by_entry_actor", (q) =>
365
- q.eq("entryId", entryId).eq("actorId", args.actorId),
539
+ q.eq("entryId", args.entryId).eq("actorId", args.actorId),
366
540
  )
367
541
  .unique();
368
542
 
369
543
  if (args.desiredState && existing === null) {
370
- await ctx.db.insert("reactions", { actorId: args.actorId, entryId });
544
+ await ctx.db.insert("reactions", {
545
+ actorId: args.actorId,
546
+ entryId: args.entryId,
547
+ });
371
548
  const upvoteCount = entry.upvoteCount + 1;
372
- await ctx.db.patch("entries", entryId, { upvoteCount });
549
+ await ctx.db.patch("entries", args.entryId, { upvoteCount });
373
550
  return { active: true, upvoteCount };
374
551
  }
375
552
 
376
553
  if (!args.desiredState && existing !== null) {
377
554
  await ctx.db.delete("reactions", existing._id);
378
555
  const upvoteCount = Math.max(0, entry.upvoteCount - 1);
379
- await ctx.db.patch("entries", entryId, { upvoteCount });
556
+ await ctx.db.patch("entries", args.entryId, { upvoteCount });
380
557
  return { active: false, upvoteCount };
381
558
  }
382
559