feat: add group DM support (3-10 members)

Unlock group DMs by removing the 2-member assumption across the stack.
No schema migration needed — dm_members junction table already supports
N members. Includes dedup bug fix, add/leave member endpoints, late-join
call support, group-aware sidebar rendering, and AddDmMemberModal.
This commit is contained in:
Jannis Braun
2026-02-23 22:39:06 +01:00
parent 653e59bfb2
commit 900eb67869
11 changed files with 475 additions and 27 deletions
+219
View File
@@ -12,6 +12,7 @@ import type {
DmMessageWithUser,
CreateDmRequest,
CreateDmMessageRequest,
AddDmMemberRequest,
PaginatedQuery,
} from '@opencord/shared';
@@ -194,6 +195,15 @@ export async function dmRoutes(app: FastifyInstance): Promise<void> {
.get();
if (otherMember) {
// Only match 1-on-1 DMs (exactly 2 members). Skip group DMs that
// happen to include the target user to avoid returning the wrong channel.
const memberCount = db.select()
.from(schema.dmMembers)
.where(eq(schema.dmMembers.dmChannelId, myDm.dmChannelId))
.all()
.length;
if (memberCount !== 2) continue;
// DM channel already exists between these users
const dmChannel = db.select()
.from(schema.dmChannels)
@@ -325,6 +335,215 @@ export async function dmRoutes(app: FastifyInstance): Promise<void> {
return reply.code(200).send({ success: true });
});
// POST /api/dm/:id/members - Add a user to an existing DM channel (group DM upgrade)
app.post<{ Params: { id: string }; Body: AddDmMemberRequest }>('/api/dm/:id/members', {
preHandler: authenticate,
}, async (request, reply) => {
const { id } = request.params;
const { userId: targetUserId } = request.body;
if (!targetUserId || typeof targetUserId !== 'string') {
return reply.code(400).send({ error: 'userId is required', statusCode: 400 });
}
const db = getDb();
// Validate caller is a member
if (!isDmMember(id, request.userId)) {
return reply.code(403).send({ error: 'You are not a member of this DM channel', statusCode: 403 });
}
// Validate target user exists
const targetUser = db.select().from(schema.users).where(eq(schema.users.id, targetUserId)).get();
if (!targetUser) {
return reply.code(404).send({ error: 'User not found', statusCode: 404 });
}
// Validate target is not already a member
const existingMembership = db.select()
.from(schema.dmMembers)
.where(and(
eq(schema.dmMembers.dmChannelId, id),
eq(schema.dmMembers.userId, targetUserId),
))
.get();
if (existingMembership) {
return reply.code(400).send({ error: 'User is already a member of this DM channel', statusCode: 400 });
}
// Validate member count < 10
const currentMembers = db.select()
.from(schema.dmMembers)
.where(eq(schema.dmMembers.dmChannelId, id))
.all();
if (currentMembers.length >= 10) {
return reply.code(400).send({ error: 'Group DM cannot exceed 10 members', statusCode: 400 });
}
// Insert dm_members row for new user
db.insert(schema.dmMembers).values({
dmChannelId: id,
userId: targetUserId,
}).run();
// Build full DmChannel response with all members
const allMemberRows = db.select()
.from(schema.dmMembers)
.where(eq(schema.dmMembers.dmChannelId, id))
.all();
const memberUserIds = allMemberRows.map(m => m.userId);
const users = memberUserIds.length > 0
? db.select().from(schema.users).where(inArray(schema.users.id, memberUserIds)).all()
: [];
const dmChannel = db.select()
.from(schema.dmChannels)
.where(eq(schema.dmChannels.id, id))
.get();
if (!dmChannel) {
return reply.code(404).send({ error: 'DM channel not found', statusCode: 404 });
}
// Fetch last message
const lastMsgRows = db.select()
.from(schema.dmMessages)
.where(eq(schema.dmMessages.dmChannelId, id))
.orderBy(desc(schema.dmMessages.createdAt))
.limit(1)
.all();
const lastMsg = lastMsgRows[0] ?? null;
const result: DmChannel = {
id: dmChannel.id,
createdAt: dmChannel.createdAt,
members: users.map(sanitizeUser),
lastMessage: lastMsg ? {
id: lastMsg.id,
dmChannelId: lastMsg.dmChannelId,
userId: lastMsg.userId,
content: lastMsg.content,
createdAt: lastMsg.createdAt,
} : null,
};
const newUser = sanitizeUser(targetUser);
// Broadcast dm_member_added to all existing members (before the new one)
for (const member of currentMembers) {
connectionManager.sendToUser(member.userId, {
type: 'dm_member_added',
dmChannelId: id,
user: newUser,
});
}
// Send dm_channel_created to the new member so their sidebar picks it up
connectionManager.sendToUser(targetUserId, {
type: 'dm_channel_created',
dmChannel: result,
});
// Active call sync: if there's an active call in this DM, notify the new member
const room = connectionManager.getRoom(id);
if (room && room.roomType === 'dm') {
const meta = room.metadata as { state: string; callerId: string };
if (meta.state === 'active' || meta.state === 'ringing') {
// Look up caller name
const callerRow = db.select().from(schema.users).where(eq(schema.users.id, meta.callerId)).get();
const callerName = callerRow?.displayName ?? callerRow?.username ?? meta.callerId;
connectionManager.sendToUser(targetUserId, {
type: 'dm_call_incoming',
dmChannelId: id,
callerId: meta.callerId,
callerName,
});
}
}
return reply.code(200).send(result);
});
// DELETE /api/dm/:id/members - Leave a group DM
app.delete<{ Params: { id: string } }>('/api/dm/:id/members', {
preHandler: authenticate,
}, async (request, reply) => {
const { id } = request.params;
const db = getDb();
// Validate caller is a member
if (!isDmMember(id, request.userId)) {
return reply.code(403).send({ error: 'You are not a member of this DM channel', statusCode: 403 });
}
// Count members — can't leave a 1-on-1
const memberRows = db.select()
.from(schema.dmMembers)
.where(eq(schema.dmMembers.dmChannelId, id))
.all();
if (memberRows.length <= 2) {
return reply.code(400).send({ error: 'Cannot leave a 1-on-1 DM. Use close instead.', statusCode: 400 });
}
// If user is in this DM's VoiceRoom, leave it first
const userRoom = connectionManager.getUserRoom(request.userId);
if (userRoom && userRoom.roomId === id) {
const left = connectionManager.leaveCurrentRoom(request.userId);
if (left) {
// Broadcast voice leave
connectionManager.sendToDmMembers(id, {
type: 'voice_state_update',
channelId: id,
userId: request.userId,
action: 'leave',
});
// Auto-end call if DM room is now empty
const updatedRoom = connectionManager.getRoom(id);
if (updatedRoom && updatedRoom.participants.size === 0) {
connectionManager.destroyRoom(id);
connectionManager.sendToDmMembers(id, {
type: 'dm_call_ended',
dmChannelId: id,
});
}
}
connectionManager.clearVoiceUserStatus(request.userId);
}
// Delete dm_members row
db.delete(schema.dmMembers)
.where(and(
eq(schema.dmMembers.dmChannelId, id),
eq(schema.dmMembers.userId, request.userId),
))
.run();
// Broadcast dm_member_removed to remaining members
const remainingMembers = db.select()
.from(schema.dmMembers)
.where(eq(schema.dmMembers.dmChannelId, id))
.all();
for (const member of remainingMembers) {
connectionManager.sendToUser(member.userId, {
type: 'dm_member_removed',
dmChannelId: id,
userId: request.userId,
});
}
// Send dm_channel_closed to the leaving user
connectionManager.sendToUser(request.userId, {
type: 'dm_channel_closed',
dmChannelId: id,
});
return reply.code(200).send({ success: true });
});
// GET /api/dm/:id/messages - Get DM messages with pagination
app.get<{ Params: { id: string }; Querystring: PaginatedQuery }>('/api/dm/:id/messages', {
preHandler: authenticate,