feat: channel deletion UI with full backend cleanup

Add Delete Channel button to channel settings modal with ConfirmDialog
confirmation. Fix backend DELETE route to disconnect voice users, clean
up attachment files from disk, and remove orphaned read_states. Make
deleteChannel federation-aware in spaceStore and clean up voiceUsers
on channel_deleted WebSocket event.
This commit is contained in:
Jannis Braun
2026-03-12 02:44:42 +01:00
parent 5193d79c39
commit 88ca2d532b
4 changed files with 171 additions and 61 deletions
+33 -1
View File
@@ -1,5 +1,5 @@
import type { FastifyInstance } from 'fastify';
import { eq, and } from 'drizzle-orm';
import { eq, and, inArray } from 'drizzle-orm';
import { getDb, schema } from '../db/index.js';
import { authenticate } from '../utils/auth.js';
import { generateSnowflake } from '../utils/snowflake.js';
@@ -7,6 +7,7 @@ import { isMember, hasPermission, getChannelSpaceId, PermissionBits, computePerm
import { permissionsToString } from '@backspace/shared/src/permissions.js';
import { connectionManager } from '../ws/handler.js';
import { checkVoicePermissions } from '../ws/events.js';
import { deleteAttachmentFiles } from '../utils/fileCleanup.js';
import type {
CreateChannelRequest,
UpdateChannelRequest,
@@ -285,6 +286,21 @@ export async function channelRoutes(app: FastifyInstance): Promise<void> {
return reply.code(403).send({ error: 'Missing MANAGE_CHANNELS permission', statusCode: 403 });
}
// Disconnect voice users before deletion
const participants = connectionManager.getRoomParticipants(id);
if (participants.size > 0) {
for (const participantId of Array.from(participants)) {
connectionManager.leaveRoom(id, participantId);
connectionManager.clearVoiceUserStatus(participantId);
connectionManager.sendToSpace(spaceId, {
type: 'voice_state_update', channelId: id, userId: participantId, action: 'leave',
});
connectionManager.sendToUser(participantId, {
type: 'voice_disconnected', userId: participantId, channelId: id,
});
}
}
// Collect viewers BEFORE deleting (overrides CASCADE-delete with the channel)
const viewerIds: string[] = [];
for (const [uid, spaceIds] of connectionManager.getUserSpaceEntries()) {
@@ -296,10 +312,26 @@ export async function channelRoutes(app: FastifyInstance): Promise<void> {
}
}
// Collect attachment filenames BEFORE cascade deletes DB records
const channelMsgIds = db.select({ id: schema.messages.id })
.from(schema.messages).where(eq(schema.messages.channelId, id)).all().map(m => m.id);
let attachmentRows: { filename: string }[] = [];
if (channelMsgIds.length > 0) {
attachmentRows = db.select({ filename: schema.attachments.filename })
.from(schema.attachments).where(inArray(schema.attachments.messageId, channelMsgIds)).all();
}
// Clean up read_states (no FK, rows would be orphaned)
db.delete(schema.readStates).where(eq(schema.readStates.channelId, id)).run();
// Delete messages in channel (attachments cascade), then channel
db.delete(schema.messages).where(eq(schema.messages.channelId, id)).run();
db.delete(schema.channels).where(eq(schema.channels.id, id)).run();
// Delete attachment files from disk
deleteAttachmentFiles(attachmentRows);
// Broadcast channel_deleted only to users who could see the channel
const deleteEvent = { type: 'channel_deleted' as const, channelId: id, spaceId };
for (const uid of viewerIds) {