convex-feedback 0.1.0 → 0.1.1

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.
@@ -43,12 +43,36 @@ export {
43
43
  type FeedbackConfigOverrides,
44
44
  } from "./config.js";
45
45
 
46
+ /**
47
+ * Minimal host context exposed to the actor resolver.
48
+ *
49
+ * The feedback component itself does not read host authentication. Use
50
+ * `auth` to resolve the current host identity into a `FeedbackActor`.
51
+ */
46
52
  export interface FeedbackAuthContext {
53
+ /** Host Convex authentication interface for the current request. */
47
54
  auth: Auth;
48
55
  }
49
56
 
57
+ /**
58
+ * Configuration used when exposing feedback functions from the host Convex
59
+ * application.
60
+ */
50
61
  export interface ExposeFeedbackOptions {
62
+ /**
63
+ * Resolves the current request into a stable feedback actor.
64
+ *
65
+ * Return `null` for an unauthenticated request. Read-only queries may still
66
+ * execute, but mutations requiring an actor will reject unauthenticated
67
+ * callers.
68
+ */
51
69
  actor: (ctx: FeedbackAuthContext) => Promise<FeedbackActor | null>;
70
+
71
+ /**
72
+ * Optional component behavior overrides.
73
+ *
74
+ * Unspecified values use `defaultFeedbackConfig`.
75
+ */
52
76
  config?: FeedbackConfigOverrides;
53
77
  }
54
78
 
@@ -95,19 +119,20 @@ export function exposeFeedbackApi<Name extends string | undefined>(
95
119
  listEntries: queryGeneric({
96
120
  args: {
97
121
  paginationOpts: paginationOptsValidator,
98
- kind: v.optional(entryKindValidator),
122
+ kinds: v.optional(v.array(entryKindValidator)),
99
123
  status: v.optional(entryStatusValidator),
100
124
  sort: v.optional(entrySortValidator),
101
125
  },
102
126
  returns: paginationResultValidator(publicEntryValidator),
103
127
  handler: async (ctx, args) => {
104
128
  const actor = await options.actor(ctx);
129
+
105
130
  return await ctx.runQuery(component.entries.list, {
106
131
  paginationOpts: clampPagination(
107
132
  args.paginationOpts,
108
133
  config.entries.maxPageSize,
109
134
  ),
110
- ...(args.kind === undefined ? {} : { kind: args.kind }),
135
+ ...(args.kinds === undefined ? {} : { kinds: args.kinds }),
111
136
  ...(args.status === undefined ? {} : { status: args.status }),
112
137
  sort: args.sort ?? config.entries.defaultSort,
113
138
  ...actorIdFields(actor),
@@ -130,16 +155,17 @@ export function exposeFeedbackApi<Name extends string | undefined>(
130
155
  searchEntries: queryGeneric({
131
156
  args: {
132
157
  searchQuery: v.string(),
133
- kind: v.optional(entryKindValidator),
158
+ kinds: v.optional(v.array(entryKindValidator)),
134
159
  status: v.optional(entryStatusValidator),
135
160
  limit: v.optional(v.number()),
136
161
  },
137
162
  returns: v.array(publicEntryValidator),
138
163
  handler: async (ctx, args) => {
139
164
  const actor = await options.actor(ctx);
165
+
140
166
  return await ctx.runQuery(component.entries.search, {
141
167
  searchQuery: args.searchQuery,
142
- ...(args.kind === undefined ? {} : { kind: args.kind }),
168
+ ...(args.kinds === undefined ? {} : { kinds: args.kinds }),
143
169
  ...(args.status === undefined ? {} : { status: args.status }),
144
170
  limit: clampPositive(
145
171
  args.limit,
@@ -163,7 +189,9 @@ export function exposeFeedbackApi<Name extends string | undefined>(
163
189
  if (!config.search.duplicateSuggestions) {
164
190
  return { exact: [], similar: [] };
165
191
  }
192
+
166
193
  const actor = await options.actor(ctx);
194
+
167
195
  return await ctx.runQuery(component.entries.similar, {
168
196
  title: args.title,
169
197
  body: args.body,
@@ -161,7 +161,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
161
161
  "query",
162
162
  "internal",
163
163
  {
164
- kind?: "feedback" | "feature_request" | "bug_report";
164
+ kinds?: Array<"feedback" | "feature_request" | "bug_report">;
165
165
  paginationOpts: {
166
166
  cursor: string | null;
167
167
  endCursor?: string | null;
@@ -211,7 +211,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
211
211
  "query",
212
212
  "internal",
213
213
  {
214
- kind?: "feedback" | "feature_request" | "bug_report";
214
+ kinds?: Array<"feedback" | "feature_request" | "bug_report">;
215
215
  limit: number;
216
216
  searchQuery: string;
217
217
  status?:
@@ -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,
@@ -117,7 +215,7 @@ export const get = query({
117
215
  export const search = query({
118
216
  args: {
119
217
  searchQuery: v.string(),
120
- kind: v.optional(entryKindValidator),
218
+ kinds: v.optional(v.array(entryKindValidator)),
121
219
  status: v.optional(entryStatusValidator),
122
220
  limit: v.number(),
123
221
  viewerActorId: v.optional(v.string()),
@@ -125,42 +223,88 @@ export const search = query({
125
223
  returns: v.array(publicEntryValidator),
126
224
  handler: async (ctx, args) => {
127
225
  const searchQuery = args.searchQuery.trim();
128
- if (searchQuery.length === 0 || args.limit <= 0) return [];
129
- const { kind, status } = args;
130
226
 
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
227
+ if (searchQuery.length === 0 || args.limit <= 0) {
228
+ return [];
229
+ }
230
+
231
+ const kinds = normalizeKindFilter(args.kinds);
232
+ const { status } = args;
233
+
234
+ let entries: Doc<"entries">[];
235
+
236
+ if (kinds === undefined) {
237
+ entries =
238
+ status === undefined
239
+ ? await ctx.db
240
+ .query("entries")
241
+ .withSearchIndex("search", (q) =>
242
+ q.search("searchText", searchQuery),
243
+ )
244
+ .take(args.limit)
245
+ : await ctx.db
246
+ .query("entries")
247
+ .withSearchIndex("search", (q) =>
248
+ q.search("searchText", searchQuery).eq("status", status),
249
+ )
250
+ .take(args.limit);
251
+ } else if (kinds.length === 1) {
252
+ const kind = kinds[0];
253
+
254
+ if (kind === undefined) {
255
+ throw new ConvexError("Invalid kind filter.");
256
+ }
257
+
258
+ entries =
259
+ status === undefined
143
260
  ? await ctx.db
144
261
  .query("entries")
145
262
  .withSearchIndex("search", (q) =>
146
263
  q.search("searchText", searchQuery).eq("kind", kind),
147
264
  )
148
265
  .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);
266
+ : await ctx.db
267
+ .query("entries")
268
+ .withSearchIndex("search", (q) =>
269
+ q
270
+ .search("searchText", searchQuery)
271
+ .eq("kind", kind)
272
+ .eq("status", status),
273
+ )
274
+ .take(args.limit);
275
+ } else {
276
+ const firstKind = kinds[0];
277
+ const secondKind = kinds[1];
278
+
279
+ if (firstKind === undefined || secondKind === undefined) {
280
+ throw new ConvexError("Invalid kind filter.");
281
+ }
282
+
283
+ const searchResults =
284
+ status === undefined
285
+ ? ctx.db
286
+ .query("entries")
287
+ .withSearchIndex("search", (q) =>
288
+ q.search("searchText", searchQuery),
289
+ )
290
+ : ctx.db
291
+ .query("entries")
292
+ .withSearchIndex("search", (q) =>
293
+ q.search("searchText", searchQuery).eq("status", status),
294
+ );
295
+
296
+ entries = await searchResults
297
+ // eslint-disable-next-line @convex-dev/no-filter-in-query
298
+ .filter((q) =>
299
+ q.or(
300
+ q.eq(q.field("kind"), firstKind),
301
+ q.eq(q.field("kind"), secondKind),
302
+ ),
303
+ )
304
+ .take(args.limit);
305
+ }
162
306
 
163
- return Promise.all(
307
+ return await Promise.all(
164
308
  entries.map((entry) => serializeEntry(ctx, entry, args.viewerActorId)),
165
309
  );
166
310
  },
@@ -176,16 +320,27 @@ export const similar = query({
176
320
  },
177
321
  returns: similarEntriesValidator,
178
322
  handler: async (ctx, args) => {
179
- if (args.limit <= 0) return { exact: [], similar: [] };
323
+ if (args.limit <= 0) {
324
+ return {
325
+ exact: [],
326
+ similar: [],
327
+ };
328
+ }
180
329
 
181
330
  const title = args.title.trim();
182
331
  const body = args.body.trim();
183
- const { kind } = args;
332
+ const kind = args.kind;
333
+
184
334
  if (title.length === 0 && body.length === 0) {
185
- return { exact: [], similar: [] };
335
+ return {
336
+ exact: [],
337
+ similar: [],
338
+ };
186
339
  }
187
340
 
188
341
  const normalizedTitle = normalizeTitle(title);
342
+
343
+ // Exact normalized-title matches always consume the available limit first.
189
344
  const exactDocs =
190
345
  title.length === 0
191
346
  ? []
@@ -203,38 +358,57 @@ export const similar = query({
203
358
  )
204
359
  .take(args.limit);
205
360
 
206
- const exactIds = new Set(exactDocs.map((entry) => entry._id));
361
+ const exact = await Promise.all(
362
+ exactDocs.map((entry) => serializeEntry(ctx, entry, args.viewerActorId)),
363
+ );
364
+
365
+ const remainingLimit = args.limit - exactDocs.length;
366
+
367
+ if (remainingLimit <= 0) {
368
+ return {
369
+ exact,
370
+ similar: [],
371
+ };
372
+ }
373
+
207
374
  const searchText = `${title}\n${body}`.trim();
208
375
 
376
+ if (searchText.length === 0) {
377
+ return {
378
+ exact,
379
+ similar: [],
380
+ };
381
+ }
382
+
383
+ const exactIds = new Set(exactDocs.map((entry) => entry._id));
384
+
385
+ // We fetch enough candidates to account for exact matches also appearing
386
+ // in the full-text search results. Those duplicates are removed below.
387
+ const candidateLimit = remainingLimit + exactIds.size;
388
+
209
389
  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);
390
+ kind === undefined
391
+ ? await ctx.db
392
+ .query("entries")
393
+ .withSearchIndex("search", (q) =>
394
+ q.search("searchText", searchText),
395
+ )
396
+ .take(candidateLimit)
397
+ : await ctx.db
398
+ .query("entries")
399
+ .withSearchIndex("search", (q) =>
400
+ q.search("searchText", searchText).eq("kind", kind),
401
+ )
402
+ .take(candidateLimit);
225
403
 
226
- const similarFiltered = similarDocs
404
+ const similarDocsWithoutExactMatches = similarDocs
227
405
  .filter((entry) => !exactIds.has(entry._id))
228
- .slice(0, args.limit);
406
+ .slice(0, remainingLimit);
229
407
 
230
408
  return {
231
- exact: await Promise.all(
232
- exactDocs.map((entry) =>
233
- serializeEntry(ctx, entry, args.viewerActorId),
234
- ),
235
- ),
409
+ exact,
236
410
  similar: await Promise.all(
237
- similarFiltered.map((entry) =>
411
+ similarDocsWithoutExactMatches.map((entry) =>
238
412
  serializeEntry(ctx, entry, args.viewerActorId),
239
413
  ),
240
414
  ),
@@ -267,7 +441,7 @@ export const create = mutation({
267
441
  );
268
442
  const body = normalizeRequiredText(args.body, "Body", args.maxBodyLength);
269
443
 
270
- return await ctx.db.insert("entries", {
444
+ const entry = await ctx.db.insert("entries", {
271
445
  actorId: args.actorId,
272
446
  kind: args.kind,
273
447
  status: args.defaultStatus,
@@ -275,9 +449,16 @@ export const create = mutation({
275
449
  body,
276
450
  normalizedTitle: normalizeTitle(title),
277
451
  searchText: `${title}\n${body}`,
278
- upvoteCount: 0,
452
+ upvoteCount: 1,
279
453
  commentCount: 0,
280
454
  });
455
+
456
+ await ctx.db.insert("reactions", {
457
+ actorId: args.actorId,
458
+ entryId: entry,
459
+ });
460
+
461
+ return entry;
281
462
  },
282
463
  });
283
464