convex-feedback 0.1.3 → 0.1.5
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 +33 -0
- package/dist/.tsbuildinfo +1 -1
- package/dist/client/api.d.ts +7 -1
- package/dist/client/api.d.ts.map +1 -1
- package/dist/client/index.d.ts +1 -1
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +12 -1
- package/dist/client/index.js.map +1 -1
- package/dist/component/_generated/api.d.ts +4 -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 +27 -0
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/comments.d.ts +8 -8
- package/dist/component/comments.d.ts.map +1 -1
- package/dist/component/comments.js +2 -1
- package/dist/component/comments.js.map +1 -1
- package/dist/component/crons.d.ts +3 -0
- package/dist/component/crons.d.ts.map +1 -0
- package/dist/component/crons.js +8 -0
- package/dist/component/crons.js.map +1 -0
- package/dist/component/entries.d.ts +53 -26
- package/dist/component/entries.d.ts.map +1 -1
- package/dist/component/entries.js +116 -9
- package/dist/component/entries.js.map +1 -1
- package/dist/component/helpers.d.ts +2 -1
- package/dist/component/helpers.d.ts.map +1 -1
- package/dist/component/helpers.js +46 -1
- package/dist/component/helpers.js.map +1 -1
- package/dist/component/migrations.d.ts +11 -0
- package/dist/component/migrations.d.ts.map +1 -0
- package/dist/component/migrations.js +35 -0
- package/dist/component/migrations.js.map +1 -0
- package/dist/component/model.d.ts +74 -3
- package/dist/component/model.d.ts.map +1 -1
- package/dist/component/model.js +11 -0
- package/dist/component/model.js.map +1 -1
- package/dist/component/schema.d.ts +19 -2
- package/dist/component/schema.d.ts.map +1 -1
- package/dist/component/schema.js +12 -2
- package/dist/component/schema.js.map +1 -1
- package/dist/react/index.d.ts +27 -3
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js +6 -0
- package/dist/react/index.js.map +1 -1
- package/dist/test.d.ts +19 -2
- package/dist/test.d.ts.map +1 -1
- package/package.json +6 -2
- package/src/client/api.ts +11 -0
- package/src/client/index.ts +16 -0
- package/src/component/README.md +2 -0
- package/src/component/_generated/api.ts +4 -0
- package/src/component/_generated/component.ts +31 -1
- package/src/component/comments.ts +2 -0
- package/src/component/crons.ts +16 -0
- package/src/component/entries.ts +158 -7
- package/src/component/helpers.ts +70 -0
- package/src/component/migrations.ts +44 -0
- package/src/component/model.ts +44 -0
- package/src/component/schema.ts +17 -2
- package/src/react/index.ts +13 -0
package/src/client/api.ts
CHANGED
|
@@ -9,8 +9,10 @@ import type {
|
|
|
9
9
|
EntryKind,
|
|
10
10
|
EntrySort,
|
|
11
11
|
EntryStatus,
|
|
12
|
+
EntryStatusFilter,
|
|
12
13
|
FeedbackComment,
|
|
13
14
|
FeedbackEntry,
|
|
15
|
+
FeedbackMetadata,
|
|
14
16
|
SimilarEntriesResult,
|
|
15
17
|
} from "../component/model.js";
|
|
16
18
|
|
|
@@ -39,6 +41,9 @@ export type ListEntriesArgs = {
|
|
|
39
41
|
/** Restricts entries to this workflow status. */
|
|
40
42
|
status?: EntryStatus;
|
|
41
43
|
|
|
44
|
+
/** Restricts entries to the indexed open or closed board bucket. */
|
|
45
|
+
statusFilter?: EntryStatusFilter;
|
|
46
|
+
|
|
42
47
|
/**
|
|
43
48
|
* Server-side ordering strategy.
|
|
44
49
|
*
|
|
@@ -73,6 +78,9 @@ export type SearchEntriesArgs = {
|
|
|
73
78
|
/** Optional workflow-status restriction. */
|
|
74
79
|
status?: EntryStatus;
|
|
75
80
|
|
|
81
|
+
/** Optional indexed open or closed board bucket. */
|
|
82
|
+
statusFilter?: EntryStatusFilter;
|
|
83
|
+
|
|
76
84
|
/**
|
|
77
85
|
* Maximum number of results to return.
|
|
78
86
|
*
|
|
@@ -134,6 +142,9 @@ export type CreateEntryArgs = {
|
|
|
134
142
|
|
|
135
143
|
/** Entry description/body. */
|
|
136
144
|
body: string;
|
|
145
|
+
|
|
146
|
+
/** Optional creation-time diagnostic metadata. */
|
|
147
|
+
metadata?: FeedbackMetadata;
|
|
137
148
|
};
|
|
138
149
|
|
|
139
150
|
/**
|
package/src/client/index.ts
CHANGED
|
@@ -26,7 +26,9 @@ import {
|
|
|
26
26
|
commentSortValidator,
|
|
27
27
|
entryKindValidator,
|
|
28
28
|
entrySortValidator,
|
|
29
|
+
entryStatusFilterValidator,
|
|
29
30
|
entryStatusValidator,
|
|
31
|
+
feedbackMetadataValidator,
|
|
30
32
|
publicCommentValidator,
|
|
31
33
|
publicEntryValidator,
|
|
32
34
|
similarEntriesValidator,
|
|
@@ -43,9 +45,12 @@ export type {
|
|
|
43
45
|
EntryKind,
|
|
44
46
|
EntrySort,
|
|
45
47
|
EntryStatus,
|
|
48
|
+
EntryStatusFilter,
|
|
46
49
|
FeedbackActor,
|
|
47
50
|
FeedbackComment,
|
|
48
51
|
FeedbackEntry,
|
|
52
|
+
FeedbackMetadata,
|
|
53
|
+
FeedbackMetadataValue,
|
|
49
54
|
SimilarEntriesResult,
|
|
50
55
|
} from "../component/model.js";
|
|
51
56
|
export type { FeedbackPublicApi } from "./api.js";
|
|
@@ -425,6 +430,7 @@ function buildFeedbackApi<
|
|
|
425
430
|
paginationOpts: paginationOptsValidator,
|
|
426
431
|
kinds: v.optional(v.array(entryKindValidator)),
|
|
427
432
|
status: v.optional(entryStatusValidator),
|
|
433
|
+
statusFilter: v.optional(entryStatusFilterValidator),
|
|
428
434
|
sort: v.optional(entrySortValidator),
|
|
429
435
|
},
|
|
430
436
|
returns: paginationResultValidator(publicEntryValidator),
|
|
@@ -438,6 +444,9 @@ function buildFeedbackApi<
|
|
|
438
444
|
),
|
|
439
445
|
...(args.kinds === undefined ? {} : { kinds: args.kinds }),
|
|
440
446
|
...(args.status === undefined ? {} : { status: args.status }),
|
|
447
|
+
...(args.statusFilter === undefined
|
|
448
|
+
? {}
|
|
449
|
+
: { statusFilter: args.statusFilter }),
|
|
441
450
|
sort: args.sort ?? config.entries.defaultSort,
|
|
442
451
|
...actorIdFields(actor),
|
|
443
452
|
});
|
|
@@ -452,6 +461,7 @@ function buildFeedbackApi<
|
|
|
452
461
|
return await ctx.runQuery(component.entries.get, {
|
|
453
462
|
entryId: args.entryId,
|
|
454
463
|
...actorIdFields(actor),
|
|
464
|
+
...(actor?.isModerator === true ? { viewerIsModerator: true } : {}),
|
|
455
465
|
});
|
|
456
466
|
},
|
|
457
467
|
}),
|
|
@@ -461,6 +471,7 @@ function buildFeedbackApi<
|
|
|
461
471
|
searchQuery: v.string(),
|
|
462
472
|
kinds: v.optional(v.array(entryKindValidator)),
|
|
463
473
|
status: v.optional(entryStatusValidator),
|
|
474
|
+
statusFilter: v.optional(entryStatusFilterValidator),
|
|
464
475
|
limit: v.optional(v.number()),
|
|
465
476
|
},
|
|
466
477
|
returns: v.array(publicEntryValidator),
|
|
@@ -471,6 +482,9 @@ function buildFeedbackApi<
|
|
|
471
482
|
searchQuery: args.searchQuery,
|
|
472
483
|
...(args.kinds === undefined ? {} : { kinds: args.kinds }),
|
|
473
484
|
...(args.status === undefined ? {} : { status: args.status }),
|
|
485
|
+
...(args.statusFilter === undefined
|
|
486
|
+
? {}
|
|
487
|
+
: { statusFilter: args.statusFilter }),
|
|
474
488
|
limit: clampPositive(
|
|
475
489
|
args.limit,
|
|
476
490
|
config.search.defaultLimit,
|
|
@@ -515,6 +529,7 @@ function buildFeedbackApi<
|
|
|
515
529
|
kind: entryKindValidator,
|
|
516
530
|
title: v.string(),
|
|
517
531
|
body: v.string(),
|
|
532
|
+
metadata: v.optional(feedbackMetadataValidator),
|
|
518
533
|
},
|
|
519
534
|
returns: idReturns,
|
|
520
535
|
handler: async (ctx, args) => {
|
|
@@ -535,6 +550,7 @@ function buildFeedbackApi<
|
|
|
535
550
|
enabledKinds: [...config.entries.enabledKinds],
|
|
536
551
|
maxTitleLength: config.limits.titleLength,
|
|
537
552
|
maxBodyLength: config.limits.bodyLength,
|
|
553
|
+
...(args.metadata === undefined ? {} : { metadata: args.metadata }),
|
|
538
554
|
});
|
|
539
555
|
},
|
|
540
556
|
}),
|
package/src/component/README.md
CHANGED
|
@@ -5,6 +5,8 @@ This directory is the isolated Convex component backend.
|
|
|
5
5
|
- `schema.ts`: three tables and query-driven indexes.
|
|
6
6
|
- `entries.ts`: entry CRUD, full-text search, duplicate suggestions, entry voting, indexed pagination.
|
|
7
7
|
- `comments.ts`: direct-child pagination, recursive write validation, soft deletion, comment likes.
|
|
8
|
+
- `migrations.ts`: bounded, idempotent legacy-entry backfills.
|
|
9
|
+
- `crons.ts`: immediate upgrade migration and temporary 30-day self-healing schedule.
|
|
8
10
|
- `model.ts`: reusable validators and public model types.
|
|
9
11
|
- `helpers.ts`: normalization, validation, and serializers.
|
|
10
12
|
- `convex.config.ts`: packaged component definition.
|
|
@@ -9,8 +9,10 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import type * as comments from "../comments.js";
|
|
12
|
+
import type * as crons from "../crons.js";
|
|
12
13
|
import type * as entries from "../entries.js";
|
|
13
14
|
import type * as helpers from "../helpers.js";
|
|
15
|
+
import type * as migrations from "../migrations.js";
|
|
14
16
|
import type * as model from "../model.js";
|
|
15
17
|
import type * as types from "../types.js";
|
|
16
18
|
|
|
@@ -23,8 +25,10 @@ import { anyApi, componentsGeneric } from "convex/server";
|
|
|
23
25
|
|
|
24
26
|
const fullApi: ApiFromModules<{
|
|
25
27
|
comments: typeof comments;
|
|
28
|
+
crons: typeof crons;
|
|
26
29
|
entries: typeof entries;
|
|
27
30
|
helpers: typeof helpers;
|
|
31
|
+
migrations: typeof migrations;
|
|
28
32
|
model: typeof model;
|
|
29
33
|
types: typeof types;
|
|
30
34
|
}> = anyApi as any;
|
|
@@ -127,6 +127,10 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
127
127
|
kind: "feedback" | "feature_request" | "bug_report";
|
|
128
128
|
maxBodyLength: number;
|
|
129
129
|
maxTitleLength: number;
|
|
130
|
+
metadata?: {
|
|
131
|
+
additional?: Record<string, string | number | boolean>;
|
|
132
|
+
standard?: Record<string, string | number | boolean>;
|
|
133
|
+
};
|
|
130
134
|
title: string;
|
|
131
135
|
},
|
|
132
136
|
string,
|
|
@@ -135,7 +139,11 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
135
139
|
get: FunctionReference<
|
|
136
140
|
"query",
|
|
137
141
|
"internal",
|
|
138
|
-
{
|
|
142
|
+
{
|
|
143
|
+
entryId: string;
|
|
144
|
+
viewerActorId?: string;
|
|
145
|
+
viewerIsModerator?: boolean;
|
|
146
|
+
},
|
|
139
147
|
{
|
|
140
148
|
actorId: string;
|
|
141
149
|
body: string;
|
|
@@ -143,6 +151,10 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
143
151
|
creationTime: number;
|
|
144
152
|
id: string;
|
|
145
153
|
kind: "feedback" | "feature_request" | "bug_report";
|
|
154
|
+
metadata?: {
|
|
155
|
+
additional?: Record<string, string | number | boolean>;
|
|
156
|
+
standard?: Record<string, string | number | boolean>;
|
|
157
|
+
};
|
|
146
158
|
status:
|
|
147
159
|
| "open"
|
|
148
160
|
| "under_review"
|
|
@@ -178,6 +190,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
178
190
|
| "in_progress"
|
|
179
191
|
| "completed"
|
|
180
192
|
| "closed";
|
|
193
|
+
statusFilter?: "open" | "closed";
|
|
181
194
|
viewerActorId?: string;
|
|
182
195
|
},
|
|
183
196
|
{
|
|
@@ -190,6 +203,10 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
190
203
|
creationTime: number;
|
|
191
204
|
id: string;
|
|
192
205
|
kind: "feedback" | "feature_request" | "bug_report";
|
|
206
|
+
metadata?: {
|
|
207
|
+
additional?: Record<string, string | number | boolean>;
|
|
208
|
+
standard?: Record<string, string | number | boolean>;
|
|
209
|
+
};
|
|
193
210
|
status:
|
|
194
211
|
| "open"
|
|
195
212
|
| "under_review"
|
|
@@ -221,6 +238,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
221
238
|
| "in_progress"
|
|
222
239
|
| "completed"
|
|
223
240
|
| "closed";
|
|
241
|
+
statusFilter?: "open" | "closed";
|
|
224
242
|
viewerActorId?: string;
|
|
225
243
|
},
|
|
226
244
|
Array<{
|
|
@@ -230,6 +248,10 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
230
248
|
creationTime: number;
|
|
231
249
|
id: string;
|
|
232
250
|
kind: "feedback" | "feature_request" | "bug_report";
|
|
251
|
+
metadata?: {
|
|
252
|
+
additional?: Record<string, string | number | boolean>;
|
|
253
|
+
standard?: Record<string, string | number | boolean>;
|
|
254
|
+
};
|
|
233
255
|
status:
|
|
234
256
|
| "open"
|
|
235
257
|
| "under_review"
|
|
@@ -286,6 +308,10 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
286
308
|
creationTime: number;
|
|
287
309
|
id: string;
|
|
288
310
|
kind: "feedback" | "feature_request" | "bug_report";
|
|
311
|
+
metadata?: {
|
|
312
|
+
additional?: Record<string, string | number | boolean>;
|
|
313
|
+
standard?: Record<string, string | number | boolean>;
|
|
314
|
+
};
|
|
289
315
|
status:
|
|
290
316
|
| "open"
|
|
291
317
|
| "under_review"
|
|
@@ -305,6 +331,10 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
305
331
|
creationTime: number;
|
|
306
332
|
id: string;
|
|
307
333
|
kind: "feedback" | "feature_request" | "bug_report";
|
|
334
|
+
metadata?: {
|
|
335
|
+
additional?: Record<string, string | number | boolean>;
|
|
336
|
+
standard?: Record<string, string | number | boolean>;
|
|
337
|
+
};
|
|
308
338
|
status:
|
|
309
339
|
| "open"
|
|
310
340
|
| "under_review"
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
import {
|
|
16
16
|
actorValidator,
|
|
17
17
|
commentSortValidator,
|
|
18
|
+
entryStatusFilterForStatus,
|
|
18
19
|
publicCommentValidator,
|
|
19
20
|
} from "./model.js";
|
|
20
21
|
import schema from "./schema.js";
|
|
@@ -121,6 +122,7 @@ export const create = mutation({
|
|
|
121
122
|
|
|
122
123
|
await ctx.db.patch("entries", args.entryId, {
|
|
123
124
|
commentCount: entry.commentCount + 1,
|
|
125
|
+
statusFilter: entryStatusFilterForStatus(entry.status),
|
|
124
126
|
});
|
|
125
127
|
|
|
126
128
|
if (args.parentCommentId !== undefined) {
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { cronJobs } from "convex/server";
|
|
2
|
+
|
|
3
|
+
import { internal } from "./_generated/api.js";
|
|
4
|
+
|
|
5
|
+
const crons = cronJobs();
|
|
6
|
+
|
|
7
|
+
// Interval jobs run as soon as they are first deployed. The 30-day repeat is a
|
|
8
|
+
// temporary, low-frequency self-healing safeguard for missed legacy records.
|
|
9
|
+
crons.interval(
|
|
10
|
+
"self-heal entry status filters",
|
|
11
|
+
{ hours: 24 * 30 },
|
|
12
|
+
internal.migrations.backfillStatusFilter,
|
|
13
|
+
{},
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
export default crons;
|
package/src/component/entries.ts
CHANGED
|
@@ -14,12 +14,16 @@ import {
|
|
|
14
14
|
normalizeRequiredText,
|
|
15
15
|
normalizeTitle,
|
|
16
16
|
serializeEntry,
|
|
17
|
+
validateFeedbackMetadata,
|
|
17
18
|
} from "./helpers.js";
|
|
18
19
|
import {
|
|
19
20
|
actorValidator,
|
|
20
21
|
entryKindValidator,
|
|
21
22
|
entrySortValidator,
|
|
23
|
+
entryStatusFilterForStatus,
|
|
24
|
+
entryStatusFilterValidator,
|
|
22
25
|
entryStatusValidator,
|
|
26
|
+
feedbackMetadataValidator,
|
|
23
27
|
publicEntryValidator,
|
|
24
28
|
similarEntriesValidator,
|
|
25
29
|
type EntryKind,
|
|
@@ -61,6 +65,7 @@ export const list = query({
|
|
|
61
65
|
paginationOpts: paginationOptsValidator,
|
|
62
66
|
kinds: v.optional(v.array(entryKindValidator)),
|
|
63
67
|
status: v.optional(entryStatusValidator),
|
|
68
|
+
statusFilter: v.optional(entryStatusFilterValidator),
|
|
64
69
|
sort: entrySortValidator,
|
|
65
70
|
viewerActorId: v.optional(v.string()),
|
|
66
71
|
},
|
|
@@ -68,11 +73,82 @@ export const list = query({
|
|
|
68
73
|
handler: async (ctx, args) => {
|
|
69
74
|
const db = paginator(ctx.db, schema);
|
|
70
75
|
const kinds = normalizeKindFilter(args.kinds);
|
|
71
|
-
const { status } = args;
|
|
76
|
+
const { status, statusFilter } = args;
|
|
77
|
+
|
|
78
|
+
if (status !== undefined && statusFilter !== undefined) {
|
|
79
|
+
throw new ConvexError(
|
|
80
|
+
"`status` and `statusFilter` cannot be used together.",
|
|
81
|
+
);
|
|
82
|
+
}
|
|
72
83
|
|
|
73
84
|
let result: PaginationResult<Doc<"entries">>;
|
|
74
85
|
|
|
75
|
-
if (
|
|
86
|
+
if (statusFilter !== undefined) {
|
|
87
|
+
if (kinds === undefined) {
|
|
88
|
+
result =
|
|
89
|
+
args.sort === "top"
|
|
90
|
+
? await db
|
|
91
|
+
.query("entries")
|
|
92
|
+
.withIndex("by_status_filter_upvotes", (q) =>
|
|
93
|
+
q.eq("statusFilter", statusFilter),
|
|
94
|
+
)
|
|
95
|
+
.order("desc")
|
|
96
|
+
.paginate(args.paginationOpts)
|
|
97
|
+
: await db
|
|
98
|
+
.query("entries")
|
|
99
|
+
.withIndex("by_status_filter", (q) =>
|
|
100
|
+
q.eq("statusFilter", statusFilter),
|
|
101
|
+
)
|
|
102
|
+
.order("desc")
|
|
103
|
+
.paginate(args.paginationOpts);
|
|
104
|
+
} else if (kinds.length === 1) {
|
|
105
|
+
const kind = kinds[0];
|
|
106
|
+
|
|
107
|
+
if (kind === undefined) {
|
|
108
|
+
throw new ConvexError("Invalid kind filter.");
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
result =
|
|
112
|
+
args.sort === "top"
|
|
113
|
+
? await db
|
|
114
|
+
.query("entries")
|
|
115
|
+
.withIndex("by_kind_status_filter_upvotes", (q) =>
|
|
116
|
+
q.eq("kind", kind).eq("statusFilter", statusFilter),
|
|
117
|
+
)
|
|
118
|
+
.order("desc")
|
|
119
|
+
.paginate(args.paginationOpts)
|
|
120
|
+
: await db
|
|
121
|
+
.query("entries")
|
|
122
|
+
.withIndex("by_kind_status_filter", (q) =>
|
|
123
|
+
q.eq("kind", kind).eq("statusFilter", statusFilter),
|
|
124
|
+
)
|
|
125
|
+
.order("desc")
|
|
126
|
+
.paginate(args.paginationOpts);
|
|
127
|
+
} else {
|
|
128
|
+
const streams = kinds.map((kind) =>
|
|
129
|
+
args.sort === "top"
|
|
130
|
+
? stream(ctx.db, schema)
|
|
131
|
+
.query("entries")
|
|
132
|
+
.withIndex("by_kind_status_filter_upvotes", (q) =>
|
|
133
|
+
q.eq("kind", kind).eq("statusFilter", statusFilter),
|
|
134
|
+
)
|
|
135
|
+
.order("desc")
|
|
136
|
+
: stream(ctx.db, schema)
|
|
137
|
+
.query("entries")
|
|
138
|
+
.withIndex("by_kind_status_filter", (q) =>
|
|
139
|
+
q.eq("kind", kind).eq("statusFilter", statusFilter),
|
|
140
|
+
)
|
|
141
|
+
.order("desc"),
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
result = await mergedStream(
|
|
145
|
+
streams,
|
|
146
|
+
args.sort === "top"
|
|
147
|
+
? ["upvoteCount", "_creationTime"]
|
|
148
|
+
: ["_creationTime"],
|
|
149
|
+
).paginate(args.paginationOpts);
|
|
150
|
+
}
|
|
151
|
+
} else if (kinds === undefined) {
|
|
76
152
|
result =
|
|
77
153
|
args.sort === "top"
|
|
78
154
|
? status === undefined
|
|
@@ -200,13 +276,19 @@ export const get = query({
|
|
|
200
276
|
args: {
|
|
201
277
|
entryId: v.id("entries"),
|
|
202
278
|
viewerActorId: v.optional(v.string()),
|
|
279
|
+
viewerIsModerator: v.optional(v.boolean()),
|
|
203
280
|
},
|
|
204
281
|
returns: v.union(publicEntryValidator, v.null()),
|
|
205
282
|
handler: async (ctx, args) => {
|
|
206
283
|
const entry = await ctx.db.get("entries", args.entryId);
|
|
207
284
|
return entry === null
|
|
208
285
|
? null
|
|
209
|
-
: serializeEntry(
|
|
286
|
+
: serializeEntry(
|
|
287
|
+
ctx,
|
|
288
|
+
entry,
|
|
289
|
+
args.viewerActorId,
|
|
290
|
+
args.viewerIsModerator === true,
|
|
291
|
+
);
|
|
210
292
|
},
|
|
211
293
|
});
|
|
212
294
|
|
|
@@ -215,6 +297,7 @@ export const search = query({
|
|
|
215
297
|
searchQuery: v.string(),
|
|
216
298
|
kinds: v.optional(v.array(entryKindValidator)),
|
|
217
299
|
status: v.optional(entryStatusValidator),
|
|
300
|
+
statusFilter: v.optional(entryStatusFilterValidator),
|
|
218
301
|
limit: v.number(),
|
|
219
302
|
viewerActorId: v.optional(v.string()),
|
|
220
303
|
},
|
|
@@ -227,11 +310,67 @@ export const search = query({
|
|
|
227
310
|
}
|
|
228
311
|
|
|
229
312
|
const kinds = normalizeKindFilter(args.kinds);
|
|
230
|
-
const { status } = args;
|
|
313
|
+
const { status, statusFilter } = args;
|
|
314
|
+
|
|
315
|
+
if (status !== undefined && statusFilter !== undefined) {
|
|
316
|
+
throw new ConvexError(
|
|
317
|
+
"`status` and `statusFilter` cannot be used together.",
|
|
318
|
+
);
|
|
319
|
+
}
|
|
231
320
|
|
|
232
321
|
let entries: Doc<"entries">[];
|
|
233
322
|
|
|
234
|
-
if (
|
|
323
|
+
if (statusFilter !== undefined) {
|
|
324
|
+
if (kinds === undefined) {
|
|
325
|
+
entries = await ctx.db
|
|
326
|
+
.query("entries")
|
|
327
|
+
.withSearchIndex("search", (q) =>
|
|
328
|
+
q
|
|
329
|
+
.search("searchText", searchQuery)
|
|
330
|
+
.eq("statusFilter", statusFilter),
|
|
331
|
+
)
|
|
332
|
+
.take(args.limit);
|
|
333
|
+
} else if (kinds.length === 1) {
|
|
334
|
+
const kind = kinds[0];
|
|
335
|
+
|
|
336
|
+
if (kind === undefined) {
|
|
337
|
+
throw new ConvexError("Invalid kind filter.");
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
entries = await ctx.db
|
|
341
|
+
.query("entries")
|
|
342
|
+
.withSearchIndex("search", (q) =>
|
|
343
|
+
q
|
|
344
|
+
.search("searchText", searchQuery)
|
|
345
|
+
.eq("kind", kind)
|
|
346
|
+
.eq("statusFilter", statusFilter),
|
|
347
|
+
)
|
|
348
|
+
.take(args.limit);
|
|
349
|
+
} else {
|
|
350
|
+
const firstKind = kinds[0];
|
|
351
|
+
const secondKind = kinds[1];
|
|
352
|
+
|
|
353
|
+
if (firstKind === undefined || secondKind === undefined) {
|
|
354
|
+
throw new ConvexError("Invalid kind filter.");
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
entries = await ctx.db
|
|
358
|
+
.query("entries")
|
|
359
|
+
.withSearchIndex("search", (q) =>
|
|
360
|
+
q
|
|
361
|
+
.search("searchText", searchQuery)
|
|
362
|
+
.eq("statusFilter", statusFilter),
|
|
363
|
+
)
|
|
364
|
+
// eslint-disable-next-line @convex-dev/no-filter-in-query
|
|
365
|
+
.filter((q) =>
|
|
366
|
+
q.or(
|
|
367
|
+
q.eq(q.field("kind"), firstKind),
|
|
368
|
+
q.eq(q.field("kind"), secondKind),
|
|
369
|
+
),
|
|
370
|
+
)
|
|
371
|
+
.take(args.limit);
|
|
372
|
+
}
|
|
373
|
+
} else if (kinds === undefined) {
|
|
235
374
|
entries =
|
|
236
375
|
status === undefined
|
|
237
376
|
? await ctx.db
|
|
@@ -424,6 +563,7 @@ export const create = mutation({
|
|
|
424
563
|
enabledKinds: v.array(entryKindValidator),
|
|
425
564
|
maxTitleLength: v.number(),
|
|
426
565
|
maxBodyLength: v.number(),
|
|
566
|
+
metadata: v.optional(feedbackMetadataValidator),
|
|
427
567
|
},
|
|
428
568
|
returns: v.id("entries"),
|
|
429
569
|
handler: async (ctx, args) => {
|
|
@@ -438,17 +578,20 @@ export const create = mutation({
|
|
|
438
578
|
args.maxTitleLength,
|
|
439
579
|
);
|
|
440
580
|
const body = normalizeRequiredText(args.body, "Body", args.maxBodyLength);
|
|
581
|
+
validateFeedbackMetadata(args.metadata);
|
|
441
582
|
|
|
442
583
|
const entry = await ctx.db.insert("entries", {
|
|
443
584
|
actorId: args.actorId,
|
|
444
585
|
kind: args.kind,
|
|
445
586
|
status: args.defaultStatus,
|
|
587
|
+
statusFilter: entryStatusFilterForStatus(args.defaultStatus),
|
|
446
588
|
title,
|
|
447
589
|
body,
|
|
448
590
|
normalizedTitle: normalizeTitle(title),
|
|
449
591
|
searchText: `${title}\n${body}`,
|
|
450
592
|
upvoteCount: 1,
|
|
451
593
|
commentCount: 0,
|
|
594
|
+
...(args.metadata === undefined ? {} : { metadata: args.metadata }),
|
|
452
595
|
});
|
|
453
596
|
|
|
454
597
|
await ctx.db.insert("reactions", {
|
|
@@ -493,6 +636,7 @@ export const update = mutation({
|
|
|
493
636
|
body,
|
|
494
637
|
normalizedTitle: normalizeTitle(title),
|
|
495
638
|
searchText: `${title}\n${body}`,
|
|
639
|
+
statusFilter: entryStatusFilterForStatus(entry.status),
|
|
496
640
|
updatedAt: Date.now(),
|
|
497
641
|
});
|
|
498
642
|
return null;
|
|
@@ -515,6 +659,7 @@ export const setStatus = mutation({
|
|
|
515
659
|
}
|
|
516
660
|
await ctx.db.patch("entries", args.entryId, {
|
|
517
661
|
status: args.status,
|
|
662
|
+
statusFilter: entryStatusFilterForStatus(args.status),
|
|
518
663
|
updatedAt: Date.now(),
|
|
519
664
|
});
|
|
520
665
|
return null;
|
|
@@ -546,14 +691,20 @@ export const setUpvote = mutation({
|
|
|
546
691
|
entryId: args.entryId,
|
|
547
692
|
});
|
|
548
693
|
const upvoteCount = entry.upvoteCount + 1;
|
|
549
|
-
await ctx.db.patch("entries", args.entryId, {
|
|
694
|
+
await ctx.db.patch("entries", args.entryId, {
|
|
695
|
+
upvoteCount,
|
|
696
|
+
statusFilter: entryStatusFilterForStatus(entry.status),
|
|
697
|
+
});
|
|
550
698
|
return { active: true, upvoteCount };
|
|
551
699
|
}
|
|
552
700
|
|
|
553
701
|
if (!args.desiredState && existing !== null) {
|
|
554
702
|
await ctx.db.delete("reactions", existing._id);
|
|
555
703
|
const upvoteCount = Math.max(0, entry.upvoteCount - 1);
|
|
556
|
-
await ctx.db.patch("entries", args.entryId, {
|
|
704
|
+
await ctx.db.patch("entries", args.entryId, {
|
|
705
|
+
upvoteCount,
|
|
706
|
+
statusFilter: entryStatusFilterForStatus(entry.status),
|
|
707
|
+
});
|
|
557
708
|
return { active: false, upvoteCount };
|
|
558
709
|
}
|
|
559
710
|
|
package/src/component/helpers.ts
CHANGED
|
@@ -4,6 +4,72 @@ import type { DataModel } from "./_generated/dataModel.js";
|
|
|
4
4
|
import type { QueryCtx } from "./types.js";
|
|
5
5
|
import type { FeedbackComment, FeedbackEntry } from "./model.js";
|
|
6
6
|
|
|
7
|
+
const metadataMaximumKeysPerSection = 32;
|
|
8
|
+
const metadataMaximumKeyLength = 64;
|
|
9
|
+
const metadataMaximumStringLength = 1_024;
|
|
10
|
+
const metadataMaximumEncodedBytes = 16 * 1_024;
|
|
11
|
+
const forbiddenMetadataKeys = new Set([
|
|
12
|
+
"__proto__",
|
|
13
|
+
"constructor",
|
|
14
|
+
"prototype",
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
export function validateFeedbackMetadata(
|
|
18
|
+
metadata: DataModel["entries"]["document"]["metadata"],
|
|
19
|
+
): void {
|
|
20
|
+
if (metadata === undefined) return;
|
|
21
|
+
|
|
22
|
+
for (const [sectionName, section] of Object.entries(metadata)) {
|
|
23
|
+
if (section === undefined) continue;
|
|
24
|
+
const entries = Object.entries(section);
|
|
25
|
+
|
|
26
|
+
if (entries.length > metadataMaximumKeysPerSection) {
|
|
27
|
+
throw new ConvexError(
|
|
28
|
+
`Metadata section '${sectionName}' must contain ${metadataMaximumKeysPerSection} keys or fewer.`,
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
for (const [key, value] of entries) {
|
|
33
|
+
if (key.length === 0) {
|
|
34
|
+
throw new ConvexError(
|
|
35
|
+
`Metadata section '${sectionName}' contains an empty key.`,
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
if (key.length > metadataMaximumKeyLength) {
|
|
39
|
+
throw new ConvexError(
|
|
40
|
+
`Metadata key '${key}' in section '${sectionName}' must be ${metadataMaximumKeyLength} characters or fewer.`,
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
if (
|
|
44
|
+
key.startsWith("_") ||
|
|
45
|
+
key.startsWith("$") ||
|
|
46
|
+
forbiddenMetadataKeys.has(key)
|
|
47
|
+
) {
|
|
48
|
+
throw new ConvexError(
|
|
49
|
+
`Metadata key '${key}' in section '${sectionName}' is reserved and cannot be used.`,
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
if (
|
|
53
|
+
typeof value === "string" &&
|
|
54
|
+
value.length > metadataMaximumStringLength
|
|
55
|
+
) {
|
|
56
|
+
throw new ConvexError(
|
|
57
|
+
`Metadata value for '${sectionName}.${key}' must be ${metadataMaximumStringLength} characters or fewer.`,
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const encodedBytes = new TextEncoder().encode(
|
|
64
|
+
JSON.stringify(metadata),
|
|
65
|
+
).length;
|
|
66
|
+
if (encodedBytes > metadataMaximumEncodedBytes) {
|
|
67
|
+
throw new ConvexError(
|
|
68
|
+
`Metadata must be ${metadataMaximumEncodedBytes} UTF-8 bytes or fewer; received ${encodedBytes} bytes.`,
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
7
73
|
export function normalizeTitle(value: string): string {
|
|
8
74
|
return value.trim().replace(/\s+/g, " ").toLocaleLowerCase("en-US");
|
|
9
75
|
}
|
|
@@ -41,6 +107,7 @@ export async function serializeEntry(
|
|
|
41
107
|
ctx: QueryCtx,
|
|
42
108
|
entry: DataModel["entries"]["document"],
|
|
43
109
|
viewerActorId: string | undefined,
|
|
110
|
+
includeMetadata = false,
|
|
44
111
|
): Promise<FeedbackEntry> {
|
|
45
112
|
const reaction =
|
|
46
113
|
viewerActorId !== undefined
|
|
@@ -64,6 +131,9 @@ export async function serializeEntry(
|
|
|
64
131
|
commentCount: entry.commentCount,
|
|
65
132
|
...(entry.updatedAt === undefined ? {} : { updatedAt: entry.updatedAt }),
|
|
66
133
|
viewerHasUpvoted: reaction !== null,
|
|
134
|
+
...(includeMetadata && entry.metadata !== undefined
|
|
135
|
+
? { metadata: entry.metadata }
|
|
136
|
+
: {}),
|
|
67
137
|
};
|
|
68
138
|
}
|
|
69
139
|
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { v } from "convex/values";
|
|
2
|
+
|
|
3
|
+
import { internal } from "./_generated/api.js";
|
|
4
|
+
import { internalMutation } from "./_generated/server.js";
|
|
5
|
+
import { entryStatusFilterForStatus } from "./model.js";
|
|
6
|
+
|
|
7
|
+
const statusFilterMigrationBatchSize = 100;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Backfills one bounded batch of legacy entries and schedules the next batch.
|
|
11
|
+
*
|
|
12
|
+
* The indexed undefined lookup and deterministic status mapping make this
|
|
13
|
+
* safe to run concurrently, retry, or invoke again after completion.
|
|
14
|
+
*/
|
|
15
|
+
export const backfillStatusFilter = internalMutation({
|
|
16
|
+
args: {},
|
|
17
|
+
returns: v.object({
|
|
18
|
+
updated: v.number(),
|
|
19
|
+
hasMore: v.boolean(),
|
|
20
|
+
}),
|
|
21
|
+
handler: async (ctx) => {
|
|
22
|
+
const entries = await ctx.db
|
|
23
|
+
.query("entries")
|
|
24
|
+
.withIndex("by_status_filter", (q) => q.eq("statusFilter", undefined))
|
|
25
|
+
.take(statusFilterMigrationBatchSize);
|
|
26
|
+
|
|
27
|
+
for (const entry of entries) {
|
|
28
|
+
await ctx.db.patch("entries", entry._id, {
|
|
29
|
+
statusFilter: entryStatusFilterForStatus(entry.status),
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const hasMore = entries.length === statusFilterMigrationBatchSize;
|
|
34
|
+
if (hasMore) {
|
|
35
|
+
await ctx.scheduler.runAfter(
|
|
36
|
+
0,
|
|
37
|
+
internal.migrations.backfillStatusFilter,
|
|
38
|
+
{},
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return { updated: entries.length, hasMore };
|
|
43
|
+
},
|
|
44
|
+
});
|