@commonpub/server 2.3.4 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/hub/hub.js CHANGED
@@ -1,9 +1,11 @@
1
- import { eq, and, or, desc, sql, ilike, inArray, isNull } from 'drizzle-orm';
2
- import { hubs, hubMembers, hubPosts, hubPostReplies, hubPostLikes, hubBans, hubInvites, hubShares, contentItems, users, } from '@commonpub/schema';
3
- import { generateSlug, hasPermission, canManageRole } from '../utils.js';
1
+ import { eq, and, desc, ilike, isNull } from 'drizzle-orm';
2
+ import { hubs, hubMembers, users, } from '@commonpub/schema';
3
+ import { generateSlug, hasPermission } from '../utils.js';
4
4
  import { ensureUniqueSlugFor, USER_REF_SELECT, normalizePagination, countRows, escapeLike } from '../query.js';
5
+ import { checkBan } from './moderation.js';
6
+ import { listFederatedHubs } from '../federation/hubMirroring.js';
5
7
  // --- Hub CRUD ---
6
- export async function listHubs(db, filters = {}) {
8
+ export async function listHubs(db, filters = {}, options) {
7
9
  const conditions = [isNull(hubs.deletedAt)];
8
10
  if (filters.search) {
9
11
  conditions.push(ilike(hubs.name, `%${escapeLike(filters.search)}%`));
@@ -27,7 +29,7 @@ export async function listHubs(db, filters = {}) {
27
29
  .offset(offset),
28
30
  countRows(db, hubs, where),
29
31
  ]);
30
- const items = rows.map((row) => ({
32
+ const localItems = rows.map((row) => ({
31
33
  id: row.hub.id,
32
34
  name: row.hub.name,
33
35
  slug: row.hub.slug,
@@ -42,7 +44,53 @@ export async function listHubs(db, filters = {}) {
42
44
  createdAt: row.hub.createdAt,
43
45
  createdBy: row.createdBy,
44
46
  }));
45
- return { items, total };
47
+ if (!options?.includeFederated) {
48
+ return { items: localItems, total };
49
+ }
50
+ // Merge with federated hubs — fetch enough from both sources to fill the page
51
+ const maxItems = offset + limit;
52
+ const [localAll, fedResult] = await Promise.all([
53
+ // Re-fetch local without offset to get enough for merge (only if offset > 0)
54
+ offset > 0
55
+ ? db
56
+ .select({ hub: hubs, createdBy: USER_REF_SELECT })
57
+ .from(hubs)
58
+ .innerJoin(users, eq(hubs.createdById, users.id))
59
+ .where(where)
60
+ .orderBy(desc(hubs.createdAt))
61
+ .limit(maxItems)
62
+ .then((r) => r.map((row) => ({
63
+ id: row.hub.id,
64
+ name: row.hub.name,
65
+ slug: row.hub.slug,
66
+ description: row.hub.description,
67
+ hubType: row.hub.hubType,
68
+ iconUrl: row.hub.iconUrl,
69
+ bannerUrl: row.hub.bannerUrl,
70
+ joinPolicy: row.hub.joinPolicy,
71
+ isOfficial: row.hub.isOfficial,
72
+ memberCount: row.hub.memberCount,
73
+ postCount: row.hub.postCount,
74
+ createdAt: row.hub.createdAt,
75
+ createdBy: row.createdBy,
76
+ })))
77
+ : Promise.resolve(localItems),
78
+ listFederatedHubs(db, { search: filters.search, limit: maxItems }),
79
+ ]);
80
+ // Merge and sort by creation date (local createdAt vs federated receivedAt)
81
+ const merged = [
82
+ ...localAll,
83
+ ...fedResult.items,
84
+ ];
85
+ merged.sort((a, b) => {
86
+ const dateA = 'createdAt' in a ? new Date(a.createdAt).getTime() : new Date(a.receivedAt).getTime();
87
+ const dateB = 'createdAt' in b ? new Date(b.createdAt).getTime() : new Date(b.receivedAt).getTime();
88
+ return dateB - dateA;
89
+ });
90
+ return {
91
+ items: merged.slice(offset, offset + limit),
92
+ total: total + fedResult.total,
93
+ };
46
94
  }
47
95
  export async function getHubBySlug(db, slug, requesterId) {
48
96
  const rows = await db
@@ -176,849 +224,4 @@ export async function deleteHub(db, hubId, userId) {
176
224
  .where(eq(hubs.id, hubId));
177
225
  return true;
178
226
  }
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
- // Backfill missing cover images on share posts (for shares created before enrichment)
451
- const sharesToEnrich = items.filter((i) => i.type === 'share' && i.sharedContent && !i.sharedContent.coverImageUrl && i.sharedContent.contentId);
452
- if (sharesToEnrich.length > 0) {
453
- const contentIds = sharesToEnrich.map((i) => i.sharedContent.contentId);
454
- const enrichData = await db
455
- .select({ id: contentItems.id, coverImageUrl: contentItems.coverImageUrl, description: contentItems.description })
456
- .from(contentItems)
457
- .where(inArray(contentItems.id, contentIds));
458
- const enrichMap = new Map(enrichData.map((e) => [e.id, e]));
459
- for (const item of sharesToEnrich) {
460
- const sc = item.sharedContent;
461
- const enrich = enrichMap.get(sc.contentId);
462
- if (enrich) {
463
- sc.coverImageUrl = enrich.coverImageUrl;
464
- sc.description = enrich.description;
465
- }
466
- }
467
- }
468
- return { items, total };
469
- }
470
- export async function deletePost(db, postId, userId, hubId) {
471
- const post = await db
472
- .select({ authorId: hubPosts.authorId })
473
- .from(hubPosts)
474
- .where(eq(hubPosts.id, postId))
475
- .limit(1);
476
- if (post.length === 0)
477
- return false;
478
- if (post[0].authorId !== userId) {
479
- const member = await db
480
- .select({ role: hubMembers.role })
481
- .from(hubMembers)
482
- .where(and(eq(hubMembers.hubId, hubId), eq(hubMembers.userId, userId)))
483
- .limit(1);
484
- if (member.length === 0 || !hasPermission(member[0].role, 'deletePost')) {
485
- return false;
486
- }
487
- }
488
- await db.delete(hubPosts).where(eq(hubPosts.id, postId));
489
- await db
490
- .update(hubs)
491
- .set({ postCount: sql `GREATEST(${hubs.postCount} - 1, 0)` })
492
- .where(eq(hubs.id, hubId));
493
- return true;
494
- }
495
- export async function togglePinPost(db, postId, userId, hubId) {
496
- const member = await db
497
- .select({ role: hubMembers.role })
498
- .from(hubMembers)
499
- .where(and(eq(hubMembers.hubId, hubId), eq(hubMembers.userId, userId)))
500
- .limit(1);
501
- if (member.length === 0 || !hasPermission(member[0].role, 'pinPost')) {
502
- return null;
503
- }
504
- const post = await db
505
- .select({ isPinned: hubPosts.isPinned })
506
- .from(hubPosts)
507
- .where(eq(hubPosts.id, postId))
508
- .limit(1);
509
- if (post.length === 0)
510
- return null;
511
- const newPinned = !post[0].isPinned;
512
- await db
513
- .update(hubPosts)
514
- .set({ isPinned: newPinned, updatedAt: new Date() })
515
- .where(eq(hubPosts.id, postId));
516
- return { pinned: newPinned };
517
- }
518
- export async function toggleLockPost(db, postId, userId, hubId) {
519
- const member = await db
520
- .select({ role: hubMembers.role })
521
- .from(hubMembers)
522
- .where(and(eq(hubMembers.hubId, hubId), eq(hubMembers.userId, userId)))
523
- .limit(1);
524
- if (member.length === 0 || !hasPermission(member[0].role, 'lockPost')) {
525
- return null;
526
- }
527
- const post = await db
528
- .select({ isLocked: hubPosts.isLocked })
529
- .from(hubPosts)
530
- .where(eq(hubPosts.id, postId))
531
- .limit(1);
532
- if (post.length === 0)
533
- return null;
534
- const newLocked = !post[0].isLocked;
535
- await db
536
- .update(hubPosts)
537
- .set({ isLocked: newLocked, updatedAt: new Date() })
538
- .where(eq(hubPosts.id, postId));
539
- return { locked: newLocked };
540
- }
541
- /**
542
- * Get a single hub post by ID with author info.
543
- */
544
- export async function getPostById(db, postId) {
545
- const [row] = await db
546
- .select({
547
- post: hubPosts,
548
- author: USER_REF_SELECT,
549
- })
550
- .from(hubPosts)
551
- .innerJoin(users, eq(hubPosts.authorId, users.id))
552
- .where(eq(hubPosts.id, postId))
553
- .limit(1);
554
- if (!row)
555
- return null;
556
- const item = {
557
- id: row.post.id,
558
- hubId: row.post.hubId,
559
- type: row.post.type,
560
- content: row.post.content,
561
- isPinned: row.post.isPinned,
562
- isLocked: row.post.isLocked,
563
- likeCount: row.post.likeCount,
564
- replyCount: row.post.replyCount,
565
- createdAt: row.post.createdAt,
566
- updatedAt: row.post.updatedAt,
567
- author: row.author,
568
- };
569
- if (row.post.type === 'share') {
570
- try {
571
- item.sharedContent = JSON.parse(row.post.content);
572
- }
573
- catch { /* not JSON */ }
574
- }
575
- return item;
576
- }
577
- /**
578
- * Like a hub post. Returns true if liked, false if already liked.
579
- */
580
- export async function likePost(db, userId, postId) {
581
- // Use ON CONFLICT to handle concurrent requests atomically
582
- const result = await db
583
- .insert(hubPostLikes)
584
- .values({ postId, userId })
585
- .onConflictDoNothing({ target: [hubPostLikes.postId, hubPostLikes.userId] })
586
- .returning({ id: hubPostLikes.id });
587
- if (result.length === 0)
588
- return false; // Already liked
589
- await db.update(hubPosts).set({ likeCount: sql `${hubPosts.likeCount} + 1` }).where(eq(hubPosts.id, postId));
590
- return true;
591
- }
592
- /**
593
- * Unlike a hub post. Returns true if unliked, false if wasn't liked.
594
- */
595
- export async function unlikePost(db, userId, postId) {
596
- const existing = await db
597
- .select({ id: hubPostLikes.id })
598
- .from(hubPostLikes)
599
- .where(and(eq(hubPostLikes.postId, postId), eq(hubPostLikes.userId, userId)))
600
- .limit(1);
601
- if (existing.length === 0)
602
- return false;
603
- await db.delete(hubPostLikes).where(eq(hubPostLikes.id, existing[0].id));
604
- await db.update(hubPosts).set({ likeCount: sql `GREATEST(${hubPosts.likeCount} - 1, 0)` }).where(eq(hubPosts.id, postId));
605
- return true;
606
- }
607
- /**
608
- * Check if a user has liked a hub post.
609
- */
610
- export async function hasLikedPost(db, userId, postId) {
611
- const [row] = await db
612
- .select({ id: hubPostLikes.id })
613
- .from(hubPostLikes)
614
- .where(and(eq(hubPostLikes.postId, postId), eq(hubPostLikes.userId, userId)))
615
- .limit(1);
616
- return !!row;
617
- }
618
- export async function createReply(db, authorId, input) {
619
- const post = await db
620
- .select({ hubId: hubPosts.hubId, isLocked: hubPosts.isLocked })
621
- .from(hubPosts)
622
- .where(eq(hubPosts.id, input.postId))
623
- .limit(1);
624
- if (post.length === 0)
625
- throw new Error('Post not found');
626
- if (post[0].isLocked)
627
- throw new Error('Post is locked');
628
- const member = await db
629
- .select({ role: hubMembers.role })
630
- .from(hubMembers)
631
- .where(and(eq(hubMembers.hubId, post[0].hubId), eq(hubMembers.userId, authorId)))
632
- .limit(1);
633
- if (member.length === 0)
634
- throw new Error('Must be a member to reply');
635
- const ban = await checkBan(db, post[0].hubId, authorId);
636
- if (ban)
637
- throw new Error('You are banned from this hub');
638
- const [reply] = await db
639
- .insert(hubPostReplies)
640
- .values({
641
- postId: input.postId,
642
- authorId,
643
- content: input.content,
644
- parentId: input.parentId ?? null,
645
- })
646
- .returning();
647
- await db
648
- .update(hubPosts)
649
- .set({ replyCount: sql `${hubPosts.replyCount} + 1` })
650
- .where(eq(hubPosts.id, input.postId));
651
- const author = await db
652
- .select(USER_REF_SELECT)
653
- .from(users)
654
- .where(eq(users.id, authorId))
655
- .limit(1);
656
- return {
657
- id: reply.id,
658
- postId: reply.postId,
659
- content: reply.content,
660
- likeCount: 0,
661
- createdAt: reply.createdAt,
662
- updatedAt: reply.updatedAt,
663
- parentId: reply.parentId,
664
- author: author[0],
665
- };
666
- }
667
- export async function listReplies(db, postId, opts = {}) {
668
- const { limit, offset } = normalizePagination(opts);
669
- // Fetch root replies with pagination
670
- const rootWhere = and(eq(hubPostReplies.postId, postId), isNull(hubPostReplies.parentId));
671
- const [rootRows, total] = await Promise.all([
672
- db
673
- .select({ id: hubPostReplies.id })
674
- .from(hubPostReplies)
675
- .where(rootWhere)
676
- .orderBy(desc(hubPostReplies.createdAt))
677
- .limit(limit)
678
- .offset(offset),
679
- countRows(db, hubPostReplies, rootWhere),
680
- ]);
681
- if (rootRows.length === 0)
682
- return { items: [], total };
683
- const rootIds = rootRows.map((r) => r.id);
684
- // Fetch root + children in one query
685
- const rows = await db
686
- .select({
687
- reply: hubPostReplies,
688
- author: {
689
- id: users.id,
690
- username: users.username,
691
- displayName: users.displayName,
692
- avatarUrl: users.avatarUrl,
693
- },
694
- })
695
- .from(hubPostReplies)
696
- .innerJoin(users, eq(hubPostReplies.authorId, users.id))
697
- .where(and(eq(hubPostReplies.postId, postId), or(and(isNull(hubPostReplies.parentId), inArray(hubPostReplies.id, rootIds)), inArray(hubPostReplies.parentId, rootIds))))
698
- .orderBy(desc(hubPostReplies.createdAt));
699
- const replyMap = new Map();
700
- const rootReplies = [];
701
- for (const row of rows) {
702
- const item = {
703
- id: row.reply.id,
704
- postId: row.reply.postId,
705
- content: row.reply.content,
706
- likeCount: row.reply.likeCount,
707
- createdAt: row.reply.createdAt,
708
- updatedAt: row.reply.updatedAt,
709
- parentId: row.reply.parentId,
710
- author: row.author,
711
- replies: [],
712
- };
713
- replyMap.set(item.id, item);
714
- }
715
- // Preserve root ordering
716
- for (const rootId of rootIds) {
717
- const item = replyMap.get(rootId);
718
- if (item)
719
- rootReplies.push(item);
720
- }
721
- for (const item of replyMap.values()) {
722
- if (item.parentId && replyMap.has(item.parentId)) {
723
- replyMap.get(item.parentId).replies.push(item);
724
- }
725
- }
726
- return { items: rootReplies, total };
727
- }
728
- export async function deleteReply(db, replyId, userId, hubId) {
729
- const reply = await db
730
- .select({ authorId: hubPostReplies.authorId, postId: hubPostReplies.postId })
731
- .from(hubPostReplies)
732
- .where(eq(hubPostReplies.id, replyId))
733
- .limit(1);
734
- if (reply.length === 0)
735
- return false;
736
- if (reply[0].authorId !== userId) {
737
- const member = await db
738
- .select({ role: hubMembers.role })
739
- .from(hubMembers)
740
- .where(and(eq(hubMembers.hubId, hubId), eq(hubMembers.userId, userId)))
741
- .limit(1);
742
- if (member.length === 0 || !hasPermission(member[0].role, 'deletePost')) {
743
- return false;
744
- }
745
- }
746
- await db.delete(hubPostReplies).where(eq(hubPostReplies.id, replyId));
747
- await db
748
- .update(hubPosts)
749
- .set({ replyCount: sql `GREATEST(${hubPosts.replyCount} - 1, 0)` })
750
- .where(eq(hubPosts.id, reply[0].postId));
751
- return true;
752
- }
753
- // --- Bans ---
754
- export async function banUser(db, actorId, hubId, targetUserId, reason, expiresAt) {
755
- const [actorMember, targetMember] = await Promise.all([
756
- db
757
- .select({ role: hubMembers.role })
758
- .from(hubMembers)
759
- .where(and(eq(hubMembers.hubId, hubId), eq(hubMembers.userId, actorId)))
760
- .limit(1),
761
- db
762
- .select({ role: hubMembers.role })
763
- .from(hubMembers)
764
- .where(and(eq(hubMembers.hubId, hubId), eq(hubMembers.userId, targetUserId)))
765
- .limit(1),
766
- ]);
767
- if (actorMember.length === 0 || !hasPermission(actorMember[0].role, 'banUser')) {
768
- return { banned: false, error: 'Insufficient permissions' };
769
- }
770
- if (actorMember[0].role === 'moderator' && !expiresAt) {
771
- return { banned: false, error: 'Moderators can only issue temporary bans' };
772
- }
773
- if (targetMember.length > 0) {
774
- if (!canManageRole(actorMember[0].role, targetMember[0].role)) {
775
- return { banned: false, error: 'Cannot ban a user with equal or higher role' };
776
- }
777
- await db
778
- .delete(hubMembers)
779
- .where(and(eq(hubMembers.hubId, hubId), eq(hubMembers.userId, targetUserId)));
780
- await db
781
- .update(hubs)
782
- .set({ memberCount: sql `GREATEST(${hubs.memberCount} - 1, 0)` })
783
- .where(eq(hubs.id, hubId));
784
- }
785
- await db.insert(hubBans).values({
786
- hubId,
787
- userId: targetUserId,
788
- bannedById: actorId,
789
- reason: reason ?? null,
790
- expiresAt: expiresAt ?? null,
791
- }).onConflictDoNothing();
792
- return { banned: true };
793
- }
794
- export async function unbanUser(db, actorId, hubId, targetUserId) {
795
- const actorMember = await db
796
- .select({ role: hubMembers.role })
797
- .from(hubMembers)
798
- .where(and(eq(hubMembers.hubId, hubId), eq(hubMembers.userId, actorId)))
799
- .limit(1);
800
- if (actorMember.length === 0 || !hasPermission(actorMember[0].role, 'banUser')) {
801
- return { unbanned: false, error: 'Insufficient permissions' };
802
- }
803
- await db
804
- .delete(hubBans)
805
- .where(and(eq(hubBans.hubId, hubId), eq(hubBans.userId, targetUserId)));
806
- return { unbanned: true };
807
- }
808
- export async function checkBan(db, hubId, userId) {
809
- const rows = await db
810
- .select({
811
- id: hubBans.id,
812
- reason: hubBans.reason,
813
- expiresAt: hubBans.expiresAt,
814
- })
815
- .from(hubBans)
816
- .where(and(eq(hubBans.hubId, hubId), eq(hubBans.userId, userId)))
817
- .limit(1);
818
- if (rows.length === 0)
819
- return null;
820
- const ban = rows[0];
821
- if (ban.expiresAt && ban.expiresAt < new Date()) {
822
- await db.delete(hubBans).where(eq(hubBans.id, ban.id));
823
- return null;
824
- }
825
- return ban;
826
- }
827
- export async function listBans(db, hubId, opts = {}) {
828
- // Alias for the banner user (self-join on users table)
829
- const bannerUser = {
830
- bannerId: sql `banner.id`.as('banner_id'),
831
- bannerUsername: sql `banner.username`.as('banner_username'),
832
- bannerDisplayName: sql `banner.display_name`.as('banner_display_name'),
833
- bannerAvatarUrl: sql `banner.avatar_url`.as('banner_avatar_url'),
834
- };
835
- const limit = Math.min(opts.limit ?? 50, 100);
836
- const offset = opts.offset ?? 0;
837
- const rows = await db
838
- .select({
839
- ban: hubBans,
840
- user: USER_REF_SELECT,
841
- ...bannerUser,
842
- })
843
- .from(hubBans)
844
- .innerJoin(users, eq(hubBans.userId, users.id))
845
- .innerJoin(sql `users AS banner`, sql `banner.id = ${hubBans.bannedById}`)
846
- .where(eq(hubBans.hubId, hubId))
847
- .orderBy(desc(hubBans.createdAt))
848
- .limit(limit)
849
- .offset(offset);
850
- return rows.map((row) => ({
851
- id: row.ban.id,
852
- reason: row.ban.reason,
853
- expiresAt: row.ban.expiresAt,
854
- createdAt: row.ban.createdAt,
855
- user: row.user,
856
- bannedBy: {
857
- id: row.bannerId,
858
- username: row.bannerUsername,
859
- displayName: row.bannerDisplayName,
860
- avatarUrl: row.bannerAvatarUrl,
861
- },
862
- }));
863
- }
864
- // --- Invites ---
865
- export async function createInvite(db, userId, hubId, maxUses, expiresAt) {
866
- const member = await db
867
- .select({ role: hubMembers.role })
868
- .from(hubMembers)
869
- .where(and(eq(hubMembers.hubId, hubId), eq(hubMembers.userId, userId)))
870
- .limit(1);
871
- if (member.length === 0 || !hasPermission(member[0].role, 'manageMembers')) {
872
- return null;
873
- }
874
- const token = crypto.randomUUID().replace(/-/g, '');
875
- const [invite] = await db
876
- .insert(hubInvites)
877
- .values({
878
- hubId,
879
- createdById: userId,
880
- token,
881
- maxUses: maxUses ?? null,
882
- expiresAt: expiresAt ?? null,
883
- })
884
- .returning();
885
- const author = await db
886
- .select(USER_REF_SELECT)
887
- .from(users)
888
- .where(eq(users.id, userId))
889
- .limit(1);
890
- return {
891
- id: invite.id,
892
- token: invite.token,
893
- maxUses: invite.maxUses,
894
- useCount: 0,
895
- expiresAt: invite.expiresAt,
896
- createdAt: invite.createdAt,
897
- createdBy: author[0],
898
- };
899
- }
900
- export async function validateAndUseInvite(db, token) {
901
- const updated = await db
902
- .update(hubInvites)
903
- .set({ useCount: sql `${hubInvites.useCount} + 1` })
904
- .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})`))
905
- .returning({ hubId: hubInvites.hubId });
906
- if (updated.length === 0)
907
- return { valid: false };
908
- return { valid: true, hubId: updated[0].hubId };
909
- }
910
- export async function revokeInvite(db, inviteId, userId, hubId) {
911
- const member = await db
912
- .select({ role: hubMembers.role })
913
- .from(hubMembers)
914
- .where(and(eq(hubMembers.hubId, hubId), eq(hubMembers.userId, userId)))
915
- .limit(1);
916
- if (member.length === 0 || !hasPermission(member[0].role, 'manageMembers')) {
917
- return false;
918
- }
919
- await db.delete(hubInvites).where(eq(hubInvites.id, inviteId));
920
- return true;
921
- }
922
- export async function listInvites(db, hubId, opts = {}) {
923
- const limit = Math.min(opts.limit ?? 50, 100);
924
- const offset = opts.offset ?? 0;
925
- const rows = await db
926
- .select({
927
- invite: hubInvites,
928
- createdBy: {
929
- id: users.id,
930
- username: users.username,
931
- displayName: users.displayName,
932
- avatarUrl: users.avatarUrl,
933
- },
934
- })
935
- .from(hubInvites)
936
- .innerJoin(users, eq(hubInvites.createdById, users.id))
937
- .where(eq(hubInvites.hubId, hubId))
938
- .orderBy(desc(hubInvites.createdAt))
939
- .limit(limit)
940
- .offset(offset);
941
- return rows.map((row) => ({
942
- id: row.invite.id,
943
- token: row.invite.token,
944
- maxUses: row.invite.maxUses,
945
- useCount: row.invite.useCount,
946
- expiresAt: row.invite.expiresAt,
947
- createdAt: row.invite.createdAt,
948
- createdBy: row.createdBy,
949
- }));
950
- }
951
- // --- Content Sharing ---
952
- export async function shareContent(db, userId, hubId, contentId) {
953
- const member = await db
954
- .select({ role: hubMembers.role })
955
- .from(hubMembers)
956
- .where(and(eq(hubMembers.hubId, hubId), eq(hubMembers.userId, userId)))
957
- .limit(1);
958
- if (member.length === 0)
959
- return null;
960
- const content = await db
961
- .select({
962
- id: contentItems.id,
963
- title: contentItems.title,
964
- slug: contentItems.slug,
965
- type: contentItems.type,
966
- coverImageUrl: contentItems.coverImageUrl,
967
- description: contentItems.description,
968
- })
969
- .from(contentItems)
970
- .where(eq(contentItems.id, contentId))
971
- .limit(1);
972
- if (content.length === 0)
973
- return null;
974
- // Check for duplicate share
975
- const existing = await db
976
- .select({ id: hubShares.id })
977
- .from(hubShares)
978
- .where(and(eq(hubShares.hubId, hubId), eq(hubShares.contentId, contentId)))
979
- .limit(1);
980
- if (existing.length > 0)
981
- return null;
982
- const sharePayload = JSON.stringify({
983
- contentId: content[0].id,
984
- title: content[0].title,
985
- slug: content[0].slug,
986
- type: content[0].type,
987
- coverImageUrl: content[0].coverImageUrl ?? null,
988
- description: content[0].description ?? null,
989
- });
990
- await db.insert(hubShares).values({
991
- hubId,
992
- contentId,
993
- sharedById: userId,
994
- });
995
- return createPost(db, userId, {
996
- hubId,
997
- type: 'share',
998
- content: sharePayload,
999
- });
1000
- }
1001
- export async function unshareContent(db, userId, hubId, contentId) {
1002
- const share = await db
1003
- .select({ id: hubShares.id })
1004
- .from(hubShares)
1005
- .where(and(eq(hubShares.hubId, hubId), eq(hubShares.contentId, contentId), eq(hubShares.sharedById, userId)))
1006
- .limit(1);
1007
- if (share.length === 0)
1008
- return false;
1009
- await db.delete(hubShares).where(eq(hubShares.id, share[0].id));
1010
- return true;
1011
- }
1012
- export async function listShares(db, hubId) {
1013
- return db
1014
- .select({
1015
- id: hubShares.id,
1016
- contentId: hubShares.contentId,
1017
- sharedById: hubShares.sharedById,
1018
- createdAt: hubShares.createdAt,
1019
- })
1020
- .from(hubShares)
1021
- .where(eq(hubShares.hubId, hubId))
1022
- .orderBy(desc(hubShares.createdAt));
1023
- }
1024
227
  //# sourceMappingURL=hub.js.map