diff --git a/packages/server/src/db/migrate.ts b/packages/server/src/db/migrate.ts index d861549c..e5a3feca 100644 --- a/packages/server/src/db/migrate.ts +++ b/packages/server/src/db/migrate.ts @@ -607,6 +607,8 @@ export function runMigrations(db: Database.Database): void { console.error('Federation mutation log backfill failed (non-fatal):', err); } + migrateFixOneOnOneOwnerIds(db); + console.log('Migrations complete.'); } @@ -1568,3 +1570,24 @@ function migrateDmChannelsFederatedId(db: Database.Database): void { console.error('migrateDmChannelsFederatedId: owner backfill failed (non-fatal):', err); } } + +/** Fix ownerId on 1-on-1 DMs: should be NULL, not the creator's ID */ +function migrateFixOneOnOneOwnerIds(db: Database.Database): void { + try { + const result = db.prepare(` + UPDATE dm_channels SET owner_id = NULL + WHERE id IN ( + SELECT dm_channel_id FROM dm_members + GROUP BY dm_channel_id + HAVING COUNT(*) = 2 + ) + AND owner_id IS NOT NULL + `).run(); + + if (result.changes > 0) { + console.log(`[migrate] Fixed ownerId on ${result.changes} 1-on-1 DM channel(s) (set to NULL)`); + } + } catch (err) { + console.error('migrateFixOneOnOneOwnerIds failed (non-fatal):', err); + } +} diff --git a/packages/server/src/routes/dm.ts b/packages/server/src/routes/dm.ts index cbc09667..b7cfef7e 100644 --- a/packages/server/src/routes/dm.ts +++ b/packages/server/src/routes/dm.ts @@ -444,7 +444,7 @@ export async function dmRoutes(app: FastifyInstance): Promise { db.transaction((tx) => { tx.insert(schema.dmChannels).values({ id: dmChannelId, - ownerId: request.userId, + ownerId: null, createdAt: now, }).run(); @@ -466,7 +466,7 @@ export async function dmRoutes(app: FastifyInstance): Promise { const result: DmChannel = { id: dmChannelId, - ownerId: request.userId, + ownerId: null, createdAt: now, members, lastMessage: null, @@ -537,12 +537,16 @@ export async function dmRoutes(app: FastifyInstance): Promise { return reply.code(403).send({ error: 'You are not a member of this DM channel', statusCode: 403 }); } - // Enforce DM channel ownership: only the owner can add members (for new-style group DMs) + // Fetch channel and enforce type + ownership constraints let dmChannel = db.select().from(schema.dmChannels).where(and(eq(schema.dmChannels.id, id), isNull(schema.dmChannels.deletedAt))).get(); if (!dmChannel) { return reply.code(404).send({ error: 'DM channel not found', statusCode: 404 }); } - if (dmChannel.ownerId && dmChannel.ownerId !== request.userId) { + // 1-on-1 DMs (ownerId=NULL) are immutable — cannot add members + if (!dmChannel.ownerId) { + return reply.code(400).send({ error: 'Cannot add members to a 1-on-1 DM. Use POST /api/dm/group to create a group.', statusCode: 400 }); + } + if (dmChannel.ownerId !== request.userId) { return reply.code(403).send({ error: 'Only the group owner can add members', statusCode: 403 }); } @@ -759,14 +763,13 @@ export async function dmRoutes(app: FastifyInstance): Promise { 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 }); + // Fetch channel — 1-on-1 DMs (ownerId=NULL) cannot be left, only closed + const dmChannel = db.select().from(schema.dmChannels).where(and(eq(schema.dmChannels.id, id), isNull(schema.dmChannels.deletedAt))).get(); + if (!dmChannel) { + return reply.code(404).send({ error: 'DM channel not found', statusCode: 404 }); + } + if (!dmChannel.ownerId) { + return reply.code(400).send({ error: 'Cannot leave a 1-on-1 DM. Use DELETE /api/dm/:id to close it.', statusCode: 400 }); } // If user is in this DM's VoiceRoom, leave it first @@ -794,9 +797,6 @@ export async function dmRoutes(app: FastifyInstance): Promise { connectionManager.clearVoiceUserStatus(request.userId); } - // Check DM channel ownership before leaving - const dmChannel = db.select().from(schema.dmChannels).where(and(eq(schema.dmChannels.id, id), isNull(schema.dmChannels.deletedAt))).get(); - // Compute federation targets BEFORE member deletion so the leaving user's peer is included let fedTargetOrigins: string[] | undefined; let leavingUser: typeof schema.users.$inferSelect | undefined; diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 057dcf93..c316c9af 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -516,6 +516,10 @@ export interface AddDmMemberRequest { userId: string; } +export interface CreateGroupDmRequest { + userIds: string[]; +} + export interface CreateDmMessageRequest { content?: string; attachments?: string[];