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.
- package/README.md +183 -0
- package/dist/.tsbuildinfo +1 -0
- package/dist/client/api.d.ts +72 -0
- package/dist/client/api.d.ts.map +1 -0
- package/dist/client/api.js +2 -0
- package/dist/client/api.js.map +1 -0
- package/dist/client/config.d.ts +37 -0
- package/dist/client/config.d.ts.map +1 -0
- package/dist/client/config.js +36 -0
- package/dist/client/config.js.map +1 -0
- package/dist/client/index.d.ts +186 -0
- package/dist/client/index.d.ts.map +1 -0
- package/dist/client/index.js +248 -0
- package/dist/client/index.js.map +1 -0
- package/dist/component/_generated/api.d.ts +42 -0
- package/dist/component/_generated/api.d.ts.map +1 -0
- package/dist/component/_generated/api.js +31 -0
- package/dist/component/_generated/api.js.map +1 -0
- package/dist/component/_generated/component.d.ts +233 -0
- package/dist/component/_generated/component.d.ts.map +1 -0
- package/dist/component/_generated/component.js +11 -0
- package/dist/component/_generated/component.js.map +1 -0
- package/dist/component/_generated/dataModel.d.ts +46 -0
- package/dist/component/_generated/dataModel.d.ts.map +1 -0
- package/dist/component/_generated/dataModel.js +11 -0
- package/dist/component/_generated/dataModel.js.map +1 -0
- package/dist/component/_generated/server.d.ts +133 -0
- package/dist/component/_generated/server.d.ts.map +1 -0
- package/dist/component/_generated/server.js +80 -0
- package/dist/component/_generated/server.js.map +1 -0
- package/dist/component/comments.d.ts +68 -0
- package/dist/component/comments.d.ts.map +1 -0
- package/dist/component/comments.js +206 -0
- package/dist/component/comments.js.map +1 -0
- package/dist/component/convex.config.d.ts +3 -0
- package/dist/component/convex.config.d.ts.map +1 -0
- package/dist/component/convex.config.js +3 -0
- package/dist/component/convex.config.js.map +1 -0
- package/dist/component/entries.d.ts +140 -0
- package/dist/component/entries.d.ts.map +1 -0
- package/dist/component/entries.js +302 -0
- package/dist/component/entries.js.map +1 -0
- package/dist/component/helpers.d.ts +10 -0
- package/dist/component/helpers.d.ts.map +1 -0
- package/dist/component/helpers.js +74 -0
- package/dist/component/helpers.js.map +1 -0
- package/dist/component/model.d.ts +176 -0
- package/dist/component/model.d.ts.map +1 -0
- package/dist/component/model.js +41 -0
- package/dist/component/model.js.map +1 -0
- package/dist/component/schema.d.ts +78 -0
- package/dist/component/schema.d.ts.map +1 -0
- package/dist/component/schema.js +56 -0
- package/dist/component/schema.js.map +1 -0
- package/dist/component/types.d.ts +5 -0
- package/dist/component/types.d.ts.map +1 -0
- package/dist/component/types.js +2 -0
- package/dist/component/types.js.map +1 -0
- package/dist/react/index.d.ts +169 -0
- package/dist/react/index.d.ts.map +1 -0
- package/dist/react/index.js +68 -0
- package/dist/react/index.js.map +1 -0
- package/dist/test.d.ts +88 -0
- package/dist/test.d.ts.map +1 -0
- package/dist/test.js +9 -0
- package/dist/test.js.map +1 -0
- package/package.json +63 -0
- package/src/client/README.md +11 -0
- package/src/client/api.ts +126 -0
- package/src/client/config.ts +80 -0
- package/src/client/index.ts +338 -0
- package/src/component/README.md +13 -0
- package/src/component/_generated/api.ts +58 -0
- package/src/component/_generated/component.ts +339 -0
- package/src/component/_generated/dataModel.ts +60 -0
- package/src/component/_generated/server.ts +169 -0
- package/src/component/comments.ts +258 -0
- package/src/component/convex.config.ts +3 -0
- package/src/component/entries.ts +385 -0
- package/src/component/helpers.ts +105 -0
- package/src/component/model.ts +76 -0
- package/src/component/schema.ts +60 -0
- package/src/component/types.ts +6 -0
- package/src/react/README.md +7 -0
- package/src/react/index.ts +140 -0
- 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,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
|
+
});
|