@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.
- package/LICENSE +661 -0
- package/README.md +59 -0
- package/dist/admin/admin.d.ts +113 -0
- package/dist/admin/admin.d.ts.map +1 -0
- package/dist/admin/admin.js +426 -0
- package/dist/admin/admin.js.map +1 -0
- package/dist/admin/index.d.ts +3 -0
- package/dist/admin/index.d.ts.map +1 -0
- package/dist/admin/index.js +2 -0
- package/dist/admin/index.js.map +1 -0
- package/dist/content/content.d.ts +39 -0
- package/dist/content/content.d.ts.map +1 -0
- package/dist/content/content.js +507 -0
- package/dist/content/content.js.map +1 -0
- package/dist/content/index.d.ts +3 -0
- package/dist/content/index.d.ts.map +1 -0
- package/dist/content/index.js +2 -0
- package/dist/content/index.js.map +1 -0
- package/dist/contest/contest.d.ts +92 -0
- package/dist/contest/contest.d.ts.map +1 -0
- package/dist/contest/contest.js +343 -0
- package/dist/contest/contest.js.map +1 -0
- package/dist/contest/index.d.ts +3 -0
- package/dist/contest/index.d.ts.map +1 -0
- package/dist/contest/index.js +2 -0
- package/dist/contest/index.js.map +1 -0
- package/dist/docs/docs.d.ts +71 -0
- package/dist/docs/docs.d.ts.map +1 -0
- package/dist/docs/docs.js +398 -0
- package/dist/docs/docs.js.map +1 -0
- package/dist/docs/index.d.ts +2 -0
- package/dist/docs/index.d.ts.map +1 -0
- package/dist/docs/index.js +2 -0
- package/dist/docs/index.js.map +1 -0
- package/dist/email.d.ts +3 -0
- package/dist/email.d.ts.map +1 -0
- package/dist/email.js +3 -0
- package/dist/email.js.map +1 -0
- package/dist/federation/federation.d.ts +46 -0
- package/dist/federation/federation.d.ts.map +1 -0
- package/dist/federation/federation.js +308 -0
- package/dist/federation/federation.js.map +1 -0
- package/dist/federation/index.d.ts +2 -0
- package/dist/federation/index.d.ts.map +1 -0
- package/dist/federation/index.js +2 -0
- package/dist/federation/index.js.map +1 -0
- package/dist/hub/hub.d.ts +110 -0
- package/dist/hub/hub.d.ts.map +1 -0
- package/dist/hub/hub.js +917 -0
- package/dist/hub/hub.js.map +1 -0
- package/dist/hub/index.d.ts +2 -0
- package/dist/hub/index.d.ts.map +1 -0
- package/dist/hub/index.js +2 -0
- package/dist/hub/index.js.map +1 -0
- package/dist/image.d.ts +3 -0
- package/dist/image.d.ts.map +1 -0
- package/dist/image.js +3 -0
- package/dist/image.js.map +1 -0
- package/dist/index.d.ts +38 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +43 -0
- package/dist/index.js.map +1 -0
- package/dist/learning/index.d.ts +6 -0
- package/dist/learning/index.d.ts.map +1 -0
- package/dist/learning/index.js +6 -0
- package/dist/learning/index.js.map +1 -0
- package/dist/learning/learning.d.ts +85 -0
- package/dist/learning/learning.d.ts.map +1 -0
- package/dist/learning/learning.js +681 -0
- package/dist/learning/learning.js.map +1 -0
- package/dist/messaging/index.d.ts +3 -0
- package/dist/messaging/index.d.ts.map +1 -0
- package/dist/messaging/index.js +2 -0
- package/dist/messaging/index.js.map +1 -0
- package/dist/messaging/messaging.d.ts +27 -0
- package/dist/messaging/messaging.d.ts.map +1 -0
- package/dist/messaging/messaging.js +132 -0
- package/dist/messaging/messaging.js.map +1 -0
- package/dist/notification/index.d.ts +3 -0
- package/dist/notification/index.d.ts.map +1 -0
- package/dist/notification/index.js +2 -0
- package/dist/notification/index.js.map +1 -0
- package/dist/notification/notification.d.ts +38 -0
- package/dist/notification/notification.d.ts.map +1 -0
- package/dist/notification/notification.js +92 -0
- package/dist/notification/notification.js.map +1 -0
- package/dist/oauthCodes.d.ts +14 -0
- package/dist/oauthCodes.d.ts.map +1 -0
- package/dist/oauthCodes.js +40 -0
- package/dist/oauthCodes.js.map +1 -0
- package/dist/product/index.d.ts +3 -0
- package/dist/product/index.d.ts.map +1 -0
- package/dist/product/index.js +2 -0
- package/dist/product/index.js.map +1 -0
- package/dist/product/product.d.ts +143 -0
- package/dist/product/product.d.ts.map +1 -0
- package/dist/product/product.js +493 -0
- package/dist/product/product.js.map +1 -0
- package/dist/profile/index.d.ts +2 -0
- package/dist/profile/index.d.ts.map +1 -0
- package/dist/profile/index.js +2 -0
- package/dist/profile/index.js.map +1 -0
- package/dist/profile/profile.d.ts +28 -0
- package/dist/profile/profile.d.ts.map +1 -0
- package/dist/profile/profile.js +122 -0
- package/dist/profile/profile.js.map +1 -0
- package/dist/query.d.ts +331 -0
- package/dist/query.d.ts.map +1 -0
- package/dist/query.js +103 -0
- package/dist/query.js.map +1 -0
- package/dist/security.d.ts +3 -0
- package/dist/security.d.ts.map +1 -0
- package/dist/security.js +3 -0
- package/dist/security.js.map +1 -0
- package/dist/social/index.d.ts +3 -0
- package/dist/social/index.d.ts.map +1 -0
- package/dist/social/index.js +2 -0
- package/dist/social/index.js.map +1 -0
- package/dist/social/social.d.ts +84 -0
- package/dist/social/social.d.ts.map +1 -0
- package/dist/social/social.js +353 -0
- package/dist/social/social.js.map +1 -0
- package/dist/storage.d.ts +3 -0
- package/dist/storage.d.ts.map +1 -0
- package/dist/storage.js +3 -0
- package/dist/storage.js.map +1 -0
- package/dist/theme.d.ts +12 -0
- package/dist/theme.d.ts.map +1 -0
- package/dist/theme.js +55 -0
- package/dist/theme.js.map +1 -0
- package/dist/types.d.ts +266 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/utils.d.ts +7 -0
- package/dist/utils.d.ts.map +1 -0
- package/dist/utils.js +46 -0
- package/dist/utils.js.map +1 -0
- package/dist/video/index.d.ts +3 -0
- package/dist/video/index.d.ts.map +1 -0
- package/dist/video/index.js +2 -0
- package/dist/video/index.js.map +1 -0
- package/dist/video/video.d.ts +61 -0
- package/dist/video/video.d.ts.map +1 -0
- package/dist/video/video.js +157 -0
- package/dist/video/video.js.map +1 -0
- package/package.json +129 -0
package/dist/hub/hub.js
ADDED
|
@@ -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
|