convex-feedback 0.2.0-beta.9 → 0.2.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 (81) hide show
  1. package/README.md +398 -7
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/client/api.d.ts +49 -9
  4. package/dist/client/api.d.ts.map +1 -1
  5. package/dist/client/config.d.ts +1 -3
  6. package/dist/client/config.d.ts.map +1 -1
  7. package/dist/client/config.js.map +1 -1
  8. package/dist/client/index.d.ts +707 -15
  9. package/dist/client/index.d.ts.map +1 -1
  10. package/dist/client/index.js +243 -12
  11. package/dist/client/index.js.map +1 -1
  12. package/dist/component/_generated/api.d.ts +2 -0
  13. package/dist/component/_generated/api.d.ts.map +1 -1
  14. package/dist/component/_generated/api.js.map +1 -1
  15. package/dist/component/_generated/component.d.ts +222 -4
  16. package/dist/component/_generated/component.d.ts.map +1 -1
  17. package/dist/component/admin.d.ts +5 -90
  18. package/dist/component/admin.d.ts.map +1 -1
  19. package/dist/component/admin.js +59 -29
  20. package/dist/component/admin.js.map +1 -1
  21. package/dist/component/comments.d.ts +107 -15
  22. package/dist/component/comments.d.ts.map +1 -1
  23. package/dist/component/comments.js +342 -28
  24. package/dist/component/comments.js.map +1 -1
  25. package/dist/component/crons.d.ts.map +1 -1
  26. package/dist/component/crons.js +4 -0
  27. package/dist/component/crons.js.map +1 -1
  28. package/dist/component/entries.d.ts +119 -91
  29. package/dist/component/entries.d.ts.map +1 -1
  30. package/dist/component/entries.js +348 -29
  31. package/dist/component/entries.js.map +1 -1
  32. package/dist/component/helpers.d.ts +32 -1
  33. package/dist/component/helpers.d.ts.map +1 -1
  34. package/dist/component/helpers.js +121 -4
  35. package/dist/component/helpers.js.map +1 -1
  36. package/dist/component/migrations.d.ts +24 -0
  37. package/dist/component/migrations.d.ts.map +1 -1
  38. package/dist/component/migrations.js +318 -0
  39. package/dist/component/migrations.js.map +1 -1
  40. package/dist/component/model.d.ts +455 -120
  41. package/dist/component/model.d.ts.map +1 -1
  42. package/dist/component/model.js +70 -2
  43. package/dist/component/model.js.map +1 -1
  44. package/dist/component/reactions.d.ts +37 -0
  45. package/dist/component/reactions.d.ts.map +1 -0
  46. package/dist/component/reactions.js +52 -0
  47. package/dist/component/reactions.js.map +1 -0
  48. package/dist/component/roadmap.d.ts +10 -46
  49. package/dist/component/roadmap.d.ts.map +1 -1
  50. package/dist/component/roadmap.js +239 -21
  51. package/dist/component/roadmap.js.map +1 -1
  52. package/dist/component/schema.d.ts +33 -5
  53. package/dist/component/schema.d.ts.map +1 -1
  54. package/dist/component/schema.js +40 -4
  55. package/dist/component/schema.js.map +1 -1
  56. package/dist/react/index.d.ts +26 -238
  57. package/dist/react/index.d.ts.map +1 -1
  58. package/dist/react/index.js +65 -2
  59. package/dist/react/index.js.map +1 -1
  60. package/dist/test.d.ts +23 -5
  61. package/dist/test.d.ts.map +1 -1
  62. package/package.json +1 -1
  63. package/src/client/README.md +6 -1
  64. package/src/client/api.ts +89 -7
  65. package/src/client/config.ts +1 -3
  66. package/src/client/index.ts +1237 -34
  67. package/src/component/README.md +32 -4
  68. package/src/component/_generated/api.ts +2 -0
  69. package/src/component/_generated/component.ts +290 -8
  70. package/src/component/admin.ts +88 -30
  71. package/src/component/comments.ts +405 -26
  72. package/src/component/crons.ts +10 -0
  73. package/src/component/entries.ts +388 -27
  74. package/src/component/helpers.ts +163 -4
  75. package/src/component/migrations.ts +415 -0
  76. package/src/component/model.ts +381 -117
  77. package/src/component/reactions.ts +60 -0
  78. package/src/component/roadmap.ts +357 -35
  79. package/src/component/schema.ts +40 -4
  80. package/src/react/README.md +5 -0
  81. package/src/react/index.ts +90 -3
package/README.md CHANGED
@@ -1,4 +1,4 @@
1
- [![npm version](https://badge.fury.io/js/convex-feedback.svg)](https://badge.fury.io/js/convex-feedback) [![Convex Component](https://www.convex.dev/components/badge/convex-feedback)](https://www.convex.dev/components/convex-feedback) ![NPM License](https://img.shields.io/npm/l/convex-feedback) ![NPM Downloads](https://img.shields.io/npm/dw/convex-feedback) ![GitHub forks](https://img.shields.io/github/forks/moumen-io/convex-feedback) ![GitHub Repo stars](https://img.shields.io/github/stars/moumen-io/convex-feedback)
1
+ ![npm version](https://badge.fury.io/js/convex-feedback.svg) ![Convex Component](https://www.convex.dev/components/badge/convex-feedback) ![NPM License](https://img.shields.io/npm/l/convex-feedback) ![NPM Downloads](https://img.shields.io/npm/dw/convex-feedback) ![GitHub forks](https://img.shields.io/github/forks/moumen-io/convex-feedback) ![GitHub Repo stars](https://img.shields.io/github/stars/moumen-io/convex-feedback)
2
2
 
3
3
  [Vite demo](https://convex-feedback-vite.vercel.app/) • [Expo demo](https://convex-feedback-expo.vercel.app/) • [React Native demo](https://convex-feedback-native.vercel.app/)
4
4
 
@@ -6,7 +6,7 @@
6
6
 
7
7
  A headless, fully typed Convex component for product feedback, feature requests, bug reports, entry upvotes, lazy nested comments, comment likes, full-text search, duplicate suggestions, and admin workflows.
8
8
 
9
- > Looking for a ready-made public interface? [`convex-feedback-ui`](../convex-feedback-ui/README.md) provides optional React DOM and React Native screens and compound primitives. For internal triage, fork the [Clerk admin panel](../../apps/admin/withClerk/README.md).
9
+ > Looking for a ready-made public interface? `[convex-feedback-ui](../convex-feedback-ui/README.md)` provides optional React DOM and React Native screens and compound primitives. For internal triage, fork the [Clerk admin panel](../../apps/admin/withClerk/README.md).
10
10
 
11
11
  ## Features
12
12
 
@@ -19,13 +19,18 @@ A headless, fully typed Convex component for product feedback, feature requests,
19
19
  - Convex full-text search.
20
20
  - Exact-title + full-text duplicate suggestions.
21
21
  - Host-controlled authentication and admin permissions.
22
+ - Indexed actor-scoped activity for entries, comments, and reactions.
22
23
  - Admin priority and roadmap workflows.
23
24
  - Optional host-defined mutation rate limiting.
25
+ - Optional host-defined lifecycle callbacks for creation and reaction changes.
24
26
  - Configurable limits and behavior
25
27
  - Typed React hooks.
26
28
  - `convex-test` helper entry point.
27
29
 
28
- The component owns four tables: `entries`, `comments`, `reactions`, and `roadmap`.
30
+ The component persists feedback domain data in `entries`, `comments`, `reactions`,
31
+ and `roadmap`. It also uses `roadmapRebalances` as internal bookkeeping while
32
+ large roadmap reorder operations complete; that state is not part of the public
33
+ feedback model.
29
34
 
30
35
  ## Requirements
31
36
 
@@ -77,6 +82,17 @@ The same job repeats every 30 days for now as a temporary, low-frequency
77
82
  self-healing safeguard for missed or legacy records. It should be removed in a
78
83
  future release after supported installations have had enough time to upgrade.
79
84
 
85
+ ### Legacy comment deletion migration
86
+
87
+ Older releases stored soft-deleted comments with an internal `deletedAt`
88
+ timestamp. The upgrade migration now drains those tombstones, their descendant
89
+ threads, comment reactions, and legacy orphan documents in bounded scheduled
90
+ batches. New comment deletion is permanent; `deletedAt` is retained only as an
91
+ optional internal compatibility field during this migration and is not exposed
92
+ by public types or UI. The migration's progress markers are internal as well.
93
+ After supported installations have migrated, a future release can remove these
94
+ compatibility fields from the schema.
95
+
80
96
  ## 2. Expose the component through your host API
81
97
 
82
98
  A Convex component cannot make authorization decisions using your host application's authentication state directly. `convex-feedback` therefore exposes a host wrapper: your app resolves the current actor, and the wrapper passes the stable actor identity into the component.
@@ -95,6 +111,7 @@ export const {
95
111
  findSimilarEntries,
96
112
  createEntry,
97
113
  updateEntry,
114
+ deleteEntry,
98
115
  setEntryStatus,
99
116
  isAdmin,
100
117
  isAuthenticated,
@@ -102,13 +119,17 @@ export const {
102
119
  adminGetEntry,
103
120
  adminSearchEntries,
104
121
  setEntryPriority,
122
+ listUserEntries,
105
123
  setEntryUpvote,
106
124
  listComments,
125
+ listUserComments,
126
+ listUserReactions,
107
127
  createComment,
108
128
  updateComment,
109
129
  deleteComment,
110
130
  setCommentLike,
111
131
  listRoadmap,
132
+ getRoadmapItem,
112
133
  searchRoadmap,
113
134
  createRoadmap,
114
135
  createRoadmapForEntry,
@@ -151,7 +172,7 @@ return {
151
172
 
152
173
  ## 3. Configure behavior
153
174
 
154
- Configuration is optional static host code; The values shown are the default configuration.
175
+ Configuration is optional static host code; the values shown are the default configuration.
155
176
 
156
177
  ```ts
157
178
  export const feedbackApi = exposeFeedbackApi(components.feedback, {
@@ -251,6 +272,286 @@ export const feedbackApi = exposeFeedbackApi(components.feedback, {
251
272
  });
252
273
  ```
253
274
 
275
+ ### Lifecycle callbacks
276
+
277
+ Lifecycle callbacks are host-wrapper hooks for business logic around creation
278
+ and reaction changes. They are grouped by domain so entry, comment, and reaction
279
+ events stay discoverable and can evolve independently without a flat list of
280
+ unrelated callback names. Configure them at the top level of
281
+ `exposeFeedbackApi`; `config` remains for component behavior:
282
+
283
+ ```ts
284
+ callbacks: {
285
+ rejection,
286
+ entries: {
287
+ beforeCreate,
288
+ afterCreate,
289
+ },
290
+ comments: {
291
+ beforeCreate,
292
+ afterCreate,
293
+ },
294
+ reactions: {
295
+ afterChange,
296
+ },
297
+ }
298
+ ```
299
+
300
+ For entry and comment creation, the lifecycle is:
301
+
302
+ ```text
303
+ auth → rate limiting → beforeCreate → component mutation → afterCreate
304
+ ```
305
+
306
+ For reactions, it is:
307
+
308
+ ```text
309
+ auth → rate limiting → component mutation → afterChange (only if state changed)
310
+ ```
311
+
312
+ `beforeCreate` executes inside the host mutation and receives the full host
313
+ mutation context, including `db`, `auth`, `storage`, `scheduler`, `runQuery`,
314
+ `runMutation`, and `meta`. It runs before the component mutation, so it can do
315
+ simple synchronous validation or business logic, query or mutate host data when
316
+ necessary, sanitize content, return a supported transform, or call `reject()`.
317
+ Callbacks may also be async when the host needs a nested Convex call. A returned
318
+ patch is still passed through the component's normal validation; a callback
319
+ cannot bypass enabled-kind, length, nesting, or other component rules. Entry
320
+ callbacks may patch `kind`, `title`, `body`, and `metadata`. Comment callbacks
321
+ may patch only `body`; `entryId` and `parentCommentId` remain component-owned.
322
+ Callback inputs are readonly snapshots. The wrapper builds component arguments
323
+ from the original mutation arguments plus only the explicitly returned patch,
324
+ so mutating an event object cannot leak changes into component relationships.
325
+
326
+ For example, a host can sanitize profanity and reject content that becomes
327
+ empty after sanitization:
328
+
329
+ ```ts
330
+ import { v } from "convex/values";
331
+
332
+ const moderationRejection = v.object({
333
+ kind: v.literal("content_rejected"),
334
+ reason: v.string(),
335
+ });
336
+
337
+ export const feedbackApi = exposeFeedbackApi(components.feedback, {
338
+ actor: resolveFeedbackActor,
339
+ callbacks: {
340
+ rejection: { behavior: "return", returns: moderationRejection },
341
+ entries: {
342
+ beforeCreate: (ctx, event, handlers) => {
343
+ const title = removeProfanity(event.input.title);
344
+ const body = removeProfanity(event.input.body);
345
+ if (!title.trim() || !body.trim()) {
346
+ return handlers.reject({
347
+ kind: "content_rejected",
348
+ reason: "Entry content is empty after sanitization",
349
+ });
350
+ }
351
+ return { title, body };
352
+ },
353
+ },
354
+ comments: {
355
+ beforeCreate: (ctx, event, handlers) => {
356
+ void ctx;
357
+ const body = removeProfanity(event.input.body);
358
+ if (!body.trim()) {
359
+ return handlers.reject({
360
+ kind: "content_rejected",
361
+ reason: "Comment content is empty after sanitization",
362
+ });
363
+ }
364
+ return { body };
365
+ },
366
+ },
367
+ },
368
+ });
369
+ ```
370
+
371
+ The default callback rejection behavior is
372
+ `callbacks.rejection.behavior = "throw"` (or omit `rejection`). It is equivalent
373
+ to:
374
+
375
+ ```ts
376
+ callbacks: {
377
+ rejection: { behavior: "throw" },
378
+ }
379
+ ```
380
+
381
+ An explicit `reject(value)` becomes a `ConvexError`, and no component mutation
382
+ occurs after that rejection. To return a validated value instead, configure:
383
+
384
+ ```ts
385
+ callbacks: {
386
+ rejection: {
387
+ behavior: "return",
388
+ returns: moderationRejection,
389
+ },
390
+ }
391
+ ```
392
+
393
+ Only explicit `reject()` calls use this behavior. Normal exceptions thrown by a
394
+ callback still throw, including when return mode is enabled. Return mode changes
395
+ the TypeScript result union of the relevant create mutation:
396
+ `createEntry` and/or `createComment` become `string | CallbackRejection` (and
397
+ retain any rate-limit rejection type). If rate limiting also uses return mode,
398
+ the create mutation's union composes both the rate-limit and callback rejection
399
+ values. Other mutations receive only their configured rate-limit result.
400
+
401
+ After callbacks are awaited in the originating host mutation. There are three
402
+ important failure models:
403
+
404
+ 1. **Throw or nested mutation failure.** An uncaught `afterCreate` or
405
+ `afterChange` error, including a failed nested `ctx.runMutation`, runs in the
406
+ same transaction and rolls back the originating host mutation and its
407
+ component write.
408
+ 2. **Scheduled action.** Schedule external work from the callback when it must
409
+ happen after commit:
410
+
411
+ ```ts
412
+ await ctx.scheduler.runAfter(
413
+ 0,
414
+ internal.notifications.sendNotificationToUser,
415
+ args,
416
+ );
417
+ ```
418
+
419
+ Scheduling is committed atomically with the mutation. The action runs after
420
+ commit, so a later action failure cannot roll back persisted entries,
421
+ comments, or reactions. Scheduled actions are at-most-once and are not
422
+ automatically retried; use an app-owned outbox or durable workflow when
423
+ stronger delivery guarantees are required.
424
+
425
+ 3. **Explicit catch.** A callback can catch an error itself when the failure is
426
+ intentionally non-blocking. There is no package-level option that silently
427
+ ignores callback exceptions; swallowing an error is an application decision.
428
+
429
+ Reaction `afterChange` runs only when the actor's desired state actually changes.
430
+ It runs for both additions and removals. The event exposes
431
+ `transition: "added" | "removed"`, `previousCount`, and `count` (the final
432
+ count), plus the reacting actor and the target author/context: `entry` for an
433
+ entry upvote, or `comment` for a comment like. Entry-upvote events also expose
434
+ `entryId`, while comment-like events expose `commentId` and `entryId`; these
435
+ top-level IDs always match the IDs in the nested target context. Comment-like
436
+ events include the comment author and `entryId` without reading or serializing
437
+ the parent entry.
438
+
439
+ Rich component callback context is requested only when the corresponding
440
+ `afterCreate` or `afterChange` callback is configured. With no after callback,
441
+ creation and reaction mutations keep their lean result path and avoid
442
+ callback-only reads and serialization.
443
+
444
+ ```ts
445
+ afterChange: async (ctx, event) => {
446
+ if (event.transition !== "added") return;
447
+
448
+ const thresholds = [5, 10, 20];
449
+ const crossed = thresholds.some(
450
+ (threshold) => event.previousCount < threshold && event.count >= threshold,
451
+ );
452
+ if (!crossed) return;
453
+
454
+ // event.count is authoritative; event.previousCount prevents duplicates
455
+ // when a count changes without crossing a configured threshold.
456
+ };
457
+ ```
458
+
459
+ For notifications, keep the provider and recipient policy in the host app.
460
+ This small example uses an app-owned `sendNotificationToUser` internal action;
461
+ the component does not know about email, push, or notification providers:
462
+
463
+ ```ts
464
+ // convex/notifications.ts
465
+ import { internalAction } from "convex/server";
466
+ import { v } from "convex/values";
467
+
468
+ export const sendNotificationToUser = internalAction({
469
+ args: {
470
+ userId: v.string(),
471
+ title: v.string(),
472
+ body: v.string(),
473
+ },
474
+ handler: async (ctx, args) => {
475
+ void ctx;
476
+ // `notificationProvider` is an app-owned email/push integration.
477
+ await notificationProvider.send(args);
478
+ },
479
+ });
480
+ ```
481
+
482
+ The host wrapper can then schedule admins, the entry author, the parent
483
+ comment author, and threshold notifications:
484
+
485
+ ```ts
486
+ // convex/feedback.ts
487
+ import { internal } from "./_generated/api";
488
+
489
+ const ADMIN_USER_IDS = ["admin-1", "admin-2"];
490
+ const REACTION_THRESHOLDS = [5, 10, 20];
491
+
492
+ export const { createEntry, createComment, setEntryUpvote, setCommentLike } =
493
+ exposeFeedbackApi(components.feedback, {
494
+ actor: resolveFeedbackActor,
495
+ callbacks: {
496
+ entries: {
497
+ afterCreate: async (ctx, event) => {
498
+ await Promise.all(
499
+ ADMIN_USER_IDS.map((userId) =>
500
+ ctx.scheduler.runAfter(
501
+ 0,
502
+ internal.notifications.sendNotificationToUser,
503
+ {
504
+ userId,
505
+ title: "New feedback",
506
+ body: event.entry.title,
507
+ },
508
+ ),
509
+ ),
510
+ );
511
+ },
512
+ },
513
+ comments: {
514
+ afterCreate: async (ctx, event) => {
515
+ const userId = event.parentComment?.actorId ?? event.entry.actorId;
516
+ await ctx.scheduler.runAfter(
517
+ 0,
518
+ internal.notifications.sendNotificationToUser,
519
+ {
520
+ userId,
521
+ title: event.parentComment ? "New reply" : "New comment",
522
+ body: event.comment.body,
523
+ },
524
+ );
525
+ },
526
+ },
527
+ reactions: {
528
+ afterChange: async (ctx, event) => {
529
+ if (event.transition !== "added") return;
530
+ const crossed = REACTION_THRESHOLDS.some(
531
+ (threshold) =>
532
+ event.previousCount < threshold && event.count >= threshold,
533
+ );
534
+ if (!crossed) return;
535
+
536
+ const userId =
537
+ event.type === "entry_upvote"
538
+ ? event.entry.actorId
539
+ : event.comment.actorId;
540
+ await ctx.scheduler.runAfter(
541
+ 0,
542
+ internal.notifications.sendNotificationToUser,
543
+ {
544
+ userId,
545
+ title: "Your feedback is getting attention",
546
+ body: `Reaction count: ${event.count}`,
547
+ },
548
+ );
549
+ },
550
+ },
551
+ },
552
+ });
553
+ ```
554
+
254
555
  ## 4. Create typed React hooks
255
556
 
256
557
  If your client uses React or React Native, bind the generated host API once. Calling `createFeedbackHooks()` without an API argument defaults to `anyApi.feedback`; pass the generated namespace explicitly when the component is exposed elsewhere:
@@ -299,6 +600,92 @@ Metadata is intentionally absent from entry lists, searches, and duplicate sugge
299
600
  Public entry results include `viewerIsAuthor` when returned by the current wrapper deployment. It is computed from the server-resolved actor and the stored entry author; clients should use it only to present author-only UI such as an Edit action. `updateEntry` still rechecks ownership in the component
300
601
  mutation, so a caller that is not the entry author is rejected. Admins retain their existing permission to edit entries through the admin workflow.
301
602
 
603
+ ### Actor-scoped activity
604
+
605
+ The host wrapper also exposes cursor-paginated activity queries for the authenticated actor:
606
+
607
+ ```ts
608
+ const userEntries = await listUserEntries({
609
+ paginationOpts: { cursor: null, numItems: 20 },
610
+ });
611
+ const userComments = await listUserComments({
612
+ paginationOpts: { cursor: null, numItems: 20 },
613
+ });
614
+ const userReactions = await listUserReactions({
615
+ paginationOpts: { cursor: null, numItems: 20 },
616
+ });
617
+ ```
618
+
619
+ A trusted Convex function can compose these wrappers through the generated host API. `ctx.runQuery` keeps the caller's authentication context, so the configured actor callback still selects the actor and no `actorId` is passed:
620
+
621
+ ```ts
622
+ import { api } from "./_generated/api";
623
+ import { query } from "./_generated/server";
624
+
625
+ export const getMyActivity = query({
626
+ args: {},
627
+ handler: async (ctx) => {
628
+ const entryQuery = api.feedback.listUserEntries;
629
+ const commentQuery = api.feedback.listUserComments;
630
+ const reactionQuery = api.feedback.listUserReactions;
631
+ const opts = { cursor: null, numItems: 20 };
632
+ const entries = await ctx.runQuery(entryQuery, { paginationOpts: opts });
633
+ const comments = await ctx.runQuery(commentQuery, { paginationOpts: opts });
634
+ const reactions = await ctx.runQuery(reactionQuery, {
635
+ paginationOpts: opts,
636
+ });
637
+ return { entries, comments, reactions };
638
+ },
639
+ });
640
+ ```
641
+
642
+ These wrappers do not accept an `actorId`; they always use the actor returned by the configured host callback. Entries include their own content, status, timestamps, and counts. Comments include their body, `parentCommentId`, and the parent entry title without loading a parent-comment body. Pending deletions are hidden immediately.
643
+
644
+ Reaction results are discriminated by `type` (`"entry_upvote"` or `"comment_like"`) and include reaction creation time plus resolved live target context. Permanent entry and comment deletion remove dependent reactions in scheduled cleanup batches, so completed deletions do not leave activity records or orphan targets behind.
645
+
646
+ Trusted server consumers can call the component-level actor query directly with a known `actorId`. `entries.listByActor` additionally accepts `includeAdminContext: true` when an export or other server-side workflow needs retained metadata, priority, or roadmap context; the normal `listUserEntries` wrapper always strips those private fields.
647
+
648
+ For example, an internal Convex function can query all three activity feeds for a known actor without requiring request authentication:
649
+
650
+ ```ts
651
+ import { components } from "./_generated/api";
652
+ import { internalQuery } from "./_generated/server";
653
+ import { v } from "convex/values";
654
+
655
+ export const getActorActivity = internalQuery({
656
+ args: { actorId: v.string() },
657
+ handler: async (ctx, args) => {
658
+ const opts = { cursor: null, numItems: 100 };
659
+ const entries = await ctx.runQuery(
660
+ components.feedback.entries.listByActor,
661
+ {
662
+ actorId: args.actorId,
663
+ paginationOpts: opts,
664
+ includeAdminContext: true,
665
+ },
666
+ );
667
+
668
+ const comments = await ctx.runQuery(
669
+ components.feedback.comments.listByActor,
670
+ {
671
+ actorId: args.actorId,
672
+ paginationOpts: opts,
673
+ },
674
+ );
675
+
676
+ const reactions = await ctx.runQuery(
677
+ components.feedback.reactions.listByActor,
678
+ {
679
+ actorId: args.actorId,
680
+ paginationOpts: opts,
681
+ },
682
+ );
683
+
684
+ return { entries, comments, reactions };
685
+ },
686
+ });
687
+ ```
688
+
302
689
  ## Admin panel
303
690
 
304
691
  The standalone [Vite and Expo Clerk admin apps](../../apps/admin/withClerk/README.md) provide an inbox, entry detail workflow, and a stage-based roadmap. They are reference applications to fork and deploy, not reusable UI exports.
@@ -319,7 +706,7 @@ actor: async (ctx) => {
319
706
 
320
707
  Set the claim and Convex Clerk provider using Clerk's current integration instructions, then export the complete wrapper API shown above as `convex/feedback.ts`. Both reference apps use `anyApi.feedback` by default and reactively check `isAdmin` at their root, offering retry when the access check fails; every admin query and mutation still rechecks the actor on the server.
321
708
 
322
- Admin entries may have an optional `low`, `medium`, or `high` priority and one roadmap relation. Deleting a roadmap item detaches all related feedback. Public entry queries and the existing public UI do not expose priority, while roadmap reads are public.
709
+ Admin entries may have an optional `low`, `medium`, or `high` priority and one roadmap relation. Deleting a roadmap item detaches all related feedback. Permanent entry deletion first hides the entry, detaches its roadmap relation, and schedules bounded cleanup of reactions and comments before hard deletion. Public entry queries and the existing public UI do not expose priority, while roadmap reads are public.
323
710
 
324
711
  ## Entry kinds and statuses
325
712
 
@@ -414,7 +801,7 @@ A comment query returns exactly one direct-child level. Opening a reply branch s
414
801
 
415
802
  `replyCount` is the number of **direct children**. `entry.commentCount` is the total number of comments/replies belonging to the entry.
416
803
 
417
- Soft-deleted comments remain as tombstones so descendants keep their position in the thread.
804
+ Deleting a comment permanently removes it, all descendants, and their reactions in bounded scheduled batches. The comment and its descendants are hidden as soon as deletion starts; `replyCount` and `entry.commentCount` are updated as documents are removed.
418
805
 
419
806
  ## Upvotes and likes
420
807
 
@@ -449,12 +836,16 @@ The wrapper exposes:
449
836
  | `isAuthenticated` | query | Whether the current request has an actor |
450
837
  | `createEntry` | mutation | Create feedback |
451
838
  | `updateEntry` | mutation | Edit feedback; admins may also change its kind |
839
+ | `deleteEntry` | mutation | Permanently delete feedback; admins only |
452
840
  | `setEntryStatus` | mutation | Admin workflow status change |
453
841
  | `setEntryUpvote` | mutation | Idempotently set entry upvote state |
842
+ | `listUserEntries` | query | Entries created by the authenticated actor |
454
843
  | `listComments` | query | One paginated direct-child comment level |
844
+ | `listUserComments` | query | Comments created by the authenticated actor |
845
+ | `listUserReactions` | query | Entry upvotes and comment likes by the actor |
455
846
  | `createComment` | mutation | Create comment or reply |
456
847
  | `updateComment` | mutation | Edit a comment |
457
- | `deleteComment` | mutation | Soft-delete a comment |
848
+ | `deleteComment` | mutation | Permanently delete a comment subtree |
458
849
  | `setCommentLike` | mutation | Idempotently set comment like state |
459
850
  | `createRoadmapForEntry` | mutation | Create a roadmap item and attach an entry atomically |
460
851