convex-feedback 0.2.0-beta.8 → 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
@@ -22,7 +22,11 @@ import {
22
22
  } from "convex/values";
23
23
 
24
24
  import type { ComponentApi } from "../component/_generated/component.js";
25
+ import { stripActivityEntryContext } from "../component/helpers.js";
25
26
  import {
27
+ activityCommentValidator,
28
+ activityEntryValidator,
29
+ actorIsAdmin,
26
30
  adminEntryValidator,
27
31
  commentSortValidator,
28
32
  entryKindValidator,
@@ -31,13 +35,16 @@ import {
31
35
  entryStatusFilterValidator,
32
36
  entryStatusValidator,
33
37
  feedbackMetadataValidator,
38
+ feedbackReactionValidator,
34
39
  publicCommentValidator,
35
40
  publicEntryValidator,
36
41
  roadmapItemValidator,
37
42
  roadmapStatusValidator,
38
43
  similarEntriesValidator,
39
- actorIsAdmin,
44
+ type EntryKind,
45
+ type EntryStatus,
40
46
  type FeedbackActor,
47
+ type FeedbackMetadata,
41
48
  } from "../component/model.js";
42
49
  import type { FeedbackPublicApi } from "./api.js";
43
50
  import {
@@ -53,16 +60,48 @@ export type {
53
60
  EntrySort,
54
61
  EntryStatus,
55
62
  EntryStatusFilter,
63
+ FeedbackActivityComment,
64
+ FeedbackActivityEntry,
65
+ FeedbackActivityEntryWithContext,
56
66
  FeedbackActor,
57
67
  FeedbackComment,
68
+ FeedbackCommentReactionTarget,
58
69
  FeedbackEntry,
70
+ FeedbackEntryReactionTarget,
59
71
  FeedbackMetadata,
60
72
  FeedbackMetadataValue,
73
+ FeedbackReaction,
61
74
  RoadmapItem,
62
75
  RoadmapStatus,
63
76
  SimilarEntriesResult,
64
77
  } from "../component/model.js";
65
- export type { FeedbackPublicApi } from "./api.js";
78
+ export type {
79
+ AdminListEntriesArgs,
80
+ AdminSearchEntriesArgs,
81
+ CreateCommentArgs,
82
+ CreateEntryArgs,
83
+ DeleteCommentArgs,
84
+ DeleteEntryArgs,
85
+ DetachFeedbackFromRoadmapArgs,
86
+ FeedbackPublicApi,
87
+ FindSimilarEntriesArgs,
88
+ GetEntryArgs,
89
+ GetRoadmapItemArgs,
90
+ ListCommentsArgs,
91
+ ListEntriesArgs,
92
+ ListRoadmapArgs,
93
+ ListRoadmapFeedbackArgs,
94
+ ListUserCommentsArgs,
95
+ ListUserEntriesArgs,
96
+ ListUserReactionsArgs,
97
+ SearchEntriesArgs,
98
+ SetCommentLikeArgs,
99
+ SetEntryPriorityArgs,
100
+ SetEntryStatusArgs,
101
+ SetEntryUpvoteArgs,
102
+ UpdateCommentArgs,
103
+ UpdateEntryArgs,
104
+ } from "./api.js";
66
105
  export {
67
106
  createFeedbackConfig,
68
107
  defaultFeedbackConfig,
@@ -219,6 +258,742 @@ type RateLimiterResult<
219
258
  ? Infer<ReturnsValidator>
220
259
  : void;
221
260
 
261
+ /**
262
+ * Full host mutation context supplied to every lifecycle callback.
263
+ *
264
+ * This is the host mutation context for the request that triggered the
265
+ * callback. It includes the host database, authentication, storage,
266
+ * scheduler, and nested function-call helpers, so callbacks can apply host
267
+ * business rules or schedule follow-up work.
268
+ *
269
+ * When a callback needs to read or write feedback data, prefer a direct
270
+ * component reference through `ctx.runQuery(components.feedback....)` or
271
+ * `ctx.runMutation(components.feedback....)`. Calling an exposed host API
272
+ * through `api.feedback...` is also valid, but it re-enters the host wrapper
273
+ * and its actor/auth-resolution path. Use the host API when those host-facing
274
+ * semantics are specifically desired.
275
+ *
276
+ * @example Query component data from a callback
277
+ * ```ts
278
+ * import { components } from "./_generated/api";
279
+ *
280
+ * const entry = await ctx.runQuery(components.feedback.entries.get, {
281
+ * entryId: event.entryId,
282
+ * });
283
+ * ```
284
+ */
285
+ export type FeedbackMutationContext = GenericMutationCtx<GenericDataModel>;
286
+
287
+ /**
288
+ * Readonly entry creation event passed after auth/rate limiting and before any
289
+ * component work. Return a {@link FeedbackEntryCreatePatch} to transform only
290
+ * the supported fields; mutating `event.input` is neither supported nor used.
291
+ */
292
+ export interface FeedbackEntryBeforeCreateEvent {
293
+ /** Actor resolved by the host for the request being created. */
294
+ readonly actor: Readonly<FeedbackActor>;
295
+
296
+ /** Readonly snapshot of the caller's validated mutation input. */
297
+ readonly input: {
298
+ /** Requested entry category. */
299
+ readonly kind: EntryKind;
300
+
301
+ /** Title submitted by the caller, before component normalization. */
302
+ readonly title: string;
303
+
304
+ /** Body submitted by the caller, before component normalization. */
305
+ readonly body: string;
306
+
307
+ /** Optional diagnostic metadata submitted with the entry. */
308
+ readonly metadata?: Readonly<{
309
+ /** Standard metadata collected by the host or UI. */
310
+ standard?: Readonly<Record<string, string | number | boolean>>;
311
+
312
+ /** Additional host-defined metadata. */
313
+ additional?: Readonly<Record<string, string | number | boolean>>;
314
+ }>;
315
+ };
316
+ }
317
+
318
+ /**
319
+ * Explicit entry creation transformations. Omitted fields retain the original
320
+ * mutation arguments, and all returned values still undergo component
321
+ * validation. Actor identity, IDs, and configured defaults cannot be changed.
322
+ */
323
+ export interface FeedbackEntryCreatePatch {
324
+ /** Replacement entry category. */
325
+ kind?: EntryKind;
326
+
327
+ /** Replacement title. */
328
+ title?: string;
329
+
330
+ /** Replacement body. */
331
+ body?: string;
332
+
333
+ /** Replacement diagnostic metadata. */
334
+ metadata?: FeedbackMetadata;
335
+ }
336
+
337
+ /**
338
+ * Readonly comment creation event passed after auth/rate limiting and before
339
+ * component work. `entryId` and `parentCommentId` are immutable relationship
340
+ * fields; only an explicitly returned `body` patch is applied.
341
+ */
342
+ export interface FeedbackCommentBeforeCreateEvent {
343
+ /** Actor resolved by the host for the request being created. */
344
+ readonly actor: Readonly<FeedbackActor>;
345
+
346
+ /** Readonly snapshot of the caller's validated mutation input. */
347
+ readonly input: {
348
+ /** ID of the entry that will own the comment. */
349
+ readonly entryId: string;
350
+
351
+ /** ID of the parent comment when this input creates a reply. */
352
+ readonly parentCommentId?: string;
353
+
354
+ /** Comment body submitted by the caller, before normalization. */
355
+ readonly body: string;
356
+ };
357
+ }
358
+
359
+ /**
360
+ * Explicit comment creation transformation. Only `body` is supported and the
361
+ * transformed value still undergoes length and permission validation.
362
+ */
363
+ export interface FeedbackCommentCreatePatch {
364
+ /** Replacement comment body. */
365
+ body?: string;
366
+ }
367
+
368
+ /**
369
+ * Helpers supplied to before-create callbacks. `reject(value)` is the only
370
+ * exception translated by callback rejection configuration: it throws a
371
+ * `ConvexError` by default or returns the validated value in return mode.
372
+ * Unexpected callback errors always propagate normally.
373
+ * The third callback parameter is commonly named `handlers`.
374
+ *
375
+ * @typeParam Rejection Value accepted by `reject`. In return mode this is
376
+ * inferred from `callbacks.rejection.returns`.
377
+ */
378
+ export interface FeedbackCallbackHelpers<Rejection = Value> {
379
+ /**
380
+ * Reject the pending entry or comment creation.
381
+ *
382
+ * The call never returns: it throws in the default mode or short-circuits
383
+ * with the validated rejection value in return mode.
384
+ */
385
+ reject: (value: Rejection) => never;
386
+ }
387
+
388
+ type MaybePromise<ValueType> = ValueType | Promise<ValueType>;
389
+
390
+ /**
391
+ * Runs after actor resolution and rate limiting, before the component entry
392
+ * mutation. It is awaited and may return an explicit creation patch or call
393
+ * `reject()`; it cannot replace normal component validation.
394
+ *
395
+ * @typeParam Rejection Value accepted by `handlers.reject`.
396
+ * @param ctx Host mutation context for the originating request. Use direct
397
+ * component references with `ctx.runQuery`/`ctx.runMutation` for feedback data.
398
+ * @param event Readonly event containing `actor` and `input` (`kind`, `title`,
399
+ * `body`, and optional `metadata`) for this request.
400
+ * @param handlers Callback handlers. Call `handlers.reject(value)` to reject
401
+ * creation.
402
+ * @returns A supported entry patch, or `undefined` to keep the original input.
403
+ *
404
+ * @example Validate and reject an entry before creation
405
+ * ```ts
406
+ * const beforeCreate: FeedbackEntryBeforeCreateCallback = (
407
+ * ctx,
408
+ * event,
409
+ * handlers,
410
+ * ) => {
411
+ * void ctx;
412
+ * if (event.input.title.trim() === "") {
413
+ * handlers.reject("An entry title is required");
414
+ * }
415
+ * };
416
+ * ```
417
+ *
418
+ * @example No-op entry before-create hook
419
+ * ```ts
420
+ * const beforeCreate: FeedbackEntryBeforeCreateCallback = (
421
+ * ctx,
422
+ * event,
423
+ * handlers,
424
+ * ) => {
425
+ * void ctx;
426
+ * void event;
427
+ * void handlers;
428
+ * };
429
+ * ```
430
+ */
431
+ export type FeedbackEntryBeforeCreateCallback<Rejection = Value> = (
432
+ ctx: FeedbackMutationContext,
433
+ event: FeedbackEntryBeforeCreateEvent,
434
+ handlers: FeedbackCallbackHelpers<Rejection>,
435
+ ) => MaybePromise<FeedbackEntryCreatePatch | undefined>;
436
+
437
+ /**
438
+ * Sanitized persisted entry passed to `entries.afterCreate`.
439
+ *
440
+ * The entry is the authoritative component result after creation and
441
+ * normalization. It is available without another component query.
442
+ */
443
+ export interface FeedbackEntryAfterCreateEvent {
444
+ /** Actor resolved by the host for the creation request. */
445
+ readonly actor: FeedbackActor;
446
+
447
+ /** Persisted entry created by the component. */
448
+ readonly entry: {
449
+ /** Public component identifier for the created entry. */
450
+ readonly id: string;
451
+
452
+ /** Stable identifier of the actor who created the entry. */
453
+ readonly actorId: string;
454
+
455
+ /** Persisted entry category. */
456
+ readonly kind: EntryKind;
457
+
458
+ /** Initial workflow status assigned by the component. */
459
+ readonly status: EntryStatus;
460
+
461
+ /** Normalized persisted title. */
462
+ readonly title: string;
463
+
464
+ /** Normalized persisted body. */
465
+ readonly body: string;
466
+
467
+ /** Persisted diagnostic metadata, when supplied. */
468
+ readonly metadata?: FeedbackMetadata;
469
+
470
+ /** Authoritative number of upvotes immediately after creation. */
471
+ readonly upvoteCount: number;
472
+
473
+ /** Authoritative number of comments immediately after creation. */
474
+ readonly commentCount: number;
475
+ };
476
+ }
477
+
478
+ /**
479
+ * Runs exactly once after successful component entry creation and is awaited
480
+ * in the same host mutation. An uncaught error rolls back creation. Rich entry
481
+ * context is requested from the component only when this callback is set.
482
+ *
483
+ * @param ctx Host mutation context for the originating request.
484
+ * @param event Persisted event containing `actor` and the created `entry`
485
+ * (`id`, `actorId`, `kind`, `status`, `title`, `body`, metadata, and counts).
486
+ * @returns Nothing. The callback may be synchronous or asynchronous.
487
+ *
488
+ * @example No-op entry after-create hook
489
+ * ```ts
490
+ * const afterCreate: FeedbackEntryAfterCreateCallback = async (ctx, event) => {
491
+ * void ctx;
492
+ * void event;
493
+ * };
494
+ * ```
495
+ */
496
+ export type FeedbackEntryAfterCreateCallback = (
497
+ ctx: FeedbackMutationContext,
498
+ event: FeedbackEntryAfterCreateEvent,
499
+ ) => MaybePromise<void>;
500
+
501
+ /**
502
+ * Runs after actor resolution and rate limiting, before the component comment
503
+ * mutation. It is awaited and may transform only `body` or call `reject()`.
504
+ *
505
+ * @typeParam Rejection Value accepted by `handlers.reject`.
506
+ * @param ctx Host mutation context for the originating request.
507
+ * @param event Readonly event containing `actor` and `input` (`entryId`,
508
+ * optional `parentCommentId`, and `body`) for this request.
509
+ * @param handlers Callback handlers. Call `handlers.reject(value)` to reject
510
+ * creation.
511
+ * @returns A supported comment patch, or `undefined` to keep the original
512
+ * input.
513
+ *
514
+ * @example No-op comment before-create hook
515
+ * ```ts
516
+ * const beforeCreate: FeedbackCommentBeforeCreateCallback = (
517
+ * ctx,
518
+ * event,
519
+ * handlers,
520
+ * ) => {
521
+ * void ctx;
522
+ * void event;
523
+ * void handlers;
524
+ * };
525
+ * ```
526
+ */
527
+ export type FeedbackCommentBeforeCreateCallback<Rejection = Value> = (
528
+ ctx: FeedbackMutationContext,
529
+ event: FeedbackCommentBeforeCreateEvent,
530
+ handlers: FeedbackCallbackHelpers<Rejection>,
531
+ ) => MaybePromise<FeedbackCommentCreatePatch | undefined>;
532
+
533
+ /**
534
+ * Persisted comment, entry, and optional parent context passed after creation.
535
+ *
536
+ * All IDs and content in this event come from the successful component write;
537
+ * the optional `parentComment` is present only when the created comment is a
538
+ * reply.
539
+ */
540
+ export interface FeedbackCommentAfterCreateEvent {
541
+ /** Actor resolved by the host for the creation request. */
542
+ readonly actor: FeedbackActor;
543
+
544
+ /** Persisted comment created by the component. */
545
+ readonly comment: {
546
+ /** Public component identifier for the created comment. */
547
+ readonly id: string;
548
+
549
+ /** Stable identifier of the actor who created the comment. */
550
+ readonly actorId: string;
551
+
552
+ /** ID of the entry containing the comment. */
553
+ readonly entryId: string;
554
+
555
+ /** ID of the parent comment when this comment is a reply. */
556
+ readonly parentCommentId?: string;
557
+
558
+ /** Normalized persisted comment body. */
559
+ readonly body: string;
560
+
561
+ /** Nesting depth assigned by the component. */
562
+ readonly depth: number;
563
+ };
564
+
565
+ /** Persisted entry containing the created comment. */
566
+ readonly entry: {
567
+ /** Public component identifier for the containing entry. */
568
+ readonly id: string;
569
+
570
+ /** Stable identifier of the entry author. */
571
+ readonly actorId: string;
572
+
573
+ /** Entry category. */
574
+ readonly kind: EntryKind;
575
+
576
+ /** Current workflow status of the entry. */
577
+ readonly status: EntryStatus;
578
+
579
+ /** Entry title. */
580
+ readonly title: string;
581
+ };
582
+
583
+ /**
584
+ * Minimal persisted parent-comment context, present only for replies.
585
+ */
586
+ readonly parentComment?: {
587
+ /** Public component identifier for the parent comment. */
588
+ readonly id: string;
589
+
590
+ /** Stable identifier of the parent comment's author. */
591
+ readonly actorId: string;
592
+ };
593
+ }
594
+
595
+ /**
596
+ * Runs exactly once after successful component comment creation and is awaited
597
+ * in the same host mutation. An uncaught error rolls back creation. Rich
598
+ * comment/entry/parent context is requested only when this callback is set.
599
+ *
600
+ * @param ctx Host mutation context for the originating request.
601
+ * @param event Persisted event containing `actor`, `comment`, `entry`, and
602
+ * optional `parentComment` context returned by the component.
603
+ * @returns Nothing. The callback may be synchronous or asynchronous.
604
+ *
605
+ * @example React to a created comment
606
+ * ```ts
607
+ * const afterCreate: FeedbackCommentAfterCreateCallback = async (
608
+ * ctx,
609
+ * event,
610
+ * ) => {
611
+ * await ctx.scheduler.runAfter(0, internal.notifications.commentCreated, {
612
+ * commentId: event.comment.id,
613
+ * entryId: event.entry.id,
614
+ * });
615
+ * };
616
+ * ```
617
+ */
618
+ export type FeedbackCommentAfterCreateCallback = (
619
+ ctx: FeedbackMutationContext,
620
+ event: FeedbackCommentAfterCreateEvent,
621
+ ) => MaybePromise<void>;
622
+
623
+ interface FeedbackReactionChangeBase {
624
+ /** Whether the actor's reaction was added or removed. */
625
+ transition: "added" | "removed";
626
+
627
+ /** Whether the actor's reaction is active after the transition. */
628
+ active: boolean;
629
+
630
+ /** Reaction count immediately before the transition. */
631
+ previousCount: number;
632
+
633
+ /** Authoritative reaction count immediately after the transition. */
634
+ count: number;
635
+
636
+ /** Actor who requested the reaction change. */
637
+ actor: FeedbackActor;
638
+ }
639
+
640
+ /**
641
+ * Discriminated reaction transition. Events exist only for real state changes:
642
+ * `added` is false→true and `removed` is true→false. `previousCount` is the
643
+ * persisted count immediately before the change and `count` is the final one.
644
+ * Comment-like events intentionally use comment context without reading or
645
+ * serializing the parent entry. The top-level target IDs are convenience
646
+ * aliases for the IDs in the nested target context and always match them.
647
+ */
648
+ export type FeedbackReactionChangeEvent =
649
+ | (FeedbackReactionChangeBase & {
650
+ /** Discriminator for an entry-upvote transition. */
651
+ type: "entry_upvote";
652
+ /** ID of the upvoted entry; always equal to `entry.id`. */
653
+ entryId: string;
654
+
655
+ /** Persisted context for the upvoted entry. */
656
+ entry: {
657
+ /** Public component identifier for the upvoted entry. */
658
+ id: string;
659
+
660
+ /** Stable identifier of the entry author. */
661
+ actorId: string;
662
+
663
+ /** Entry category. */
664
+ kind: EntryKind;
665
+
666
+ /** Current workflow status of the entry. */
667
+ status: EntryStatus;
668
+
669
+ /** Entry title. */
670
+ title: string;
671
+ };
672
+ })
673
+ | (FeedbackReactionChangeBase & {
674
+ /** Discriminator for a comment-like transition. */
675
+ type: "comment_like";
676
+ /** ID of the liked comment; always equal to `comment.id`. */
677
+ commentId: string;
678
+
679
+ /** ID of the entry containing the liked comment. */
680
+ entryId: string;
681
+
682
+ /** Persisted context for the liked comment. */
683
+ comment: {
684
+ /** Public component identifier for the liked comment. */
685
+ id: string;
686
+
687
+ /** Stable identifier of the comment author. */
688
+ actorId: string;
689
+
690
+ /** ID of the entry containing the comment. */
691
+ entryId: string;
692
+
693
+ /** ID of the parent comment when the target is a reply. */
694
+ parentCommentId?: string;
695
+
696
+ /** Persisted comment body. */
697
+ body: string;
698
+ };
699
+ });
700
+
701
+ /**
702
+ * Runs after a successful reaction mutation only for an actual transition and
703
+ * is awaited in the same transaction, so uncaught errors roll back the change.
704
+ * Extra reaction context is requested only when this callback is configured.
705
+ *
706
+ * @param ctx Host mutation context for the originating request. For feedback
707
+ * reads, prefer direct component references through `ctx.runQuery` or
708
+ * `ctx.runMutation`.
709
+ * @param event The authoritative event containing `type`, `transition`,
710
+ * `active`, `previousCount`, `count`, `actor`, and the target IDs/context.
711
+ * Use `event.transition` to distinguish additions from removals and the
712
+ * top-level target IDs for convenient lookups.
713
+ * @returns Nothing. The callback may be synchronous or asynchronous.
714
+ *
715
+ * @example Handle a reaction transition and query component data
716
+ * ```ts
717
+ * import { components } from "./_generated/api";
718
+ *
719
+ * const afterChange: FeedbackReactionAfterChangeCallback = async (ctx, event) => {
720
+ * if (event.transition !== "added") return;
721
+ *
722
+ * const entry = await ctx.runQuery(components.feedback.entries.get, {
723
+ * entryId: event.entryId,
724
+ * });
725
+ * if (entry === null) return;
726
+ *
727
+ * const targetId =
728
+ * event.type === "entry_upvote" ? event.entryId : event.commentId;
729
+ * // Use `targetId` and the component result for host-side work.
730
+ * };
731
+ * ```
732
+ *
733
+ * Calling the exposed host API through `api.feedback...` is also valid, but it
734
+ * re-enters the host wrapper and actor/auth-resolution path. Use that route
735
+ * only when those host-facing semantics are desired.
736
+ *
737
+ * @example No-op reaction after-change hook
738
+ * ```ts
739
+ * const afterChange: FeedbackReactionAfterChangeCallback = async (ctx, event) => {
740
+ * void ctx;
741
+ * void event;
742
+ * };
743
+ * ```
744
+ */
745
+ export type FeedbackReactionAfterChangeCallback = (
746
+ ctx: FeedbackMutationContext,
747
+ event: FeedbackReactionChangeEvent,
748
+ ) => MaybePromise<void>;
749
+
750
+ /**
751
+ * Convex validator for an explicit callback rejection returned to the client.
752
+ *
753
+ * Use this with `callbacks.rejection` when a before-create callback should
754
+ * return a typed result instead of throwing a `ConvexError`.
755
+ */
756
+ export type FeedbackCallbackReturnValidator = Validator<
757
+ Value,
758
+ "required",
759
+ string
760
+ >;
761
+
762
+ /**
763
+ * Default callback rejection mode: `handlers.reject(value)` throws a
764
+ * `ConvexError` and prevents the component mutation.
765
+ */
766
+ export interface ThrowingFeedbackCallbackRejectionConfig {
767
+ /** Selects exception-based callback rejection. This is the default. */
768
+ behavior?: "throw";
769
+
770
+ /** Return-mode validators are not accepted in throwing mode. */
771
+ returns?: never;
772
+ }
773
+
774
+ /**
775
+ * Return mode: `handlers.reject(value)` short-circuits with a validated client
776
+ * result.
777
+ *
778
+ * @typeParam ReturnsValidator Validator for the value passed to `reject`.
779
+ */
780
+ export interface ReturningFeedbackCallbackRejectionConfig<
781
+ ReturnsValidator extends FeedbackCallbackReturnValidator,
782
+ > {
783
+ /** Selects validated return-mode callback rejection. */
784
+ behavior: "return";
785
+
786
+ /** Validator for the value returned when a callback calls `reject`. */
787
+ returns: ReturnsValidator;
788
+ }
789
+
790
+ /**
791
+ * Controls only explicit `reject()` calls from entry/comment before callbacks.
792
+ * Runtime/programming errors are never converted. Return-mode inference is
793
+ * added only to `createEntry` and `createComment`.
794
+ *
795
+ * @typeParam ReturnsValidator Validator for the value returned to the client
796
+ * in callback rejection return mode.
797
+ */
798
+ export type FeedbackCallbackRejectionConfig<
799
+ ReturnsValidator extends FeedbackCallbackReturnValidator | undefined =
800
+ undefined,
801
+ > = ReturnsValidator extends FeedbackCallbackReturnValidator
802
+ ? ReturningFeedbackCallbackRejectionConfig<ReturnsValidator>
803
+ : ThrowingFeedbackCallbackRejectionConfig;
804
+
805
+ type CallbackRejectionValue<
806
+ ReturnsValidator extends FeedbackCallbackReturnValidator | undefined,
807
+ > = ReturnsValidator extends FeedbackCallbackReturnValidator
808
+ ? Infer<ReturnsValidator>
809
+ : Value;
810
+
811
+ /**
812
+ * Callback properties for entry creation and post-creation work.
813
+ *
814
+ * @typeParam Rejection Value accepted by `beforeCreate`'s `handlers.reject`.
815
+ */
816
+ export interface FeedbackEntryCallbacks<Rejection = Value> {
817
+ /**
818
+ * Validate, reject, or transform an entry before the component creates it.
819
+ *
820
+ * The callback runs after actor resolution and rate limiting. Returned
821
+ * fields are still checked by the component's normal validation.
822
+ * It receives the host `ctx`, readonly `event` (`actor` and `input`), and
823
+ * `handlers` with `handlers.reject(value)`.
824
+ *
825
+ * @example
826
+ * ```ts
827
+ * beforeCreate: (ctx, event, handlers) => {},
828
+ * ```
829
+ */
830
+ beforeCreate?: FeedbackEntryBeforeCreateCallback<Rejection>;
831
+
832
+ /**
833
+ * React to an entry after the component has created it successfully.
834
+ *
835
+ * The callback is awaited in the originating host mutation; an uncaught
836
+ * error rolls back the entry creation.
837
+ * It receives the host `ctx` and persisted `event` containing the created
838
+ * actor and entry.
839
+ *
840
+ * @example
841
+ * ```ts
842
+ * afterCreate: async (ctx, event) => {},
843
+ * ```
844
+ */
845
+ afterCreate?: FeedbackEntryAfterCreateCallback;
846
+ }
847
+
848
+ /**
849
+ * Callback properties for comment and reply creation.
850
+ *
851
+ * @typeParam Rejection Value accepted by `beforeCreate`'s `handlers.reject`.
852
+ */
853
+ export interface FeedbackCommentCallbacks<Rejection = Value> {
854
+ /**
855
+ * Validate, reject, or transform a comment body before the component
856
+ * creates the comment or reply.
857
+ * It receives the host `ctx`, readonly `event` (`actor` and `input`), and
858
+ * `handlers` with `handlers.reject(value)`.
859
+ *
860
+ * @example
861
+ * ```ts
862
+ * beforeCreate: (ctx, event, handlers) => {},
863
+ * ```
864
+ */
865
+ beforeCreate?: FeedbackCommentBeforeCreateCallback<Rejection>;
866
+
867
+ /**
868
+ * React to a comment or reply after the component has created it
869
+ * successfully.
870
+ *
871
+ * The event includes the created comment, its containing entry, and parent
872
+ * comment context when the created comment is a reply.
873
+ * The parameters are the host `ctx` and persisted `event`.
874
+ *
875
+ * @example
876
+ * ```ts
877
+ * afterCreate: async (ctx, event) => {},
878
+ * ```
879
+ */
880
+ afterCreate?: FeedbackCommentAfterCreateCallback;
881
+ }
882
+
883
+ /** Callback properties for entry upvotes and comment likes. */
884
+ export interface FeedbackReactionCallbacks {
885
+ /**
886
+ * React to a real reaction transition after the component mutation.
887
+ *
888
+ * The event contains authoritative counts and direct target IDs. It also
889
+ * retains the nested `entry` or `comment` context for existing consumers.
890
+ * The parameters are the host `ctx` and transition `event`.
891
+ *
892
+ * @example
893
+ * ```ts
894
+ * afterChange: async (ctx, event) => {},
895
+ * ```
896
+ */
897
+ afterChange?: FeedbackReactionAfterChangeCallback;
898
+ }
899
+
900
+ /**
901
+ * Type of the lifecycle callback configuration accepted by
902
+ * `options.callbacks`.
903
+ *
904
+ * See the `entries`, `comments`, and `reactions` properties for the callback
905
+ * group documentation shown directly in configuration IntelliSense.
906
+ *
907
+ * @typeParam ReturnsValidator Validator that defines the value returned by
908
+ * `handlers.reject` when callback rejection uses return mode.
909
+ */
910
+ export type FeedbackCallbacks<
911
+ ReturnsValidator extends FeedbackCallbackReturnValidator | undefined =
912
+ undefined,
913
+ > = {
914
+ /**
915
+ * Lifecycle callbacks for entries.
916
+ *
917
+ * `beforeCreate` runs after actor resolution and rate limiting, and can
918
+ * validate, reject, or transform an entry before the component writes it.
919
+ * `afterCreate` runs after a successful component write and is awaited in
920
+ * the originating host mutation.
921
+ *
922
+ * @example
923
+ * ```ts
924
+ * entries: {
925
+ * beforeCreate: (ctx, event, handlers) => {
926
+ * const titleContainsProfanity = checkForProfanity(event.input.title);
927
+ * const bodyContainsProfanity = checkForProfanity(event.input.body);
928
+ *
929
+ * if (titleContainsProfanity || bodyContainsProfanity) {
930
+ * handlers.reject({
931
+ * kind: "profanity_detected",
932
+ * reason: "Entry content contains profanity",
933
+ * });
934
+ * }
935
+ * },
936
+ * }
937
+ * ```
938
+ */
939
+ entries?: FeedbackEntryCallbacks<CallbackRejectionValue<ReturnsValidator>>;
940
+
941
+ /**
942
+ * Lifecycle callbacks for comments and replies.
943
+ *
944
+ * `beforeCreate` can validate, reject, or transform the comment body.
945
+ * `afterCreate` receives the persisted comment, its containing entry, and
946
+ * parent-comment context when the created comment is a reply.
947
+ *
948
+ * @example
949
+ * ```ts
950
+ * comments: {
951
+ * afterCreate: async (ctx, event) => {
952
+ * await ctx.scheduler.runAfter(0, internal.notifications.commentCreated, {
953
+ * commentId: event.comment.id,
954
+ * entryId: event.entry.id,
955
+ * });
956
+ * },
957
+ * }
958
+ * ```
959
+ */
960
+ comments?: FeedbackCommentCallbacks<CallbackRejectionValue<ReturnsValidator>>;
961
+
962
+ /**
963
+ * Lifecycle callbacks for entry upvotes and comment likes.
964
+ *
965
+ * `afterChange` runs only for a real `added` or `removed` transition.
966
+ * Repeated requests for the current state remain idempotent and do not
967
+ * invoke it. Direct target IDs are available alongside nested context.
968
+ *
969
+ * @example
970
+ * ```ts
971
+ * reactions: {
972
+ * afterChange: async (ctx, event) => {
973
+ * if (event.transition !== "added") return;
974
+ *
975
+ * const docId = event.type === "entry_upvote" ? event.entryId : event.commentId;
976
+ * // Notify the target author using docId.
977
+ * },
978
+ * }
979
+ * ```
980
+ */
981
+ reactions?: FeedbackReactionCallbacks;
982
+ } & (ReturnsValidator extends FeedbackCallbackReturnValidator
983
+ ? {
984
+ /**
985
+ * Validated return-mode behavior for explicit `beforeCreate` rejects.
986
+ */
987
+ rejection: ReturningFeedbackCallbackRejectionConfig<ReturnsValidator>;
988
+ }
989
+ : {
990
+ /**
991
+ * Optional rejection behavior for explicit `beforeCreate` rejects.
992
+ * Defaults to throwing a `ConvexError`.
993
+ */
994
+ rejection?: ThrowingFeedbackCallbackRejectionConfig;
995
+ });
996
+
222
997
  type RegisteredFeedbackFunction<Function> =
223
998
  Function extends FunctionReference<
224
999
  "mutation",
@@ -240,11 +1015,19 @@ type RegisteredFeedbackFunction<Function> =
240
1015
  : never
241
1016
  : never;
242
1017
 
243
- type ExposedFeedbackApi<RateLimitResult> = {
1018
+ type ExposedFeedbackApi<RateLimitResult, CallbackRejectionResult> = {
244
1019
  [
245
- FunctionName in keyof FeedbackPublicApi<string | undefined, RateLimitResult>
1020
+ FunctionName in keyof FeedbackPublicApi<
1021
+ string | undefined,
1022
+ RateLimitResult,
1023
+ CallbackRejectionResult
1024
+ >
246
1025
  ]: RegisteredFeedbackFunction<
247
- FeedbackPublicApi<string | undefined, RateLimitResult>[FunctionName]
1026
+ FeedbackPublicApi<
1027
+ string | undefined,
1028
+ RateLimitResult,
1029
+ CallbackRejectionResult
1030
+ >[FunctionName]
248
1031
  >;
249
1032
  };
250
1033
 
@@ -266,11 +1049,82 @@ interface ExposeFeedbackOptionsBase {
266
1049
  config?: FeedbackConfigOverrides;
267
1050
  }
268
1051
 
269
- /** Options for the default mode, where limiter functions reject by throwing. */
270
- export type ThrowingExposeFeedbackOptions = Omit<
271
- ExposeFeedbackOptionsBase,
272
- "config"
273
- > & {
1052
+ type FeedbackCallbackOptions<
1053
+ ReturnsValidator extends FeedbackCallbackReturnValidator | undefined,
1054
+ > = ReturnsValidator extends FeedbackCallbackReturnValidator
1055
+ ? {
1056
+ /**
1057
+ * Host lifecycle callbacks grouped by domain with validated return-mode
1058
+ * rejection.
1059
+ *
1060
+ * Creation callbacks run in the order actor/auth → rate limiting →
1061
+ * `beforeCreate` → component mutation → `afterCreate`. Reaction
1062
+ * callbacks run after the component mutation only for a real transition.
1063
+ * All callbacks are awaited in the originating host mutation.
1064
+ *
1065
+ * For feedback data, prefer direct component references such as
1066
+ * `ctx.runQuery(components.feedback.entries.get, ...)`. Calling an
1067
+ * exposed host API through `api.feedback...` is also valid, but it
1068
+ * re-enters the host wrapper and actor/auth-resolution path; use it only
1069
+ * when those host-facing semantics are desired.
1070
+ *
1071
+ * @example Configure entry validation
1072
+ * ```ts
1073
+ * callbacks: {
1074
+ * entries: {
1075
+ * beforeCreate: (ctx, event, handlers) => {
1076
+ * void ctx;
1077
+ * if (event.input.title.trim() === "") {
1078
+ * handlers.reject("A title is required");
1079
+ * }
1080
+ * },
1081
+ * },
1082
+ * }
1083
+ * ```
1084
+ */
1085
+ callbacks: FeedbackCallbacks<ReturnsValidator>;
1086
+ }
1087
+ : {
1088
+ /**
1089
+ * Optional host lifecycle callbacks grouped by domain. Explicit
1090
+ * rejection throws by default.
1091
+ *
1092
+ * Creation callbacks run in the order actor/auth → rate limiting →
1093
+ * `beforeCreate` → component mutation → `afterCreate`. Reaction
1094
+ * callbacks run after the component mutation only for a real transition.
1095
+ * All callbacks are awaited in the originating host mutation.
1096
+ *
1097
+ * For feedback data, prefer direct component references such as
1098
+ * `ctx.runQuery(components.feedback.entries.get, ...)`. Calling an
1099
+ * exposed host API through `api.feedback...` is also valid, but it
1100
+ * re-enters the host wrapper and actor/auth-resolution path; use it only
1101
+ * when those host-facing semantics are desired.
1102
+ *
1103
+ * @example Configure entry validation
1104
+ * ```ts
1105
+ * callbacks: {
1106
+ * entries: {
1107
+ * beforeCreate: (ctx, event, handlers) => {
1108
+ * void ctx;
1109
+ * if (event.input.title.trim() === "") {
1110
+ * handlers.reject("A title is required");
1111
+ * }
1112
+ * },
1113
+ * },
1114
+ * }
1115
+ * ```
1116
+ */
1117
+ callbacks?: FeedbackCallbacks;
1118
+ };
1119
+
1120
+ /**
1121
+ * Exposure options with throwing rate limits and optional lifecycle callbacks.
1122
+ * Callback execution/rollback semantics are documented by {@link FeedbackCallbacks}.
1123
+ */
1124
+ export type ThrowingExposeFeedbackOptions<
1125
+ CallbackReturnsValidator extends FeedbackCallbackReturnValidator | undefined =
1126
+ undefined,
1127
+ > = Omit<ExposeFeedbackOptionsBase, "config"> & {
274
1128
  /** Optional throwing rate limiters for the component's mutation groups. */
275
1129
  rateLimiters?: FeedbackRateLimiters;
276
1130
 
@@ -284,11 +1138,16 @@ export type ThrowingExposeFeedbackOptions = Omit<
284
1138
  */
285
1139
  rateLimiting?: FeedbackRateLimitConfig;
286
1140
  };
287
- };
1141
+ } & FeedbackCallbackOptions<CallbackReturnsValidator>;
288
1142
 
289
- /** Options for returning a validated rejection value instead of throwing. */
1143
+ /**
1144
+ * Exposure options for validated rate-limit returns plus optional lifecycle
1145
+ * callbacks and independently inferred callback-rejection behavior.
1146
+ */
290
1147
  export type ReturningExposeFeedbackOptions<
291
1148
  ReturnsValidator extends FeedbackRateLimitReturnValidator,
1149
+ CallbackReturnsValidator extends FeedbackCallbackReturnValidator | undefined =
1150
+ undefined,
292
1151
  > = Omit<ExposeFeedbackOptionsBase, "config"> & {
293
1152
  /**
294
1153
  * Optional returning rate limiters for the component's mutation groups.
@@ -308,21 +1167,24 @@ export type ReturningExposeFeedbackOptions<
308
1167
  */
309
1168
  rateLimiting: FeedbackRateLimitConfig<ReturnsValidator>;
310
1169
  };
311
- };
1170
+ } & FeedbackCallbackOptions<CallbackReturnsValidator>;
312
1171
 
313
1172
  /**
314
1173
  * Configuration used when exposing feedback functions from the host app.
315
1174
  *
316
1175
  * When `ReturnsValidator` is omitted, limiter functions use throwing behavior.
317
1176
  * Supplying a validator selects return behavior and adds its inferred value to
318
- * the exposed mutation result types.
1177
+ * the exposed mutation result types. `CallbackReturnsValidator` independently
1178
+ * controls explicit before-callback rejection inference.
319
1179
  */
320
1180
  export type ExposeFeedbackOptions<
321
1181
  ReturnsValidator extends FeedbackRateLimitReturnValidator | undefined =
322
1182
  undefined,
1183
+ CallbackReturnsValidator extends FeedbackCallbackReturnValidator | undefined =
1184
+ undefined,
323
1185
  > = ReturnsValidator extends FeedbackRateLimitReturnValidator
324
- ? ReturningExposeFeedbackOptions<ReturnsValidator>
325
- : ThrowingExposeFeedbackOptions;
1186
+ ? ReturningExposeFeedbackOptions<ReturnsValidator, CallbackReturnsValidator>
1187
+ : ThrowingExposeFeedbackOptions<CallbackReturnsValidator>;
326
1188
 
327
1189
  function requireActor(actor: FeedbackActor | null): FeedbackActor {
328
1190
  if (actor === null) {
@@ -353,6 +1215,78 @@ function rateLimitedReturns<
353
1215
  ) as RateLimitedReturnsValidator<Base, Limit>;
354
1216
  }
355
1217
 
1218
+ type CallbackLimitedReturnsValidator<
1219
+ Base extends FeedbackFunctionReturnValidator,
1220
+ Rejection extends FeedbackCallbackReturnValidator | undefined,
1221
+ > = Rejection extends FeedbackCallbackReturnValidator
1222
+ ? VUnion<Infer<Base> | Infer<Rejection>, [Base, Rejection]>
1223
+ : Base;
1224
+
1225
+ function callbackLimitedReturns<
1226
+ Base extends FeedbackFunctionReturnValidator,
1227
+ Rejection extends FeedbackCallbackReturnValidator | undefined,
1228
+ >(
1229
+ base: Base,
1230
+ rejection: Rejection,
1231
+ ): CallbackLimitedReturnsValidator<Base, Rejection> {
1232
+ return (
1233
+ rejection === undefined ? base : v.union(base, rejection)
1234
+ ) as CallbackLimitedReturnsValidator<Base, Rejection>;
1235
+ }
1236
+
1237
+ type CallbackResult<
1238
+ ReturnsValidator extends FeedbackCallbackReturnValidator | undefined,
1239
+ > = ReturnsValidator extends FeedbackCallbackReturnValidator
1240
+ ? Infer<ReturnsValidator>
1241
+ : never;
1242
+
1243
+ class ExplicitFeedbackCallbackRejection extends Error {
1244
+ constructor(readonly value: Value) {
1245
+ super("Feedback callback rejected creation.");
1246
+ this.name = "ExplicitFeedbackCallbackRejection";
1247
+ }
1248
+ }
1249
+
1250
+ async function runBeforeCreate<Patch, Event, Rejection extends Value>(
1251
+ ctx: FeedbackMutationContext,
1252
+ event: Event,
1253
+ callback:
1254
+ | ((
1255
+ ctx: FeedbackMutationContext,
1256
+ event: Event,
1257
+ helpers: FeedbackCallbackHelpers<Rejection>,
1258
+ ) => MaybePromise<Patch | undefined>)
1259
+ | undefined,
1260
+ rejectionConfig:
1261
+ | ThrowingFeedbackCallbackRejectionConfig
1262
+ | ReturningFeedbackCallbackRejectionConfig<FeedbackCallbackReturnValidator>
1263
+ | undefined,
1264
+ ): Promise<
1265
+ | { rejected: false; patch: Patch | undefined }
1266
+ | { rejected: true; value: Rejection }
1267
+ > {
1268
+ if (callback === undefined) {
1269
+ return { rejected: false, patch: undefined };
1270
+ }
1271
+
1272
+ try {
1273
+ const patch = await callback(ctx, event, {
1274
+ reject: (value) => {
1275
+ throw new ExplicitFeedbackCallbackRejection(value);
1276
+ },
1277
+ });
1278
+ return { rejected: false, patch };
1279
+ } catch (error) {
1280
+ if (!(error instanceof ExplicitFeedbackCallbackRejection)) {
1281
+ throw error;
1282
+ }
1283
+ if (rejectionConfig?.behavior === "return") {
1284
+ return { rejected: true, value: error.value as Rejection };
1285
+ }
1286
+ throw new ConvexError(error.value);
1287
+ }
1288
+ }
1289
+
356
1290
  async function applyRateLimiter<
357
1291
  ReturnsValidator extends FeedbackRateLimitReturnValidator | undefined,
358
1292
  >(
@@ -386,6 +1320,10 @@ function asRateLimitContext(ctx: unknown): FeedbackRateLimitContext {
386
1320
  return ctx as FeedbackRateLimitContext;
387
1321
  }
388
1322
 
1323
+ function asMutationContext(ctx: unknown): FeedbackMutationContext {
1324
+ return ctx as FeedbackMutationContext;
1325
+ }
1326
+
389
1327
  function clampPositive(
390
1328
  value: number | undefined,
391
1329
  fallback: number,
@@ -412,13 +1350,29 @@ function actorIdFields(actor: FeedbackActor | null): {
412
1350
  return actor === null ? {} : { viewerActorId: actor.id };
413
1351
  }
414
1352
 
1353
+ function cloneFeedbackMetadata(
1354
+ metadata: FeedbackMetadata | undefined,
1355
+ ): FeedbackMetadata | undefined {
1356
+ if (metadata === undefined) return undefined;
1357
+ return {
1358
+ ...(metadata.standard === undefined
1359
+ ? {}
1360
+ : { standard: { ...metadata.standard } }),
1361
+ ...(metadata.additional === undefined
1362
+ ? {}
1363
+ : { additional: { ...metadata.additional } }),
1364
+ };
1365
+ }
1366
+
415
1367
  function buildFeedbackApi<
416
1368
  Name extends string | undefined,
417
1369
  ReturnsValidator extends FeedbackRateLimitReturnValidator | undefined =
418
1370
  undefined,
1371
+ CallbackReturnsValidator extends FeedbackCallbackReturnValidator | undefined =
1372
+ undefined,
419
1373
  >(
420
1374
  component: ComponentApi<Name>,
421
- options: ExposeFeedbackOptions<ReturnsValidator>,
1375
+ options: ExposeFeedbackOptions<ReturnsValidator, CallbackReturnsValidator>,
422
1376
  ) {
423
1377
  const config = createFeedbackConfig(options.config);
424
1378
  const rateLimitConfig = options.config?.rateLimiting;
@@ -426,8 +1380,17 @@ function buildFeedbackApi<
426
1380
  rateLimitConfig?.behavior === "return"
427
1381
  ? rateLimitConfig.returns
428
1382
  : undefined;
1383
+ const callbackRejectionConfig = options.callbacks?.rejection;
1384
+ const callbackReturnValidator =
1385
+ callbackRejectionConfig?.behavior === "return"
1386
+ ? callbackRejectionConfig.returns
1387
+ : undefined;
429
1388
 
430
1389
  const idReturns = rateLimitedReturns(v.string(), rateLimitReturnValidator);
1390
+ const createIdReturns = callbackLimitedReturns(
1391
+ idReturns,
1392
+ callbackReturnValidator,
1393
+ );
431
1394
  const nullReturns = rateLimitedReturns(v.null(), rateLimitReturnValidator);
432
1395
  const entryUpvoteReturns = rateLimitedReturns(
433
1396
  v.object({ active: v.boolean(), upvoteCount: v.number() }),
@@ -503,6 +1466,29 @@ function buildFeedbackApi<
503
1466
  },
504
1467
  }),
505
1468
 
1469
+ listUserEntries: queryGeneric({
1470
+ args: {
1471
+ paginationOpts: paginationOptsValidator,
1472
+ },
1473
+ returns: paginationResultValidator(activityEntryValidator),
1474
+ handler: async (ctx, args) => {
1475
+ const actor = requireActor(await options.actor(ctx));
1476
+ const result = await ctx.runQuery(component.entries.listByActor, {
1477
+ actorId: actor.id,
1478
+ paginationOpts: clampPagination(
1479
+ args.paginationOpts,
1480
+ config.entries.maxPageSize,
1481
+ ),
1482
+ includeAdminContext: false,
1483
+ });
1484
+
1485
+ return {
1486
+ ...result,
1487
+ page: result.page.map(stripActivityEntryContext),
1488
+ };
1489
+ },
1490
+ }),
1491
+
506
1492
  getEntry: queryGeneric({
507
1493
  args: { entryId: v.string() },
508
1494
  returns: v.union(publicEntryValidator, v.null()),
@@ -583,7 +1569,7 @@ function buildFeedbackApi<
583
1569
  body: v.string(),
584
1570
  metadata: v.optional(feedbackMetadataValidator),
585
1571
  },
586
- returns: idReturns,
1572
+ returns: createIdReturns,
587
1573
  handler: async (ctx, args) => {
588
1574
  const actor = requireActor(await options.actor(ctx));
589
1575
  const limited = await applyRateLimiter(
@@ -593,17 +1579,57 @@ function buildFeedbackApi<
593
1579
  rateLimitConfig,
594
1580
  );
595
1581
  if (limited !== undefined) return limited;
596
- return await ctx.runMutation(component.entries.create, {
1582
+
1583
+ const beforeCreate = options.callbacks?.entries?.beforeCreate;
1584
+ let patch: FeedbackEntryCreatePatch | undefined;
1585
+ if (beforeCreate !== undefined) {
1586
+ const callbackInput: FeedbackEntryBeforeCreateEvent["input"] = {
1587
+ kind: args.kind,
1588
+ title: args.title,
1589
+ body: args.body,
1590
+ ...(args.metadata === undefined
1591
+ ? {}
1592
+ : { metadata: cloneFeedbackMetadata(args.metadata) }),
1593
+ };
1594
+ const before = await runBeforeCreate(
1595
+ asMutationContext(ctx),
1596
+ { actor: { ...actor }, input: callbackInput },
1597
+ beforeCreate,
1598
+ callbackRejectionConfig,
1599
+ );
1600
+ if (before.rejected) {
1601
+ return before.value as CallbackResult<CallbackReturnsValidator>;
1602
+ }
1603
+ patch = before.patch;
1604
+ }
1605
+ const metadata =
1606
+ patch !== undefined &&
1607
+ Object.prototype.hasOwnProperty.call(patch, "metadata")
1608
+ ? patch.metadata
1609
+ : args.metadata;
1610
+ const afterCreate = options.callbacks?.entries?.afterCreate;
1611
+ const result = await ctx.runMutation(component.entries.create, {
597
1612
  actorId: actor.id,
598
- kind: args.kind,
599
- title: args.title,
600
- body: args.body,
1613
+ kind: patch?.kind ?? args.kind,
1614
+ title: patch?.title ?? args.title,
1615
+ body: patch?.body ?? args.body,
601
1616
  defaultStatus: config.entries.defaultStatus,
602
1617
  enabledKinds: [...config.entries.enabledKinds],
603
1618
  maxTitleLength: config.limits.titleLength,
604
1619
  maxBodyLength: config.limits.bodyLength,
605
- ...(args.metadata === undefined ? {} : { metadata: args.metadata }),
1620
+ ...(metadata === undefined ? {} : { metadata }),
1621
+ includeCallbackContext: afterCreate !== undefined,
606
1622
  });
1623
+ if (afterCreate !== undefined) {
1624
+ if (!("entry" in result)) {
1625
+ throw new ConvexError("Entry callback context was not returned.");
1626
+ }
1627
+ await afterCreate(asMutationContext(ctx), {
1628
+ actor,
1629
+ entry: result.entry,
1630
+ });
1631
+ }
1632
+ return result.id;
607
1633
  },
608
1634
  }),
609
1635
 
@@ -637,6 +1663,23 @@ function buildFeedbackApi<
637
1663
  },
638
1664
  }),
639
1665
 
1666
+ deleteEntry: mutationGeneric({
1667
+ args: { entryId: v.string() },
1668
+ returns: nullReturns,
1669
+ handler: async (ctx, args) => {
1670
+ const actor = await requireAdminActor(ctx);
1671
+ const limited = await applyAdminEditLimit(
1672
+ asRateLimitContext(ctx),
1673
+ actor,
1674
+ );
1675
+ if (limited !== undefined) return limited;
1676
+ return await ctx.runMutation(component.entries.remove, {
1677
+ actor,
1678
+ entryId: args.entryId,
1679
+ });
1680
+ },
1681
+ }),
1682
+
640
1683
  setEntryStatus: mutationGeneric({
641
1684
  args: { entryId: v.string(), status: entryStatusValidator },
642
1685
  returns: nullReturns,
@@ -663,6 +1706,7 @@ function buildFeedbackApi<
663
1706
  paginationOpts: paginationOptsValidator,
664
1707
  kinds: v.optional(v.array(entryKindValidator)),
665
1708
  status: v.optional(entryStatusValidator),
1709
+ statusFilter: v.optional(entryStatusFilterValidator),
666
1710
  priority: v.optional(entryPriorityValidator),
667
1711
  },
668
1712
  returns: paginationResultValidator(adminEntryValidator),
@@ -671,6 +1715,9 @@ function buildFeedbackApi<
671
1715
  return await ctx.runQuery(component.admin.listEntries, {
672
1716
  ...(args.kinds === undefined ? {} : { kinds: args.kinds }),
673
1717
  ...(args.status === undefined ? {} : { status: args.status }),
1718
+ ...(args.statusFilter === undefined
1719
+ ? {}
1720
+ : { statusFilter: args.statusFilter }),
674
1721
  ...(args.priority === undefined ? {} : { priority: args.priority }),
675
1722
  paginationOpts: clampPagination(
676
1723
  args.paginationOpts,
@@ -699,6 +1746,7 @@ function buildFeedbackApi<
699
1746
  searchQuery: v.string(),
700
1747
  kinds: v.optional(v.array(entryKindValidator)),
701
1748
  status: v.optional(entryStatusValidator),
1749
+ statusFilter: v.optional(entryStatusFilterValidator),
702
1750
  priority: v.optional(entryPriorityValidator),
703
1751
  },
704
1752
  returns: paginationResultValidator(adminEntryValidator),
@@ -708,6 +1756,9 @@ function buildFeedbackApi<
708
1756
  searchQuery: args.searchQuery,
709
1757
  ...(args.kinds === undefined ? {} : { kinds: args.kinds }),
710
1758
  ...(args.status === undefined ? {} : { status: args.status }),
1759
+ ...(args.statusFilter === undefined
1760
+ ? {}
1761
+ : { statusFilter: args.statusFilter }),
711
1762
  ...(args.priority === undefined ? {} : { priority: args.priority }),
712
1763
  paginationOpts: clampPagination(
713
1764
  args.paginationOpts,
@@ -751,11 +1802,32 @@ function buildFeedbackApi<
751
1802
  rateLimitConfig,
752
1803
  );
753
1804
  if (limited !== undefined) return limited;
754
- return await ctx.runMutation(component.entries.setUpvote, {
1805
+ const afterChange = options.callbacks?.reactions?.afterChange;
1806
+ const result = await ctx.runMutation(component.entries.setUpvote, {
755
1807
  actorId: actor.id,
756
1808
  entryId: args.entryId,
757
1809
  desiredState: args.desiredState,
1810
+ includeCallbackContext: afterChange !== undefined,
758
1811
  });
1812
+ if (
1813
+ "changed" in result &&
1814
+ result.changed &&
1815
+ afterChange !== undefined
1816
+ ) {
1817
+ await afterChange(asMutationContext(ctx), {
1818
+ type: "entry_upvote",
1819
+ entryId: result.entry.id,
1820
+ transition: result.transition,
1821
+ active: result.active,
1822
+ previousCount: result.previousCount,
1823
+ count: result.count,
1824
+ actor,
1825
+ entry: result.entry,
1826
+ });
1827
+ }
1828
+ return "upvoteCount" in result
1829
+ ? result
1830
+ : { active: result.active, upvoteCount: result.count };
759
1831
  },
760
1832
  }),
761
1833
 
@@ -784,13 +1856,30 @@ function buildFeedbackApi<
784
1856
  },
785
1857
  }),
786
1858
 
1859
+ listUserComments: queryGeneric({
1860
+ args: {
1861
+ paginationOpts: paginationOptsValidator,
1862
+ },
1863
+ returns: paginationResultValidator(activityCommentValidator),
1864
+ handler: async (ctx, args) => {
1865
+ const actor = requireActor(await options.actor(ctx));
1866
+ return await ctx.runQuery(component.comments.listByActor, {
1867
+ actorId: actor.id,
1868
+ paginationOpts: clampPagination(
1869
+ args.paginationOpts,
1870
+ config.comments.maxPageSize,
1871
+ ),
1872
+ });
1873
+ },
1874
+ }),
1875
+
787
1876
  createComment: mutationGeneric({
788
1877
  args: {
789
1878
  entryId: v.string(),
790
1879
  parentCommentId: v.optional(v.string()),
791
1880
  body: v.string(),
792
1881
  },
793
- returns: idReturns,
1882
+ returns: createIdReturns,
794
1883
  handler: async (ctx, args) => {
795
1884
  const actor = requireActor(await options.actor(ctx));
796
1885
  const limited = await applyRateLimiter(
@@ -800,16 +1889,54 @@ function buildFeedbackApi<
800
1889
  rateLimitConfig,
801
1890
  );
802
1891
  if (limited !== undefined) return limited;
803
- return await ctx.runMutation(component.comments.create, {
1892
+
1893
+ const beforeCreate = options.callbacks?.comments?.beforeCreate;
1894
+ let patch: FeedbackCommentCreatePatch | undefined;
1895
+ if (beforeCreate !== undefined) {
1896
+ const callbackInput: FeedbackCommentBeforeCreateEvent["input"] = {
1897
+ entryId: args.entryId,
1898
+ ...(args.parentCommentId === undefined
1899
+ ? {}
1900
+ : { parentCommentId: args.parentCommentId }),
1901
+ body: args.body,
1902
+ };
1903
+ const before = await runBeforeCreate(
1904
+ asMutationContext(ctx),
1905
+ { actor: { ...actor }, input: callbackInput },
1906
+ beforeCreate,
1907
+ callbackRejectionConfig,
1908
+ );
1909
+ if (before.rejected) {
1910
+ return before.value as CallbackResult<CallbackReturnsValidator>;
1911
+ }
1912
+ patch = before.patch;
1913
+ }
1914
+ const afterCreate = options.callbacks?.comments?.afterCreate;
1915
+ const result = await ctx.runMutation(component.comments.create, {
804
1916
  actorId: actor.id,
805
1917
  entryId: args.entryId,
806
1918
  ...(args.parentCommentId === undefined
807
1919
  ? {}
808
1920
  : { parentCommentId: args.parentCommentId }),
809
- body: args.body,
1921
+ body: patch?.body ?? args.body,
810
1922
  maxDepth: config.comments.maxDepth,
811
1923
  maxCommentLength: config.limits.commentLength,
1924
+ includeCallbackContext: afterCreate !== undefined,
812
1925
  });
1926
+ if (afterCreate !== undefined) {
1927
+ if (!("comment" in result)) {
1928
+ throw new ConvexError("Comment callback context was not returned.");
1929
+ }
1930
+ await afterCreate(asMutationContext(ctx), {
1931
+ actor,
1932
+ comment: result.comment,
1933
+ entry: result.entry,
1934
+ ...(result.parentComment === undefined
1935
+ ? {}
1936
+ : { parentComment: result.parentComment }),
1937
+ });
1938
+ }
1939
+ return result.id;
813
1940
  },
814
1941
  }),
815
1942
 
@@ -867,10 +1994,49 @@ function buildFeedbackApi<
867
1994
  rateLimitConfig,
868
1995
  );
869
1996
  if (limited !== undefined) return limited;
870
- return await ctx.runMutation(component.comments.setLike, {
1997
+ const afterChange = options.callbacks?.reactions?.afterChange;
1998
+ const result = await ctx.runMutation(component.comments.setLike, {
871
1999
  actorId: actor.id,
872
2000
  commentId: args.commentId,
873
2001
  desiredState: args.desiredState,
2002
+ includeCallbackContext: afterChange !== undefined,
2003
+ });
2004
+ if (
2005
+ "changed" in result &&
2006
+ result.changed &&
2007
+ afterChange !== undefined
2008
+ ) {
2009
+ await afterChange(asMutationContext(ctx), {
2010
+ type: "comment_like",
2011
+ commentId: result.comment.id,
2012
+ entryId: result.comment.entryId,
2013
+ transition: result.transition,
2014
+ active: result.active,
2015
+ previousCount: result.previousCount,
2016
+ count: result.count,
2017
+ actor,
2018
+ comment: result.comment,
2019
+ });
2020
+ }
2021
+ return "likeCount" in result
2022
+ ? result
2023
+ : { active: result.active, likeCount: result.count };
2024
+ },
2025
+ }),
2026
+
2027
+ listUserReactions: queryGeneric({
2028
+ args: {
2029
+ paginationOpts: paginationOptsValidator,
2030
+ },
2031
+ returns: paginationResultValidator(feedbackReactionValidator),
2032
+ handler: async (ctx, args) => {
2033
+ const actor = requireActor(await options.actor(ctx));
2034
+ return await ctx.runQuery(component.reactions.listByActor, {
2035
+ actorId: actor.id,
2036
+ paginationOpts: clampPagination(
2037
+ args.paginationOpts,
2038
+ config.entries.maxPageSize,
2039
+ ),
874
2040
  });
875
2041
  },
876
2042
  }),
@@ -892,6 +2058,14 @@ function buildFeedbackApi<
892
2058
  },
893
2059
  }),
894
2060
 
2061
+ getRoadmapItem: queryGeneric({
2062
+ args: { roadmapId: v.string() },
2063
+ returns: v.union(roadmapItemValidator, v.null()),
2064
+ handler: async (ctx, args) => {
2065
+ return await ctx.runQuery(component.roadmap.get, args);
2066
+ },
2067
+ }),
2068
+
895
2069
  searchRoadmap: queryGeneric({
896
2070
  args: { searchQuery: v.string(), limit: v.optional(v.number()) },
897
2071
  returns: v.array(roadmapItemValidator),
@@ -1071,24 +2245,53 @@ function buildFeedbackApi<
1071
2245
  */
1072
2246
  export function exposeFeedbackApi<
1073
2247
  Name extends string | undefined,
1074
- ReturnsValidator extends FeedbackRateLimitReturnValidator,
2248
+ RateReturnsValidator extends FeedbackRateLimitReturnValidator,
2249
+ CallbackReturnsValidator extends FeedbackCallbackReturnValidator,
1075
2250
  >(
1076
2251
  component: ComponentApi<Name>,
1077
- options: ReturningExposeFeedbackOptions<ReturnsValidator>,
1078
- ): ExposedFeedbackApi<Infer<ReturnsValidator>>;
2252
+ options: ReturningExposeFeedbackOptions<
2253
+ RateReturnsValidator,
2254
+ CallbackReturnsValidator
2255
+ >,
2256
+ ): ExposedFeedbackApi<
2257
+ Infer<RateReturnsValidator>,
2258
+ Infer<CallbackReturnsValidator>
2259
+ >;
2260
+
2261
+ /** Exposes returning rate limits with throwing callback rejections. */
2262
+ export function exposeFeedbackApi<
2263
+ Name extends string | undefined,
2264
+ RateReturnsValidator extends FeedbackRateLimitReturnValidator,
2265
+ >(
2266
+ component: ComponentApi<Name>,
2267
+ options: ReturningExposeFeedbackOptions<RateReturnsValidator>,
2268
+ ): ExposedFeedbackApi<Infer<RateReturnsValidator>, never>;
2269
+
2270
+ /** Exposes throwing rate limits with returning callback rejections. */
2271
+ export function exposeFeedbackApi<
2272
+ Name extends string | undefined,
2273
+ CallbackReturnsValidator extends FeedbackCallbackReturnValidator,
2274
+ >(
2275
+ component: ComponentApi<Name>,
2276
+ options: ThrowingExposeFeedbackOptions<CallbackReturnsValidator>,
2277
+ ): ExposedFeedbackApi<never, Infer<CallbackReturnsValidator>>;
1079
2278
 
1080
2279
  /** Exposes feedback using optional throwing rate limiters. */
1081
2280
  export function exposeFeedbackApi<Name extends string | undefined>(
1082
2281
  component: ComponentApi<Name>,
1083
2282
  options: ThrowingExposeFeedbackOptions,
1084
- ): ExposedFeedbackApi<never>;
2283
+ ): ExposedFeedbackApi<never, never>;
1085
2284
 
1086
2285
  export function exposeFeedbackApi<
1087
2286
  Name extends string | undefined,
1088
2287
  ReturnsValidator extends FeedbackRateLimitReturnValidator | undefined,
2288
+ CallbackReturnsValidator extends FeedbackCallbackReturnValidator | undefined,
1089
2289
  >(
1090
2290
  component: ComponentApi<Name>,
1091
- options: ExposeFeedbackOptions<ReturnsValidator>,
1092
- ): ExposedFeedbackApi<RateLimitResult<ReturnsValidator>> {
2291
+ options: ExposeFeedbackOptions<ReturnsValidator, CallbackReturnsValidator>,
2292
+ ): ExposedFeedbackApi<
2293
+ RateLimitResult<ReturnsValidator>,
2294
+ CallbackResult<CallbackReturnsValidator>
2295
+ > {
1093
2296
  return buildFeedbackApi(component, options);
1094
2297
  }