@commonpub/server 2.3.3 → 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,831 +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
- 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
- /**
524
- * Get a single hub post by ID with author info.
525
- */
526
- export async function getPostById(db, postId) {
527
- const [row] = await db
528
- .select({
529
- post: hubPosts,
530
- author: USER_REF_SELECT,
531
- })
532
- .from(hubPosts)
533
- .innerJoin(users, eq(hubPosts.authorId, users.id))
534
- .where(eq(hubPosts.id, postId))
535
- .limit(1);
536
- if (!row)
537
- return null;
538
- const item = {
539
- id: row.post.id,
540
- hubId: row.post.hubId,
541
- type: row.post.type,
542
- content: row.post.content,
543
- isPinned: row.post.isPinned,
544
- isLocked: row.post.isLocked,
545
- likeCount: row.post.likeCount,
546
- replyCount: row.post.replyCount,
547
- createdAt: row.post.createdAt,
548
- updatedAt: row.post.updatedAt,
549
- author: row.author,
550
- };
551
- if (row.post.type === 'share') {
552
- try {
553
- item.sharedContent = JSON.parse(row.post.content);
554
- }
555
- catch { /* not JSON */ }
556
- }
557
- return item;
558
- }
559
- /**
560
- * Like a hub post. Returns true if liked, false if already liked.
561
- */
562
- export async function likePost(db, userId, postId) {
563
- // Use ON CONFLICT to handle concurrent requests atomically
564
- const result = await db
565
- .insert(hubPostLikes)
566
- .values({ postId, userId })
567
- .onConflictDoNothing({ target: [hubPostLikes.postId, hubPostLikes.userId] })
568
- .returning({ id: hubPostLikes.id });
569
- if (result.length === 0)
570
- return false; // Already liked
571
- await db.update(hubPosts).set({ likeCount: sql `${hubPosts.likeCount} + 1` }).where(eq(hubPosts.id, postId));
572
- return true;
573
- }
574
- /**
575
- * Unlike a hub post. Returns true if unliked, false if wasn't liked.
576
- */
577
- export async function unlikePost(db, userId, postId) {
578
- const existing = await db
579
- .select({ id: hubPostLikes.id })
580
- .from(hubPostLikes)
581
- .where(and(eq(hubPostLikes.postId, postId), eq(hubPostLikes.userId, userId)))
582
- .limit(1);
583
- if (existing.length === 0)
584
- return false;
585
- await db.delete(hubPostLikes).where(eq(hubPostLikes.id, existing[0].id));
586
- await db.update(hubPosts).set({ likeCount: sql `GREATEST(${hubPosts.likeCount} - 1, 0)` }).where(eq(hubPosts.id, postId));
587
- return true;
588
- }
589
- /**
590
- * Check if a user has liked a hub post.
591
- */
592
- export async function hasLikedPost(db, userId, postId) {
593
- const [row] = await db
594
- .select({ id: hubPostLikes.id })
595
- .from(hubPostLikes)
596
- .where(and(eq(hubPostLikes.postId, postId), eq(hubPostLikes.userId, userId)))
597
- .limit(1);
598
- return !!row;
599
- }
600
- export async function createReply(db, authorId, input) {
601
- const post = await db
602
- .select({ hubId: hubPosts.hubId, isLocked: hubPosts.isLocked })
603
- .from(hubPosts)
604
- .where(eq(hubPosts.id, input.postId))
605
- .limit(1);
606
- if (post.length === 0)
607
- throw new Error('Post not found');
608
- if (post[0].isLocked)
609
- throw new Error('Post is locked');
610
- const member = await db
611
- .select({ role: hubMembers.role })
612
- .from(hubMembers)
613
- .where(and(eq(hubMembers.hubId, post[0].hubId), eq(hubMembers.userId, authorId)))
614
- .limit(1);
615
- if (member.length === 0)
616
- throw new Error('Must be a member to reply');
617
- const ban = await checkBan(db, post[0].hubId, authorId);
618
- if (ban)
619
- throw new Error('You are banned from this hub');
620
- const [reply] = await db
621
- .insert(hubPostReplies)
622
- .values({
623
- postId: input.postId,
624
- authorId,
625
- content: input.content,
626
- parentId: input.parentId ?? null,
627
- })
628
- .returning();
629
- await db
630
- .update(hubPosts)
631
- .set({ replyCount: sql `${hubPosts.replyCount} + 1` })
632
- .where(eq(hubPosts.id, input.postId));
633
- const author = await db
634
- .select(USER_REF_SELECT)
635
- .from(users)
636
- .where(eq(users.id, authorId))
637
- .limit(1);
638
- return {
639
- id: reply.id,
640
- postId: reply.postId,
641
- content: reply.content,
642
- likeCount: 0,
643
- createdAt: reply.createdAt,
644
- updatedAt: reply.updatedAt,
645
- parentId: reply.parentId,
646
- author: author[0],
647
- };
648
- }
649
- export async function listReplies(db, postId, opts = {}) {
650
- const { limit, offset } = normalizePagination(opts);
651
- // Fetch root replies with pagination
652
- const rootWhere = and(eq(hubPostReplies.postId, postId), isNull(hubPostReplies.parentId));
653
- const [rootRows, total] = await Promise.all([
654
- db
655
- .select({ id: hubPostReplies.id })
656
- .from(hubPostReplies)
657
- .where(rootWhere)
658
- .orderBy(desc(hubPostReplies.createdAt))
659
- .limit(limit)
660
- .offset(offset),
661
- countRows(db, hubPostReplies, rootWhere),
662
- ]);
663
- if (rootRows.length === 0)
664
- return { items: [], total };
665
- const rootIds = rootRows.map((r) => r.id);
666
- // Fetch root + children in one query
667
- const rows = await db
668
- .select({
669
- reply: hubPostReplies,
670
- author: {
671
- id: users.id,
672
- username: users.username,
673
- displayName: users.displayName,
674
- avatarUrl: users.avatarUrl,
675
- },
676
- })
677
- .from(hubPostReplies)
678
- .innerJoin(users, eq(hubPostReplies.authorId, users.id))
679
- .where(and(eq(hubPostReplies.postId, postId), or(and(isNull(hubPostReplies.parentId), inArray(hubPostReplies.id, rootIds)), inArray(hubPostReplies.parentId, rootIds))))
680
- .orderBy(desc(hubPostReplies.createdAt));
681
- const replyMap = new Map();
682
- const rootReplies = [];
683
- for (const row of rows) {
684
- const item = {
685
- id: row.reply.id,
686
- postId: row.reply.postId,
687
- content: row.reply.content,
688
- likeCount: row.reply.likeCount,
689
- createdAt: row.reply.createdAt,
690
- updatedAt: row.reply.updatedAt,
691
- parentId: row.reply.parentId,
692
- author: row.author,
693
- replies: [],
694
- };
695
- replyMap.set(item.id, item);
696
- }
697
- // Preserve root ordering
698
- for (const rootId of rootIds) {
699
- const item = replyMap.get(rootId);
700
- if (item)
701
- rootReplies.push(item);
702
- }
703
- for (const item of replyMap.values()) {
704
- if (item.parentId && replyMap.has(item.parentId)) {
705
- replyMap.get(item.parentId).replies.push(item);
706
- }
707
- }
708
- return { items: rootReplies, total };
709
- }
710
- export async function deleteReply(db, replyId, userId, hubId) {
711
- const reply = await db
712
- .select({ authorId: hubPostReplies.authorId, postId: hubPostReplies.postId })
713
- .from(hubPostReplies)
714
- .where(eq(hubPostReplies.id, replyId))
715
- .limit(1);
716
- if (reply.length === 0)
717
- return false;
718
- if (reply[0].authorId !== userId) {
719
- const member = await db
720
- .select({ role: hubMembers.role })
721
- .from(hubMembers)
722
- .where(and(eq(hubMembers.hubId, hubId), eq(hubMembers.userId, userId)))
723
- .limit(1);
724
- if (member.length === 0 || !hasPermission(member[0].role, 'deletePost')) {
725
- return false;
726
- }
727
- }
728
- await db.delete(hubPostReplies).where(eq(hubPostReplies.id, replyId));
729
- await db
730
- .update(hubPosts)
731
- .set({ replyCount: sql `GREATEST(${hubPosts.replyCount} - 1, 0)` })
732
- .where(eq(hubPosts.id, reply[0].postId));
733
- return true;
734
- }
735
- // --- Bans ---
736
- export async function banUser(db, actorId, hubId, targetUserId, reason, expiresAt) {
737
- const [actorMember, targetMember] = await Promise.all([
738
- db
739
- .select({ role: hubMembers.role })
740
- .from(hubMembers)
741
- .where(and(eq(hubMembers.hubId, hubId), eq(hubMembers.userId, actorId)))
742
- .limit(1),
743
- db
744
- .select({ role: hubMembers.role })
745
- .from(hubMembers)
746
- .where(and(eq(hubMembers.hubId, hubId), eq(hubMembers.userId, targetUserId)))
747
- .limit(1),
748
- ]);
749
- if (actorMember.length === 0 || !hasPermission(actorMember[0].role, 'banUser')) {
750
- return { banned: false, error: 'Insufficient permissions' };
751
- }
752
- if (actorMember[0].role === 'moderator' && !expiresAt) {
753
- return { banned: false, error: 'Moderators can only issue temporary bans' };
754
- }
755
- if (targetMember.length > 0) {
756
- if (!canManageRole(actorMember[0].role, targetMember[0].role)) {
757
- return { banned: false, error: 'Cannot ban a user with equal or higher role' };
758
- }
759
- await db
760
- .delete(hubMembers)
761
- .where(and(eq(hubMembers.hubId, hubId), eq(hubMembers.userId, targetUserId)));
762
- await db
763
- .update(hubs)
764
- .set({ memberCount: sql `GREATEST(${hubs.memberCount} - 1, 0)` })
765
- .where(eq(hubs.id, hubId));
766
- }
767
- await db.insert(hubBans).values({
768
- hubId,
769
- userId: targetUserId,
770
- bannedById: actorId,
771
- reason: reason ?? null,
772
- expiresAt: expiresAt ?? null,
773
- }).onConflictDoNothing();
774
- return { banned: true };
775
- }
776
- export async function unbanUser(db, actorId, hubId, targetUserId) {
777
- const actorMember = await db
778
- .select({ role: hubMembers.role })
779
- .from(hubMembers)
780
- .where(and(eq(hubMembers.hubId, hubId), eq(hubMembers.userId, actorId)))
781
- .limit(1);
782
- if (actorMember.length === 0 || !hasPermission(actorMember[0].role, 'banUser')) {
783
- return { unbanned: false, error: 'Insufficient permissions' };
784
- }
785
- await db
786
- .delete(hubBans)
787
- .where(and(eq(hubBans.hubId, hubId), eq(hubBans.userId, targetUserId)));
788
- return { unbanned: true };
789
- }
790
- export async function checkBan(db, hubId, userId) {
791
- const rows = await db
792
- .select({
793
- id: hubBans.id,
794
- reason: hubBans.reason,
795
- expiresAt: hubBans.expiresAt,
796
- })
797
- .from(hubBans)
798
- .where(and(eq(hubBans.hubId, hubId), eq(hubBans.userId, userId)))
799
- .limit(1);
800
- if (rows.length === 0)
801
- return null;
802
- const ban = rows[0];
803
- if (ban.expiresAt && ban.expiresAt < new Date()) {
804
- await db.delete(hubBans).where(eq(hubBans.id, ban.id));
805
- return null;
806
- }
807
- return ban;
808
- }
809
- export async function listBans(db, hubId, opts = {}) {
810
- // Alias for the banner user (self-join on users table)
811
- const bannerUser = {
812
- bannerId: sql `banner.id`.as('banner_id'),
813
- bannerUsername: sql `banner.username`.as('banner_username'),
814
- bannerDisplayName: sql `banner.display_name`.as('banner_display_name'),
815
- bannerAvatarUrl: sql `banner.avatar_url`.as('banner_avatar_url'),
816
- };
817
- const limit = Math.min(opts.limit ?? 50, 100);
818
- const offset = opts.offset ?? 0;
819
- const rows = await db
820
- .select({
821
- ban: hubBans,
822
- user: USER_REF_SELECT,
823
- ...bannerUser,
824
- })
825
- .from(hubBans)
826
- .innerJoin(users, eq(hubBans.userId, users.id))
827
- .innerJoin(sql `users AS banner`, sql `banner.id = ${hubBans.bannedById}`)
828
- .where(eq(hubBans.hubId, hubId))
829
- .orderBy(desc(hubBans.createdAt))
830
- .limit(limit)
831
- .offset(offset);
832
- return rows.map((row) => ({
833
- id: row.ban.id,
834
- reason: row.ban.reason,
835
- expiresAt: row.ban.expiresAt,
836
- createdAt: row.ban.createdAt,
837
- user: row.user,
838
- bannedBy: {
839
- id: row.bannerId,
840
- username: row.bannerUsername,
841
- displayName: row.bannerDisplayName,
842
- avatarUrl: row.bannerAvatarUrl,
843
- },
844
- }));
845
- }
846
- // --- Invites ---
847
- export async function createInvite(db, userId, hubId, maxUses, expiresAt) {
848
- const member = await db
849
- .select({ role: hubMembers.role })
850
- .from(hubMembers)
851
- .where(and(eq(hubMembers.hubId, hubId), eq(hubMembers.userId, userId)))
852
- .limit(1);
853
- if (member.length === 0 || !hasPermission(member[0].role, 'manageMembers')) {
854
- return null;
855
- }
856
- const token = crypto.randomUUID().replace(/-/g, '');
857
- const [invite] = await db
858
- .insert(hubInvites)
859
- .values({
860
- hubId,
861
- createdById: userId,
862
- token,
863
- maxUses: maxUses ?? null,
864
- expiresAt: expiresAt ?? null,
865
- })
866
- .returning();
867
- const author = await db
868
- .select(USER_REF_SELECT)
869
- .from(users)
870
- .where(eq(users.id, userId))
871
- .limit(1);
872
- return {
873
- id: invite.id,
874
- token: invite.token,
875
- maxUses: invite.maxUses,
876
- useCount: 0,
877
- expiresAt: invite.expiresAt,
878
- createdAt: invite.createdAt,
879
- createdBy: author[0],
880
- };
881
- }
882
- export async function validateAndUseInvite(db, token) {
883
- const updated = await db
884
- .update(hubInvites)
885
- .set({ useCount: sql `${hubInvites.useCount} + 1` })
886
- .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})`))
887
- .returning({ hubId: hubInvites.hubId });
888
- if (updated.length === 0)
889
- return { valid: false };
890
- return { valid: true, hubId: updated[0].hubId };
891
- }
892
- export async function revokeInvite(db, inviteId, userId, hubId) {
893
- const member = await db
894
- .select({ role: hubMembers.role })
895
- .from(hubMembers)
896
- .where(and(eq(hubMembers.hubId, hubId), eq(hubMembers.userId, userId)))
897
- .limit(1);
898
- if (member.length === 0 || !hasPermission(member[0].role, 'manageMembers')) {
899
- return false;
900
- }
901
- await db.delete(hubInvites).where(eq(hubInvites.id, inviteId));
902
- return true;
903
- }
904
- export async function listInvites(db, hubId, opts = {}) {
905
- const limit = Math.min(opts.limit ?? 50, 100);
906
- const offset = opts.offset ?? 0;
907
- const rows = await db
908
- .select({
909
- invite: hubInvites,
910
- createdBy: {
911
- id: users.id,
912
- username: users.username,
913
- displayName: users.displayName,
914
- avatarUrl: users.avatarUrl,
915
- },
916
- })
917
- .from(hubInvites)
918
- .innerJoin(users, eq(hubInvites.createdById, users.id))
919
- .where(eq(hubInvites.hubId, hubId))
920
- .orderBy(desc(hubInvites.createdAt))
921
- .limit(limit)
922
- .offset(offset);
923
- return rows.map((row) => ({
924
- id: row.invite.id,
925
- token: row.invite.token,
926
- maxUses: row.invite.maxUses,
927
- useCount: row.invite.useCount,
928
- expiresAt: row.invite.expiresAt,
929
- createdAt: row.invite.createdAt,
930
- createdBy: row.createdBy,
931
- }));
932
- }
933
- // --- Content Sharing ---
934
- export async function shareContent(db, userId, hubId, contentId) {
935
- const member = await db
936
- .select({ role: hubMembers.role })
937
- .from(hubMembers)
938
- .where(and(eq(hubMembers.hubId, hubId), eq(hubMembers.userId, userId)))
939
- .limit(1);
940
- if (member.length === 0)
941
- return null;
942
- const content = await db
943
- .select({
944
- id: contentItems.id,
945
- title: contentItems.title,
946
- slug: contentItems.slug,
947
- type: contentItems.type,
948
- coverImageUrl: contentItems.coverImageUrl,
949
- description: contentItems.description,
950
- })
951
- .from(contentItems)
952
- .where(eq(contentItems.id, contentId))
953
- .limit(1);
954
- if (content.length === 0)
955
- return null;
956
- // Check for duplicate share
957
- const existing = await db
958
- .select({ id: hubShares.id })
959
- .from(hubShares)
960
- .where(and(eq(hubShares.hubId, hubId), eq(hubShares.contentId, contentId)))
961
- .limit(1);
962
- if (existing.length > 0)
963
- return null;
964
- const sharePayload = JSON.stringify({
965
- contentId: content[0].id,
966
- title: content[0].title,
967
- slug: content[0].slug,
968
- type: content[0].type,
969
- coverImageUrl: content[0].coverImageUrl ?? null,
970
- description: content[0].description ?? null,
971
- });
972
- await db.insert(hubShares).values({
973
- hubId,
974
- contentId,
975
- sharedById: userId,
976
- });
977
- return createPost(db, userId, {
978
- hubId,
979
- type: 'share',
980
- content: sharePayload,
981
- });
982
- }
983
- export async function unshareContent(db, userId, hubId, contentId) {
984
- const share = await db
985
- .select({ id: hubShares.id })
986
- .from(hubShares)
987
- .where(and(eq(hubShares.hubId, hubId), eq(hubShares.contentId, contentId), eq(hubShares.sharedById, userId)))
988
- .limit(1);
989
- if (share.length === 0)
990
- return false;
991
- await db.delete(hubShares).where(eq(hubShares.id, share[0].id));
992
- return true;
993
- }
994
- export async function listShares(db, hubId) {
995
- return db
996
- .select({
997
- id: hubShares.id,
998
- contentId: hubShares.contentId,
999
- sharedById: hubShares.sharedById,
1000
- createdAt: hubShares.createdAt,
1001
- })
1002
- .from(hubShares)
1003
- .where(eq(hubShares.hubId, hubId))
1004
- .orderBy(desc(hubShares.createdAt));
1005
- }
1006
227
  //# sourceMappingURL=hub.js.map