convex-feedback 0.1.4 → 0.2.0-beta.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 +87 -27
- package/dist/.tsbuildinfo +1 -1
- package/dist/client/api.d.ts +80 -1
- package/dist/client/api.d.ts.map +1 -1
- package/dist/client/config.d.ts +1 -1
- package/dist/client/index.d.ts +9 -5
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +261 -8
- package/dist/client/index.js.map +1 -1
- package/dist/component/_generated/api.d.ts +8 -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 +295 -5
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/admin.d.ts +130 -0
- package/dist/component/admin.d.ts.map +1 -0
- package/dist/component/admin.js +125 -0
- package/dist/component/admin.js.map +1 -0
- package/dist/component/comments.d.ts +7 -5
- package/dist/component/comments.d.ts.map +1 -1
- package/dist/component/comments.js +4 -3
- 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 +29 -16
- package/dist/component/entries.d.ts.map +1 -1
- package/dist/component/entries.js +136 -12
- package/dist/component/entries.js.map +1 -1
- package/dist/component/helpers.d.ts +3 -1
- package/dist/component/helpers.d.ts.map +1 -1
- package/dist/component/helpers.js +28 -0
- 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 +144 -24
- package/dist/component/model.d.ts.map +1 -1
- package/dist/component/model.js +34 -1
- package/dist/component/model.js.map +1 -1
- package/dist/component/roadmap.d.ts +166 -0
- package/dist/component/roadmap.d.ts.map +1 -0
- package/dist/component/roadmap.js +534 -0
- package/dist/component/roadmap.js.map +1 -0
- package/dist/component/schema.d.ts +67 -5
- package/dist/component/schema.d.ts.map +1 -1
- package/dist/component/schema.js +57 -2
- package/dist/component/schema.js.map +1 -1
- package/dist/component/tags.d.ts +58 -0
- package/dist/component/tags.d.ts.map +1 -0
- package/dist/component/tags.js +216 -0
- package/dist/component/tags.js.map +1 -0
- package/dist/react/index.d.ts +173 -12
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js +78 -1
- package/dist/react/index.js.map +1 -1
- package/dist/test.d.ts +67 -5
- package/dist/test.d.ts.map +1 -1
- package/package.json +6 -2
- package/src/client/README.md +1 -1
- package/src/client/api.ts +183 -0
- package/src/client/config.ts +1 -1
- package/src/client/index.ts +340 -13
- package/src/component/README.md +3 -1
- package/src/component/_generated/api.ts +8 -0
- package/src/component/_generated/component.ts +373 -9
- package/src/component/admin.ts +180 -0
- package/src/component/comments.ts +5 -2
- package/src/component/crons.ts +16 -0
- package/src/component/entries.ts +175 -11
- package/src/component/helpers.ts +44 -1
- package/src/component/migrations.ts +44 -0
- package/src/component/model.ts +79 -4
- package/src/component/roadmap.ts +627 -0
- package/src/component/schema.ts +61 -1
- package/src/react/README.md +2 -2
- package/src/react/index.ts +152 -4
|
@@ -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
|
@@ -18,8 +18,12 @@ import {
|
|
|
18
18
|
} from "./helpers.js";
|
|
19
19
|
import {
|
|
20
20
|
actorValidator,
|
|
21
|
+
actorIsAdmin,
|
|
21
22
|
entryKindValidator,
|
|
23
|
+
entryPriorityValidator,
|
|
22
24
|
entrySortValidator,
|
|
25
|
+
entryStatusFilterForStatus,
|
|
26
|
+
entryStatusFilterValidator,
|
|
23
27
|
entryStatusValidator,
|
|
24
28
|
feedbackMetadataValidator,
|
|
25
29
|
publicEntryValidator,
|
|
@@ -63,6 +67,7 @@ export const list = query({
|
|
|
63
67
|
paginationOpts: paginationOptsValidator,
|
|
64
68
|
kinds: v.optional(v.array(entryKindValidator)),
|
|
65
69
|
status: v.optional(entryStatusValidator),
|
|
70
|
+
statusFilter: v.optional(entryStatusFilterValidator),
|
|
66
71
|
sort: entrySortValidator,
|
|
67
72
|
viewerActorId: v.optional(v.string()),
|
|
68
73
|
},
|
|
@@ -70,11 +75,82 @@ export const list = query({
|
|
|
70
75
|
handler: async (ctx, args) => {
|
|
71
76
|
const db = paginator(ctx.db, schema);
|
|
72
77
|
const kinds = normalizeKindFilter(args.kinds);
|
|
73
|
-
const { status } = args;
|
|
78
|
+
const { status, statusFilter } = args;
|
|
79
|
+
|
|
80
|
+
if (status !== undefined && statusFilter !== undefined) {
|
|
81
|
+
throw new ConvexError(
|
|
82
|
+
"`status` and `statusFilter` cannot be used together.",
|
|
83
|
+
);
|
|
84
|
+
}
|
|
74
85
|
|
|
75
86
|
let result: PaginationResult<Doc<"entries">>;
|
|
76
87
|
|
|
77
|
-
if (
|
|
88
|
+
if (statusFilter !== undefined) {
|
|
89
|
+
if (kinds === undefined) {
|
|
90
|
+
result =
|
|
91
|
+
args.sort === "top"
|
|
92
|
+
? await db
|
|
93
|
+
.query("entries")
|
|
94
|
+
.withIndex("by_status_filter_upvotes", (q) =>
|
|
95
|
+
q.eq("statusFilter", statusFilter),
|
|
96
|
+
)
|
|
97
|
+
.order("desc")
|
|
98
|
+
.paginate(args.paginationOpts)
|
|
99
|
+
: await db
|
|
100
|
+
.query("entries")
|
|
101
|
+
.withIndex("by_status_filter", (q) =>
|
|
102
|
+
q.eq("statusFilter", statusFilter),
|
|
103
|
+
)
|
|
104
|
+
.order("desc")
|
|
105
|
+
.paginate(args.paginationOpts);
|
|
106
|
+
} else if (kinds.length === 1) {
|
|
107
|
+
const kind = kinds[0];
|
|
108
|
+
|
|
109
|
+
if (kind === undefined) {
|
|
110
|
+
throw new ConvexError("Invalid kind filter.");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
result =
|
|
114
|
+
args.sort === "top"
|
|
115
|
+
? await db
|
|
116
|
+
.query("entries")
|
|
117
|
+
.withIndex("by_kind_status_filter_upvotes", (q) =>
|
|
118
|
+
q.eq("kind", kind).eq("statusFilter", statusFilter),
|
|
119
|
+
)
|
|
120
|
+
.order("desc")
|
|
121
|
+
.paginate(args.paginationOpts)
|
|
122
|
+
: await db
|
|
123
|
+
.query("entries")
|
|
124
|
+
.withIndex("by_kind_status_filter", (q) =>
|
|
125
|
+
q.eq("kind", kind).eq("statusFilter", statusFilter),
|
|
126
|
+
)
|
|
127
|
+
.order("desc")
|
|
128
|
+
.paginate(args.paginationOpts);
|
|
129
|
+
} else {
|
|
130
|
+
const streams = kinds.map((kind) =>
|
|
131
|
+
args.sort === "top"
|
|
132
|
+
? stream(ctx.db, schema)
|
|
133
|
+
.query("entries")
|
|
134
|
+
.withIndex("by_kind_status_filter_upvotes", (q) =>
|
|
135
|
+
q.eq("kind", kind).eq("statusFilter", statusFilter),
|
|
136
|
+
)
|
|
137
|
+
.order("desc")
|
|
138
|
+
: stream(ctx.db, schema)
|
|
139
|
+
.query("entries")
|
|
140
|
+
.withIndex("by_kind_status_filter", (q) =>
|
|
141
|
+
q.eq("kind", kind).eq("statusFilter", statusFilter),
|
|
142
|
+
)
|
|
143
|
+
.order("desc"),
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
result = await mergedStream(
|
|
147
|
+
streams,
|
|
148
|
+
args.sort === "top"
|
|
149
|
+
? ["upvoteCount", "_creationTime"]
|
|
150
|
+
: ["_creationTime"],
|
|
151
|
+
).paginate(args.paginationOpts);
|
|
152
|
+
}
|
|
153
|
+
} else if (kinds === undefined) {
|
|
78
154
|
result =
|
|
79
155
|
args.sort === "top"
|
|
80
156
|
? status === undefined
|
|
@@ -202,7 +278,7 @@ export const get = query({
|
|
|
202
278
|
args: {
|
|
203
279
|
entryId: v.id("entries"),
|
|
204
280
|
viewerActorId: v.optional(v.string()),
|
|
205
|
-
|
|
281
|
+
viewerIsAdmin: v.optional(v.boolean()),
|
|
206
282
|
},
|
|
207
283
|
returns: v.union(publicEntryValidator, v.null()),
|
|
208
284
|
handler: async (ctx, args) => {
|
|
@@ -213,7 +289,7 @@ export const get = query({
|
|
|
213
289
|
ctx,
|
|
214
290
|
entry,
|
|
215
291
|
args.viewerActorId,
|
|
216
|
-
args.
|
|
292
|
+
args.viewerIsAdmin === true,
|
|
217
293
|
);
|
|
218
294
|
},
|
|
219
295
|
});
|
|
@@ -223,6 +299,7 @@ export const search = query({
|
|
|
223
299
|
searchQuery: v.string(),
|
|
224
300
|
kinds: v.optional(v.array(entryKindValidator)),
|
|
225
301
|
status: v.optional(entryStatusValidator),
|
|
302
|
+
statusFilter: v.optional(entryStatusFilterValidator),
|
|
226
303
|
limit: v.number(),
|
|
227
304
|
viewerActorId: v.optional(v.string()),
|
|
228
305
|
},
|
|
@@ -235,11 +312,67 @@ export const search = query({
|
|
|
235
312
|
}
|
|
236
313
|
|
|
237
314
|
const kinds = normalizeKindFilter(args.kinds);
|
|
238
|
-
const { status } = args;
|
|
315
|
+
const { status, statusFilter } = args;
|
|
316
|
+
|
|
317
|
+
if (status !== undefined && statusFilter !== undefined) {
|
|
318
|
+
throw new ConvexError(
|
|
319
|
+
"`status` and `statusFilter` cannot be used together.",
|
|
320
|
+
);
|
|
321
|
+
}
|
|
239
322
|
|
|
240
323
|
let entries: Doc<"entries">[];
|
|
241
324
|
|
|
242
|
-
if (
|
|
325
|
+
if (statusFilter !== undefined) {
|
|
326
|
+
if (kinds === undefined) {
|
|
327
|
+
entries = await ctx.db
|
|
328
|
+
.query("entries")
|
|
329
|
+
.withSearchIndex("search", (q) =>
|
|
330
|
+
q
|
|
331
|
+
.search("searchText", searchQuery)
|
|
332
|
+
.eq("statusFilter", statusFilter),
|
|
333
|
+
)
|
|
334
|
+
.take(args.limit);
|
|
335
|
+
} else if (kinds.length === 1) {
|
|
336
|
+
const kind = kinds[0];
|
|
337
|
+
|
|
338
|
+
if (kind === undefined) {
|
|
339
|
+
throw new ConvexError("Invalid kind filter.");
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
entries = await ctx.db
|
|
343
|
+
.query("entries")
|
|
344
|
+
.withSearchIndex("search", (q) =>
|
|
345
|
+
q
|
|
346
|
+
.search("searchText", searchQuery)
|
|
347
|
+
.eq("kind", kind)
|
|
348
|
+
.eq("statusFilter", statusFilter),
|
|
349
|
+
)
|
|
350
|
+
.take(args.limit);
|
|
351
|
+
} else {
|
|
352
|
+
const firstKind = kinds[0];
|
|
353
|
+
const secondKind = kinds[1];
|
|
354
|
+
|
|
355
|
+
if (firstKind === undefined || secondKind === undefined) {
|
|
356
|
+
throw new ConvexError("Invalid kind filter.");
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
entries = await ctx.db
|
|
360
|
+
.query("entries")
|
|
361
|
+
.withSearchIndex("search", (q) =>
|
|
362
|
+
q
|
|
363
|
+
.search("searchText", searchQuery)
|
|
364
|
+
.eq("statusFilter", statusFilter),
|
|
365
|
+
)
|
|
366
|
+
// eslint-disable-next-line @convex-dev/no-filter-in-query
|
|
367
|
+
.filter((q) =>
|
|
368
|
+
q.or(
|
|
369
|
+
q.eq(q.field("kind"), firstKind),
|
|
370
|
+
q.eq(q.field("kind"), secondKind),
|
|
371
|
+
),
|
|
372
|
+
)
|
|
373
|
+
.take(args.limit);
|
|
374
|
+
}
|
|
375
|
+
} else if (kinds === undefined) {
|
|
243
376
|
entries =
|
|
244
377
|
status === undefined
|
|
245
378
|
? await ctx.db
|
|
@@ -453,6 +586,7 @@ export const create = mutation({
|
|
|
453
586
|
actorId: args.actorId,
|
|
454
587
|
kind: args.kind,
|
|
455
588
|
status: args.defaultStatus,
|
|
589
|
+
statusFilter: entryStatusFilterForStatus(args.defaultStatus),
|
|
456
590
|
title,
|
|
457
591
|
body,
|
|
458
592
|
normalizedTitle: normalizeTitle(title),
|
|
@@ -488,7 +622,7 @@ export const update = mutation({
|
|
|
488
622
|
if (entry === null) throw new ConvexError("Entry not found.");
|
|
489
623
|
|
|
490
624
|
const canEdit =
|
|
491
|
-
args.actor
|
|
625
|
+
actorIsAdmin(args.actor) ||
|
|
492
626
|
(args.editableByAuthor && entry.actorId === args.actor.id);
|
|
493
627
|
if (!canEdit) throw new ConvexError("Not authorized to edit this entry.");
|
|
494
628
|
|
|
@@ -504,6 +638,7 @@ export const update = mutation({
|
|
|
504
638
|
body,
|
|
505
639
|
normalizedTitle: normalizeTitle(title),
|
|
506
640
|
searchText: `${title}\n${body}`,
|
|
641
|
+
statusFilter: entryStatusFilterForStatus(entry.status),
|
|
507
642
|
updatedAt: Date.now(),
|
|
508
643
|
});
|
|
509
644
|
return null;
|
|
@@ -518,14 +653,37 @@ export const setStatus = mutation({
|
|
|
518
653
|
},
|
|
519
654
|
returns: v.null(),
|
|
520
655
|
handler: async (ctx, args) => {
|
|
521
|
-
if (!args.actor
|
|
522
|
-
throw new ConvexError("
|
|
656
|
+
if (!actorIsAdmin(args.actor)) {
|
|
657
|
+
throw new ConvexError("Admin access is required to change status.");
|
|
523
658
|
}
|
|
524
659
|
if ((await ctx.db.get("entries", args.entryId)) === null) {
|
|
525
660
|
throw new ConvexError("Entry not found.");
|
|
526
661
|
}
|
|
527
662
|
await ctx.db.patch("entries", args.entryId, {
|
|
528
663
|
status: args.status,
|
|
664
|
+
statusFilter: entryStatusFilterForStatus(args.status),
|
|
665
|
+
updatedAt: Date.now(),
|
|
666
|
+
});
|
|
667
|
+
return null;
|
|
668
|
+
},
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
export const setPriority = mutation({
|
|
672
|
+
args: {
|
|
673
|
+
actor: actorValidator,
|
|
674
|
+
entryId: v.id("entries"),
|
|
675
|
+
priority: v.union(entryPriorityValidator, v.null()),
|
|
676
|
+
},
|
|
677
|
+
returns: v.null(),
|
|
678
|
+
handler: async (ctx, args) => {
|
|
679
|
+
if (!actorIsAdmin(args.actor)) {
|
|
680
|
+
throw new ConvexError("Admin access is required to change priority.");
|
|
681
|
+
}
|
|
682
|
+
if ((await ctx.db.get("entries", args.entryId)) === null) {
|
|
683
|
+
throw new ConvexError("Entry not found.");
|
|
684
|
+
}
|
|
685
|
+
await ctx.db.patch("entries", args.entryId, {
|
|
686
|
+
priority: args.priority === null ? undefined : args.priority,
|
|
529
687
|
updatedAt: Date.now(),
|
|
530
688
|
});
|
|
531
689
|
return null;
|
|
@@ -557,14 +715,20 @@ export const setUpvote = mutation({
|
|
|
557
715
|
entryId: args.entryId,
|
|
558
716
|
});
|
|
559
717
|
const upvoteCount = entry.upvoteCount + 1;
|
|
560
|
-
await ctx.db.patch("entries", args.entryId, {
|
|
718
|
+
await ctx.db.patch("entries", args.entryId, {
|
|
719
|
+
upvoteCount,
|
|
720
|
+
statusFilter: entryStatusFilterForStatus(entry.status),
|
|
721
|
+
});
|
|
561
722
|
return { active: true, upvoteCount };
|
|
562
723
|
}
|
|
563
724
|
|
|
564
725
|
if (!args.desiredState && existing !== null) {
|
|
565
726
|
await ctx.db.delete("reactions", existing._id);
|
|
566
727
|
const upvoteCount = Math.max(0, entry.upvoteCount - 1);
|
|
567
|
-
await ctx.db.patch("entries", args.entryId, {
|
|
728
|
+
await ctx.db.patch("entries", args.entryId, {
|
|
729
|
+
upvoteCount,
|
|
730
|
+
statusFilter: entryStatusFilterForStatus(entry.status),
|
|
731
|
+
});
|
|
568
732
|
return { active: false, upvoteCount };
|
|
569
733
|
}
|
|
570
734
|
|
package/src/component/helpers.ts
CHANGED
|
@@ -2,7 +2,12 @@ import { ConvexError } from "convex/values";
|
|
|
2
2
|
|
|
3
3
|
import type { DataModel } from "./_generated/dataModel.js";
|
|
4
4
|
import type { QueryCtx } from "./types.js";
|
|
5
|
-
import type {
|
|
5
|
+
import type {
|
|
6
|
+
AdminFeedbackEntry,
|
|
7
|
+
FeedbackComment,
|
|
8
|
+
FeedbackEntry,
|
|
9
|
+
RoadmapItem,
|
|
10
|
+
} from "./model.js";
|
|
6
11
|
|
|
7
12
|
const metadataMaximumKeysPerSection = 32;
|
|
8
13
|
const metadataMaximumKeyLength = 64;
|
|
@@ -137,6 +142,44 @@ export async function serializeEntry(
|
|
|
137
142
|
};
|
|
138
143
|
}
|
|
139
144
|
|
|
145
|
+
export function serializeRoadmapItem(
|
|
146
|
+
item: DataModel["roadmap"]["document"],
|
|
147
|
+
position = item.position,
|
|
148
|
+
): RoadmapItem {
|
|
149
|
+
return {
|
|
150
|
+
id: item._id,
|
|
151
|
+
creationTime: item._creationTime,
|
|
152
|
+
title: item.title,
|
|
153
|
+
...(item.description === undefined
|
|
154
|
+
? {}
|
|
155
|
+
: { description: item.description }),
|
|
156
|
+
status: item.status,
|
|
157
|
+
position,
|
|
158
|
+
createdAt: item.createdAt,
|
|
159
|
+
updatedAt: item.updatedAt,
|
|
160
|
+
feedbackCount: item.feedbackCount,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export async function serializeAdminEntry(
|
|
165
|
+
ctx: QueryCtx,
|
|
166
|
+
entry: DataModel["entries"]["document"],
|
|
167
|
+
viewerActorId: string,
|
|
168
|
+
): Promise<AdminFeedbackEntry> {
|
|
169
|
+
const [base, roadmap] = await Promise.all([
|
|
170
|
+
serializeEntry(ctx, entry, viewerActorId, true),
|
|
171
|
+
entry.roadmapId === undefined
|
|
172
|
+
? null
|
|
173
|
+
: ctx.db.get("roadmap", entry.roadmapId),
|
|
174
|
+
]);
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
...base,
|
|
178
|
+
...(entry.priority === undefined ? {} : { priority: entry.priority }),
|
|
179
|
+
...(roadmap === null ? {} : { roadmap: serializeRoadmapItem(roadmap) }),
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
140
183
|
export async function serializeComment(
|
|
141
184
|
ctx: QueryCtx,
|
|
142
185
|
comment: DataModel["comments"]["document"],
|
|
@@ -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
|
+
});
|
package/src/component/model.ts
CHANGED
|
@@ -16,11 +16,28 @@ export const entryStatusValidator = v.union(
|
|
|
16
16
|
v.literal("closed"),
|
|
17
17
|
);
|
|
18
18
|
|
|
19
|
+
export const entryStatusFilterValidator = v.union(
|
|
20
|
+
v.literal("open"),
|
|
21
|
+
v.literal("closed"),
|
|
22
|
+
);
|
|
23
|
+
|
|
19
24
|
export const entrySortValidator = v.union(
|
|
20
25
|
v.literal("top"),
|
|
21
26
|
v.literal("newest"),
|
|
22
27
|
);
|
|
23
28
|
|
|
29
|
+
export const entryPriorityValidator = v.union(
|
|
30
|
+
v.literal("low"),
|
|
31
|
+
v.literal("medium"),
|
|
32
|
+
v.literal("high"),
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
export const roadmapStatusValidator = v.union(
|
|
36
|
+
v.literal("planned"),
|
|
37
|
+
v.literal("in_progress"),
|
|
38
|
+
v.literal("shipped"),
|
|
39
|
+
);
|
|
40
|
+
|
|
24
41
|
export const commentSortValidator = v.union(
|
|
25
42
|
v.literal("top"),
|
|
26
43
|
v.literal("newest"),
|
|
@@ -29,7 +46,34 @@ export const commentSortValidator = v.union(
|
|
|
29
46
|
|
|
30
47
|
export const actorValidator = v.object({
|
|
31
48
|
id: v.string(),
|
|
32
|
-
|
|
49
|
+
isAdmin: v.optional(v.boolean()),
|
|
50
|
+
/** @deprecated Use `isAdmin`. */
|
|
51
|
+
isModerator: v.optional(v.boolean()),
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Resolves the current admin flag while keeping the pre-`isAdmin` actor shape
|
|
56
|
+
* working for existing hosts. The new field wins when both are present.
|
|
57
|
+
*/
|
|
58
|
+
export function actorIsAdmin(actor: {
|
|
59
|
+
isAdmin?: boolean;
|
|
60
|
+
isModerator?: boolean;
|
|
61
|
+
}): boolean {
|
|
62
|
+
return actor.isAdmin === undefined
|
|
63
|
+
? actor.isModerator === true
|
|
64
|
+
: actor.isAdmin === true;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export const roadmapItemValidator = v.object({
|
|
68
|
+
id: v.string(),
|
|
69
|
+
creationTime: v.number(),
|
|
70
|
+
title: v.string(),
|
|
71
|
+
description: v.optional(v.string()),
|
|
72
|
+
status: roadmapStatusValidator,
|
|
73
|
+
position: v.number(),
|
|
74
|
+
createdAt: v.number(),
|
|
75
|
+
updatedAt: v.number(),
|
|
76
|
+
feedbackCount: v.number(),
|
|
33
77
|
});
|
|
34
78
|
|
|
35
79
|
export const feedbackMetadataValueValidator = v.union(
|
|
@@ -83,6 +127,12 @@ export const similarEntriesValidator = v.object({
|
|
|
83
127
|
similar: v.array(publicEntryValidator),
|
|
84
128
|
});
|
|
85
129
|
|
|
130
|
+
export const adminEntryValidator = publicEntryValidator.extend({
|
|
131
|
+
priority: v.optional(entryPriorityValidator),
|
|
132
|
+
roadmap: v.optional(roadmapItemValidator),
|
|
133
|
+
metadata: v.optional(feedbackMetadataValidator),
|
|
134
|
+
});
|
|
135
|
+
|
|
86
136
|
/**
|
|
87
137
|
* Category of feedback represented by an entry.
|
|
88
138
|
*
|
|
@@ -100,6 +150,15 @@ export type EntryKind = Infer<typeof entryKindValidator>;
|
|
|
100
150
|
*/
|
|
101
151
|
export type EntryStatus = Infer<typeof entryStatusValidator>;
|
|
102
152
|
|
|
153
|
+
/** Coarse status bucket used by the indexed board and search filters. */
|
|
154
|
+
export type EntryStatusFilter = Infer<typeof entryStatusFilterValidator>;
|
|
155
|
+
|
|
156
|
+
export function entryStatusFilterForStatus(
|
|
157
|
+
status: EntryStatus,
|
|
158
|
+
): EntryStatusFilter {
|
|
159
|
+
return status === "closed" ? "closed" : "open";
|
|
160
|
+
}
|
|
161
|
+
|
|
103
162
|
/**
|
|
104
163
|
* Server-side ordering strategy for feedback entries.
|
|
105
164
|
*
|
|
@@ -109,6 +168,15 @@ export type EntryStatus = Infer<typeof entryStatusValidator>;
|
|
|
109
168
|
*/
|
|
110
169
|
export type EntrySort = Infer<typeof entrySortValidator>;
|
|
111
170
|
|
|
171
|
+
/** Internal triage priority visible only through the admin API. */
|
|
172
|
+
export type EntryPriority = Infer<typeof entryPriorityValidator>;
|
|
173
|
+
|
|
174
|
+
/** Workflow stage for an admin roadmap item. */
|
|
175
|
+
export type RoadmapStatus = Infer<typeof roadmapStatusValidator>;
|
|
176
|
+
|
|
177
|
+
/** Admin-managed roadmap item and its current attached-feedback count. */
|
|
178
|
+
export type RoadmapItem = Infer<typeof roadmapItemValidator>;
|
|
179
|
+
|
|
112
180
|
/**
|
|
113
181
|
* Server-side ordering strategy for comments and replies.
|
|
114
182
|
*
|
|
@@ -130,9 +198,13 @@ export type CommentSort = Infer<typeof commentSortValidator>;
|
|
|
130
198
|
* application user ID, anonymous installation ID, or another stable
|
|
131
199
|
* host-controlled identifier.
|
|
132
200
|
*
|
|
201
|
+
* @property isAdmin
|
|
202
|
+
* Whether the actor can perform admin-only actions such as changing entry
|
|
203
|
+
* status or modifying content they do not own. When omitted, the deprecated
|
|
204
|
+
* `isModerator` field is used.
|
|
205
|
+
*
|
|
133
206
|
* @property isModerator
|
|
134
|
-
*
|
|
135
|
-
* status or modifying content they do not own.
|
|
207
|
+
* @deprecated Use `isAdmin`.
|
|
136
208
|
*/
|
|
137
209
|
export type FeedbackActor = Infer<typeof actorValidator>;
|
|
138
210
|
|
|
@@ -185,11 +257,14 @@ export type FeedbackMetadata = Infer<typeof feedbackMetadataValidator>;
|
|
|
185
257
|
*
|
|
186
258
|
* @property metadata
|
|
187
259
|
* Creation-time diagnostic metadata. Present only when `getEntry` is queried
|
|
188
|
-
* by
|
|
260
|
+
* by an admin. Ordinary entry lists, searches, and non-admin reads omit
|
|
189
261
|
* this property.
|
|
190
262
|
*/
|
|
191
263
|
export type FeedbackEntry = Infer<typeof publicEntryValidator>;
|
|
192
264
|
|
|
265
|
+
/** Feedback entry enriched with private triage metadata for admin clients. */
|
|
266
|
+
export type AdminFeedbackEntry = Infer<typeof adminEntryValidator>;
|
|
267
|
+
|
|
193
268
|
/**
|
|
194
269
|
* Public representation of a comment or reply.
|
|
195
270
|
*
|