convex-feedback 0.1.0 → 0.1.2

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 (36) hide show
  1. package/README.md +351 -92
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/client/api.d.ts +267 -68
  4. package/dist/client/api.d.ts.map +1 -1
  5. package/dist/client/config.d.ts +168 -29
  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 +167 -172
  9. package/dist/client/index.d.ts.map +1 -1
  10. package/dist/client/index.js +75 -14
  11. package/dist/client/index.js.map +1 -1
  12. package/dist/component/_generated/component.d.ts +2 -2
  13. package/dist/component/_generated/component.d.ts.map +1 -1
  14. package/dist/component/comments.d.ts +12 -12
  15. package/dist/component/comments.d.ts.map +1 -1
  16. package/dist/component/comments.js +43 -60
  17. package/dist/component/comments.js.map +1 -1
  18. package/dist/component/entries.d.ts +30 -30
  19. package/dist/component/entries.d.ts.map +1 -1
  20. package/dist/component/entries.js +216 -97
  21. package/dist/component/entries.js.map +1 -1
  22. package/dist/component/model.d.ts +151 -0
  23. package/dist/component/model.d.ts.map +1 -1
  24. package/dist/react/index.d.ts +178 -56
  25. package/dist/react/index.d.ts.map +1 -1
  26. package/dist/react/index.js +70 -4
  27. package/dist/react/index.js.map +1 -1
  28. package/package.json +1 -1
  29. package/src/client/api.ts +326 -36
  30. package/src/client/config.ts +191 -29
  31. package/src/client/index.ts +437 -16
  32. package/src/component/_generated/component.ts +2 -2
  33. package/src/component/comments.ts +43 -61
  34. package/src/component/entries.ts +300 -123
  35. package/src/component/model.ts +158 -0
  36. package/src/react/index.ts +217 -9
package/README.md CHANGED
@@ -1,91 +1,128 @@
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)
2
+
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
+
1
5
  # convex-feedback
2
6
 
3
- Headless Convex component for product feedback, feature requests, bug reports, entry upvotes, recursive discussions, comment likes, and duplicate suggestions.
7
+ A headless, fully typed Convex component for product feedback, feature requests, bug reports, entry upvotes, lazy nested comments, comment likes, full-text search, and duplicate suggestions.
4
8
 
5
- ## Data model
9
+ > Looking for a ready-made interface? `[convex-feedback-ui](../convex-feedback-ui/README.md)` provides optional React DOM and React Native screens and compound primitives on top of this package.
6
10
 
7
- The component owns exactly three tables.
11
+ ## Features
8
12
 
9
- ### `entries`
13
+ - Feedback, feature requests, and bug reports.
14
+ - Canny-style entry upvotes.
15
+ - Recursive comments loaded one level at a time.
16
+ - Comment likes.
17
+ - Indexed `top` / `newest` entry ordering.
18
+ - Indexed `top` / `newest` / `oldest` comment ordering.
19
+ - Convex full-text search.
20
+ - Exact-title + full-text duplicate suggestions.
21
+ - Host-controlled authentication and moderator permissions.
22
+ - Optional host-defined mutation rate limiting.
23
+ - Configurable limits and behavior
24
+ - Typed React hooks.
25
+ - `convex-test` helper entry point.
10
26
 
11
- Stores the feedback body and list-query counters:
27
+ The component owns only three tables: `entries`, `comments`, and `reactions`.
12
28
 
13
- - `kind`: `feedback | feature_request | bug_report`
14
- - `status`: `open | under_review | planned | in_progress | completed | closed`
15
- - `actorId`, `title`, `body`
16
- - `normalizedTitle`, `searchText`
17
- - `upvoteCount`, `commentCount`
18
- - optional `updatedAt`
29
+ ## Requirements
19
30
 
20
- `_creationTime` is used instead of a duplicate `createdAt` field.
31
+ - An existing Convex application.
32
+ - `convex` installed in the host project.
33
+ - React is required only when using `convex-feedback/react`.
21
34
 
22
- ### `comments`
35
+ ## Installation
23
36
 
24
- Stores one recursive adjacency-list node per comment:
37
+ ```bash
38
+ npm install convex-feedback
39
+ ```
25
40
 
26
- - `entryId`
27
- - optional `parentCommentId`
28
- - `actorId`, `depth`, `body`
29
- - `likeCount`
30
- - `replyCount` (direct children only)
31
- - optional `updatedAt`, `deletedAt`
41
+ ## 1. Install the component in Convex
32
42
 
33
- A deleted comment remains as a tombstone so descendants keep their place in the tree. Public serializers return `body: null` after deletion.
43
+ Create or update your host application's `convex/convex.config.ts`:
34
44
 
35
- ### `reactions`
45
+ ```ts
46
+ import { defineApp } from "convex/server";
47
+ import feedback from "convex-feedback/convex.config.js";
36
48
 
37
- A single sparse table stores both entry upvotes and comment likes. A record has either `entryId` or `commentId`, never both. Query-specific indexes enforce one reaction lookup per actor/target.
49
+ const app = defineApp();
50
+ app.use(feedback);
38
51
 
39
- ## Lazy comments
52
+ export default app;
53
+ ```
40
54
 
41
- `listComments` accepts an optional `parentCommentId` and returns only direct children of that parent. Omitting `parentCommentId` returns top-level comments. Each level is independently paginated.
55
+ You can install multiple independent instances by giving them different component names using Convex's normal component configuration APIs.
42
56
 
43
- Default UI behavior is therefore:
57
+ Run Convex so the host application's component references are generated:
44
58
 
45
- ```text
46
- root comments query
47
- comment A
48
- click "View replies"
49
- direct replies query for A
50
- reply A1
51
- click "View replies"
52
- direct replies query for A1
59
+ ```bash
60
+ npx convex dev
53
61
  ```
54
62
 
55
- `maxDepth` is enforced when a reply is created. The default host config is `5`.
63
+ ## 2. Expose the component through your host API
64
+
65
+ 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.
56
66
 
57
- ## Ordering
67
+ Create a host module such as `convex/feedback.ts`:
58
68
 
59
- Comments support three global server-side sorts:
69
+ ```ts
70
+ import { exposeFeedbackApi } from "convex-feedback";
71
+
72
+ import { components } from "./_generated/api";
73
+
74
+ export const {
75
+ listEntries,
76
+ getEntry,
77
+ searchEntries,
78
+ findSimilarEntries,
79
+ createEntry,
80
+ updateEntry,
81
+ setEntryStatus,
82
+ setEntryUpvote,
83
+ listComments,
84
+ createComment,
85
+ updateComment,
86
+ deleteComment,
87
+ setCommentLike,
88
+ } = exposeFeedbackApi(components.feedback, {
89
+ actor: async (ctx) => {
90
+ const identity = await ctx.auth.getUserIdentity();
91
+
92
+ if (identity === null) return null;
93
+
94
+ return {
95
+ id: identity.tokenIdentifier,
96
+ isModerator: false,
97
+ };
98
+ },
99
+ });
100
+ ```
60
101
 
61
- - `top`: `likeCount DESC`, then Convex index `_creationTime DESC`
62
- - `newest`: `_creationTime DESC`
63
- - `oldest`: `_creationTime ASC`
102
+ ### Actor IDs
64
103
 
65
- All three are index-backed. Do not add an arbitrary server comparator: it cannot preserve correct cursor pagination without an index. UI clients may transform already-loaded comments locally.
104
+ `actor.id` should be stable for the same user.
66
105
 
67
- Entries support `top` and `newest` with filter-specific indexes for kind/status combinations.
106
+ The component does not store a user/profile table. Keep display names, avatars, roles, and profile data in your application.
68
107
 
69
- ## Search and duplicates
108
+ ### Moderators
70
109
 
71
- The component stores one derived `searchText` (`title + body`) and uses a Convex search index. `searchEntries` returns full-text results. `findSimilarEntries` returns:
110
+ Return `isModerator: true` for actors that may perform moderator-only operations such as status changes.
72
111
 
73
112
  ```ts
74
- {
75
- exact: FeedbackEntry[];
76
- similar: FeedbackEntry[];
77
- }
113
+ return {
114
+ id: identity.tokenIdentifier,
115
+ isModerator: await isFeedbackModerator(ctx, identity.tokenIdentifier),
116
+ };
78
117
  ```
79
118
 
80
- `exact` uses normalized title equality. `similar` uses full-text relevance and excludes exact matches. These are lexical suggestions, not semantic/AI duplicate claims.
81
-
82
- ## Static configuration
119
+ ## 3. Configure behavior
83
120
 
84
- Configuration is host code, not a database table.
121
+ Configuration is optional static host code; The values shown are the default configuration.
85
122
 
86
123
  ```ts
87
- exposeFeedbackApi(components.feedback, {
88
- actor: resolveActor,
124
+ export const feedbackApi = exposeFeedbackApi(components.feedback, {
125
+ actor: resolveFeedbackActor,
89
126
  config: {
90
127
  entries: {
91
128
  enabledKinds: ["feedback", "feature_request", "bug_report"],
@@ -109,75 +146,297 @@ exposeFeedbackApi(components.feedback, {
109
146
  },
110
147
  limits: {
111
148
  titleLength: 160,
112
- bodyLength: 10000,
113
- commentLength: 5000,
149
+ bodyLength: 10_000,
150
+ commentLength: 5_000,
114
151
  },
115
152
  },
116
153
  });
117
154
  ```
118
155
 
119
- All nested values are optional in overrides; defaults are merged by `createFeedbackConfig`.
156
+ All configuration fields are documented in the exported TypeScript types and appear in editor IntelliSense.
120
157
 
121
- ## Authentication boundary
158
+ ### Rate limiting
122
159
 
123
- A Convex component is isolated from the host app. The host wrapper receives a resolver:
160
+ Pass optional limiter functions to protect related mutation groups. Each limiter receives the mutation context and the resolved `actor.id`. By default, limiters allow a request by returning `undefined` and reject it by throwing.
124
161
 
125
162
  ```ts
126
- actor: async (ctx) => {
127
- const identity = await ctx.auth.getUserIdentity();
128
- if (identity === null) return null;
129
- return { id: identity.subject, isModerator: false };
130
- };
163
+ export const feedbackApi = exposeFeedbackApi(components.feedback, {
164
+ actor: resolveFeedbackActor,
165
+ rateLimiters: {
166
+ createEntry: feedbackEntryRateLimiter,
167
+ createComment: feedbackCommentRateLimiter,
168
+ editContent: feedbackEditRateLimiter,
169
+ reactions: feedbackReactionRateLimiter,
170
+ },
171
+ });
172
+ ```
173
+
174
+ The groups cover:
175
+
176
+ - `createEntry`: entry creation;
177
+ - `createComment`: comments and replies;
178
+ - `editContent`: entry edits, status changes, comment edits, and comment deletion;
179
+ - `reactions`: entry upvotes and comment likes.
180
+
181
+ Moderators bypass all limiters by default. Set `limitModerators: true` to apply them to moderators as well. `setEntryStatus` always requires a moderator, regardless of rate-limit configuration.
182
+
183
+ To return a value to the client instead of throwing, use `"return"` behavior and provide its Convex validator. In this mode, `undefined` means the request is allowed; any defined value is returned immediately and the feedback mutation does not run. The validator is required by TypeScript and its inferred type is added to every mutation's result type.
184
+
185
+ A discriminated object validator is recommended so callers can reliably distinguish a rejection from each mutation's normal success value.
186
+
187
+ `null` is not allowed as a rejection because several feedback mutations already return `null` on success. Return `undefined` to allow a request or a non-null value matching `returns` to reject it.
188
+
189
+ ```ts
190
+ import { v } from "convex/values";
191
+
192
+ const rateLimitRejection = v.object({
193
+ kind: v.literal("rate_limited"),
194
+ retryAt: v.number(),
195
+ });
196
+
197
+ export const feedbackApi = exposeFeedbackApi(components.feedback, {
198
+ actor: resolveFeedbackActor,
199
+ rateLimiters: {
200
+ createEntry: async (ctx, key) => {
201
+ const status = await rateLimiter.limit(ctx, "feedbackEntryCreation", {
202
+ key,
203
+ });
204
+ if (status.ok) return undefined;
205
+ return {
206
+ kind: "rate_limited" as const,
207
+ retryAt: Date.now() + (status.retryAfter ?? 0),
208
+ };
209
+ },
210
+ },
211
+ config: {
212
+ rateLimiting: {
213
+ behavior: "return",
214
+ returns: rateLimitRejection,
215
+ limitModerators: false,
216
+ },
217
+ },
218
+ });
131
219
  ```
132
220
 
133
- Reads can be anonymous. Writes call `requireActor`. Status changes require `isModerator: true`. Author edit/delete rules are still checked inside the component using the stable actor id passed by the host.
221
+ ## 4. Create typed React hooks
134
222
 
135
- ## Host-facing API
223
+ If your client uses React or React Native, bind the generated host API once:
136
224
 
137
- `exposeFeedbackApi` returns:
225
+ ```ts
226
+ // src/feedback.ts
227
+ import { createFeedbackHooks } from "convex-feedback/react";
138
228
 
139
- - `listEntries`
140
- - `getEntry`
141
- - `searchEntries`
142
- - `findSimilarEntries`
143
- - `createEntry`
144
- - `updateEntry`
145
- - `setEntryStatus`
146
- - `setEntryUpvote`
147
- - `listComments`
148
- - `createComment`
149
- - `updateComment`
150
- - `deleteComment`
151
- - `setCommentLike`
229
+ import { api } from "../convex/_generated/api";
152
230
 
153
- Export these from one of your host Convex modules so your generated host `api` can expose them to clients.
231
+ export const feedbackHooks = createFeedbackHooks(api.feedback, {
232
+ entryPageSize: 20,
233
+ commentPageSize: 20,
234
+ replyPageSize: 10,
235
+ });
236
+ ```
154
237
 
155
- ## React hooks
238
+ Then use the hooks directly or pass `feedbackHooks` to `convex-feedback-ui`.
156
239
 
157
- `convex-feedback/react` exports `createFeedbackHooks(api, options)`. It binds the generated host references once and returns hooks for every read/write operation. Client page sizes are configured there (`entryPageSize`, `commentPageSize`, and `replyPageSize`) while the host config enforces the hard maximum page sizes. The resolved sizes are also exposed as `hooks.pageSizes` for UI layers.
240
+ ```tsx
241
+ const entries = feedbackHooks.useEntries({
242
+ kinds: ["feature_request", "bug_report"],
243
+ sort: "top",
244
+ });
158
245
 
159
- Pagination uses the `convex-helpers/react` pagination hook because component pagination uses the stream/paginator helper and needs `endCursor` stitching for reactive, gap-free pages.
246
+ const createEntry = feedbackHooks.useCreateEntry();
247
+ ```
160
248
 
161
- ## Testing
249
+ ## Entry kinds and statuses
162
250
 
163
- The package exports `convex-feedback/test`:
251
+ ### Kinds
164
252
 
165
253
  ```ts
254
+ type EntryKind = "feedback" | "feature_request" | "bug_report";
255
+ ```
256
+
257
+ ### Statuses
258
+
259
+ ```ts
260
+ type EntryStatus =
261
+ "open" | "under_review" | "planned" | "in_progress" | "completed" | "closed";
262
+ ```
263
+
264
+ The values are intentionally fixed for type safety. UI labels and presentation can be localized/customized in `convex-feedback-ui`.
265
+
266
+ ## Search
267
+
268
+ Full-text search is backed by Convex search indexes:
269
+
270
+ ```ts
271
+ const results = feedbackHooks.useSearchEntries({
272
+ searchQuery: "dark mode",
273
+ kinds: ["feature_request"],
274
+ limit: 10,
275
+ });
276
+ ```
277
+
278
+ ## Duplicate suggestions
279
+
280
+ ```ts
281
+ const result = feedbackHooks.useSimilarEntries({
282
+ title,
283
+ body,
284
+ kind: "feature_request",
285
+ limit: 3,
286
+ });
287
+ ```
288
+
289
+ The result has two groups:
290
+
291
+ ```ts
292
+ {
293
+ exact: FeedbackEntry[];
294
+ similar: FeedbackEntry[];
295
+ }
296
+ ```
297
+
298
+ `limit` is the **maximum combined number of suggestions**. Exact normalized-title matches consume the limit first; only remaining slots are filled by relevance-ranked full-text matches.
299
+
300
+ For `limit: 3`:
301
+
302
+ - 3 exact matches → 3 exact, 0 similar;
303
+ - 2 exact matches → 2 exact, at most 1 similar;
304
+ - 0 exact matches → at most 3 similar.
305
+
306
+ Exact matches are never duplicated in `similar`.
307
+
308
+ This is lexical/full-text duplicate detection.
309
+
310
+ ## Comments and replies
311
+
312
+ Comments are recursive but deliberately lazy.
313
+
314
+ ```ts
315
+ // Top-level comments
316
+ const comments = feedbackHooks.useComments({
317
+ entryId,
318
+ sort: "top",
319
+ });
320
+
321
+ // Direct replies to one comment
322
+ const replies = feedbackHooks.useComments({
323
+ entryId,
324
+ parentCommentId: comment.id,
325
+ sort: "top",
326
+ });
327
+ ```
328
+
329
+ A comment query returns exactly one direct-child level. Opening a reply branch should mount another query for that child's direct replies.
330
+
331
+ `replyCount` is the number of **direct children**. `entry.commentCount` is the total number of comments/replies belonging to the entry.
332
+
333
+ Soft-deleted comments remain as tombstones so descendants keep their position in the thread.
334
+
335
+ ## Upvotes and likes
336
+
337
+ Entry upvotes and comment likes have separate public APIs even though the component stores both efficiently in one reactions table.
338
+
339
+ Both state mutations are idempotent and accept a desired final state:
340
+
341
+ ```ts
342
+ await setEntryUpvote({
343
+ entryId,
344
+ desiredState: true,
345
+ });
346
+
347
+ await setCommentLike({
348
+ commentId,
349
+ desiredState: false,
350
+ });
351
+ ```
352
+
353
+ The mutation result uses `active` to report the authoritative final state returned by the server.
354
+
355
+ ## Host API
356
+
357
+ The wrapper exposes:
358
+
359
+ | Function | Type | Purpose |
360
+ | -------------------- | -------- | -------------------------------------------------- |
361
+ | `listEntries` | query | Paginated entry list with server-side filters/sort |
362
+ | `getEntry` | query | Fetch one entry |
363
+ | `searchEntries` | query | Full-text search |
364
+ | `findSimilarEntries` | query | Exact + similar duplicate suggestions |
365
+ | `createEntry` | mutation | Create feedback |
366
+ | `updateEntry` | mutation | Edit author-owned/moderated feedback |
367
+ | `setEntryStatus` | mutation | Moderator workflow status change |
368
+ | `setEntryUpvote` | mutation | Idempotently set entry upvote state |
369
+ | `listComments` | query | One paginated direct-child comment level |
370
+ | `createComment` | mutation | Create comment or reply |
371
+ | `updateComment` | mutation | Edit a comment |
372
+ | `deleteComment` | mutation | Soft-delete a comment |
373
+ | `setCommentLike` | mutation | Idempotently set comment like state |
374
+
375
+ Every public argument/result type is exported and documented for editor IntelliSense.
376
+
377
+ ## Package entry points
378
+
379
+ ```ts
380
+ // Host wrapper, configuration, public model/API types
381
+ import { exposeFeedbackApi } from "convex-feedback";
382
+
383
+ // React / React Native hooks
384
+ import { createFeedbackHooks } from "convex-feedback/react";
385
+
386
+ // Convex component definition
387
+ import feedback from "convex-feedback/convex.config.js";
388
+
389
+ // ComponentApi type generated by Convex
390
+ import type { ComponentApi } from "convex-feedback/_generated/component";
391
+
392
+ // convex-test helper
166
393
  import feedbackTest from "convex-feedback/test";
394
+ ```
395
+
396
+ ## Testing host integrations
397
+
398
+ The package exposes a `/test` entry point for `convex-test`.
399
+
400
+ ```ts
167
401
  import { convexTest } from "convex-test";
402
+ import feedbackTest from "convex-feedback/test";
403
+
404
+ import schema from "./convex/schema";
405
+
406
+ const modules = import.meta.glob("./convex/**/*.ts");
407
+ const t = convexTest(schema, modules);
168
408
 
169
- const t = convexTest();
170
409
  feedbackTest.register(t, "feedback");
171
410
  ```
172
411
 
173
- The repository tests cover idempotent entry upvotes, direct-child comment loading, max depth, idempotent comment likes/top ordering, exact duplicate normalization, and config merging.
412
+ Use host-level tests when you need to verify your authentication wrapper and public host API in the same shape your client will consume.
174
413
 
175
- ## Generated files
414
+ ## UI package
176
415
 
177
- The committed `_generated` files are starter/bootstrap output. After installing dependencies, regenerate them:
416
+ | Expo | React Native |
417
+ | --------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
418
+ | ![Expo](https://raw.githubusercontent.com/Moumen-io/convex-feedback/main/docs/screenshots/expo.png) | ![React Native](https://raw.githubusercontent.com/Moumen-io/convex-feedback/main/docs/screenshots/native.png) |
419
+
420
+ `convex-feedback` is intentionally headless. For a complete board or customizable primitives, install:
178
421
 
179
422
  ```bash
180
- npm run codegen -w convex-feedback
423
+ npm install convex-feedback-ui
181
424
  ```
182
425
 
183
- Do not manually maintain generated API/data-model definitions.
426
+ Then read `[convex-feedback-ui](../convex-feedback-ui/README.md)`.
427
+
428
+ ## Development in this repository
429
+
430
+ From the monorepo root:
431
+
432
+ ```bash
433
+ npm install
434
+ npm run codegen
435
+ npm run test:all
436
+ ```
437
+
438
+ When changing the component's schema or functions, regenerate component code before committing generated output.
439
+
440
+ ## License
441
+
442
+ Apache-2.0