fix(dm): restore ownerId=NULL semantics for 1-on-1 DM channels
- Add migrateFixOneOnOneOwnerIds migration to NULL-out ownerId on all existing 1-on-1 DMs (those with exactly 2 members) - Fix POST /api/dm to create 1-on-1 channels with ownerId=null instead of the creator's ID - Guard POST /api/dm/:id/members: reject with 400 if channel has no owner (i.e. is a 1-on-1), directing callers to POST /api/dm/group - Guard DELETE /api/dm/:id/members: replace member-count check with ownerId check; remove now-duplicate dmChannel query in that handler - Add CreateGroupDmRequest type to shared types
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -444,7 +444,7 @@ export async function dmRoutes(app: FastifyInstance): Promise<void> {
|
||||
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<void> {
|
||||
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user