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.
Files changed (79) hide show
  1. package/README.md +87 -27
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/client/api.d.ts +80 -1
  4. package/dist/client/api.d.ts.map +1 -1
  5. package/dist/client/config.d.ts +1 -1
  6. package/dist/client/index.d.ts +9 -5
  7. package/dist/client/index.d.ts.map +1 -1
  8. package/dist/client/index.js +261 -8
  9. package/dist/client/index.js.map +1 -1
  10. package/dist/component/_generated/api.d.ts +8 -0
  11. package/dist/component/_generated/api.d.ts.map +1 -1
  12. package/dist/component/_generated/api.js.map +1 -1
  13. package/dist/component/_generated/component.d.ts +295 -5
  14. package/dist/component/_generated/component.d.ts.map +1 -1
  15. package/dist/component/admin.d.ts +130 -0
  16. package/dist/component/admin.d.ts.map +1 -0
  17. package/dist/component/admin.js +125 -0
  18. package/dist/component/admin.js.map +1 -0
  19. package/dist/component/comments.d.ts +7 -5
  20. package/dist/component/comments.d.ts.map +1 -1
  21. package/dist/component/comments.js +4 -3
  22. package/dist/component/comments.js.map +1 -1
  23. package/dist/component/crons.d.ts +3 -0
  24. package/dist/component/crons.d.ts.map +1 -0
  25. package/dist/component/crons.js +8 -0
  26. package/dist/component/crons.js.map +1 -0
  27. package/dist/component/entries.d.ts +29 -16
  28. package/dist/component/entries.d.ts.map +1 -1
  29. package/dist/component/entries.js +136 -12
  30. package/dist/component/entries.js.map +1 -1
  31. package/dist/component/helpers.d.ts +3 -1
  32. package/dist/component/helpers.d.ts.map +1 -1
  33. package/dist/component/helpers.js +28 -0
  34. package/dist/component/helpers.js.map +1 -1
  35. package/dist/component/migrations.d.ts +11 -0
  36. package/dist/component/migrations.d.ts.map +1 -0
  37. package/dist/component/migrations.js +35 -0
  38. package/dist/component/migrations.js.map +1 -0
  39. package/dist/component/model.d.ts +144 -24
  40. package/dist/component/model.d.ts.map +1 -1
  41. package/dist/component/model.js +34 -1
  42. package/dist/component/model.js.map +1 -1
  43. package/dist/component/roadmap.d.ts +166 -0
  44. package/dist/component/roadmap.d.ts.map +1 -0
  45. package/dist/component/roadmap.js +534 -0
  46. package/dist/component/roadmap.js.map +1 -0
  47. package/dist/component/schema.d.ts +67 -5
  48. package/dist/component/schema.d.ts.map +1 -1
  49. package/dist/component/schema.js +57 -2
  50. package/dist/component/schema.js.map +1 -1
  51. package/dist/component/tags.d.ts +58 -0
  52. package/dist/component/tags.d.ts.map +1 -0
  53. package/dist/component/tags.js +216 -0
  54. package/dist/component/tags.js.map +1 -0
  55. package/dist/react/index.d.ts +173 -12
  56. package/dist/react/index.d.ts.map +1 -1
  57. package/dist/react/index.js +78 -1
  58. package/dist/react/index.js.map +1 -1
  59. package/dist/test.d.ts +67 -5
  60. package/dist/test.d.ts.map +1 -1
  61. package/package.json +6 -2
  62. package/src/client/README.md +1 -1
  63. package/src/client/api.ts +183 -0
  64. package/src/client/config.ts +1 -1
  65. package/src/client/index.ts +340 -13
  66. package/src/component/README.md +3 -1
  67. package/src/component/_generated/api.ts +8 -0
  68. package/src/component/_generated/component.ts +373 -9
  69. package/src/component/admin.ts +180 -0
  70. package/src/component/comments.ts +5 -2
  71. package/src/component/crons.ts +16 -0
  72. package/src/component/entries.ts +175 -11
  73. package/src/component/helpers.ts +44 -1
  74. package/src/component/migrations.ts +44 -0
  75. package/src/component/model.ts +79 -4
  76. package/src/component/roadmap.ts +627 -0
  77. package/src/component/schema.ts +61 -1
  78. package/src/react/README.md +2 -2
  79. 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;
@@ -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 (kinds === undefined) {
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
- viewerIsModerator: v.optional(v.boolean()),
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.viewerIsModerator === true,
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 (kinds === undefined) {
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.isModerator ||
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.isModerator) {
522
- throw new ConvexError("Moderator access is required to change status.");
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, { upvoteCount });
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, { upvoteCount });
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
 
@@ -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 { FeedbackComment, FeedbackEntry } from "./model.js";
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
+ });
@@ -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
- isModerator: v.boolean(),
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
- * Whether the actor can perform moderator-only actions such as changing entry
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 a moderator. Ordinary entry lists, searches, and non-moderator reads omit
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
  *