feat: orphaned data cleanup, file deletion on message/account removal, and group DM improvements
- Add fileCleanup utility to delete uploaded files from disk on message/account deletion - Add migration to clean orphaned DM channels, attachments, reactions, read states, and stale moderator refs - Add FK constraints on dm_message_id in attachments and dm_reactions schema - Make bans.bannedBy and voiceRestrictions.moderatorId nullable for deleted moderators - Transfer group DM ownership on member leave or account deletion - Fully clean up orphaned DM channels (zero members) including files - Add "Leave Group" context menu for group DMs in ChannelSidebar - Add leaveDm action to spaceStore
This commit is contained in:
@@ -17,6 +17,7 @@ import type {
|
||||
Reaction,
|
||||
} from '@backspace/shared';
|
||||
import { sanitizeUser } from '../utils/sanitize.js';
|
||||
import { deleteUploadFile, deleteAttachmentFiles } from '../utils/fileCleanup.js';
|
||||
|
||||
/**
|
||||
* Batch-fetch reactions for a set of DM message IDs.
|
||||
@@ -638,6 +639,9 @@ 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(eq(schema.dmChannels.id, id)).get();
|
||||
|
||||
// Delete dm_members row
|
||||
db.delete(schema.dmMembers)
|
||||
.where(and(
|
||||
@@ -646,18 +650,59 @@ export async function dmRoutes(app: FastifyInstance): Promise<void> {
|
||||
))
|
||||
.run();
|
||||
|
||||
// Broadcast dm_member_removed to remaining members
|
||||
// Check 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,
|
||||
});
|
||||
if (remainingMembers.length > 0) {
|
||||
// Transfer ownership if the leaving user was the owner
|
||||
const nextOwner = remainingMembers[0];
|
||||
if (dmChannel && dmChannel.ownerId === request.userId && nextOwner) {
|
||||
db.update(schema.dmChannels)
|
||||
.set({ ownerId: nextOwner.userId })
|
||||
.where(eq(schema.dmChannels.id, id))
|
||||
.run();
|
||||
}
|
||||
|
||||
// Broadcast dm_member_removed to remaining members
|
||||
for (const member of remainingMembers) {
|
||||
connectionManager.sendToUser(member.userId, {
|
||||
type: 'dm_member_removed',
|
||||
dmChannelId: id,
|
||||
userId: request.userId,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Last member left — clean up the entire DM channel
|
||||
// Collect attachment filenames for disk cleanup
|
||||
const msgIds = db.select({ id: schema.dmMessages.id })
|
||||
.from(schema.dmMessages)
|
||||
.where(eq(schema.dmMessages.dmChannelId, id))
|
||||
.all()
|
||||
.map(m => m.id);
|
||||
|
||||
const filesToDelete: { filename: string }[] = [];
|
||||
if (msgIds.length > 0) {
|
||||
const attachmentRows = db.select({ filename: schema.attachments.filename })
|
||||
.from(schema.attachments)
|
||||
.where(inArray(schema.attachments.dmMessageId, msgIds))
|
||||
.all();
|
||||
filesToDelete.push(...attachmentRows);
|
||||
|
||||
// Delete attachments and reactions before cascade
|
||||
db.transaction((tx) => {
|
||||
tx.delete(schema.attachments).where(inArray(schema.attachments.dmMessageId, msgIds)).run();
|
||||
tx.delete(schema.dmReactions).where(inArray(schema.dmReactions.dmMessageId, msgIds)).run();
|
||||
});
|
||||
}
|
||||
|
||||
// Delete the DM channel (cascades to dm_messages)
|
||||
db.delete(schema.dmChannels).where(eq(schema.dmChannels.id, id)).run();
|
||||
|
||||
// Clean up files from disk
|
||||
deleteAttachmentFiles(filesToDelete);
|
||||
}
|
||||
|
||||
// Send dm_channel_closed to the leaving user
|
||||
@@ -903,6 +948,12 @@ export async function dmRoutes(app: FastifyInstance): Promise<void> {
|
||||
return reply.code(403).send({ error: 'You can only delete your own messages', statusCode: 403 });
|
||||
}
|
||||
|
||||
// Collect attachment filenames before deleting
|
||||
const attachmentRows = db.select({ filename: schema.attachments.filename })
|
||||
.from(schema.attachments)
|
||||
.where(eq(schema.attachments.dmMessageId, id))
|
||||
.all();
|
||||
|
||||
// Delete attachments, reactions, and message atomically
|
||||
db.transaction((tx) => {
|
||||
tx.delete(schema.attachments)
|
||||
@@ -918,6 +969,9 @@ export async function dmRoutes(app: FastifyInstance): Promise<void> {
|
||||
.run();
|
||||
});
|
||||
|
||||
// Clean up files from disk after transaction commits
|
||||
deleteAttachmentFiles(attachmentRows);
|
||||
|
||||
// Broadcast to all DM members
|
||||
const dmMembers = db.select()
|
||||
.from(schema.dmMembers)
|
||||
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
Reaction,
|
||||
} from '@backspace/shared';
|
||||
import { sanitizeUser } from '../utils/sanitize.js';
|
||||
import { deleteAttachmentFiles } from '../utils/fileCleanup.js';
|
||||
|
||||
/**
|
||||
* Fetch reactions for a set of message IDs.
|
||||
@@ -417,12 +418,21 @@ export async function messageRoutes(app: FastifyInstance): Promise<void> {
|
||||
return reply.code(403).send({ error: 'You cannot delete this message', statusCode: 403 });
|
||||
}
|
||||
|
||||
// Collect attachment filenames before deleting
|
||||
const attachmentRows = db.select({ filename: schema.attachments.filename })
|
||||
.from(schema.attachments)
|
||||
.where(eq(schema.attachments.messageId, id))
|
||||
.all();
|
||||
|
||||
// Delete attachments and message atomically
|
||||
db.transaction((tx) => {
|
||||
tx.delete(schema.attachments).where(eq(schema.attachments.messageId, id)).run();
|
||||
tx.delete(schema.messages).where(eq(schema.messages.id, id)).run();
|
||||
});
|
||||
|
||||
// Clean up files from disk after transaction commits
|
||||
deleteAttachmentFiles(attachmentRows);
|
||||
|
||||
// Broadcast deletion
|
||||
connectionManager.sendToSpace(spaceId, {
|
||||
type: 'message_deleted',
|
||||
|
||||
@@ -7,6 +7,7 @@ import { connectionManager } from '../ws/handler.js';
|
||||
import type { UpdateUserRequest, VerifyPasswordRequest, VerifyPasswordResponse, ChangePasswordRequest, ChangePasswordResponse, DeleteAccountRequest, ReplicatedInstance } from '@backspace/shared';
|
||||
import { AVATAR_COLORS } from '@backspace/shared';
|
||||
import { sanitizeUser } from '../utils/sanitize.js';
|
||||
import { deleteUploadFile, deleteAttachmentFiles } from '../utils/fileCleanup.js';
|
||||
|
||||
export async function userRoutes(app: FastifyInstance): Promise<void> {
|
||||
app.get('/api/users/@me', { preHandler: authenticate }, async (request, reply) => {
|
||||
@@ -123,10 +124,21 @@ export async function userRoutes(app: FastifyInstance): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
const uid = request.userId;
|
||||
|
||||
// Collect file references before the transaction (avatar, banner)
|
||||
const filesToDelete: string[] = [];
|
||||
if (user.avatar) filesToDelete.push(user.avatar);
|
||||
if (user.banner) filesToDelete.push(user.banner);
|
||||
|
||||
// Find group DMs this user owns so we can transfer ownership
|
||||
const ownedGroupDms = db.select({ id: schema.dmChannels.id })
|
||||
.from(schema.dmChannels)
|
||||
.where(eq(schema.dmChannels.ownerId, uid))
|
||||
.all();
|
||||
|
||||
// Run all cleanup in a single transaction
|
||||
db.transaction((tx) => {
|
||||
const uid = request.userId;
|
||||
|
||||
// Remove from spaces, roles, friends, DMs, read states, reactions, folders, bans, join requests, voice restrictions, channel overrides
|
||||
tx.delete(schema.spaceMembers).where(eq(schema.spaceMembers.userId, uid)).run();
|
||||
tx.delete(schema.memberRoles).where(eq(schema.memberRoles.userId, uid)).run();
|
||||
@@ -143,11 +155,75 @@ export async function userRoutes(app: FastifyInstance): Promise<void> {
|
||||
try { tx.delete(schema.joinRequests).where(eq(schema.joinRequests.userId, uid)).run(); } catch { /* table may not exist */ }
|
||||
try { tx.delete(schema.voiceRestrictions).where(eq(schema.voiceRestrictions.userId, uid)).run(); } catch { /* table may not exist */ }
|
||||
|
||||
// Nullify moderator references pointing to this user
|
||||
try {
|
||||
tx.update(schema.bans).set({ bannedBy: null }).where(eq(schema.bans.bannedBy, uid)).run();
|
||||
} catch { /* table may not exist */ }
|
||||
try {
|
||||
tx.update(schema.voiceRestrictions).set({ moderatorId: null }).where(eq(schema.voiceRestrictions.moderatorId, uid)).run();
|
||||
} catch { /* table may not exist */ }
|
||||
try {
|
||||
tx.update(schema.joinRequests).set({ decidedBy: null }).where(eq(schema.joinRequests.decidedBy, uid)).run();
|
||||
} catch { /* table may not exist */ }
|
||||
|
||||
// Remove member-type channel overrides for this user
|
||||
tx.delete(schema.channelOverrides).where(
|
||||
and(eq(schema.channelOverrides.targetType, 'member'), eq(schema.channelOverrides.targetId, uid))
|
||||
).run();
|
||||
|
||||
// Transfer ownership of group DMs to the next remaining member
|
||||
for (const { id: dmId } of ownedGroupDms) {
|
||||
const nextMember = tx.select({ userId: schema.dmMembers.userId })
|
||||
.from(schema.dmMembers)
|
||||
.where(eq(schema.dmMembers.dmChannelId, dmId))
|
||||
.limit(1)
|
||||
.get();
|
||||
if (nextMember) {
|
||||
tx.update(schema.dmChannels)
|
||||
.set({ ownerId: nextMember.userId })
|
||||
.where(eq(schema.dmChannels.id, dmId))
|
||||
.run();
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up orphaned DM channels (zero members after our removal)
|
||||
const orphanedDmIds = tx.select({ id: schema.dmChannels.id })
|
||||
.from(schema.dmChannels)
|
||||
.all()
|
||||
.filter(dc => {
|
||||
const memberCount = tx.select({ id: schema.dmMembers.dmChannelId })
|
||||
.from(schema.dmMembers)
|
||||
.where(eq(schema.dmMembers.dmChannelId, dc.id))
|
||||
.all()
|
||||
.length;
|
||||
return memberCount === 0;
|
||||
})
|
||||
.map(dc => dc.id);
|
||||
|
||||
for (const dmId of orphanedDmIds) {
|
||||
// Collect message IDs for this orphaned DM channel
|
||||
const msgIds = tx.select({ id: schema.dmMessages.id })
|
||||
.from(schema.dmMessages)
|
||||
.where(eq(schema.dmMessages.dmChannelId, dmId))
|
||||
.all()
|
||||
.map(m => m.id);
|
||||
|
||||
if (msgIds.length > 0) {
|
||||
// Collect attachment filenames for cleanup after tx
|
||||
const dmAttachments = tx.select({ filename: schema.attachments.filename })
|
||||
.from(schema.attachments)
|
||||
.where(inArray(schema.attachments.dmMessageId, msgIds))
|
||||
.all();
|
||||
for (const att of dmAttachments) filesToDelete.push(att.filename);
|
||||
|
||||
// Delete attachments + reactions for all messages in this DM channel
|
||||
tx.delete(schema.attachments).where(inArray(schema.attachments.dmMessageId, msgIds)).run();
|
||||
tx.delete(schema.dmReactions).where(inArray(schema.dmReactions.dmMessageId, msgIds)).run();
|
||||
}
|
||||
// Delete the DM channel (cascades to dm_messages)
|
||||
tx.delete(schema.dmChannels).where(eq(schema.dmChannels.id, dmId)).run();
|
||||
}
|
||||
|
||||
// Tombstone user row — rename username to free it for reuse
|
||||
tx.update(schema.users).set({
|
||||
username: `!deleted:${uid}`,
|
||||
@@ -166,6 +242,11 @@ export async function userRoutes(app: FastifyInstance): Promise<void> {
|
||||
}).where(eq(schema.users.id, uid)).run();
|
||||
});
|
||||
|
||||
// Clean up files from disk after transaction commits
|
||||
for (const filename of filesToDelete) {
|
||||
deleteUploadFile(filename);
|
||||
}
|
||||
|
||||
// Force-close all WebSocket connections
|
||||
connectionManager.forceDisconnectUser(request.userId);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user