convex-feedback 0.1.2 → 0.1.4
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 +16 -0
- package/dist/.tsbuildinfo +1 -1
- package/dist/client/api.d.ts +3 -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 +4 -1
- package/dist/client/index.js.map +1 -1
- package/dist/component/_generated/component.d.ts +25 -0
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/comments.d.ts +10 -10
- package/dist/component/entries.d.ts +53 -28
- package/dist/component/entries.d.ts.map +1 -1
- package/dist/component/entries.js +7 -3
- 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/model.d.ts +70 -3
- package/dist/component/model.d.ts.map +1 -1
- package/dist/component/model.js +7 -0
- package/dist/component/model.js.map +1 -1
- package/dist/component/schema.d.ts +12 -1
- package/dist/component/schema.d.ts.map +1 -1
- package/dist/component/schema.js +2 -1
- package/dist/component/schema.js.map +1 -1
- package/dist/react/index.d.ts +28 -2
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js +4 -1
- package/dist/react/index.js.map +1 -1
- package/dist/test.d.ts +12 -1
- package/dist/test.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/client/api.ts +4 -0
- package/src/client/index.ts +6 -0
- package/src/component/_generated/component.ts +29 -1
- package/src/component/entries.ts +12 -1
- package/src/component/helpers.ts +70 -0
- package/src/component/model.ts +30 -0
- package/src/component/schema.ts +6 -1
- package/src/react/index.ts +26 -4
package/src/component/entries.ts
CHANGED
|
@@ -14,12 +14,14 @@ 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,
|
|
22
23
|
entryStatusValidator,
|
|
24
|
+
feedbackMetadataValidator,
|
|
23
25
|
publicEntryValidator,
|
|
24
26
|
similarEntriesValidator,
|
|
25
27
|
type EntryKind,
|
|
@@ -200,13 +202,19 @@ export const get = query({
|
|
|
200
202
|
args: {
|
|
201
203
|
entryId: v.id("entries"),
|
|
202
204
|
viewerActorId: v.optional(v.string()),
|
|
205
|
+
viewerIsModerator: v.optional(v.boolean()),
|
|
203
206
|
},
|
|
204
207
|
returns: v.union(publicEntryValidator, v.null()),
|
|
205
208
|
handler: async (ctx, args) => {
|
|
206
209
|
const entry = await ctx.db.get("entries", args.entryId);
|
|
207
210
|
return entry === null
|
|
208
211
|
? null
|
|
209
|
-
: serializeEntry(
|
|
212
|
+
: serializeEntry(
|
|
213
|
+
ctx,
|
|
214
|
+
entry,
|
|
215
|
+
args.viewerActorId,
|
|
216
|
+
args.viewerIsModerator === true,
|
|
217
|
+
);
|
|
210
218
|
},
|
|
211
219
|
});
|
|
212
220
|
|
|
@@ -424,6 +432,7 @@ export const create = mutation({
|
|
|
424
432
|
enabledKinds: v.array(entryKindValidator),
|
|
425
433
|
maxTitleLength: v.number(),
|
|
426
434
|
maxBodyLength: v.number(),
|
|
435
|
+
metadata: v.optional(feedbackMetadataValidator),
|
|
427
436
|
},
|
|
428
437
|
returns: v.id("entries"),
|
|
429
438
|
handler: async (ctx, args) => {
|
|
@@ -438,6 +447,7 @@ export const create = mutation({
|
|
|
438
447
|
args.maxTitleLength,
|
|
439
448
|
);
|
|
440
449
|
const body = normalizeRequiredText(args.body, "Body", args.maxBodyLength);
|
|
450
|
+
validateFeedbackMetadata(args.metadata);
|
|
441
451
|
|
|
442
452
|
const entry = await ctx.db.insert("entries", {
|
|
443
453
|
actorId: args.actorId,
|
|
@@ -449,6 +459,7 @@ export const create = mutation({
|
|
|
449
459
|
searchText: `${title}\n${body}`,
|
|
450
460
|
upvoteCount: 1,
|
|
451
461
|
commentCount: 0,
|
|
462
|
+
...(args.metadata === undefined ? {} : { metadata: args.metadata }),
|
|
452
463
|
});
|
|
453
464
|
|
|
454
465
|
await ctx.db.insert("reactions", {
|
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
|
|
package/src/component/model.ts
CHANGED
|
@@ -32,6 +32,22 @@ export const actorValidator = v.object({
|
|
|
32
32
|
isModerator: v.boolean(),
|
|
33
33
|
});
|
|
34
34
|
|
|
35
|
+
export const feedbackMetadataValueValidator = v.union(
|
|
36
|
+
v.string(),
|
|
37
|
+
v.number(),
|
|
38
|
+
v.boolean(),
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
export const feedbackMetadataRecordValidator = v.record(
|
|
42
|
+
v.string(),
|
|
43
|
+
feedbackMetadataValueValidator,
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
export const feedbackMetadataValidator = v.object({
|
|
47
|
+
standard: v.optional(feedbackMetadataRecordValidator),
|
|
48
|
+
additional: v.optional(feedbackMetadataRecordValidator),
|
|
49
|
+
});
|
|
50
|
+
|
|
35
51
|
export const publicEntryValidator = v.object({
|
|
36
52
|
id: v.string(),
|
|
37
53
|
creationTime: v.number(),
|
|
@@ -44,6 +60,7 @@ export const publicEntryValidator = v.object({
|
|
|
44
60
|
commentCount: v.number(),
|
|
45
61
|
updatedAt: v.optional(v.number()),
|
|
46
62
|
viewerHasUpvoted: v.boolean(),
|
|
63
|
+
metadata: v.optional(feedbackMetadataValidator),
|
|
47
64
|
});
|
|
48
65
|
|
|
49
66
|
export const publicCommentValidator = v.object({
|
|
@@ -119,6 +136,14 @@ export type CommentSort = Infer<typeof commentSortValidator>;
|
|
|
119
136
|
*/
|
|
120
137
|
export type FeedbackActor = Infer<typeof actorValidator>;
|
|
121
138
|
|
|
139
|
+
/** Scalar value accepted in entry diagnostic metadata. */
|
|
140
|
+
export type FeedbackMetadataValue = Infer<
|
|
141
|
+
typeof feedbackMetadataValueValidator
|
|
142
|
+
>;
|
|
143
|
+
|
|
144
|
+
/** Flat metadata values grouped by their source. */
|
|
145
|
+
export type FeedbackMetadata = Infer<typeof feedbackMetadataValidator>;
|
|
146
|
+
|
|
122
147
|
/**
|
|
123
148
|
* Public representation of a feedback, feature-request, or bug-report entry.
|
|
124
149
|
*
|
|
@@ -157,6 +182,11 @@ export type FeedbackActor = Infer<typeof actorValidator>;
|
|
|
157
182
|
* @property viewerHasUpvoted
|
|
158
183
|
* Whether the actor associated with the current query has upvoted the entry.
|
|
159
184
|
* `false` when no viewer actor is available.
|
|
185
|
+
*
|
|
186
|
+
* @property metadata
|
|
187
|
+
* Creation-time diagnostic metadata. Present only when `getEntry` is queried
|
|
188
|
+
* by a moderator. Ordinary entry lists, searches, and non-moderator reads omit
|
|
189
|
+
* this property.
|
|
160
190
|
*/
|
|
161
191
|
export type FeedbackEntry = Infer<typeof publicEntryValidator>;
|
|
162
192
|
|
package/src/component/schema.ts
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import { defineSchema, defineTable } from "convex/server";
|
|
2
2
|
import { v } from "convex/values";
|
|
3
3
|
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
entryKindValidator,
|
|
6
|
+
entryStatusValidator,
|
|
7
|
+
feedbackMetadataValidator,
|
|
8
|
+
} from "./model.js";
|
|
5
9
|
|
|
6
10
|
const schema = defineSchema({
|
|
7
11
|
entries: defineTable({
|
|
@@ -15,6 +19,7 @@ const schema = defineSchema({
|
|
|
15
19
|
upvoteCount: v.number(),
|
|
16
20
|
commentCount: v.number(),
|
|
17
21
|
updatedAt: v.optional(v.number()),
|
|
22
|
+
metadata: v.optional(feedbackMetadataValidator),
|
|
18
23
|
})
|
|
19
24
|
.index("by_kind", ["kind"])
|
|
20
25
|
.index("by_status", ["status"])
|
package/src/react/index.ts
CHANGED
|
@@ -186,7 +186,7 @@ function positivePageSize(value: number | undefined, fallback: number): number {
|
|
|
186
186
|
* @typeParam RateLimitResult The validated non-throwing rate-limit rejection
|
|
187
187
|
* type returned by mutation hooks. It is inferred from the supplied API.
|
|
188
188
|
*/
|
|
189
|
-
|
|
189
|
+
function createFeedbackHooksImplementation<RateLimitResult>(
|
|
190
190
|
api: FeedbackPublicApi<string | undefined, RateLimitResult>,
|
|
191
191
|
options: FeedbackHooksOptions = {},
|
|
192
192
|
) {
|
|
@@ -334,6 +334,29 @@ export function createFeedbackHooks<RateLimitResult = never>(
|
|
|
334
334
|
};
|
|
335
335
|
}
|
|
336
336
|
|
|
337
|
+
type CreatedFeedbackHooks<RateLimitResult> = ReturnType<
|
|
338
|
+
typeof createFeedbackHooksImplementation<RateLimitResult>
|
|
339
|
+
>;
|
|
340
|
+
|
|
341
|
+
/** Creates hooks for a feedback API whose rate limiters reject by throwing. */
|
|
342
|
+
export function createFeedbackHooks(
|
|
343
|
+
api: FeedbackPublicApi<string | undefined, never>,
|
|
344
|
+
options?: FeedbackHooksOptions,
|
|
345
|
+
): CreatedFeedbackHooks<never>;
|
|
346
|
+
|
|
347
|
+
/** Creates hooks carrying a validated non-throwing rate-limit result. */
|
|
348
|
+
export function createFeedbackHooks<RateLimitResult>(
|
|
349
|
+
api: FeedbackPublicApi<string | undefined, RateLimitResult>,
|
|
350
|
+
options?: FeedbackHooksOptions,
|
|
351
|
+
): CreatedFeedbackHooks<RateLimitResult>;
|
|
352
|
+
|
|
353
|
+
export function createFeedbackHooks<RateLimitResult>(
|
|
354
|
+
api: FeedbackPublicApi<string | undefined, RateLimitResult>,
|
|
355
|
+
options: FeedbackHooksOptions = {},
|
|
356
|
+
): CreatedFeedbackHooks<RateLimitResult> {
|
|
357
|
+
return createFeedbackHooksImplementation(api, options);
|
|
358
|
+
}
|
|
359
|
+
|
|
337
360
|
/**
|
|
338
361
|
* Hook collection returned by `createFeedbackHooks`.
|
|
339
362
|
*
|
|
@@ -343,6 +366,5 @@ export function createFeedbackHooks<RateLimitResult = never>(
|
|
|
343
366
|
* @typeParam RateLimitResult The validated non-throwing rate-limit rejection
|
|
344
367
|
* type returned by mutation hooks.
|
|
345
368
|
*/
|
|
346
|
-
export type FeedbackHooks<RateLimitResult = never> =
|
|
347
|
-
|
|
348
|
-
>;
|
|
369
|
+
export type FeedbackHooks<RateLimitResult = never> =
|
|
370
|
+
CreatedFeedbackHooks<RateLimitResult>;
|