@commonpub/server 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (147) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +59 -0
  3. package/dist/admin/admin.d.ts +113 -0
  4. package/dist/admin/admin.d.ts.map +1 -0
  5. package/dist/admin/admin.js +426 -0
  6. package/dist/admin/admin.js.map +1 -0
  7. package/dist/admin/index.d.ts +3 -0
  8. package/dist/admin/index.d.ts.map +1 -0
  9. package/dist/admin/index.js +2 -0
  10. package/dist/admin/index.js.map +1 -0
  11. package/dist/content/content.d.ts +39 -0
  12. package/dist/content/content.d.ts.map +1 -0
  13. package/dist/content/content.js +507 -0
  14. package/dist/content/content.js.map +1 -0
  15. package/dist/content/index.d.ts +3 -0
  16. package/dist/content/index.d.ts.map +1 -0
  17. package/dist/content/index.js +2 -0
  18. package/dist/content/index.js.map +1 -0
  19. package/dist/contest/contest.d.ts +92 -0
  20. package/dist/contest/contest.d.ts.map +1 -0
  21. package/dist/contest/contest.js +343 -0
  22. package/dist/contest/contest.js.map +1 -0
  23. package/dist/contest/index.d.ts +3 -0
  24. package/dist/contest/index.d.ts.map +1 -0
  25. package/dist/contest/index.js +2 -0
  26. package/dist/contest/index.js.map +1 -0
  27. package/dist/docs/docs.d.ts +71 -0
  28. package/dist/docs/docs.d.ts.map +1 -0
  29. package/dist/docs/docs.js +398 -0
  30. package/dist/docs/docs.js.map +1 -0
  31. package/dist/docs/index.d.ts +2 -0
  32. package/dist/docs/index.d.ts.map +1 -0
  33. package/dist/docs/index.js +2 -0
  34. package/dist/docs/index.js.map +1 -0
  35. package/dist/email.d.ts +3 -0
  36. package/dist/email.d.ts.map +1 -0
  37. package/dist/email.js +3 -0
  38. package/dist/email.js.map +1 -0
  39. package/dist/federation/federation.d.ts +46 -0
  40. package/dist/federation/federation.d.ts.map +1 -0
  41. package/dist/federation/federation.js +308 -0
  42. package/dist/federation/federation.js.map +1 -0
  43. package/dist/federation/index.d.ts +2 -0
  44. package/dist/federation/index.d.ts.map +1 -0
  45. package/dist/federation/index.js +2 -0
  46. package/dist/federation/index.js.map +1 -0
  47. package/dist/hub/hub.d.ts +110 -0
  48. package/dist/hub/hub.d.ts.map +1 -0
  49. package/dist/hub/hub.js +917 -0
  50. package/dist/hub/hub.js.map +1 -0
  51. package/dist/hub/index.d.ts +2 -0
  52. package/dist/hub/index.d.ts.map +1 -0
  53. package/dist/hub/index.js +2 -0
  54. package/dist/hub/index.js.map +1 -0
  55. package/dist/image.d.ts +3 -0
  56. package/dist/image.d.ts.map +1 -0
  57. package/dist/image.js +3 -0
  58. package/dist/image.js.map +1 -0
  59. package/dist/index.d.ts +38 -0
  60. package/dist/index.d.ts.map +1 -0
  61. package/dist/index.js +43 -0
  62. package/dist/index.js.map +1 -0
  63. package/dist/learning/index.d.ts +6 -0
  64. package/dist/learning/index.d.ts.map +1 -0
  65. package/dist/learning/index.js +6 -0
  66. package/dist/learning/index.js.map +1 -0
  67. package/dist/learning/learning.d.ts +85 -0
  68. package/dist/learning/learning.d.ts.map +1 -0
  69. package/dist/learning/learning.js +681 -0
  70. package/dist/learning/learning.js.map +1 -0
  71. package/dist/messaging/index.d.ts +3 -0
  72. package/dist/messaging/index.d.ts.map +1 -0
  73. package/dist/messaging/index.js +2 -0
  74. package/dist/messaging/index.js.map +1 -0
  75. package/dist/messaging/messaging.d.ts +27 -0
  76. package/dist/messaging/messaging.d.ts.map +1 -0
  77. package/dist/messaging/messaging.js +132 -0
  78. package/dist/messaging/messaging.js.map +1 -0
  79. package/dist/notification/index.d.ts +3 -0
  80. package/dist/notification/index.d.ts.map +1 -0
  81. package/dist/notification/index.js +2 -0
  82. package/dist/notification/index.js.map +1 -0
  83. package/dist/notification/notification.d.ts +38 -0
  84. package/dist/notification/notification.d.ts.map +1 -0
  85. package/dist/notification/notification.js +92 -0
  86. package/dist/notification/notification.js.map +1 -0
  87. package/dist/oauthCodes.d.ts +14 -0
  88. package/dist/oauthCodes.d.ts.map +1 -0
  89. package/dist/oauthCodes.js +40 -0
  90. package/dist/oauthCodes.js.map +1 -0
  91. package/dist/product/index.d.ts +3 -0
  92. package/dist/product/index.d.ts.map +1 -0
  93. package/dist/product/index.js +2 -0
  94. package/dist/product/index.js.map +1 -0
  95. package/dist/product/product.d.ts +143 -0
  96. package/dist/product/product.d.ts.map +1 -0
  97. package/dist/product/product.js +493 -0
  98. package/dist/product/product.js.map +1 -0
  99. package/dist/profile/index.d.ts +2 -0
  100. package/dist/profile/index.d.ts.map +1 -0
  101. package/dist/profile/index.js +2 -0
  102. package/dist/profile/index.js.map +1 -0
  103. package/dist/profile/profile.d.ts +28 -0
  104. package/dist/profile/profile.d.ts.map +1 -0
  105. package/dist/profile/profile.js +122 -0
  106. package/dist/profile/profile.js.map +1 -0
  107. package/dist/query.d.ts +331 -0
  108. package/dist/query.d.ts.map +1 -0
  109. package/dist/query.js +103 -0
  110. package/dist/query.js.map +1 -0
  111. package/dist/security.d.ts +3 -0
  112. package/dist/security.d.ts.map +1 -0
  113. package/dist/security.js +3 -0
  114. package/dist/security.js.map +1 -0
  115. package/dist/social/index.d.ts +3 -0
  116. package/dist/social/index.d.ts.map +1 -0
  117. package/dist/social/index.js +2 -0
  118. package/dist/social/index.js.map +1 -0
  119. package/dist/social/social.d.ts +84 -0
  120. package/dist/social/social.d.ts.map +1 -0
  121. package/dist/social/social.js +353 -0
  122. package/dist/social/social.js.map +1 -0
  123. package/dist/storage.d.ts +3 -0
  124. package/dist/storage.d.ts.map +1 -0
  125. package/dist/storage.js +3 -0
  126. package/dist/storage.js.map +1 -0
  127. package/dist/theme.d.ts +12 -0
  128. package/dist/theme.d.ts.map +1 -0
  129. package/dist/theme.js +55 -0
  130. package/dist/theme.js.map +1 -0
  131. package/dist/types.d.ts +266 -0
  132. package/dist/types.d.ts.map +1 -0
  133. package/dist/types.js +2 -0
  134. package/dist/types.js.map +1 -0
  135. package/dist/utils.d.ts +7 -0
  136. package/dist/utils.d.ts.map +1 -0
  137. package/dist/utils.js +46 -0
  138. package/dist/utils.js.map +1 -0
  139. package/dist/video/index.d.ts +3 -0
  140. package/dist/video/index.d.ts.map +1 -0
  141. package/dist/video/index.js +2 -0
  142. package/dist/video/index.js.map +1 -0
  143. package/dist/video/video.d.ts +61 -0
  144. package/dist/video/video.d.ts.map +1 -0
  145. package/dist/video/video.js +157 -0
  146. package/dist/video/video.js.map +1 -0
  147. package/package.json +129 -0
@@ -0,0 +1,917 @@
1
+ import { eq, and, or, desc, sql, ilike, inArray, isNull } from 'drizzle-orm';
2
+ import { hubs, hubMembers, hubPosts, hubPostReplies, hubBans, hubInvites, hubShares, contentItems, users, } from '@commonpub/schema';
3
+ import { generateSlug, hasPermission, canManageRole } from '../utils.js';
4
+ import { ensureUniqueSlugFor, USER_REF_SELECT, normalizePagination, countRows, escapeLike } from '../query.js';
5
+ // --- Hub CRUD ---
6
+ export async function listHubs(db, filters = {}) {
7
+ const conditions = [isNull(hubs.deletedAt)];
8
+ if (filters.search) {
9
+ conditions.push(ilike(hubs.name, `%${escapeLike(filters.search)}%`));
10
+ }
11
+ if (filters.joinPolicy) {
12
+ conditions.push(eq(hubs.joinPolicy, filters.joinPolicy));
13
+ }
14
+ const where = conditions.length > 0 ? and(...conditions) : undefined;
15
+ const { limit, offset } = normalizePagination(filters);
16
+ const [rows, total] = await Promise.all([
17
+ db
18
+ .select({
19
+ hub: hubs,
20
+ createdBy: USER_REF_SELECT,
21
+ })
22
+ .from(hubs)
23
+ .innerJoin(users, eq(hubs.createdById, users.id))
24
+ .where(where)
25
+ .orderBy(desc(hubs.createdAt))
26
+ .limit(limit)
27
+ .offset(offset),
28
+ countRows(db, hubs, where),
29
+ ]);
30
+ const items = rows.map((row) => ({
31
+ id: row.hub.id,
32
+ name: row.hub.name,
33
+ slug: row.hub.slug,
34
+ description: row.hub.description,
35
+ hubType: row.hub.hubType,
36
+ iconUrl: row.hub.iconUrl,
37
+ bannerUrl: row.hub.bannerUrl,
38
+ joinPolicy: row.hub.joinPolicy,
39
+ isOfficial: row.hub.isOfficial,
40
+ memberCount: row.hub.memberCount,
41
+ postCount: row.hub.postCount,
42
+ createdAt: row.hub.createdAt,
43
+ createdBy: row.createdBy,
44
+ }));
45
+ return { items, total };
46
+ }
47
+ export async function getHubBySlug(db, slug, requesterId) {
48
+ const rows = await db
49
+ .select({
50
+ hub: hubs,
51
+ createdBy: {
52
+ id: users.id,
53
+ username: users.username,
54
+ displayName: users.displayName,
55
+ avatarUrl: users.avatarUrl,
56
+ },
57
+ })
58
+ .from(hubs)
59
+ .innerJoin(users, eq(hubs.createdById, users.id))
60
+ .where(and(eq(hubs.slug, slug), isNull(hubs.deletedAt)))
61
+ .limit(1);
62
+ if (rows.length === 0)
63
+ return null;
64
+ const row = rows[0];
65
+ let currentUserRole = null;
66
+ let isBanned = false;
67
+ if (requesterId) {
68
+ const [memberRows, banResult] = await Promise.all([
69
+ db
70
+ .select({ role: hubMembers.role })
71
+ .from(hubMembers)
72
+ .where(and(eq(hubMembers.hubId, row.hub.id), eq(hubMembers.userId, requesterId)))
73
+ .limit(1),
74
+ checkBan(db, row.hub.id, requesterId),
75
+ ]);
76
+ currentUserRole = memberRows[0]?.role ?? null;
77
+ isBanned = banResult !== null;
78
+ }
79
+ return {
80
+ id: row.hub.id,
81
+ name: row.hub.name,
82
+ slug: row.hub.slug,
83
+ description: row.hub.description,
84
+ iconUrl: row.hub.iconUrl,
85
+ bannerUrl: row.hub.bannerUrl,
86
+ joinPolicy: row.hub.joinPolicy,
87
+ isOfficial: row.hub.isOfficial,
88
+ memberCount: row.hub.memberCount,
89
+ postCount: row.hub.postCount,
90
+ createdAt: row.hub.createdAt,
91
+ createdBy: row.createdBy,
92
+ rules: row.hub.rules,
93
+ updatedAt: row.hub.updatedAt,
94
+ currentUserRole,
95
+ isBanned,
96
+ hubType: row.hub.hubType,
97
+ privacy: row.hub.privacy,
98
+ website: row.hub.website,
99
+ categories: row.hub.categories,
100
+ };
101
+ }
102
+ export async function createHub(db, userId, input) {
103
+ const slug = await ensureUniqueSlugFor(db, hubs, hubs.slug, hubs.id, generateSlug(input.name), 'hub');
104
+ const [inserted] = await db
105
+ .insert(hubs)
106
+ .values({
107
+ name: input.name,
108
+ slug,
109
+ description: input.description ?? null,
110
+ rules: input.rules ?? null,
111
+ joinPolicy: input.joinPolicy ?? 'open',
112
+ createdById: userId,
113
+ memberCount: 1,
114
+ })
115
+ .returning();
116
+ // Auto-add creator as owner
117
+ await db.insert(hubMembers).values({
118
+ hubId: inserted.id,
119
+ userId,
120
+ role: 'owner',
121
+ });
122
+ return (await getHubBySlug(db, inserted.slug, userId));
123
+ }
124
+ export async function updateHub(db, hubId, userId, input) {
125
+ // Permission check: must be admin+
126
+ const member = await db
127
+ .select({ role: hubMembers.role })
128
+ .from(hubMembers)
129
+ .where(and(eq(hubMembers.hubId, hubId), eq(hubMembers.userId, userId)))
130
+ .limit(1);
131
+ if (member.length === 0 || !hasPermission(member[0].role, 'editHub')) {
132
+ return null;
133
+ }
134
+ const updates = { updatedAt: new Date() };
135
+ if (input.name !== undefined) {
136
+ updates.name = input.name;
137
+ updates.slug = await ensureUniqueSlugFor(db, hubs, hubs.slug, hubs.id, generateSlug(input.name), 'hub', hubId);
138
+ }
139
+ if (input.description !== undefined)
140
+ updates.description = input.description;
141
+ if (input.rules !== undefined)
142
+ updates.rules = input.rules;
143
+ if (input.joinPolicy !== undefined)
144
+ updates.joinPolicy = input.joinPolicy;
145
+ if (input.iconUrl !== undefined)
146
+ updates.iconUrl = input.iconUrl;
147
+ if (input.bannerUrl !== undefined)
148
+ updates.bannerUrl = input.bannerUrl;
149
+ await db.update(hubs).set(updates).where(eq(hubs.id, hubId));
150
+ const slug = updates.slug ?? undefined;
151
+ if (slug) {
152
+ return getHubBySlug(db, slug, userId);
153
+ }
154
+ // Fetch updated hub
155
+ const current = await db
156
+ .select({ slug: hubs.slug })
157
+ .from(hubs)
158
+ .where(eq(hubs.id, hubId))
159
+ .limit(1);
160
+ return getHubBySlug(db, current[0].slug, userId);
161
+ }
162
+ export async function deleteHub(db, hubId, userId) {
163
+ // Owner only
164
+ const member = await db
165
+ .select({ role: hubMembers.role })
166
+ .from(hubMembers)
167
+ .where(and(eq(hubMembers.hubId, hubId), eq(hubMembers.userId, userId)))
168
+ .limit(1);
169
+ if (member.length === 0 || member[0].role !== 'owner') {
170
+ return false;
171
+ }
172
+ // Soft delete — set deletedAt instead of destroying data
173
+ await db
174
+ .update(hubs)
175
+ .set({ deletedAt: new Date(), updatedAt: new Date() })
176
+ .where(eq(hubs.id, hubId));
177
+ return true;
178
+ }
179
+ // --- Membership ---
180
+ export async function joinHub(db, userId, hubId, inviteToken) {
181
+ // Check ban
182
+ const ban = await checkBan(db, hubId, userId);
183
+ if (ban) {
184
+ return { joined: false, error: 'You are banned from this hub' };
185
+ }
186
+ // Check join policy
187
+ const hubRow = await db
188
+ .select({ joinPolicy: hubs.joinPolicy })
189
+ .from(hubs)
190
+ .where(eq(hubs.id, hubId))
191
+ .limit(1);
192
+ if (hubRow.length === 0) {
193
+ return { joined: false, error: 'Hub not found' };
194
+ }
195
+ const policy = hubRow[0].joinPolicy;
196
+ if (policy !== 'open') {
197
+ if (!inviteToken) {
198
+ return { joined: false, error: 'Invite token required' };
199
+ }
200
+ const tokenResult = await validateAndUseInvite(db, inviteToken);
201
+ if (!tokenResult.valid) {
202
+ return { joined: false, error: 'Invalid or expired invite token' };
203
+ }
204
+ // Verify the invite belongs to this specific hub
205
+ if (tokenResult.hubId !== hubId) {
206
+ return { joined: false, error: 'Invite token is not valid for this hub' };
207
+ }
208
+ }
209
+ return db.transaction(async (tx) => {
210
+ const inserted = await tx
211
+ .insert(hubMembers)
212
+ .values({ hubId, userId, role: 'member' })
213
+ .onConflictDoNothing()
214
+ .returning();
215
+ if (inserted.length === 0) {
216
+ return { joined: true };
217
+ }
218
+ await tx
219
+ .update(hubs)
220
+ .set({ memberCount: sql `${hubs.memberCount} + 1` })
221
+ .where(eq(hubs.id, hubId));
222
+ return { joined: true };
223
+ });
224
+ }
225
+ export async function leaveHub(db, userId, hubId) {
226
+ const member = await db
227
+ .select({ role: hubMembers.role })
228
+ .from(hubMembers)
229
+ .where(and(eq(hubMembers.hubId, hubId), eq(hubMembers.userId, userId)))
230
+ .limit(1);
231
+ if (member.length === 0) {
232
+ return { left: false, error: 'Not a member' };
233
+ }
234
+ if (member[0].role === 'owner') {
235
+ return { left: false, error: 'Owner cannot leave the hub' };
236
+ }
237
+ await db
238
+ .delete(hubMembers)
239
+ .where(and(eq(hubMembers.hubId, hubId), eq(hubMembers.userId, userId)));
240
+ await db
241
+ .update(hubs)
242
+ .set({ memberCount: sql `GREATEST(${hubs.memberCount} - 1, 0)` })
243
+ .where(eq(hubs.id, hubId));
244
+ return { left: true };
245
+ }
246
+ export async function getMember(db, hubId, userId) {
247
+ const rows = await db
248
+ .select({
249
+ member: hubMembers,
250
+ user: USER_REF_SELECT,
251
+ })
252
+ .from(hubMembers)
253
+ .innerJoin(users, eq(hubMembers.userId, users.id))
254
+ .where(and(eq(hubMembers.hubId, hubId), eq(hubMembers.userId, userId)))
255
+ .limit(1);
256
+ if (rows.length === 0)
257
+ return null;
258
+ const row = rows[0];
259
+ return {
260
+ hubId: row.member.hubId,
261
+ userId: row.member.userId,
262
+ role: row.member.role,
263
+ joinedAt: row.member.joinedAt,
264
+ user: row.user,
265
+ };
266
+ }
267
+ export async function listMembers(db, hubId, opts = {}) {
268
+ const { limit, offset } = normalizePagination(opts);
269
+ const where = eq(hubMembers.hubId, hubId);
270
+ const [rows, total] = await Promise.all([
271
+ db
272
+ .select({
273
+ member: hubMembers,
274
+ user: USER_REF_SELECT,
275
+ })
276
+ .from(hubMembers)
277
+ .innerJoin(users, eq(hubMembers.userId, users.id))
278
+ .where(where)
279
+ .orderBy(desc(hubMembers.joinedAt))
280
+ .limit(limit)
281
+ .offset(offset),
282
+ countRows(db, hubMembers, where),
283
+ ]);
284
+ const items = rows.map((row) => ({
285
+ hubId: row.member.hubId,
286
+ userId: row.member.userId,
287
+ role: row.member.role,
288
+ joinedAt: row.member.joinedAt,
289
+ user: row.user,
290
+ }));
291
+ return { items, total };
292
+ }
293
+ export async function changeRole(db, actorId, hubId, targetUserId, newRole) {
294
+ const [actorMember, targetMember] = await Promise.all([
295
+ db
296
+ .select({ role: hubMembers.role })
297
+ .from(hubMembers)
298
+ .where(and(eq(hubMembers.hubId, hubId), eq(hubMembers.userId, actorId)))
299
+ .limit(1),
300
+ db
301
+ .select({ role: hubMembers.role })
302
+ .from(hubMembers)
303
+ .where(and(eq(hubMembers.hubId, hubId), eq(hubMembers.userId, targetUserId)))
304
+ .limit(1),
305
+ ]);
306
+ if (actorMember.length === 0) {
307
+ return { changed: false, error: 'Not a member' };
308
+ }
309
+ if (targetMember.length === 0) {
310
+ return { changed: false, error: 'Target is not a member' };
311
+ }
312
+ if (!hasPermission(actorMember[0].role, 'manageMembers')) {
313
+ return { changed: false, error: 'Insufficient permissions' };
314
+ }
315
+ if (!canManageRole(actorMember[0].role, targetMember[0].role)) {
316
+ return { changed: false, error: 'Cannot manage a user with equal or higher role' };
317
+ }
318
+ if (newRole === 'owner') {
319
+ return { changed: false, error: 'Cannot promote to owner' };
320
+ }
321
+ await db
322
+ .update(hubMembers)
323
+ .set({ role: newRole })
324
+ .where(and(eq(hubMembers.hubId, hubId), eq(hubMembers.userId, targetUserId)));
325
+ return { changed: true };
326
+ }
327
+ export async function kickMember(db, actorId, hubId, targetUserId) {
328
+ const [actorMember, targetMember] = await Promise.all([
329
+ db
330
+ .select({ role: hubMembers.role })
331
+ .from(hubMembers)
332
+ .where(and(eq(hubMembers.hubId, hubId), eq(hubMembers.userId, actorId)))
333
+ .limit(1),
334
+ db
335
+ .select({ role: hubMembers.role })
336
+ .from(hubMembers)
337
+ .where(and(eq(hubMembers.hubId, hubId), eq(hubMembers.userId, targetUserId)))
338
+ .limit(1),
339
+ ]);
340
+ if (actorMember.length === 0) {
341
+ return { kicked: false, error: 'Not a member' };
342
+ }
343
+ if (targetMember.length === 0) {
344
+ return { kicked: false, error: 'Target is not a member' };
345
+ }
346
+ if (!hasPermission(actorMember[0].role, 'kickMember')) {
347
+ return { kicked: false, error: 'Insufficient permissions' };
348
+ }
349
+ if (!canManageRole(actorMember[0].role, targetMember[0].role)) {
350
+ return { kicked: false, error: 'Cannot kick a user with equal or higher role' };
351
+ }
352
+ await db
353
+ .delete(hubMembers)
354
+ .where(and(eq(hubMembers.hubId, hubId), eq(hubMembers.userId, targetUserId)));
355
+ await db
356
+ .update(hubs)
357
+ .set({ memberCount: sql `GREATEST(${hubs.memberCount} - 1, 0)` })
358
+ .where(eq(hubs.id, hubId));
359
+ return { kicked: true };
360
+ }
361
+ // --- Posts & Replies ---
362
+ export async function createPost(db, authorId, input) {
363
+ const member = await db
364
+ .select({ role: hubMembers.role })
365
+ .from(hubMembers)
366
+ .where(and(eq(hubMembers.hubId, input.hubId), eq(hubMembers.userId, authorId)))
367
+ .limit(1);
368
+ if (member.length === 0) {
369
+ throw new Error('Must be a member to post');
370
+ }
371
+ return db.transaction(async (tx) => {
372
+ const [post] = await tx
373
+ .insert(hubPosts)
374
+ .values({
375
+ hubId: input.hubId,
376
+ authorId,
377
+ type: input.type ?? 'text',
378
+ content: input.content,
379
+ })
380
+ .returning();
381
+ await tx
382
+ .update(hubs)
383
+ .set({ postCount: sql `${hubs.postCount} + 1` })
384
+ .where(eq(hubs.id, input.hubId));
385
+ const author = await tx
386
+ .select(USER_REF_SELECT)
387
+ .from(users)
388
+ .where(eq(users.id, authorId))
389
+ .limit(1);
390
+ return {
391
+ id: post.id,
392
+ hubId: post.hubId,
393
+ type: post.type,
394
+ content: post.content,
395
+ isPinned: post.isPinned,
396
+ isLocked: post.isLocked,
397
+ likeCount: 0,
398
+ replyCount: 0,
399
+ createdAt: post.createdAt,
400
+ updatedAt: post.updatedAt,
401
+ author: author[0] ?? { id: authorId, username: 'unknown', displayName: null, avatarUrl: null },
402
+ };
403
+ });
404
+ }
405
+ export async function listPosts(db, hubId, filters = {}) {
406
+ const conditions = [eq(hubPosts.hubId, hubId)];
407
+ if (filters.type) {
408
+ conditions.push(eq(hubPosts.type, filters.type));
409
+ }
410
+ const where = and(...conditions);
411
+ const { limit, offset } = normalizePagination(filters);
412
+ const [rows, total] = await Promise.all([
413
+ db
414
+ .select({
415
+ post: hubPosts,
416
+ author: USER_REF_SELECT,
417
+ })
418
+ .from(hubPosts)
419
+ .innerJoin(users, eq(hubPosts.authorId, users.id))
420
+ .where(where)
421
+ .orderBy(desc(hubPosts.isPinned), desc(hubPosts.createdAt))
422
+ .limit(limit)
423
+ .offset(offset),
424
+ countRows(db, hubPosts, where),
425
+ ]);
426
+ const items = rows.map((row) => {
427
+ const item = {
428
+ id: row.post.id,
429
+ hubId: row.post.hubId,
430
+ type: row.post.type,
431
+ content: row.post.content,
432
+ isPinned: row.post.isPinned,
433
+ isLocked: row.post.isLocked,
434
+ likeCount: row.post.likeCount,
435
+ replyCount: row.post.replyCount,
436
+ createdAt: row.post.createdAt,
437
+ updatedAt: row.post.updatedAt,
438
+ author: row.author,
439
+ };
440
+ if (row.post.type === 'share') {
441
+ try {
442
+ item.sharedContent = JSON.parse(row.post.content);
443
+ }
444
+ catch {
445
+ // Content is not valid JSON, leave as-is
446
+ }
447
+ }
448
+ return item;
449
+ });
450
+ return { items, total };
451
+ }
452
+ export async function deletePost(db, postId, userId, hubId) {
453
+ const post = await db
454
+ .select({ authorId: hubPosts.authorId })
455
+ .from(hubPosts)
456
+ .where(eq(hubPosts.id, postId))
457
+ .limit(1);
458
+ if (post.length === 0)
459
+ return false;
460
+ if (post[0].authorId !== userId) {
461
+ const member = await db
462
+ .select({ role: hubMembers.role })
463
+ .from(hubMembers)
464
+ .where(and(eq(hubMembers.hubId, hubId), eq(hubMembers.userId, userId)))
465
+ .limit(1);
466
+ if (member.length === 0 || !hasPermission(member[0].role, 'deletePost')) {
467
+ return false;
468
+ }
469
+ }
470
+ await db.delete(hubPosts).where(eq(hubPosts.id, postId));
471
+ await db
472
+ .update(hubs)
473
+ .set({ postCount: sql `GREATEST(${hubs.postCount} - 1, 0)` })
474
+ .where(eq(hubs.id, hubId));
475
+ return true;
476
+ }
477
+ export async function togglePinPost(db, postId, userId, hubId) {
478
+ const member = await db
479
+ .select({ role: hubMembers.role })
480
+ .from(hubMembers)
481
+ .where(and(eq(hubMembers.hubId, hubId), eq(hubMembers.userId, userId)))
482
+ .limit(1);
483
+ if (member.length === 0 || !hasPermission(member[0].role, 'pinPost')) {
484
+ return null;
485
+ }
486
+ const post = await db
487
+ .select({ isPinned: hubPosts.isPinned })
488
+ .from(hubPosts)
489
+ .where(eq(hubPosts.id, postId))
490
+ .limit(1);
491
+ if (post.length === 0)
492
+ return null;
493
+ const newPinned = !post[0].isPinned;
494
+ await db
495
+ .update(hubPosts)
496
+ .set({ isPinned: newPinned, updatedAt: new Date() })
497
+ .where(eq(hubPosts.id, postId));
498
+ return { pinned: newPinned };
499
+ }
500
+ export async function toggleLockPost(db, postId, userId, hubId) {
501
+ const member = await db
502
+ .select({ role: hubMembers.role })
503
+ .from(hubMembers)
504
+ .where(and(eq(hubMembers.hubId, hubId), eq(hubMembers.userId, userId)))
505
+ .limit(1);
506
+ if (member.length === 0 || !hasPermission(member[0].role, 'lockPost')) {
507
+ return null;
508
+ }
509
+ const post = await db
510
+ .select({ isLocked: hubPosts.isLocked })
511
+ .from(hubPosts)
512
+ .where(eq(hubPosts.id, postId))
513
+ .limit(1);
514
+ if (post.length === 0)
515
+ return null;
516
+ const newLocked = !post[0].isLocked;
517
+ await db
518
+ .update(hubPosts)
519
+ .set({ isLocked: newLocked, updatedAt: new Date() })
520
+ .where(eq(hubPosts.id, postId));
521
+ return { locked: newLocked };
522
+ }
523
+ export async function createReply(db, authorId, input) {
524
+ const post = await db
525
+ .select({ hubId: hubPosts.hubId, isLocked: hubPosts.isLocked })
526
+ .from(hubPosts)
527
+ .where(eq(hubPosts.id, input.postId))
528
+ .limit(1);
529
+ if (post.length === 0)
530
+ throw new Error('Post not found');
531
+ if (post[0].isLocked)
532
+ throw new Error('Post is locked');
533
+ const member = await db
534
+ .select({ role: hubMembers.role })
535
+ .from(hubMembers)
536
+ .where(and(eq(hubMembers.hubId, post[0].hubId), eq(hubMembers.userId, authorId)))
537
+ .limit(1);
538
+ if (member.length === 0)
539
+ throw new Error('Must be a member to reply');
540
+ const ban = await checkBan(db, post[0].hubId, authorId);
541
+ if (ban)
542
+ throw new Error('You are banned from this hub');
543
+ const [reply] = await db
544
+ .insert(hubPostReplies)
545
+ .values({
546
+ postId: input.postId,
547
+ authorId,
548
+ content: input.content,
549
+ parentId: input.parentId ?? null,
550
+ })
551
+ .returning();
552
+ await db
553
+ .update(hubPosts)
554
+ .set({ replyCount: sql `${hubPosts.replyCount} + 1` })
555
+ .where(eq(hubPosts.id, input.postId));
556
+ const author = await db
557
+ .select(USER_REF_SELECT)
558
+ .from(users)
559
+ .where(eq(users.id, authorId))
560
+ .limit(1);
561
+ return {
562
+ id: reply.id,
563
+ postId: reply.postId,
564
+ content: reply.content,
565
+ likeCount: 0,
566
+ createdAt: reply.createdAt,
567
+ updatedAt: reply.updatedAt,
568
+ parentId: reply.parentId,
569
+ author: author[0],
570
+ };
571
+ }
572
+ export async function listReplies(db, postId, opts = {}) {
573
+ const { limit, offset } = normalizePagination(opts);
574
+ // Fetch root replies with pagination
575
+ const rootWhere = and(eq(hubPostReplies.postId, postId), isNull(hubPostReplies.parentId));
576
+ const [rootRows, total] = await Promise.all([
577
+ db
578
+ .select({ id: hubPostReplies.id })
579
+ .from(hubPostReplies)
580
+ .where(rootWhere)
581
+ .orderBy(desc(hubPostReplies.createdAt))
582
+ .limit(limit)
583
+ .offset(offset),
584
+ countRows(db, hubPostReplies, rootWhere),
585
+ ]);
586
+ if (rootRows.length === 0)
587
+ return { items: [], total };
588
+ const rootIds = rootRows.map((r) => r.id);
589
+ // Fetch root + children in one query
590
+ const rows = await db
591
+ .select({
592
+ reply: hubPostReplies,
593
+ author: {
594
+ id: users.id,
595
+ username: users.username,
596
+ displayName: users.displayName,
597
+ avatarUrl: users.avatarUrl,
598
+ },
599
+ })
600
+ .from(hubPostReplies)
601
+ .innerJoin(users, eq(hubPostReplies.authorId, users.id))
602
+ .where(and(eq(hubPostReplies.postId, postId), or(and(isNull(hubPostReplies.parentId), inArray(hubPostReplies.id, rootIds)), inArray(hubPostReplies.parentId, rootIds))))
603
+ .orderBy(desc(hubPostReplies.createdAt));
604
+ const replyMap = new Map();
605
+ const rootReplies = [];
606
+ for (const row of rows) {
607
+ const item = {
608
+ id: row.reply.id,
609
+ postId: row.reply.postId,
610
+ content: row.reply.content,
611
+ likeCount: row.reply.likeCount,
612
+ createdAt: row.reply.createdAt,
613
+ updatedAt: row.reply.updatedAt,
614
+ parentId: row.reply.parentId,
615
+ author: row.author,
616
+ replies: [],
617
+ };
618
+ replyMap.set(item.id, item);
619
+ }
620
+ // Preserve root ordering
621
+ for (const rootId of rootIds) {
622
+ const item = replyMap.get(rootId);
623
+ if (item)
624
+ rootReplies.push(item);
625
+ }
626
+ for (const item of replyMap.values()) {
627
+ if (item.parentId && replyMap.has(item.parentId)) {
628
+ replyMap.get(item.parentId).replies.push(item);
629
+ }
630
+ }
631
+ return { items: rootReplies, total };
632
+ }
633
+ export async function deleteReply(db, replyId, userId, hubId) {
634
+ const reply = await db
635
+ .select({ authorId: hubPostReplies.authorId, postId: hubPostReplies.postId })
636
+ .from(hubPostReplies)
637
+ .where(eq(hubPostReplies.id, replyId))
638
+ .limit(1);
639
+ if (reply.length === 0)
640
+ return false;
641
+ if (reply[0].authorId !== userId) {
642
+ const member = await db
643
+ .select({ role: hubMembers.role })
644
+ .from(hubMembers)
645
+ .where(and(eq(hubMembers.hubId, hubId), eq(hubMembers.userId, userId)))
646
+ .limit(1);
647
+ if (member.length === 0 || !hasPermission(member[0].role, 'deletePost')) {
648
+ return false;
649
+ }
650
+ }
651
+ await db.delete(hubPostReplies).where(eq(hubPostReplies.id, replyId));
652
+ await db
653
+ .update(hubPosts)
654
+ .set({ replyCount: sql `GREATEST(${hubPosts.replyCount} - 1, 0)` })
655
+ .where(eq(hubPosts.id, reply[0].postId));
656
+ return true;
657
+ }
658
+ // --- Bans ---
659
+ export async function banUser(db, actorId, hubId, targetUserId, reason, expiresAt) {
660
+ const [actorMember, targetMember] = await Promise.all([
661
+ db
662
+ .select({ role: hubMembers.role })
663
+ .from(hubMembers)
664
+ .where(and(eq(hubMembers.hubId, hubId), eq(hubMembers.userId, actorId)))
665
+ .limit(1),
666
+ db
667
+ .select({ role: hubMembers.role })
668
+ .from(hubMembers)
669
+ .where(and(eq(hubMembers.hubId, hubId), eq(hubMembers.userId, targetUserId)))
670
+ .limit(1),
671
+ ]);
672
+ if (actorMember.length === 0 || !hasPermission(actorMember[0].role, 'banUser')) {
673
+ return { banned: false, error: 'Insufficient permissions' };
674
+ }
675
+ if (actorMember[0].role === 'moderator' && !expiresAt) {
676
+ return { banned: false, error: 'Moderators can only issue temporary bans' };
677
+ }
678
+ if (targetMember.length > 0) {
679
+ if (!canManageRole(actorMember[0].role, targetMember[0].role)) {
680
+ return { banned: false, error: 'Cannot ban a user with equal or higher role' };
681
+ }
682
+ await db
683
+ .delete(hubMembers)
684
+ .where(and(eq(hubMembers.hubId, hubId), eq(hubMembers.userId, targetUserId)));
685
+ await db
686
+ .update(hubs)
687
+ .set({ memberCount: sql `GREATEST(${hubs.memberCount} - 1, 0)` })
688
+ .where(eq(hubs.id, hubId));
689
+ }
690
+ await db.insert(hubBans).values({
691
+ hubId,
692
+ userId: targetUserId,
693
+ bannedById: actorId,
694
+ reason: reason ?? null,
695
+ expiresAt: expiresAt ?? null,
696
+ }).onConflictDoNothing();
697
+ return { banned: true };
698
+ }
699
+ export async function unbanUser(db, actorId, hubId, targetUserId) {
700
+ const actorMember = await db
701
+ .select({ role: hubMembers.role })
702
+ .from(hubMembers)
703
+ .where(and(eq(hubMembers.hubId, hubId), eq(hubMembers.userId, actorId)))
704
+ .limit(1);
705
+ if (actorMember.length === 0 || !hasPermission(actorMember[0].role, 'banUser')) {
706
+ return { unbanned: false, error: 'Insufficient permissions' };
707
+ }
708
+ await db
709
+ .delete(hubBans)
710
+ .where(and(eq(hubBans.hubId, hubId), eq(hubBans.userId, targetUserId)));
711
+ return { unbanned: true };
712
+ }
713
+ export async function checkBan(db, hubId, userId) {
714
+ const rows = await db
715
+ .select({
716
+ id: hubBans.id,
717
+ reason: hubBans.reason,
718
+ expiresAt: hubBans.expiresAt,
719
+ })
720
+ .from(hubBans)
721
+ .where(and(eq(hubBans.hubId, hubId), eq(hubBans.userId, userId)))
722
+ .limit(1);
723
+ if (rows.length === 0)
724
+ return null;
725
+ const ban = rows[0];
726
+ if (ban.expiresAt && ban.expiresAt < new Date()) {
727
+ await db.delete(hubBans).where(eq(hubBans.id, ban.id));
728
+ return null;
729
+ }
730
+ return ban;
731
+ }
732
+ export async function listBans(db, hubId, opts = {}) {
733
+ // Alias for the banner user (self-join on users table)
734
+ const bannerUser = {
735
+ bannerId: sql `banner.id`.as('banner_id'),
736
+ bannerUsername: sql `banner.username`.as('banner_username'),
737
+ bannerDisplayName: sql `banner.display_name`.as('banner_display_name'),
738
+ bannerAvatarUrl: sql `banner.avatar_url`.as('banner_avatar_url'),
739
+ };
740
+ const limit = Math.min(opts.limit ?? 50, 100);
741
+ const offset = opts.offset ?? 0;
742
+ const rows = await db
743
+ .select({
744
+ ban: hubBans,
745
+ user: USER_REF_SELECT,
746
+ ...bannerUser,
747
+ })
748
+ .from(hubBans)
749
+ .innerJoin(users, eq(hubBans.userId, users.id))
750
+ .innerJoin(sql `users AS banner`, sql `banner.id = ${hubBans.bannedById}`)
751
+ .where(eq(hubBans.hubId, hubId))
752
+ .orderBy(desc(hubBans.createdAt))
753
+ .limit(limit)
754
+ .offset(offset);
755
+ return rows.map((row) => ({
756
+ id: row.ban.id,
757
+ reason: row.ban.reason,
758
+ expiresAt: row.ban.expiresAt,
759
+ createdAt: row.ban.createdAt,
760
+ user: row.user,
761
+ bannedBy: {
762
+ id: row.bannerId,
763
+ username: row.bannerUsername,
764
+ displayName: row.bannerDisplayName,
765
+ avatarUrl: row.bannerAvatarUrl,
766
+ },
767
+ }));
768
+ }
769
+ // --- Invites ---
770
+ export async function createInvite(db, userId, hubId, maxUses, expiresAt) {
771
+ const member = await db
772
+ .select({ role: hubMembers.role })
773
+ .from(hubMembers)
774
+ .where(and(eq(hubMembers.hubId, hubId), eq(hubMembers.userId, userId)))
775
+ .limit(1);
776
+ if (member.length === 0 || !hasPermission(member[0].role, 'manageMembers')) {
777
+ return null;
778
+ }
779
+ const token = crypto.randomUUID().replace(/-/g, '');
780
+ const [invite] = await db
781
+ .insert(hubInvites)
782
+ .values({
783
+ hubId,
784
+ createdById: userId,
785
+ token,
786
+ maxUses: maxUses ?? null,
787
+ expiresAt: expiresAt ?? null,
788
+ })
789
+ .returning();
790
+ const author = await db
791
+ .select(USER_REF_SELECT)
792
+ .from(users)
793
+ .where(eq(users.id, userId))
794
+ .limit(1);
795
+ return {
796
+ id: invite.id,
797
+ token: invite.token,
798
+ maxUses: invite.maxUses,
799
+ useCount: 0,
800
+ expiresAt: invite.expiresAt,
801
+ createdAt: invite.createdAt,
802
+ createdBy: author[0],
803
+ };
804
+ }
805
+ export async function validateAndUseInvite(db, token) {
806
+ const updated = await db
807
+ .update(hubInvites)
808
+ .set({ useCount: sql `${hubInvites.useCount} + 1` })
809
+ .where(and(eq(hubInvites.token, token), sql `(${hubInvites.expiresAt} IS NULL OR ${hubInvites.expiresAt} > NOW())`, sql `(${hubInvites.maxUses} IS NULL OR ${hubInvites.useCount} < ${hubInvites.maxUses})`))
810
+ .returning({ hubId: hubInvites.hubId });
811
+ if (updated.length === 0)
812
+ return { valid: false };
813
+ return { valid: true, hubId: updated[0].hubId };
814
+ }
815
+ export async function revokeInvite(db, inviteId, userId, hubId) {
816
+ const member = await db
817
+ .select({ role: hubMembers.role })
818
+ .from(hubMembers)
819
+ .where(and(eq(hubMembers.hubId, hubId), eq(hubMembers.userId, userId)))
820
+ .limit(1);
821
+ if (member.length === 0 || !hasPermission(member[0].role, 'manageMembers')) {
822
+ return false;
823
+ }
824
+ await db.delete(hubInvites).where(eq(hubInvites.id, inviteId));
825
+ return true;
826
+ }
827
+ export async function listInvites(db, hubId, opts = {}) {
828
+ const limit = Math.min(opts.limit ?? 50, 100);
829
+ const offset = opts.offset ?? 0;
830
+ const rows = await db
831
+ .select({
832
+ invite: hubInvites,
833
+ createdBy: {
834
+ id: users.id,
835
+ username: users.username,
836
+ displayName: users.displayName,
837
+ avatarUrl: users.avatarUrl,
838
+ },
839
+ })
840
+ .from(hubInvites)
841
+ .innerJoin(users, eq(hubInvites.createdById, users.id))
842
+ .where(eq(hubInvites.hubId, hubId))
843
+ .orderBy(desc(hubInvites.createdAt))
844
+ .limit(limit)
845
+ .offset(offset);
846
+ return rows.map((row) => ({
847
+ id: row.invite.id,
848
+ token: row.invite.token,
849
+ maxUses: row.invite.maxUses,
850
+ useCount: row.invite.useCount,
851
+ expiresAt: row.invite.expiresAt,
852
+ createdAt: row.invite.createdAt,
853
+ createdBy: row.createdBy,
854
+ }));
855
+ }
856
+ // --- Content Sharing ---
857
+ export async function shareContent(db, userId, hubId, contentId) {
858
+ const member = await db
859
+ .select({ role: hubMembers.role })
860
+ .from(hubMembers)
861
+ .where(and(eq(hubMembers.hubId, hubId), eq(hubMembers.userId, userId)))
862
+ .limit(1);
863
+ if (member.length === 0)
864
+ return null;
865
+ const content = await db
866
+ .select({
867
+ id: contentItems.id,
868
+ title: contentItems.title,
869
+ slug: contentItems.slug,
870
+ type: contentItems.type,
871
+ })
872
+ .from(contentItems)
873
+ .where(eq(contentItems.id, contentId))
874
+ .limit(1);
875
+ if (content.length === 0)
876
+ return null;
877
+ const sharePayload = JSON.stringify({
878
+ contentId: content[0].id,
879
+ title: content[0].title,
880
+ slug: content[0].slug,
881
+ type: content[0].type,
882
+ });
883
+ await db.insert(hubShares).values({
884
+ hubId,
885
+ contentId,
886
+ sharedById: userId,
887
+ });
888
+ return createPost(db, userId, {
889
+ hubId,
890
+ type: 'share',
891
+ content: sharePayload,
892
+ });
893
+ }
894
+ export async function unshareContent(db, userId, hubId, contentId) {
895
+ const share = await db
896
+ .select({ id: hubShares.id })
897
+ .from(hubShares)
898
+ .where(and(eq(hubShares.hubId, hubId), eq(hubShares.contentId, contentId), eq(hubShares.sharedById, userId)))
899
+ .limit(1);
900
+ if (share.length === 0)
901
+ return false;
902
+ await db.delete(hubShares).where(eq(hubShares.id, share[0].id));
903
+ return true;
904
+ }
905
+ export async function listShares(db, hubId) {
906
+ return db
907
+ .select({
908
+ id: hubShares.id,
909
+ contentId: hubShares.contentId,
910
+ sharedById: hubShares.sharedById,
911
+ createdAt: hubShares.createdAt,
912
+ })
913
+ .from(hubShares)
914
+ .where(eq(hubShares.hubId, hubId))
915
+ .orderBy(desc(hubShares.createdAt));
916
+ }
917
+ //# sourceMappingURL=hub.js.map