convex-feedback 0.1.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 (86) hide show
  1. package/README.md +183 -0
  2. package/dist/.tsbuildinfo +1 -0
  3. package/dist/client/api.d.ts +72 -0
  4. package/dist/client/api.d.ts.map +1 -0
  5. package/dist/client/api.js +2 -0
  6. package/dist/client/api.js.map +1 -0
  7. package/dist/client/config.d.ts +37 -0
  8. package/dist/client/config.d.ts.map +1 -0
  9. package/dist/client/config.js +36 -0
  10. package/dist/client/config.js.map +1 -0
  11. package/dist/client/index.d.ts +186 -0
  12. package/dist/client/index.d.ts.map +1 -0
  13. package/dist/client/index.js +248 -0
  14. package/dist/client/index.js.map +1 -0
  15. package/dist/component/_generated/api.d.ts +42 -0
  16. package/dist/component/_generated/api.d.ts.map +1 -0
  17. package/dist/component/_generated/api.js +31 -0
  18. package/dist/component/_generated/api.js.map +1 -0
  19. package/dist/component/_generated/component.d.ts +233 -0
  20. package/dist/component/_generated/component.d.ts.map +1 -0
  21. package/dist/component/_generated/component.js +11 -0
  22. package/dist/component/_generated/component.js.map +1 -0
  23. package/dist/component/_generated/dataModel.d.ts +46 -0
  24. package/dist/component/_generated/dataModel.d.ts.map +1 -0
  25. package/dist/component/_generated/dataModel.js +11 -0
  26. package/dist/component/_generated/dataModel.js.map +1 -0
  27. package/dist/component/_generated/server.d.ts +133 -0
  28. package/dist/component/_generated/server.d.ts.map +1 -0
  29. package/dist/component/_generated/server.js +80 -0
  30. package/dist/component/_generated/server.js.map +1 -0
  31. package/dist/component/comments.d.ts +68 -0
  32. package/dist/component/comments.d.ts.map +1 -0
  33. package/dist/component/comments.js +206 -0
  34. package/dist/component/comments.js.map +1 -0
  35. package/dist/component/convex.config.d.ts +3 -0
  36. package/dist/component/convex.config.d.ts.map +1 -0
  37. package/dist/component/convex.config.js +3 -0
  38. package/dist/component/convex.config.js.map +1 -0
  39. package/dist/component/entries.d.ts +140 -0
  40. package/dist/component/entries.d.ts.map +1 -0
  41. package/dist/component/entries.js +302 -0
  42. package/dist/component/entries.js.map +1 -0
  43. package/dist/component/helpers.d.ts +10 -0
  44. package/dist/component/helpers.d.ts.map +1 -0
  45. package/dist/component/helpers.js +74 -0
  46. package/dist/component/helpers.js.map +1 -0
  47. package/dist/component/model.d.ts +176 -0
  48. package/dist/component/model.d.ts.map +1 -0
  49. package/dist/component/model.js +41 -0
  50. package/dist/component/model.js.map +1 -0
  51. package/dist/component/schema.d.ts +78 -0
  52. package/dist/component/schema.d.ts.map +1 -0
  53. package/dist/component/schema.js +56 -0
  54. package/dist/component/schema.js.map +1 -0
  55. package/dist/component/types.d.ts +5 -0
  56. package/dist/component/types.d.ts.map +1 -0
  57. package/dist/component/types.js +2 -0
  58. package/dist/component/types.js.map +1 -0
  59. package/dist/react/index.d.ts +169 -0
  60. package/dist/react/index.d.ts.map +1 -0
  61. package/dist/react/index.js +68 -0
  62. package/dist/react/index.js.map +1 -0
  63. package/dist/test.d.ts +88 -0
  64. package/dist/test.d.ts.map +1 -0
  65. package/dist/test.js +9 -0
  66. package/dist/test.js.map +1 -0
  67. package/package.json +63 -0
  68. package/src/client/README.md +11 -0
  69. package/src/client/api.ts +126 -0
  70. package/src/client/config.ts +80 -0
  71. package/src/client/index.ts +338 -0
  72. package/src/component/README.md +13 -0
  73. package/src/component/_generated/api.ts +58 -0
  74. package/src/component/_generated/component.ts +339 -0
  75. package/src/component/_generated/dataModel.ts +60 -0
  76. package/src/component/_generated/server.ts +169 -0
  77. package/src/component/comments.ts +258 -0
  78. package/src/component/convex.config.ts +3 -0
  79. package/src/component/entries.ts +385 -0
  80. package/src/component/helpers.ts +105 -0
  81. package/src/component/model.ts +76 -0
  82. package/src/component/schema.ts +60 -0
  83. package/src/component/types.ts +6 -0
  84. package/src/react/README.md +7 -0
  85. package/src/react/index.ts +140 -0
  86. package/src/test.ts +18 -0
@@ -0,0 +1,258 @@
1
+ import { paginator } from "convex-helpers/server/pagination";
2
+ import {
3
+ paginationOptsValidator,
4
+ paginationResultValidator,
5
+ } from "convex/server";
6
+ import { ConvexError, v } from "convex/values";
7
+
8
+ import { mutation, query } from "./_generated/server.js";
9
+ import {
10
+ assertActorId,
11
+ assertPositiveInteger,
12
+ normalizeRequiredText,
13
+ serializeComment,
14
+ } from "./helpers.js";
15
+ import {
16
+ actorValidator,
17
+ commentSortValidator,
18
+ publicCommentValidator,
19
+ } from "./model.js";
20
+ import schema from "./schema.js";
21
+
22
+ export const list = query({
23
+ args: {
24
+ paginationOpts: paginationOptsValidator,
25
+ entryId: v.string(),
26
+ parentCommentId: v.optional(v.string()),
27
+ sort: commentSortValidator,
28
+ viewerActorId: v.optional(v.string()),
29
+ },
30
+ returns: paginationResultValidator(publicCommentValidator),
31
+ handler: async (ctx, args) => {
32
+ const entryId = ctx.db.normalizeId("entries", args.entryId);
33
+ if (entryId === null) throw new ConvexError("Entry not found.");
34
+
35
+ const parentCommentId =
36
+ args.parentCommentId === undefined
37
+ ? undefined
38
+ : ctx.db.normalizeId("comments", args.parentCommentId);
39
+
40
+ if (parentCommentId === null) {
41
+ throw new ConvexError("Parent comment not found.");
42
+ }
43
+
44
+ if (parentCommentId !== undefined && parentCommentId !== null) {
45
+ const parent = await ctx.db.get("comments", parentCommentId);
46
+ if (parent === null || parent.entryId !== entryId) {
47
+ throw new ConvexError("Parent comment not found on this entry.");
48
+ }
49
+ }
50
+
51
+ const db = paginator(ctx.db, schema);
52
+ const result =
53
+ args.sort === "top"
54
+ ? await db
55
+ .query("comments")
56
+ .withIndex("by_entry_parent_likes", (q) =>
57
+ q.eq("entryId", entryId).eq("parentCommentId", parentCommentId),
58
+ )
59
+ .order("desc")
60
+ .paginate(args.paginationOpts)
61
+ : await db
62
+ .query("comments")
63
+ .withIndex("by_entry_parent", (q) =>
64
+ q.eq("entryId", entryId).eq("parentCommentId", parentCommentId),
65
+ )
66
+ .order(args.sort === "newest" ? "desc" : "asc")
67
+ .paginate(args.paginationOpts);
68
+
69
+ return {
70
+ ...result,
71
+ page: await Promise.all(
72
+ result.page.map((comment) =>
73
+ serializeComment(ctx, comment, args.viewerActorId),
74
+ ),
75
+ ),
76
+ };
77
+ },
78
+ });
79
+
80
+ export const create = mutation({
81
+ args: {
82
+ actorId: v.string(),
83
+ entryId: v.string(),
84
+ parentCommentId: v.optional(v.string()),
85
+ body: v.string(),
86
+ maxDepth: v.number(),
87
+ maxCommentLength: v.number(),
88
+ },
89
+ returns: v.string(),
90
+ handler: async (ctx, args) => {
91
+ assertActorId(args.actorId);
92
+ assertPositiveInteger(args.maxDepth, "Maximum comment depth");
93
+ assertPositiveInteger(args.maxCommentLength, "Maximum comment length");
94
+
95
+ const entryId = ctx.db.normalizeId("entries", args.entryId);
96
+ if (entryId === null) throw new ConvexError("Entry not found.");
97
+ const entry = await ctx.db.get("entries", entryId);
98
+ if (entry === null) throw new ConvexError("Entry not found.");
99
+
100
+ const body = normalizeRequiredText(
101
+ args.body,
102
+ "Comment",
103
+ args.maxCommentLength,
104
+ );
105
+
106
+ const parentCommentId =
107
+ args.parentCommentId === undefined
108
+ ? undefined
109
+ : ctx.db.normalizeId("comments", args.parentCommentId);
110
+ if (args.parentCommentId !== undefined && parentCommentId === null) {
111
+ throw new ConvexError("Parent comment not found.");
112
+ }
113
+
114
+ let depth = 0;
115
+ if (parentCommentId !== undefined && parentCommentId !== null) {
116
+ const parent = await ctx.db.get("comments", parentCommentId);
117
+ if (parent === null || parent.entryId !== entryId) {
118
+ throw new ConvexError("Parent comment not found on this entry.");
119
+ }
120
+ depth = parent.depth + 1;
121
+ if (depth > args.maxDepth) {
122
+ throw new ConvexError(
123
+ `Comments may not exceed depth ${args.maxDepth}.`,
124
+ );
125
+ }
126
+ }
127
+
128
+ const commentId = await ctx.db.insert("comments", {
129
+ entryId,
130
+ ...(parentCommentId === undefined || parentCommentId === null
131
+ ? {}
132
+ : { parentCommentId }),
133
+ actorId: args.actorId,
134
+ depth,
135
+ body,
136
+ likeCount: 0,
137
+ replyCount: 0,
138
+ });
139
+
140
+ await ctx.db.patch("entries", entryId, {
141
+ commentCount: entry.commentCount + 1,
142
+ });
143
+
144
+ if (parentCommentId !== undefined && parentCommentId !== null) {
145
+ const parent = await ctx.db.get("comments", parentCommentId);
146
+ if (parent !== null) {
147
+ await ctx.db.patch("comments", parentCommentId, {
148
+ replyCount: parent.replyCount + 1,
149
+ });
150
+ }
151
+ }
152
+
153
+ return commentId;
154
+ },
155
+ });
156
+
157
+ export const update = mutation({
158
+ args: {
159
+ actor: actorValidator,
160
+ commentId: v.string(),
161
+ body: v.string(),
162
+ editableByAuthor: v.boolean(),
163
+ maxCommentLength: v.number(),
164
+ },
165
+ returns: v.null(),
166
+ handler: async (ctx, args) => {
167
+ assertActorId(args.actor.id);
168
+ const commentId = ctx.db.normalizeId("comments", args.commentId);
169
+ if (commentId === null) throw new ConvexError("Comment not found.");
170
+ const comment = await ctx.db.get("comments", commentId);
171
+ if (comment === null) throw new ConvexError("Comment not found.");
172
+ if (comment.deletedAt !== undefined) {
173
+ throw new ConvexError("Deleted comments cannot be edited.");
174
+ }
175
+
176
+ const canEdit =
177
+ args.actor.isModerator ||
178
+ (args.editableByAuthor && comment.actorId === args.actor.id);
179
+ if (!canEdit) throw new ConvexError("Not authorized to edit this comment.");
180
+
181
+ const body = normalizeRequiredText(
182
+ args.body,
183
+ "Comment",
184
+ args.maxCommentLength,
185
+ );
186
+ await ctx.db.patch("comments", commentId, { body, updatedAt: Date.now() });
187
+ return null;
188
+ },
189
+ });
190
+
191
+ export const remove = mutation({
192
+ args: {
193
+ actor: actorValidator,
194
+ commentId: v.string(),
195
+ deletableByAuthor: v.boolean(),
196
+ },
197
+ returns: v.null(),
198
+ handler: async (ctx, args) => {
199
+ assertActorId(args.actor.id);
200
+ const commentId = ctx.db.normalizeId("comments", args.commentId);
201
+ if (commentId === null) throw new ConvexError("Comment not found.");
202
+ const comment = await ctx.db.get("comments", commentId);
203
+ if (comment === null) throw new ConvexError("Comment not found.");
204
+ if (comment.deletedAt !== undefined) return null;
205
+
206
+ const canDelete =
207
+ args.actor.isModerator ||
208
+ (args.deletableByAuthor && comment.actorId === args.actor.id);
209
+ if (!canDelete) {
210
+ throw new ConvexError("Not authorized to delete this comment.");
211
+ }
212
+
213
+ await ctx.db.patch("comments", commentId, { deletedAt: Date.now() });
214
+ return null;
215
+ },
216
+ });
217
+
218
+ export const setLike = mutation({
219
+ args: {
220
+ actorId: v.string(),
221
+ commentId: v.string(),
222
+ desiredState: v.boolean(),
223
+ },
224
+ returns: v.object({ active: v.boolean(), likeCount: v.number() }),
225
+ handler: async (ctx, args) => {
226
+ assertActorId(args.actorId);
227
+ const commentId = ctx.db.normalizeId("comments", args.commentId);
228
+ if (commentId === null) throw new ConvexError("Comment not found.");
229
+ const comment = await ctx.db.get("comments", commentId);
230
+ if (comment === null) throw new ConvexError("Comment not found.");
231
+ if (comment.deletedAt !== undefined) {
232
+ throw new ConvexError("Deleted comments cannot receive likes.");
233
+ }
234
+
235
+ const existing = await ctx.db
236
+ .query("reactions")
237
+ .withIndex("by_comment_actor", (q) =>
238
+ q.eq("commentId", commentId).eq("actorId", args.actorId),
239
+ )
240
+ .unique();
241
+
242
+ if (args.desiredState && existing === null) {
243
+ await ctx.db.insert("reactions", { actorId: args.actorId, commentId });
244
+ const likeCount = comment.likeCount + 1;
245
+ await ctx.db.patch("comments", commentId, { likeCount });
246
+ return { active: true, likeCount };
247
+ }
248
+
249
+ if (!args.desiredState && existing !== null) {
250
+ await ctx.db.delete("reactions", existing._id);
251
+ const likeCount = Math.max(0, comment.likeCount - 1);
252
+ await ctx.db.patch("comments", commentId, { likeCount });
253
+ return { active: false, likeCount };
254
+ }
255
+
256
+ return { active: args.desiredState, likeCount: comment.likeCount };
257
+ },
258
+ });
@@ -0,0 +1,3 @@
1
+ import { defineComponent } from "convex/server";
2
+
3
+ export default defineComponent("feedback");
@@ -0,0 +1,385 @@
1
+ import { paginator } from "convex-helpers/server/pagination";
2
+ import {
3
+ paginationOptsValidator,
4
+ paginationResultValidator,
5
+ } from "convex/server";
6
+ import { ConvexError, v } from "convex/values";
7
+
8
+ import { mutation, query } from "./_generated/server.js";
9
+ import {
10
+ assertActorId,
11
+ normalizeRequiredText,
12
+ normalizeTitle,
13
+ serializeEntry,
14
+ } from "./helpers.js";
15
+ import {
16
+ actorValidator,
17
+ entryKindValidator,
18
+ entrySortValidator,
19
+ entryStatusValidator,
20
+ publicEntryValidator,
21
+ similarEntriesValidator,
22
+ } from "./model.js";
23
+ import schema from "./schema.js";
24
+
25
+ export const list = query({
26
+ args: {
27
+ paginationOpts: paginationOptsValidator,
28
+ kind: v.optional(entryKindValidator),
29
+ status: v.optional(entryStatusValidator),
30
+ sort: entrySortValidator,
31
+ viewerActorId: v.optional(v.string()),
32
+ },
33
+ returns: paginationResultValidator(publicEntryValidator),
34
+ handler: async (ctx, args) => {
35
+ const db = paginator(ctx.db, schema);
36
+ const { kind, status } = args;
37
+
38
+ const result =
39
+ args.sort === "top"
40
+ ? kind !== undefined && status !== undefined
41
+ ? await db
42
+ .query("entries")
43
+ .withIndex("by_kind_status_upvotes", (q) =>
44
+ q.eq("kind", kind).eq("status", status),
45
+ )
46
+ .order("desc")
47
+ .paginate(args.paginationOpts)
48
+ : kind !== undefined
49
+ ? await db
50
+ .query("entries")
51
+ .withIndex("by_kind_upvotes", (q) => q.eq("kind", kind))
52
+ .order("desc")
53
+ .paginate(args.paginationOpts)
54
+ : status !== undefined
55
+ ? await db
56
+ .query("entries")
57
+ .withIndex("by_status_upvotes", (q) => q.eq("status", status))
58
+ .order("desc")
59
+ .paginate(args.paginationOpts)
60
+ : await db
61
+ .query("entries")
62
+ .withIndex("by_upvotes")
63
+ .order("desc")
64
+ .paginate(args.paginationOpts)
65
+ : kind !== undefined && status !== undefined
66
+ ? await db
67
+ .query("entries")
68
+ .withIndex("by_kind_status", (q) =>
69
+ q.eq("kind", kind).eq("status", status),
70
+ )
71
+ .order("desc")
72
+ .paginate(args.paginationOpts)
73
+ : kind !== undefined
74
+ ? await db
75
+ .query("entries")
76
+ .withIndex("by_kind", (q) => q.eq("kind", kind))
77
+ .order("desc")
78
+ .paginate(args.paginationOpts)
79
+ : status !== undefined
80
+ ? await db
81
+ .query("entries")
82
+ .withIndex("by_status", (q) => q.eq("status", status))
83
+ .order("desc")
84
+ .paginate(args.paginationOpts)
85
+ : await db
86
+ .query("entries")
87
+ .order("desc")
88
+ .paginate(args.paginationOpts);
89
+
90
+ return {
91
+ ...result,
92
+ page: await Promise.all(
93
+ result.page.map((entry) =>
94
+ serializeEntry(ctx, entry, args.viewerActorId),
95
+ ),
96
+ ),
97
+ };
98
+ },
99
+ });
100
+
101
+ export const get = query({
102
+ args: {
103
+ entryId: v.string(),
104
+ viewerActorId: v.optional(v.string()),
105
+ },
106
+ returns: v.union(publicEntryValidator, v.null()),
107
+ handler: async (ctx, args) => {
108
+ const entryId = ctx.db.normalizeId("entries", args.entryId);
109
+ if (entryId === null) return null;
110
+ const entry = await ctx.db.get("entries", entryId);
111
+ return entry === null
112
+ ? null
113
+ : serializeEntry(ctx, entry, args.viewerActorId);
114
+ },
115
+ });
116
+
117
+ export const search = query({
118
+ args: {
119
+ searchQuery: v.string(),
120
+ kind: v.optional(entryKindValidator),
121
+ status: v.optional(entryStatusValidator),
122
+ limit: v.number(),
123
+ viewerActorId: v.optional(v.string()),
124
+ },
125
+ returns: v.array(publicEntryValidator),
126
+ handler: async (ctx, args) => {
127
+ const searchQuery = args.searchQuery.trim();
128
+ if (searchQuery.length === 0 || args.limit <= 0) return [];
129
+ const { kind, status } = args;
130
+
131
+ const entries =
132
+ kind !== undefined && status !== undefined
133
+ ? await ctx.db
134
+ .query("entries")
135
+ .withSearchIndex("search", (q) =>
136
+ q
137
+ .search("searchText", searchQuery)
138
+ .eq("kind", kind)
139
+ .eq("status", status),
140
+ )
141
+ .take(args.limit)
142
+ : kind !== undefined
143
+ ? await ctx.db
144
+ .query("entries")
145
+ .withSearchIndex("search", (q) =>
146
+ q.search("searchText", searchQuery).eq("kind", kind),
147
+ )
148
+ .take(args.limit)
149
+ : status !== undefined
150
+ ? await ctx.db
151
+ .query("entries")
152
+ .withSearchIndex("search", (q) =>
153
+ q.search("searchText", searchQuery).eq("status", status),
154
+ )
155
+ .take(args.limit)
156
+ : await ctx.db
157
+ .query("entries")
158
+ .withSearchIndex("search", (q) =>
159
+ q.search("searchText", searchQuery),
160
+ )
161
+ .take(args.limit);
162
+
163
+ return Promise.all(
164
+ entries.map((entry) => serializeEntry(ctx, entry, args.viewerActorId)),
165
+ );
166
+ },
167
+ });
168
+
169
+ export const similar = query({
170
+ args: {
171
+ title: v.string(),
172
+ body: v.string(),
173
+ kind: v.optional(entryKindValidator),
174
+ limit: v.number(),
175
+ viewerActorId: v.optional(v.string()),
176
+ },
177
+ returns: similarEntriesValidator,
178
+ handler: async (ctx, args) => {
179
+ if (args.limit <= 0) return { exact: [], similar: [] };
180
+
181
+ const title = args.title.trim();
182
+ const body = args.body.trim();
183
+ const { kind } = args;
184
+ if (title.length === 0 && body.length === 0) {
185
+ return { exact: [], similar: [] };
186
+ }
187
+
188
+ const normalizedTitle = normalizeTitle(title);
189
+ const exactDocs =
190
+ title.length === 0
191
+ ? []
192
+ : kind === undefined
193
+ ? await ctx.db
194
+ .query("entries")
195
+ .withIndex("by_normalized_title", (q) =>
196
+ q.eq("normalizedTitle", normalizedTitle),
197
+ )
198
+ .take(args.limit)
199
+ : await ctx.db
200
+ .query("entries")
201
+ .withIndex("by_kind_normalized_title", (q) =>
202
+ q.eq("kind", kind).eq("normalizedTitle", normalizedTitle),
203
+ )
204
+ .take(args.limit);
205
+
206
+ const exactIds = new Set(exactDocs.map((entry) => entry._id));
207
+ const searchText = `${title}\n${body}`.trim();
208
+
209
+ const similarDocs =
210
+ searchText.length === 0
211
+ ? []
212
+ : kind === undefined
213
+ ? await ctx.db
214
+ .query("entries")
215
+ .withSearchIndex("search", (q) =>
216
+ q.search("searchText", searchText),
217
+ )
218
+ .take(args.limit + exactIds.size)
219
+ : await ctx.db
220
+ .query("entries")
221
+ .withSearchIndex("search", (q) =>
222
+ q.search("searchText", searchText).eq("kind", kind),
223
+ )
224
+ .take(args.limit + exactIds.size);
225
+
226
+ const similarFiltered = similarDocs
227
+ .filter((entry) => !exactIds.has(entry._id))
228
+ .slice(0, args.limit);
229
+
230
+ return {
231
+ exact: await Promise.all(
232
+ exactDocs.map((entry) =>
233
+ serializeEntry(ctx, entry, args.viewerActorId),
234
+ ),
235
+ ),
236
+ similar: await Promise.all(
237
+ similarFiltered.map((entry) =>
238
+ serializeEntry(ctx, entry, args.viewerActorId),
239
+ ),
240
+ ),
241
+ };
242
+ },
243
+ });
244
+
245
+ export const create = mutation({
246
+ args: {
247
+ actorId: v.string(),
248
+ kind: entryKindValidator,
249
+ title: v.string(),
250
+ body: v.string(),
251
+ defaultStatus: entryStatusValidator,
252
+ enabledKinds: v.array(entryKindValidator),
253
+ maxTitleLength: v.number(),
254
+ maxBodyLength: v.number(),
255
+ },
256
+ returns: v.string(),
257
+ handler: async (ctx, args) => {
258
+ assertActorId(args.actorId);
259
+ if (!args.enabledKinds.includes(args.kind)) {
260
+ throw new ConvexError(`Entry kind '${args.kind}' is disabled.`);
261
+ }
262
+
263
+ const title = normalizeRequiredText(
264
+ args.title,
265
+ "Title",
266
+ args.maxTitleLength,
267
+ );
268
+ const body = normalizeRequiredText(args.body, "Body", args.maxBodyLength);
269
+
270
+ return await ctx.db.insert("entries", {
271
+ actorId: args.actorId,
272
+ kind: args.kind,
273
+ status: args.defaultStatus,
274
+ title,
275
+ body,
276
+ normalizedTitle: normalizeTitle(title),
277
+ searchText: `${title}\n${body}`,
278
+ upvoteCount: 0,
279
+ commentCount: 0,
280
+ });
281
+ },
282
+ });
283
+
284
+ export const update = mutation({
285
+ args: {
286
+ actor: actorValidator,
287
+ entryId: v.string(),
288
+ title: v.string(),
289
+ body: v.string(),
290
+ editableByAuthor: v.boolean(),
291
+ maxTitleLength: v.number(),
292
+ maxBodyLength: v.number(),
293
+ },
294
+ returns: v.null(),
295
+ handler: async (ctx, args) => {
296
+ assertActorId(args.actor.id);
297
+ const entryId = ctx.db.normalizeId("entries", args.entryId);
298
+ if (entryId === null) throw new ConvexError("Entry not found.");
299
+ const entry = await ctx.db.get("entries", entryId);
300
+ if (entry === null) throw new ConvexError("Entry not found.");
301
+
302
+ const canEdit =
303
+ args.actor.isModerator ||
304
+ (args.editableByAuthor && entry.actorId === args.actor.id);
305
+ if (!canEdit) throw new ConvexError("Not authorized to edit this entry.");
306
+
307
+ const title = normalizeRequiredText(
308
+ args.title,
309
+ "Title",
310
+ args.maxTitleLength,
311
+ );
312
+ const body = normalizeRequiredText(args.body, "Body", args.maxBodyLength);
313
+
314
+ await ctx.db.patch("entries", entryId, {
315
+ title,
316
+ body,
317
+ normalizedTitle: normalizeTitle(title),
318
+ searchText: `${title}\n${body}`,
319
+ updatedAt: Date.now(),
320
+ });
321
+ return null;
322
+ },
323
+ });
324
+
325
+ export const setStatus = mutation({
326
+ args: {
327
+ actor: actorValidator,
328
+ entryId: v.string(),
329
+ status: entryStatusValidator,
330
+ },
331
+ returns: v.null(),
332
+ handler: async (ctx, args) => {
333
+ if (!args.actor.isModerator) {
334
+ throw new ConvexError("Moderator access is required to change status.");
335
+ }
336
+ const entryId = ctx.db.normalizeId("entries", args.entryId);
337
+ if (entryId === null || (await ctx.db.get("entries", entryId)) === null) {
338
+ throw new ConvexError("Entry not found.");
339
+ }
340
+ await ctx.db.patch("entries", entryId, {
341
+ status: args.status,
342
+ updatedAt: Date.now(),
343
+ });
344
+ return null;
345
+ },
346
+ });
347
+
348
+ export const setUpvote = mutation({
349
+ args: {
350
+ actorId: v.string(),
351
+ entryId: v.string(),
352
+ desiredState: v.boolean(),
353
+ },
354
+ returns: v.object({ active: v.boolean(), upvoteCount: v.number() }),
355
+ handler: async (ctx, args) => {
356
+ assertActorId(args.actorId);
357
+ const entryId = ctx.db.normalizeId("entries", args.entryId);
358
+ if (entryId === null) throw new ConvexError("Entry not found.");
359
+ const entry = await ctx.db.get("entries", entryId);
360
+ if (entry === null) throw new ConvexError("Entry not found.");
361
+
362
+ const existing = await ctx.db
363
+ .query("reactions")
364
+ .withIndex("by_entry_actor", (q) =>
365
+ q.eq("entryId", entryId).eq("actorId", args.actorId),
366
+ )
367
+ .unique();
368
+
369
+ if (args.desiredState && existing === null) {
370
+ await ctx.db.insert("reactions", { actorId: args.actorId, entryId });
371
+ const upvoteCount = entry.upvoteCount + 1;
372
+ await ctx.db.patch("entries", entryId, { upvoteCount });
373
+ return { active: true, upvoteCount };
374
+ }
375
+
376
+ if (!args.desiredState && existing !== null) {
377
+ await ctx.db.delete("reactions", existing._id);
378
+ const upvoteCount = Math.max(0, entry.upvoteCount - 1);
379
+ await ctx.db.patch("entries", entryId, { upvoteCount });
380
+ return { active: false, upvoteCount };
381
+ }
382
+
383
+ return { active: args.desiredState, upvoteCount: entry.upvoteCount };
384
+ },
385
+ });