@btst/stack 2.7.0 → 2.8.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 (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 +67 -2
  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,628 @@
1
+ import type { DBAdapter as Adapter } from "@btst/db";
2
+ import { defineBackendPlugin, createEndpoint } from "@btst/stack/plugins/api";
3
+ import { z } from "zod";
4
+ import { commentsSchema as dbSchema } from "../db";
5
+ import type { Comment } from "../types";
6
+ import {
7
+ CommentListQuerySchema,
8
+ CommentListParamsSchema,
9
+ CommentCountQuerySchema,
10
+ createCommentSchema,
11
+ updateCommentSchema,
12
+ updateCommentStatusSchema,
13
+ } from "../schemas";
14
+ import { listComments, getCommentById, getCommentCount } from "./getters";
15
+ import {
16
+ createComment,
17
+ updateComment,
18
+ updateCommentStatus,
19
+ deleteComment,
20
+ toggleCommentLike,
21
+ } from "./mutations";
22
+ import { runHookWithShim } from "../../utils";
23
+
24
+ /**
25
+ * Context passed to comments API hooks
26
+ */
27
+ export interface CommentsApiContext {
28
+ body?: unknown;
29
+ params?: unknown;
30
+ query?: unknown;
31
+ request?: Request;
32
+ headers?: Headers;
33
+ [key: string]: unknown;
34
+ }
35
+
36
+ /** Shared hook and config fields that are always present regardless of allowPosting. */
37
+ interface CommentsBackendOptionsBase {
38
+ /**
39
+ * When true, new comments are automatically approved (status: "approved").
40
+ * Default: false — all comments start as "pending" until a moderator approves.
41
+ */
42
+ autoApprove?: boolean;
43
+
44
+ /**
45
+ * When false, the `PATCH /comments/:id` endpoint is not registered and
46
+ * comment bodies cannot be edited.
47
+ * Default: true.
48
+ */
49
+ allowEditing?: boolean;
50
+
51
+ /**
52
+ * Server-side user resolution hook. Called once per unique authorId when
53
+ * serving GET /comments. Return null for deleted/unknown users (shown as "[deleted]").
54
+ * Deduplicates lookups — each unique authorId is resolved only once per request.
55
+ */
56
+ resolveUser?: (
57
+ authorId: string,
58
+ ) => Promise<{ name: string; avatarUrl?: string } | null>;
59
+
60
+ /**
61
+ * Called before the comment list or count is returned. Throw to reject.
62
+ * When this hook is absent, any request with `status` other than "approved"
63
+ * is automatically rejected with 403 on both `GET /comments` and
64
+ * `GET /comments/count` — preventing anonymous callers from reading or
65
+ * probing the pending/spam moderation queues. Configure this hook to
66
+ * authorize admin callers (e.g. check session role).
67
+ */
68
+ onBeforeList?: (
69
+ query: z.infer<typeof CommentListQuerySchema>,
70
+ context: CommentsApiContext,
71
+ ) => Promise<void> | void;
72
+
73
+ /**
74
+ * Called after a comment is successfully created.
75
+ */
76
+ onAfterPost?: (
77
+ comment: Comment,
78
+ context: CommentsApiContext,
79
+ ) => Promise<void> | void;
80
+
81
+ /**
82
+ * Called before a comment body is edited. Throw an error to reject the edit.
83
+ * Use this to enforce that only the comment owner can edit (compare authorId to session).
84
+ */
85
+ onBeforeEdit?: (
86
+ commentId: string,
87
+ update: { body: string },
88
+ context: CommentsApiContext,
89
+ ) => Promise<void> | void;
90
+
91
+ /**
92
+ * Called after a comment is successfully edited.
93
+ */
94
+ onAfterEdit?: (
95
+ comment: Comment,
96
+ context: CommentsApiContext,
97
+ ) => Promise<void> | void;
98
+
99
+ /**
100
+ * Called before a like is toggled. Throw to reject.
101
+ *
102
+ * When this hook is **absent**, any like/unlike request is automatically
103
+ * rejected with 403 — preventing unauthenticated callers from toggling likes
104
+ * on behalf of arbitrary user IDs. Configure this hook to verify `authorId`
105
+ * matches the authenticated session.
106
+ */
107
+ onBeforeLike?: (
108
+ commentId: string,
109
+ authorId: string,
110
+ context: CommentsApiContext,
111
+ ) => Promise<void> | void;
112
+
113
+ /**
114
+ * Called before a comment's status is changed. Throw to reject.
115
+ *
116
+ * When this hook is **absent**, any status-change request is automatically
117
+ * rejected with 403 — preventing unauthenticated callers from moderating
118
+ * comments. Configure this hook to verify the caller has admin/moderator
119
+ * privileges.
120
+ */
121
+ onBeforeStatusChange?: (
122
+ commentId: string,
123
+ status: "pending" | "approved" | "spam",
124
+ context: CommentsApiContext,
125
+ ) => Promise<void> | void;
126
+
127
+ /**
128
+ * Called after a comment status is changed to "approved".
129
+ */
130
+ onAfterApprove?: (
131
+ comment: Comment,
132
+ context: CommentsApiContext,
133
+ ) => Promise<void> | void;
134
+
135
+ /**
136
+ * Called before a comment is deleted. Throw to reject.
137
+ *
138
+ * When this hook is **absent**, any delete request is automatically rejected
139
+ * with 403 — preventing unauthenticated callers from deleting comments.
140
+ * Configure this hook to enforce admin-only access.
141
+ */
142
+ onBeforeDelete?: (
143
+ commentId: string,
144
+ context: CommentsApiContext,
145
+ ) => Promise<void> | void;
146
+
147
+ /**
148
+ * Called after a comment is deleted.
149
+ */
150
+ onAfterDelete?: (
151
+ commentId: string,
152
+ context: CommentsApiContext,
153
+ ) => Promise<void> | void;
154
+
155
+ /**
156
+ * Called before the comment list is returned for an author-scoped query
157
+ * (i.e. when `authorId` is present in `GET /comments`). Throw to reject.
158
+ *
159
+ * When this hook is **absent**, any request that includes `authorId` is
160
+ * automatically rejected with 403 — preventing anonymous callers from
161
+ * reading or probing any user's comment history.
162
+ */
163
+ onBeforeListByAuthor?: (
164
+ authorId: string,
165
+ query: z.infer<typeof CommentListQuerySchema>,
166
+ context: CommentsApiContext,
167
+ ) => Promise<void> | void;
168
+ }
169
+
170
+ /**
171
+ * Configuration options for the comments backend plugin.
172
+ *
173
+ * TypeScript enforces the security-critical hooks based on `allowPosting`:
174
+ * - When `allowPosting` is absent or `true`, `onBeforePost` and
175
+ * `resolveCurrentUserId` are **required**.
176
+ * - When `allowPosting` is `false`, both become optional (the POST endpoint
177
+ * is not registered so neither hook is ever called).
178
+ */
179
+ export type CommentsBackendOptions = CommentsBackendOptionsBase &
180
+ (
181
+ | {
182
+ /**
183
+ * Posting is enabled (default). `onBeforePost` and `resolveCurrentUserId`
184
+ * are required to prevent anonymous authorship and impersonation.
185
+ */
186
+ allowPosting?: true;
187
+
188
+ /**
189
+ * Called before a comment is created. Must return `{ authorId: string }` —
190
+ * the server-resolved identity of the commenter.
191
+ *
192
+ * ⚠️ SECURITY REQUIRED: Derive `authorId` from the authenticated session
193
+ * (e.g. JWT / session cookie). Never trust any ID supplied by the client.
194
+ * Throw to reject the request (e.g. when the user is not authenticated).
195
+ *
196
+ * `authorId` is intentionally absent from the POST body schema. This hook
197
+ * is the only place it can be set.
198
+ */
199
+ onBeforePost: (
200
+ input: z.infer<typeof createCommentSchema>,
201
+ context: CommentsApiContext,
202
+ ) => Promise<{ authorId: string }> | { authorId: string };
203
+
204
+ /**
205
+ * Resolve the current authenticated user's ID from the request context
206
+ * (e.g. session cookie or JWT). Used to include the user's own pending
207
+ * comments alongside approved ones in `GET /comments` responses so they
208
+ * remain visible immediately after posting.
209
+ *
210
+ * Return `null` or `undefined` for unauthenticated requests.
211
+ *
212
+ * ```ts
213
+ * resolveCurrentUserId: async (ctx) => {
214
+ * const session = await getSession(ctx.headers)
215
+ * return session?.user?.id ?? null
216
+ * }
217
+ * ```
218
+ */
219
+ resolveCurrentUserId: (
220
+ context: CommentsApiContext,
221
+ ) => Promise<string | null | undefined> | string | null | undefined;
222
+ }
223
+ | {
224
+ /**
225
+ * When `false`, the `POST /comments` endpoint is not registered.
226
+ * No new comments or replies can be submitted — users can only read
227
+ * existing comments. `onBeforePost` and `resolveCurrentUserId` become
228
+ * optional because they are never called.
229
+ */
230
+ allowPosting: false;
231
+ onBeforePost?: (
232
+ input: z.infer<typeof createCommentSchema>,
233
+ context: CommentsApiContext,
234
+ ) => Promise<{ authorId: string }> | { authorId: string };
235
+ resolveCurrentUserId?: (
236
+ context: CommentsApiContext,
237
+ ) => Promise<string | null | undefined> | string | null | undefined;
238
+ }
239
+ );
240
+
241
+ export const commentsBackendPlugin = (options: CommentsBackendOptions) => {
242
+ const postingEnabled = options.allowPosting !== false;
243
+ const editingEnabled = options.allowEditing !== false;
244
+
245
+ // Narrow once so closures below see fully-typed (non-optional) hooks.
246
+ // TypeScript resolves onBeforePost / resolveCurrentUserId as required in
247
+ // the allowPosting?: true branch, so these will be Hook | undefined — but
248
+ // we only call them when postingEnabled is true.
249
+ const onBeforePost =
250
+ options.allowPosting !== false ? options.onBeforePost : undefined;
251
+ const resolveCurrentUserId =
252
+ options.allowPosting !== false ? options.resolveCurrentUserId : undefined;
253
+
254
+ return defineBackendPlugin({
255
+ name: "comments",
256
+ dbPlugin: dbSchema,
257
+
258
+ api: (adapter: Adapter) => ({
259
+ listComments: (params: z.infer<typeof CommentListParamsSchema>) =>
260
+ listComments(adapter, params, options?.resolveUser),
261
+ getCommentById: (id: string, currentUserId?: string) =>
262
+ getCommentById(adapter, id, options?.resolveUser, currentUserId),
263
+ getCommentCount: (params: z.infer<typeof CommentCountQuerySchema>) =>
264
+ getCommentCount(adapter, params),
265
+ }),
266
+
267
+ routes: (adapter: Adapter) => {
268
+ // GET /comments
269
+ const listCommentsEndpoint = createEndpoint(
270
+ "/comments",
271
+ {
272
+ method: "GET",
273
+ query: CommentListQuerySchema,
274
+ },
275
+ async (ctx) => {
276
+ const context: CommentsApiContext = {
277
+ query: ctx.query,
278
+ request: ctx.request,
279
+ headers: ctx.headers,
280
+ };
281
+
282
+ if (ctx.query.authorId) {
283
+ if (!options?.onBeforeListByAuthor) {
284
+ throw ctx.error(403, {
285
+ message:
286
+ "Forbidden: authorId filter requires onBeforeListByAuthor hook",
287
+ });
288
+ }
289
+ await runHookWithShim(
290
+ () =>
291
+ options.onBeforeListByAuthor!(
292
+ ctx.query.authorId!,
293
+ ctx.query,
294
+ context,
295
+ ),
296
+ ctx.error,
297
+ "Forbidden: Cannot list comments for this author",
298
+ );
299
+ }
300
+
301
+ if (ctx.query.status && ctx.query.status !== "approved") {
302
+ if (!options?.onBeforeList) {
303
+ throw ctx.error(403, {
304
+ message: "Forbidden: status filter requires authorization",
305
+ });
306
+ }
307
+ await runHookWithShim(
308
+ () => options.onBeforeList!(ctx.query, context),
309
+ ctx.error,
310
+ "Forbidden: Cannot list comments with this status filter",
311
+ );
312
+ } else if (options?.onBeforeList && !ctx.query.authorId) {
313
+ await runHookWithShim(
314
+ () => options.onBeforeList!(ctx.query, context),
315
+ ctx.error,
316
+ "Forbidden: Cannot list comments",
317
+ );
318
+ }
319
+
320
+ let resolvedCurrentUserId: string | undefined;
321
+ if (resolveCurrentUserId) {
322
+ try {
323
+ const result = await resolveCurrentUserId(context);
324
+ resolvedCurrentUserId = result ?? undefined;
325
+ } catch {
326
+ resolvedCurrentUserId = undefined;
327
+ }
328
+ }
329
+
330
+ return await listComments(
331
+ adapter,
332
+ { ...ctx.query, currentUserId: resolvedCurrentUserId },
333
+ options?.resolveUser,
334
+ );
335
+ },
336
+ );
337
+
338
+ // POST /comments
339
+ const createCommentEndpoint = createEndpoint(
340
+ "/comments",
341
+ {
342
+ method: "POST",
343
+ body: createCommentSchema,
344
+ },
345
+ async (ctx) => {
346
+ if (!postingEnabled) {
347
+ throw ctx.error(403, { message: "Posting comments is disabled" });
348
+ }
349
+
350
+ const context: CommentsApiContext = {
351
+ body: ctx.body,
352
+ headers: ctx.headers,
353
+ };
354
+
355
+ const { authorId } = await runHookWithShim(
356
+ () => onBeforePost!(ctx.body, context),
357
+ ctx.error,
358
+ "Unauthorized: Cannot post comment",
359
+ );
360
+
361
+ const status = options?.autoApprove ? "approved" : "pending";
362
+ const comment = await createComment(adapter, {
363
+ ...ctx.body,
364
+ authorId,
365
+ status,
366
+ });
367
+
368
+ if (options?.onAfterPost) {
369
+ await options.onAfterPost(comment, context);
370
+ }
371
+
372
+ const serialized = await getCommentById(
373
+ adapter,
374
+ comment.id,
375
+ options?.resolveUser,
376
+ );
377
+ if (!serialized) {
378
+ throw ctx.error(500, {
379
+ message: "Failed to retrieve created comment",
380
+ });
381
+ }
382
+ return serialized;
383
+ },
384
+ );
385
+
386
+ // PATCH /comments/:id (edit body)
387
+ const updateCommentEndpoint = createEndpoint(
388
+ "/comments/:id",
389
+ {
390
+ method: "PATCH",
391
+ body: updateCommentSchema,
392
+ },
393
+ async (ctx) => {
394
+ if (!editingEnabled) {
395
+ throw ctx.error(403, { message: "Editing comments is disabled" });
396
+ }
397
+
398
+ const { id } = ctx.params;
399
+ const context: CommentsApiContext = {
400
+ params: ctx.params,
401
+ body: ctx.body,
402
+ headers: ctx.headers,
403
+ };
404
+
405
+ if (!options?.onBeforeEdit) {
406
+ throw ctx.error(403, {
407
+ message:
408
+ "Forbidden: editing comments requires the onBeforeEdit hook",
409
+ });
410
+ }
411
+ await runHookWithShim(
412
+ () => options.onBeforeEdit!(id, { body: ctx.body.body }, context),
413
+ ctx.error,
414
+ "Unauthorized: Cannot edit comment",
415
+ );
416
+
417
+ const updated = await updateComment(adapter, id, ctx.body.body);
418
+ if (!updated) {
419
+ throw ctx.error(404, { message: "Comment not found" });
420
+ }
421
+
422
+ if (options?.onAfterEdit) {
423
+ await options.onAfterEdit(updated, context);
424
+ }
425
+
426
+ const serialized = await getCommentById(
427
+ adapter,
428
+ updated.id,
429
+ options?.resolveUser,
430
+ );
431
+ if (!serialized) {
432
+ throw ctx.error(500, {
433
+ message: "Failed to retrieve updated comment",
434
+ });
435
+ }
436
+ return serialized;
437
+ },
438
+ );
439
+
440
+ // GET /comments/count
441
+ const getCommentCountEndpoint = createEndpoint(
442
+ "/comments/count",
443
+ {
444
+ method: "GET",
445
+ query: CommentCountQuerySchema,
446
+ },
447
+ async (ctx) => {
448
+ const context: CommentsApiContext = {
449
+ query: ctx.query,
450
+ headers: ctx.headers,
451
+ };
452
+
453
+ if (ctx.query.status && ctx.query.status !== "approved") {
454
+ if (!options?.onBeforeList) {
455
+ throw ctx.error(403, {
456
+ message: "Forbidden: status filter requires authorization",
457
+ });
458
+ }
459
+ await runHookWithShim(
460
+ () =>
461
+ options.onBeforeList!(
462
+ { ...ctx.query, status: ctx.query.status },
463
+ context,
464
+ ),
465
+ ctx.error,
466
+ "Forbidden: Cannot count comments with this status filter",
467
+ );
468
+ } else if (options?.onBeforeList) {
469
+ await runHookWithShim(
470
+ () =>
471
+ options.onBeforeList!(
472
+ { ...ctx.query, status: ctx.query.status },
473
+ context,
474
+ ),
475
+ ctx.error,
476
+ "Forbidden: Cannot count comments",
477
+ );
478
+ }
479
+
480
+ const count = await getCommentCount(adapter, ctx.query);
481
+ return { count };
482
+ },
483
+ );
484
+
485
+ // POST /comments/:id/like (toggle)
486
+ const toggleLikeEndpoint = createEndpoint(
487
+ "/comments/:id/like",
488
+ {
489
+ method: "POST",
490
+ body: z.object({ authorId: z.string().min(1) }),
491
+ },
492
+ async (ctx) => {
493
+ const { id } = ctx.params;
494
+ const context: CommentsApiContext = {
495
+ params: ctx.params,
496
+ body: ctx.body,
497
+ headers: ctx.headers,
498
+ };
499
+
500
+ if (!options?.onBeforeLike) {
501
+ throw ctx.error(403, {
502
+ message:
503
+ "Forbidden: toggling likes requires the onBeforeLike hook",
504
+ });
505
+ }
506
+ await runHookWithShim(
507
+ () => options.onBeforeLike!(id, ctx.body.authorId, context),
508
+ ctx.error,
509
+ "Unauthorized: Cannot like comment",
510
+ );
511
+
512
+ const result = await toggleCommentLike(
513
+ adapter,
514
+ id,
515
+ ctx.body.authorId,
516
+ );
517
+ return result;
518
+ },
519
+ );
520
+
521
+ // PATCH /comments/:id/status (admin)
522
+ const updateStatusEndpoint = createEndpoint(
523
+ "/comments/:id/status",
524
+ {
525
+ method: "PATCH",
526
+ body: updateCommentStatusSchema,
527
+ },
528
+ async (ctx) => {
529
+ const { id } = ctx.params;
530
+ const context: CommentsApiContext = {
531
+ params: ctx.params,
532
+ body: ctx.body,
533
+ headers: ctx.headers,
534
+ };
535
+
536
+ if (!options?.onBeforeStatusChange) {
537
+ throw ctx.error(403, {
538
+ message:
539
+ "Forbidden: changing comment status requires the onBeforeStatusChange hook",
540
+ });
541
+ }
542
+ await runHookWithShim(
543
+ () => options.onBeforeStatusChange!(id, ctx.body.status, context),
544
+ ctx.error,
545
+ "Unauthorized: Cannot change comment status",
546
+ );
547
+
548
+ const updated = await updateCommentStatus(
549
+ adapter,
550
+ id,
551
+ ctx.body.status,
552
+ );
553
+ if (!updated) {
554
+ throw ctx.error(404, { message: "Comment not found" });
555
+ }
556
+
557
+ if (ctx.body.status === "approved" && options?.onAfterApprove) {
558
+ await options.onAfterApprove(updated, context);
559
+ }
560
+
561
+ const serialized = await getCommentById(
562
+ adapter,
563
+ updated.id,
564
+ options?.resolveUser,
565
+ );
566
+ if (!serialized) {
567
+ throw ctx.error(500, {
568
+ message: "Failed to retrieve updated comment",
569
+ });
570
+ }
571
+ return serialized;
572
+ },
573
+ );
574
+
575
+ // DELETE /comments/:id (admin)
576
+ const deleteCommentEndpoint = createEndpoint(
577
+ "/comments/:id",
578
+ {
579
+ method: "DELETE",
580
+ },
581
+ async (ctx) => {
582
+ const { id } = ctx.params;
583
+ const context: CommentsApiContext = {
584
+ params: ctx.params,
585
+ headers: ctx.headers,
586
+ };
587
+
588
+ if (!options?.onBeforeDelete) {
589
+ throw ctx.error(403, {
590
+ message:
591
+ "Forbidden: deleting comments requires the onBeforeDelete hook",
592
+ });
593
+ }
594
+ await runHookWithShim(
595
+ () => options.onBeforeDelete!(id, context),
596
+ ctx.error,
597
+ "Unauthorized: Cannot delete comment",
598
+ );
599
+
600
+ const deleted = await deleteComment(adapter, id);
601
+ if (!deleted) {
602
+ throw ctx.error(404, { message: "Comment not found" });
603
+ }
604
+
605
+ if (options?.onAfterDelete) {
606
+ await options.onAfterDelete(id, context);
607
+ }
608
+
609
+ return { success: true };
610
+ },
611
+ );
612
+
613
+ return {
614
+ listComments: listCommentsEndpoint,
615
+ ...(postingEnabled && { createComment: createCommentEndpoint }),
616
+ ...(editingEnabled && { updateComment: updateCommentEndpoint }),
617
+ getCommentCount: getCommentCountEndpoint,
618
+ toggleLike: toggleLikeEndpoint,
619
+ updateCommentStatus: updateStatusEndpoint,
620
+ deleteComment: deleteCommentEndpoint,
621
+ } as const;
622
+ },
623
+ });
624
+ };
625
+
626
+ export type CommentsApiRouter = ReturnType<
627
+ ReturnType<typeof commentsBackendPlugin>["routes"]
628
+ >;