@btst/stack 2.7.0 → 2.8.1

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 (181) hide show
  1. package/README.md +1 -0
  2. package/dist/packages/stack/src/plugins/blog/client/components/loading/post-navigation-skeleton.cjs +13 -0
  3. package/dist/packages/stack/src/plugins/blog/client/components/loading/post-navigation-skeleton.mjs +11 -0
  4. package/dist/packages/stack/src/plugins/blog/client/components/loading/recent-posts-carousel-skeleton.cjs +17 -0
  5. package/dist/packages/stack/src/plugins/blog/client/components/loading/recent-posts-carousel-skeleton.mjs +15 -0
  6. package/dist/packages/stack/src/plugins/blog/client/components/pages/post-page.internal.cjs +18 -7
  7. package/dist/packages/stack/src/plugins/blog/client/components/pages/post-page.internal.mjs +18 -7
  8. package/dist/packages/stack/src/plugins/blog/client/components/shared/post-navigation.cjs +48 -52
  9. package/dist/packages/stack/src/plugins/blog/client/components/shared/post-navigation.mjs +49 -53
  10. package/dist/packages/stack/src/plugins/blog/client/components/shared/recent-posts-carousel.cjs +34 -37
  11. package/dist/packages/stack/src/plugins/blog/client/components/shared/recent-posts-carousel.mjs +35 -38
  12. package/dist/packages/stack/src/plugins/blog/client/hooks/blog-hooks.cjs +4 -21
  13. package/dist/packages/stack/src/plugins/blog/client/hooks/blog-hooks.mjs +4 -21
  14. package/dist/packages/stack/src/plugins/comments/api/getters.cjs +284 -0
  15. package/dist/packages/stack/src/plugins/comments/api/getters.mjs +280 -0
  16. package/dist/packages/stack/src/plugins/comments/api/mutations.cjs +118 -0
  17. package/dist/packages/stack/src/plugins/comments/api/mutations.mjs +112 -0
  18. package/dist/packages/stack/src/plugins/comments/api/plugin.cjs +335 -0
  19. package/dist/packages/stack/src/plugins/comments/api/plugin.mjs +333 -0
  20. package/dist/packages/stack/src/plugins/comments/api/query-key-defs.cjs +60 -0
  21. package/dist/packages/stack/src/plugins/comments/api/query-key-defs.mjs +55 -0
  22. package/dist/packages/stack/src/plugins/comments/api/serializers.cjs +23 -0
  23. package/dist/packages/stack/src/plugins/comments/api/serializers.mjs +21 -0
  24. package/dist/packages/stack/src/plugins/comments/client/components/comment-count.cjs +46 -0
  25. package/dist/packages/stack/src/plugins/comments/client/components/comment-count.mjs +44 -0
  26. package/dist/packages/stack/src/plugins/comments/client/components/comment-form.cjs +86 -0
  27. package/dist/packages/stack/src/plugins/comments/client/components/comment-form.mjs +84 -0
  28. package/dist/packages/stack/src/plugins/comments/client/components/comment-thread.cjs +540 -0
  29. package/dist/packages/stack/src/plugins/comments/client/components/comment-thread.mjs +538 -0
  30. package/dist/packages/stack/src/plugins/comments/client/components/pages/moderation-page.cjs +64 -0
  31. package/dist/packages/stack/src/plugins/comments/client/components/pages/moderation-page.internal.cjs +426 -0
  32. package/dist/packages/stack/src/plugins/comments/client/components/pages/moderation-page.internal.mjs +424 -0
  33. package/dist/packages/stack/src/plugins/comments/client/components/pages/moderation-page.mjs +62 -0
  34. package/dist/packages/stack/src/plugins/comments/client/components/pages/my-comments-page.cjs +66 -0
  35. package/dist/packages/stack/src/plugins/comments/client/components/pages/my-comments-page.internal.cjs +256 -0
  36. package/dist/packages/stack/src/plugins/comments/client/components/pages/my-comments-page.internal.mjs +254 -0
  37. package/dist/packages/stack/src/plugins/comments/client/components/pages/my-comments-page.mjs +64 -0
  38. package/dist/packages/stack/src/plugins/comments/client/components/pages/resource-comments-page.cjs +86 -0
  39. package/dist/packages/stack/src/plugins/comments/client/components/pages/resource-comments-page.internal.cjs +191 -0
  40. package/dist/packages/stack/src/plugins/comments/client/components/pages/resource-comments-page.internal.mjs +189 -0
  41. package/dist/packages/stack/src/plugins/comments/client/components/pages/resource-comments-page.mjs +84 -0
  42. package/dist/packages/stack/src/plugins/comments/client/components/shared/page-wrapper.cjs +27 -0
  43. package/dist/packages/stack/src/plugins/comments/client/components/shared/page-wrapper.mjs +25 -0
  44. package/dist/packages/stack/src/plugins/comments/client/components/shared/pagination.cjs +37 -0
  45. package/dist/packages/stack/src/plugins/comments/client/components/shared/pagination.mjs +35 -0
  46. package/dist/packages/stack/src/plugins/comments/client/hooks/use-comments.cjs +476 -0
  47. package/dist/packages/stack/src/plugins/comments/client/hooks/use-comments.mjs +464 -0
  48. package/dist/packages/stack/src/plugins/comments/client/localization/comments-moderation.cjs +67 -0
  49. package/dist/packages/stack/src/plugins/comments/client/localization/comments-moderation.mjs +65 -0
  50. package/dist/packages/stack/src/plugins/comments/client/localization/comments-my.cjs +27 -0
  51. package/dist/packages/stack/src/plugins/comments/client/localization/comments-my.mjs +25 -0
  52. package/dist/packages/stack/src/plugins/comments/client/localization/comments-thread.cjs +30 -0
  53. package/dist/packages/stack/src/plugins/comments/client/localization/comments-thread.mjs +28 -0
  54. package/dist/packages/stack/src/plugins/comments/client/localization/index.cjs +13 -0
  55. package/dist/packages/stack/src/plugins/comments/client/localization/index.mjs +11 -0
  56. package/dist/packages/stack/src/plugins/comments/client/plugin.cjs +116 -0
  57. package/dist/packages/stack/src/plugins/comments/client/plugin.mjs +114 -0
  58. package/dist/packages/stack/src/plugins/comments/client/utils.cjs +41 -0
  59. package/dist/packages/stack/src/plugins/comments/client/utils.mjs +37 -0
  60. package/dist/packages/stack/src/plugins/comments/db.cjs +75 -0
  61. package/dist/packages/stack/src/plugins/comments/db.mjs +73 -0
  62. package/dist/packages/stack/src/plugins/comments/schemas.cjs +45 -0
  63. package/dist/packages/stack/src/plugins/comments/schemas.mjs +38 -0
  64. package/dist/packages/stack/src/plugins/kanban/client/components/forms/task-form.cjs +0 -1
  65. package/dist/packages/stack/src/plugins/kanban/client/components/forms/task-form.mjs +0 -1
  66. package/dist/packages/stack/src/plugins/kanban/client/components/pages/board-page.internal.cjs +39 -22
  67. package/dist/packages/stack/src/plugins/kanban/client/components/pages/board-page.internal.mjs +40 -23
  68. package/dist/packages/ui/src/components/avatar.mjs +1 -1
  69. package/dist/packages/ui/src/components/pagination-controls.cjs +64 -0
  70. package/dist/packages/ui/src/components/pagination-controls.mjs +62 -0
  71. package/dist/packages/ui/src/components/when-visible.cjs +39 -0
  72. package/dist/packages/ui/src/components/when-visible.mjs +37 -0
  73. package/dist/plugins/blog/client/hooks/index.d.cts +1 -1
  74. package/dist/plugins/blog/client/hooks/index.d.mts +1 -1
  75. package/dist/plugins/blog/client/hooks/index.d.ts +1 -1
  76. package/dist/plugins/blog/client/index.d.cts +24 -2
  77. package/dist/plugins/blog/client/index.d.mts +24 -2
  78. package/dist/plugins/blog/client/index.d.ts +24 -2
  79. package/dist/plugins/comments/api/index.cjs +21 -0
  80. package/dist/plugins/comments/api/index.d.cts +126 -0
  81. package/dist/plugins/comments/api/index.d.mts +126 -0
  82. package/dist/plugins/comments/api/index.d.ts +126 -0
  83. package/dist/plugins/comments/api/index.mjs +5 -0
  84. package/dist/plugins/comments/client/components/index.cjs +15 -0
  85. package/dist/plugins/comments/client/components/index.d.cts +125 -0
  86. package/dist/plugins/comments/client/components/index.d.mts +125 -0
  87. package/dist/plugins/comments/client/components/index.d.ts +125 -0
  88. package/dist/plugins/comments/client/components/index.mjs +5 -0
  89. package/dist/plugins/comments/client/hooks/index.cjs +17 -0
  90. package/dist/plugins/comments/client/hooks/index.d.cts +200 -0
  91. package/dist/plugins/comments/client/hooks/index.d.mts +200 -0
  92. package/dist/plugins/comments/client/hooks/index.d.ts +200 -0
  93. package/dist/plugins/comments/client/hooks/index.mjs +1 -0
  94. package/dist/plugins/comments/client/index.cjs +9 -0
  95. package/dist/plugins/comments/client/index.d.cts +262 -0
  96. package/dist/plugins/comments/client/index.d.mts +262 -0
  97. package/dist/plugins/comments/client/index.d.ts +262 -0
  98. package/dist/plugins/comments/client/index.mjs +2 -0
  99. package/dist/plugins/comments/client.css +2 -0
  100. package/dist/plugins/comments/query-keys.cjs +113 -0
  101. package/dist/plugins/comments/query-keys.d.cts +71 -0
  102. package/dist/plugins/comments/query-keys.d.mts +71 -0
  103. package/dist/plugins/comments/query-keys.d.ts +71 -0
  104. package/dist/plugins/comments/query-keys.mjs +111 -0
  105. package/dist/plugins/comments/style.css +15 -0
  106. package/dist/plugins/kanban/api/index.d.cts +1 -1
  107. package/dist/plugins/kanban/api/index.d.mts +1 -1
  108. package/dist/plugins/kanban/api/index.d.ts +1 -1
  109. package/dist/plugins/kanban/client/hooks/index.d.cts +1 -1
  110. package/dist/plugins/kanban/client/hooks/index.d.mts +1 -1
  111. package/dist/plugins/kanban/client/hooks/index.d.ts +1 -1
  112. package/dist/plugins/kanban/client/index.d.cts +1 -1
  113. package/dist/plugins/kanban/client/index.d.mts +1 -1
  114. package/dist/plugins/kanban/client/index.d.ts +1 -1
  115. package/dist/plugins/kanban/query-keys.d.cts +1 -1
  116. package/dist/plugins/kanban/query-keys.d.mts +1 -1
  117. package/dist/plugins/kanban/query-keys.d.ts +1 -1
  118. package/dist/shared/{stack.FeaWkglm.d.ts → stack.BxFl46lB.d.cts} +24 -1
  119. package/dist/shared/stack.C-b3Sn8j.d.cts +142 -0
  120. package/dist/shared/stack.C-b3Sn8j.d.mts +142 -0
  121. package/dist/shared/stack.C-b3Sn8j.d.ts +142 -0
  122. package/dist/shared/stack.CJE9sAjV.d.ts +335 -0
  123. package/dist/shared/stack.CmHRdhl8.d.cts +335 -0
  124. package/dist/shared/{stack.CNLHlv7r.d.mts → stack.DOZ1EXjM.d.mts} +6 -12
  125. package/dist/shared/{stack.FeaWkglm.d.mts → stack.DRpeDS6X.d.ts} +24 -1
  126. package/dist/shared/{stack.CQAZwXhV.d.cts → stack.DX-tQ93o.d.cts} +6 -12
  127. package/dist/shared/stack.Dcz6636A.d.mts +335 -0
  128. package/dist/shared/{stack.FeaWkglm.d.cts → stack.Jb0kQDJC.d.mts} +24 -1
  129. package/dist/shared/stack.Ldfkr5b2.d.cts +112 -0
  130. package/dist/shared/stack.Ldfkr5b2.d.mts +112 -0
  131. package/dist/shared/stack.Ldfkr5b2.d.ts +112 -0
  132. package/dist/shared/{stack.D3BsrpAz.d.ts → stack.VF6FhyZw.d.ts} +6 -12
  133. package/package.json +69 -4
  134. package/src/plugins/blog/client/components/loading/post-navigation-skeleton.tsx +10 -0
  135. package/src/plugins/blog/client/components/loading/recent-posts-carousel-skeleton.tsx +18 -0
  136. package/src/plugins/blog/client/components/pages/post-page.internal.tsx +23 -8
  137. package/src/plugins/blog/client/components/shared/post-navigation.tsx +0 -5
  138. package/src/plugins/blog/client/components/shared/recent-posts-carousel.tsx +1 -5
  139. package/src/plugins/blog/client/hooks/blog-hooks.tsx +8 -33
  140. package/src/plugins/blog/client/overrides.ts +26 -1
  141. package/src/plugins/cms/client/components/shared/pagination.tsx +14 -42
  142. package/src/plugins/comments/api/getters.ts +444 -0
  143. package/src/plugins/comments/api/index.ts +21 -0
  144. package/src/plugins/comments/api/mutations.ts +206 -0
  145. package/src/plugins/comments/api/plugin.ts +628 -0
  146. package/src/plugins/comments/api/query-key-defs.ts +143 -0
  147. package/src/plugins/comments/api/serializers.ts +37 -0
  148. package/src/plugins/comments/client/components/comment-count.tsx +66 -0
  149. package/src/plugins/comments/client/components/comment-form.tsx +112 -0
  150. package/src/plugins/comments/client/components/comment-thread.tsx +799 -0
  151. package/src/plugins/comments/client/components/index.tsx +11 -0
  152. package/src/plugins/comments/client/components/pages/moderation-page.internal.tsx +550 -0
  153. package/src/plugins/comments/client/components/pages/moderation-page.tsx +70 -0
  154. package/src/plugins/comments/client/components/pages/my-comments-page.internal.tsx +367 -0
  155. package/src/plugins/comments/client/components/pages/my-comments-page.tsx +72 -0
  156. package/src/plugins/comments/client/components/pages/resource-comments-page.internal.tsx +225 -0
  157. package/src/plugins/comments/client/components/pages/resource-comments-page.tsx +97 -0
  158. package/src/plugins/comments/client/components/shared/page-wrapper.tsx +32 -0
  159. package/src/plugins/comments/client/components/shared/pagination.tsx +44 -0
  160. package/src/plugins/comments/client/hooks/index.tsx +13 -0
  161. package/src/plugins/comments/client/hooks/use-comments.tsx +717 -0
  162. package/src/plugins/comments/client/index.ts +14 -0
  163. package/src/plugins/comments/client/localization/comments-moderation.ts +75 -0
  164. package/src/plugins/comments/client/localization/comments-my.ts +32 -0
  165. package/src/plugins/comments/client/localization/comments-thread.ts +32 -0
  166. package/src/plugins/comments/client/localization/index.ts +11 -0
  167. package/src/plugins/comments/client/overrides.ts +164 -0
  168. package/src/plugins/comments/client/plugin.tsx +195 -0
  169. package/src/plugins/comments/client/utils.ts +67 -0
  170. package/src/plugins/comments/client.css +2 -0
  171. package/src/plugins/comments/db.ts +77 -0
  172. package/src/plugins/comments/query-keys.ts +189 -0
  173. package/src/plugins/comments/schemas.ts +72 -0
  174. package/src/plugins/comments/style.css +15 -0
  175. package/src/plugins/comments/types.ts +73 -0
  176. package/src/plugins/kanban/client/components/forms/task-form.tsx +0 -1
  177. package/src/plugins/kanban/client/components/pages/board-page.internal.tsx +46 -27
  178. package/src/plugins/kanban/client/overrides.ts +27 -1
  179. package/dist/shared/{stack.Rtcvl8sS.d.cts → stack.BOokfhZD.d.cts} +3 -3
  180. package/dist/shared/{stack.D4Cea8II.d.ts → stack.BvCR4-9H.d.ts} +3 -3
  181. package/dist/shared/{stack.HE_IvqV5.d.mts → stack.CWxAl9K3.d.mts} +3 -3
@@ -0,0 +1,444 @@
1
+ import type { DBAdapter as Adapter } from "@btst/db";
2
+ import type {
3
+ Comment,
4
+ CommentLike,
5
+ CommentListResult,
6
+ SerializedComment,
7
+ } from "../types";
8
+ import type { z } from "zod";
9
+ import type {
10
+ CommentListParamsSchema,
11
+ CommentCountQuerySchema,
12
+ } from "../schemas";
13
+
14
+ /**
15
+ * Resolve display info for a batch of authorIds using the consumer-supplied resolveUser hook.
16
+ * Deduplicates lookups — each unique authorId is resolved only once per call.
17
+ *
18
+ * @remarks **Security:** No authorization hooks are called. The caller is responsible for
19
+ * any access-control checks before invoking this function.
20
+ */
21
+ async function resolveAuthors(
22
+ authorIds: string[],
23
+ resolveUser?: (
24
+ authorId: string,
25
+ ) => Promise<{ name: string; avatarUrl?: string } | null>,
26
+ ): Promise<Map<string, { name: string; avatarUrl: string | null }>> {
27
+ const unique = [...new Set(authorIds)];
28
+ const map = new Map<string, { name: string; avatarUrl: string | null }>();
29
+
30
+ if (!resolveUser || unique.length === 0) {
31
+ for (const id of unique) {
32
+ map.set(id, { name: "[deleted]", avatarUrl: null });
33
+ }
34
+ return map;
35
+ }
36
+
37
+ await Promise.all(
38
+ unique.map(async (id) => {
39
+ try {
40
+ const result = await resolveUser(id);
41
+ map.set(id, {
42
+ name: result?.name ?? "[deleted]",
43
+ avatarUrl: result?.avatarUrl ?? null,
44
+ });
45
+ } catch {
46
+ map.set(id, { name: "[deleted]", avatarUrl: null });
47
+ }
48
+ }),
49
+ );
50
+
51
+ return map;
52
+ }
53
+
54
+ /**
55
+ * Serialize a raw Comment from the DB into a SerializedComment for the API response.
56
+ * Enriches with resolved author info and like status.
57
+ */
58
+ function enrichComment(
59
+ comment: Comment,
60
+ authorMap: Map<string, { name: string; avatarUrl: string | null }>,
61
+ likedCommentIds: Set<string>,
62
+ replyCount = 0,
63
+ ): SerializedComment {
64
+ const author = authorMap.get(comment.authorId) ?? {
65
+ name: "[deleted]",
66
+ avatarUrl: null,
67
+ };
68
+ return {
69
+ id: comment.id,
70
+ resourceId: comment.resourceId,
71
+ resourceType: comment.resourceType,
72
+ parentId: comment.parentId ?? null,
73
+ authorId: comment.authorId,
74
+ resolvedAuthorName: author.name,
75
+ resolvedAvatarUrl: author.avatarUrl,
76
+ body: comment.body,
77
+ status: comment.status,
78
+ likes: comment.likes,
79
+ isLikedByCurrentUser: likedCommentIds.has(comment.id),
80
+ editedAt: comment.editedAt?.toISOString() ?? null,
81
+ createdAt: comment.createdAt.toISOString(),
82
+ updatedAt: comment.updatedAt.toISOString(),
83
+ replyCount,
84
+ };
85
+ }
86
+
87
+ type WhereCondition = {
88
+ field: string;
89
+ value: string | number | boolean | Date | string[] | number[] | null;
90
+ operator: "eq" | "lt" | "gt";
91
+ };
92
+
93
+ /**
94
+ * Build the base WHERE conditions from common list params (excluding status).
95
+ */
96
+ function buildBaseConditions(
97
+ params: z.infer<typeof CommentListParamsSchema>,
98
+ ): WhereCondition[] {
99
+ const conditions: WhereCondition[] = [];
100
+
101
+ if (params.resourceId) {
102
+ conditions.push({
103
+ field: "resourceId",
104
+ value: params.resourceId,
105
+ operator: "eq",
106
+ });
107
+ }
108
+ if (params.resourceType) {
109
+ conditions.push({
110
+ field: "resourceType",
111
+ value: params.resourceType,
112
+ operator: "eq",
113
+ });
114
+ }
115
+ if (params.parentId !== undefined) {
116
+ const parentValue =
117
+ params.parentId === null || params.parentId === "null"
118
+ ? null
119
+ : params.parentId;
120
+ conditions.push({ field: "parentId", value: parentValue, operator: "eq" });
121
+ }
122
+ if (params.authorId) {
123
+ conditions.push({
124
+ field: "authorId",
125
+ value: params.authorId,
126
+ operator: "eq",
127
+ });
128
+ }
129
+
130
+ return conditions;
131
+ }
132
+
133
+ /**
134
+ * List comments for a resource, optionally filtered by status and parentId.
135
+ * Server-side resolves author display info and like status.
136
+ *
137
+ * When `status` is "approved" (default) and `currentUserId` is provided, the
138
+ * result also includes the current user's own pending comments so they remain
139
+ * visible after a page refresh without requiring admin access.
140
+ *
141
+ * Pure DB function — no hooks, no HTTP context. Safe for server-side use.
142
+ *
143
+ * @param adapter - The database adapter
144
+ * @param params - Filter/pagination parameters
145
+ * @param resolveUser - Optional consumer hook to resolve author display info
146
+ */
147
+ export async function listComments(
148
+ adapter: Adapter,
149
+ params: z.infer<typeof CommentListParamsSchema>,
150
+ resolveUser?: (
151
+ authorId: string,
152
+ ) => Promise<{ name: string; avatarUrl?: string } | null>,
153
+ ): Promise<CommentListResult> {
154
+ const limit = params.limit ?? 20;
155
+ const offset = params.offset ?? 0;
156
+ const sortDirection = params.sort ?? "asc";
157
+
158
+ // When authorId is provided and no explicit status filter is requested,
159
+ // return all statuses (the "my comments" mode — the caller owns the data).
160
+ // Otherwise default to "approved" to prevent leaking pending/spam to
161
+ // unauthenticated callers.
162
+ const omitStatusFilter = !!params.authorId && !params.status;
163
+ const statusFilter = omitStatusFilter ? null : (params.status ?? "approved");
164
+ const baseConditions = buildBaseConditions(params);
165
+
166
+ let comments: Comment[];
167
+ let total: number;
168
+
169
+ if (
170
+ !omitStatusFilter &&
171
+ statusFilter === "approved" &&
172
+ params.currentUserId
173
+ ) {
174
+ // Fetch the current user's own pending comments (always a small, bounded
175
+ // set — typically 0–5 per user per resource). Then paginate approved
176
+ // comments entirely at the DB level by computing each pending comment's
177
+ // exact position in the merged sorted list.
178
+ //
179
+ // Algorithm:
180
+ // For each pending p_i (sorted, 0-indexed):
181
+ // mergedPosition[i] = countApprovedBefore(p_i) + i
182
+ // where countApprovedBefore uses a `lt`/`gt` DB count on createdAt.
183
+ // This lets us derive the exact approvedOffset and approvedLimit for
184
+ // the requested page without loading the full approved set.
185
+ const [ownPendingAll, approvedCount] = await Promise.all([
186
+ adapter.findMany<Comment>({
187
+ model: "comment",
188
+ where: [
189
+ ...baseConditions,
190
+ { field: "status", value: "pending", operator: "eq" },
191
+ { field: "authorId", value: params.currentUserId, operator: "eq" },
192
+ ],
193
+ sortBy: { field: "createdAt", direction: sortDirection },
194
+ }),
195
+ adapter.count({
196
+ model: "comment",
197
+ where: [
198
+ ...baseConditions,
199
+ { field: "status", value: "approved", operator: "eq" },
200
+ ],
201
+ }),
202
+ ]);
203
+
204
+ total = approvedCount + ownPendingAll.length;
205
+
206
+ if (ownPendingAll.length === 0) {
207
+ // Fast path: no pending — paginate approved directly.
208
+ comments = await adapter.findMany<Comment>({
209
+ model: "comment",
210
+ limit,
211
+ offset,
212
+ where: [
213
+ ...baseConditions,
214
+ { field: "status", value: "approved", operator: "eq" },
215
+ ],
216
+ sortBy: { field: "createdAt", direction: sortDirection },
217
+ });
218
+ } else {
219
+ // For each pending comment, count how many approved records precede
220
+ // it in the merged sort order. The adapter supports `lt`/`gt` on
221
+ // date fields, so this is a single count query per pending comment
222
+ // (N_pending is tiny, so O(N_pending) queries is acceptable).
223
+ const dateOp = sortDirection === "asc" ? "lt" : "gt";
224
+ const pendingWithPositions = await Promise.all(
225
+ ownPendingAll.map(async (p, i) => {
226
+ const approvedBefore = await adapter.count({
227
+ model: "comment",
228
+ where: [
229
+ ...baseConditions,
230
+ { field: "status", value: "approved", operator: "eq" },
231
+ {
232
+ field: "createdAt",
233
+ value: p.createdAt,
234
+ operator: dateOp,
235
+ },
236
+ ],
237
+ });
238
+ return { comment: p, mergedPosition: approvedBefore + i };
239
+ }),
240
+ );
241
+
242
+ // Partition pending into those that fall within [offset, offset+limit).
243
+ const pendingInWindow = pendingWithPositions.filter(
244
+ ({ mergedPosition }) =>
245
+ mergedPosition >= offset && mergedPosition < offset + limit,
246
+ );
247
+ const countPendingBeforeWindow = pendingWithPositions.filter(
248
+ ({ mergedPosition }) => mergedPosition < offset,
249
+ ).length;
250
+
251
+ const approvedOffset = Math.max(0, offset - countPendingBeforeWindow);
252
+ const approvedLimit = limit - pendingInWindow.length;
253
+
254
+ const approvedPage =
255
+ approvedLimit > 0
256
+ ? await adapter.findMany<Comment>({
257
+ model: "comment",
258
+ limit: approvedLimit,
259
+ offset: approvedOffset,
260
+ where: [
261
+ ...baseConditions,
262
+ { field: "status", value: "approved", operator: "eq" },
263
+ ],
264
+ sortBy: { field: "createdAt", direction: sortDirection },
265
+ })
266
+ : [];
267
+
268
+ // Merge the approved page with the pending slice and re-sort.
269
+ const merged = [
270
+ ...approvedPage,
271
+ ...pendingInWindow.map(({ comment }) => comment),
272
+ ];
273
+ merged.sort((a, b) => {
274
+ const diff = a.createdAt.getTime() - b.createdAt.getTime();
275
+ return sortDirection === "desc" ? -diff : diff;
276
+ });
277
+ comments = merged;
278
+ }
279
+ } else {
280
+ const where: WhereCondition[] = [...baseConditions];
281
+ if (statusFilter !== null) {
282
+ where.push({
283
+ field: "status",
284
+ value: statusFilter,
285
+ operator: "eq",
286
+ });
287
+ }
288
+
289
+ const [found, count] = await Promise.all([
290
+ adapter.findMany<Comment>({
291
+ model: "comment",
292
+ limit,
293
+ offset,
294
+ where,
295
+ sortBy: { field: "createdAt", direction: sortDirection },
296
+ }),
297
+ adapter.count({ model: "comment", where }),
298
+ ]);
299
+ comments = found;
300
+ total = count;
301
+ }
302
+
303
+ // Resolve author display info server-side
304
+ const authorIds = comments.map((c) => c.authorId);
305
+ const authorMap = await resolveAuthors(authorIds, resolveUser);
306
+
307
+ // Resolve like status for currentUserId (if provided)
308
+ const likedCommentIds = new Set<string>();
309
+ if (params.currentUserId && comments.length > 0) {
310
+ const commentIds = comments.map((c) => c.id);
311
+ // Fetch all likes by the currentUser for these comments
312
+ const likes = await Promise.all(
313
+ commentIds.map((commentId) =>
314
+ adapter.findOne<CommentLike>({
315
+ model: "commentLike",
316
+ where: [
317
+ { field: "commentId", value: commentId, operator: "eq" },
318
+ {
319
+ field: "authorId",
320
+ value: params.currentUserId!,
321
+ operator: "eq",
322
+ },
323
+ ],
324
+ }),
325
+ ),
326
+ );
327
+ likes.forEach((like, i) => {
328
+ if (like) likedCommentIds.add(commentIds[i]!);
329
+ });
330
+ }
331
+
332
+ // Batch-count replies for top-level comments so the client can show the
333
+ // expand button without firing a separate request per comment.
334
+ // When currentUserId is provided, also count the user's own pending replies
335
+ // so the button appears immediately after a page refresh.
336
+ const replyCounts = new Map<string, number>();
337
+ const isTopLevelQuery =
338
+ params.parentId === null || params.parentId === "null";
339
+ if (isTopLevelQuery && comments.length > 0) {
340
+ await Promise.all(
341
+ comments.map(async (c) => {
342
+ const approvedCount = await adapter.count({
343
+ model: "comment",
344
+ where: [
345
+ { field: "parentId", value: c.id, operator: "eq" },
346
+ { field: "status", value: "approved", operator: "eq" },
347
+ ],
348
+ });
349
+
350
+ let ownPendingCount = 0;
351
+ if (params.currentUserId) {
352
+ ownPendingCount = await adapter.count({
353
+ model: "comment",
354
+ where: [
355
+ { field: "parentId", value: c.id, operator: "eq" },
356
+ { field: "status", value: "pending", operator: "eq" },
357
+ {
358
+ field: "authorId",
359
+ value: params.currentUserId,
360
+ operator: "eq",
361
+ },
362
+ ],
363
+ });
364
+ }
365
+
366
+ replyCounts.set(c.id, approvedCount + ownPendingCount);
367
+ }),
368
+ );
369
+ }
370
+
371
+ const items = comments.map((c) =>
372
+ enrichComment(c, authorMap, likedCommentIds, replyCounts.get(c.id) ?? 0),
373
+ );
374
+
375
+ return { items, total, limit, offset };
376
+ }
377
+
378
+ /**
379
+ * Get a single comment by ID, enriched with author info.
380
+ * Returns null if not found.
381
+ *
382
+ * Pure DB function — no hooks, no HTTP context.
383
+ */
384
+ export async function getCommentById(
385
+ adapter: Adapter,
386
+ id: string,
387
+ resolveUser?: (
388
+ authorId: string,
389
+ ) => Promise<{ name: string; avatarUrl?: string } | null>,
390
+ currentUserId?: string,
391
+ ): Promise<SerializedComment | null> {
392
+ const comment = await adapter.findOne<Comment>({
393
+ model: "comment",
394
+ where: [{ field: "id", value: id, operator: "eq" }],
395
+ });
396
+
397
+ if (!comment) return null;
398
+
399
+ const authorMap = await resolveAuthors([comment.authorId], resolveUser);
400
+
401
+ const likedCommentIds = new Set<string>();
402
+ if (currentUserId) {
403
+ const like = await adapter.findOne<CommentLike>({
404
+ model: "commentLike",
405
+ where: [
406
+ { field: "commentId", value: id, operator: "eq" },
407
+ { field: "authorId", value: currentUserId, operator: "eq" },
408
+ ],
409
+ });
410
+ if (like) likedCommentIds.add(id);
411
+ }
412
+
413
+ return enrichComment(comment, authorMap, likedCommentIds);
414
+ }
415
+
416
+ /**
417
+ * Count comments for a resource, optionally filtered by status.
418
+ *
419
+ * Pure DB function — no hooks, no HTTP context.
420
+ */
421
+ export async function getCommentCount(
422
+ adapter: Adapter,
423
+ params: z.infer<typeof CommentCountQuerySchema>,
424
+ ): Promise<number> {
425
+ const whereConditions: Array<{
426
+ field: string;
427
+ value: string | number | boolean | Date | string[] | number[] | null;
428
+ operator: "eq";
429
+ }> = [
430
+ { field: "resourceId", value: params.resourceId, operator: "eq" },
431
+ { field: "resourceType", value: params.resourceType, operator: "eq" },
432
+ ];
433
+
434
+ // Default to "approved" when no status is provided so that omitting the
435
+ // parameter never leaks pending/spam counts to unauthenticated callers.
436
+ const statusFilter = params.status ?? "approved";
437
+ whereConditions.push({
438
+ field: "status",
439
+ value: statusFilter,
440
+ operator: "eq",
441
+ });
442
+
443
+ return adapter.count({ model: "comment", where: whereConditions });
444
+ }
@@ -0,0 +1,21 @@
1
+ export {
2
+ commentsBackendPlugin,
3
+ type CommentsApiRouter,
4
+ type CommentsApiContext,
5
+ type CommentsBackendOptions,
6
+ } from "./plugin";
7
+ export {
8
+ listComments,
9
+ getCommentById,
10
+ getCommentCount,
11
+ } from "./getters";
12
+ export {
13
+ createComment,
14
+ updateComment,
15
+ updateCommentStatus,
16
+ deleteComment,
17
+ toggleCommentLike,
18
+ type CreateCommentInput,
19
+ } from "./mutations";
20
+ export { serializeComment } from "./serializers";
21
+ export { COMMENTS_QUERY_KEYS } from "./query-key-defs";
@@ -0,0 +1,206 @@
1
+ import type { DBAdapter as Adapter } from "@btst/db";
2
+ import type { Comment, CommentLike } from "../types";
3
+
4
+ /**
5
+ * Input for creating a new comment.
6
+ */
7
+ export interface CreateCommentInput {
8
+ resourceId: string;
9
+ resourceType: string;
10
+ parentId?: string | null;
11
+ authorId: string;
12
+ body: string;
13
+ status?: "pending" | "approved" | "spam";
14
+ }
15
+
16
+ /**
17
+ * Create a new comment.
18
+ *
19
+ * @remarks **Security:** No authorization hooks are called. The caller is
20
+ * responsible for any access-control checks (e.g., onBeforePost) before
21
+ * invoking this function.
22
+ */
23
+ export async function createComment(
24
+ adapter: Adapter,
25
+ input: CreateCommentInput,
26
+ ): Promise<Comment> {
27
+ return adapter.create<Comment>({
28
+ model: "comment",
29
+ data: {
30
+ resourceId: input.resourceId,
31
+ resourceType: input.resourceType,
32
+ parentId: input.parentId ?? null,
33
+ authorId: input.authorId,
34
+ body: input.body,
35
+ status: input.status ?? "pending",
36
+ likes: 0,
37
+ createdAt: new Date(),
38
+ updatedAt: new Date(),
39
+ },
40
+ });
41
+ }
42
+
43
+ /**
44
+ * Update the body of an existing comment and set editedAt.
45
+ *
46
+ * @remarks **Security:** No authorization hooks are called. The caller is
47
+ * responsible for ensuring the requesting user owns the comment (onBeforeEdit).
48
+ */
49
+ export async function updateComment(
50
+ adapter: Adapter,
51
+ id: string,
52
+ body: string,
53
+ ): Promise<Comment | null> {
54
+ const existing = await adapter.findOne<Comment>({
55
+ model: "comment",
56
+ where: [{ field: "id", value: id, operator: "eq" }],
57
+ });
58
+ if (!existing) return null;
59
+
60
+ return adapter.update<Comment>({
61
+ model: "comment",
62
+ where: [{ field: "id", value: id, operator: "eq" }],
63
+ update: {
64
+ body,
65
+ editedAt: new Date(),
66
+ updatedAt: new Date(),
67
+ },
68
+ });
69
+ }
70
+
71
+ /**
72
+ * Update the status of a comment (approve, reject, spam).
73
+ *
74
+ * @remarks **Security:** No authorization hooks are called. Callers should
75
+ * ensure the requesting user has moderation privileges.
76
+ */
77
+ export async function updateCommentStatus(
78
+ adapter: Adapter,
79
+ id: string,
80
+ status: "pending" | "approved" | "spam",
81
+ ): Promise<Comment | null> {
82
+ const existing = await adapter.findOne<Comment>({
83
+ model: "comment",
84
+ where: [{ field: "id", value: id, operator: "eq" }],
85
+ });
86
+ if (!existing) return null;
87
+
88
+ return adapter.update<Comment>({
89
+ model: "comment",
90
+ where: [{ field: "id", value: id, operator: "eq" }],
91
+ update: { status, updatedAt: new Date() },
92
+ });
93
+ }
94
+
95
+ /**
96
+ * Delete a comment by ID, cascading to any child replies.
97
+ *
98
+ * Replies reference the parent via `parentId`. Because the schema declares no
99
+ * DB-level cascade on `comment.parentId`, orphaned replies must be removed here
100
+ * in the application layer. `commentLike` rows are covered by the FK cascade
101
+ * on `commentLike.commentId` (declared in `db.ts`).
102
+ *
103
+ * Comments are only one level deep (the UI prevents replying to replies), so a
104
+ * single-level cascade is sufficient — no recursive walk is needed.
105
+ *
106
+ * @remarks **Security:** No authorization hooks are called. Callers should
107
+ * ensure the requesting user has permission to delete this comment.
108
+ */
109
+ export async function deleteComment(
110
+ adapter: Adapter,
111
+ id: string,
112
+ ): Promise<boolean> {
113
+ const existing = await adapter.findOne<Comment>({
114
+ model: "comment",
115
+ where: [{ field: "id", value: id, operator: "eq" }],
116
+ });
117
+ if (!existing) return false;
118
+
119
+ await adapter.transaction(async (tx) => {
120
+ // Remove child replies first so they don't become orphans.
121
+ // Their commentLike rows are cleaned up by the FK cascade on commentLike.commentId.
122
+ await tx.delete({
123
+ model: "comment",
124
+ where: [{ field: "parentId", value: id, operator: "eq" }],
125
+ });
126
+
127
+ // Remove the comment itself (its commentLike rows cascade via FK).
128
+ await tx.delete({
129
+ model: "comment",
130
+ where: [{ field: "id", value: id, operator: "eq" }],
131
+ });
132
+ });
133
+ return true;
134
+ }
135
+
136
+ /**
137
+ * Toggle a like on a comment for a given authorId.
138
+ * - If the user has not liked the comment: creates a commentLike row and increments the likes counter.
139
+ * - If the user has already liked the comment: deletes the commentLike row and decrements the likes counter.
140
+ * Returns the updated likes count.
141
+ *
142
+ * All reads and writes are performed inside a single transaction to prevent
143
+ * concurrent requests from causing counter drift or duplicate like rows.
144
+ *
145
+ * @remarks **Security:** No authorization hooks are called. The caller is
146
+ * responsible for ensuring the requesting user is authenticated (authorId is valid).
147
+ */
148
+ export async function toggleCommentLike(
149
+ adapter: Adapter,
150
+ commentId: string,
151
+ authorId: string,
152
+ ): Promise<{ likes: number; isLiked: boolean }> {
153
+ return adapter.transaction(async (tx) => {
154
+ const comment = await tx.findOne<Comment>({
155
+ model: "comment",
156
+ where: [{ field: "id", value: commentId, operator: "eq" }],
157
+ });
158
+ if (!comment) {
159
+ throw new Error("Comment not found");
160
+ }
161
+
162
+ const existingLike = await tx.findOne<CommentLike>({
163
+ model: "commentLike",
164
+ where: [
165
+ { field: "commentId", value: commentId, operator: "eq" },
166
+ { field: "authorId", value: authorId, operator: "eq" },
167
+ ],
168
+ });
169
+
170
+ let newLikes: number;
171
+ let isLiked: boolean;
172
+
173
+ if (existingLike) {
174
+ // Unlike
175
+ await tx.delete({
176
+ model: "commentLike",
177
+ where: [
178
+ { field: "commentId", value: commentId, operator: "eq" },
179
+ { field: "authorId", value: authorId, operator: "eq" },
180
+ ],
181
+ });
182
+ newLikes = Math.max(0, comment.likes - 1);
183
+ isLiked = false;
184
+ } else {
185
+ // Like
186
+ await tx.create<CommentLike>({
187
+ model: "commentLike",
188
+ data: {
189
+ commentId,
190
+ authorId,
191
+ createdAt: new Date(),
192
+ },
193
+ });
194
+ newLikes = comment.likes + 1;
195
+ isLiked = true;
196
+ }
197
+
198
+ await tx.update<Comment>({
199
+ model: "comment",
200
+ where: [{ field: "id", value: commentId, operator: "eq" }],
201
+ update: { likes: newLikes, updatedAt: new Date() },
202
+ });
203
+
204
+ return { likes: newLikes, isLiked };
205
+ });
206
+ }