fix: wire DM file attachments through the full send/fetch/broadcast chain

DM uploads silently failed because the 5-point chain (types, frontend,
POST, GET, WS broadcast) was never wired for attachments. Added
buildDmMessageWithUser/getDmMessageWithUser helpers mirroring the server
channel pattern, and plumbed attachmentIds + replyToId through all DM
code paths.
This commit is contained in:
Jannis Braun
2026-02-24 00:08:45 +01:00
parent 900eb67869
commit b6adf310fc
4 changed files with 224 additions and 69 deletions
+192 -36
View File
@@ -14,6 +14,8 @@ import type {
CreateDmMessageRequest, CreateDmMessageRequest,
AddDmMemberRequest, AddDmMemberRequest,
PaginatedQuery, PaginatedQuery,
Attachment,
Reaction,
} from '@opencord/shared'; } from '@opencord/shared';
function sanitizeUser(row: typeof schema.users.$inferSelect): User { function sanitizeUser(row: typeof schema.users.$inferSelect): User {
@@ -28,6 +30,121 @@ function sanitizeUser(row: typeof schema.users.$inferSelect): User {
}; };
} }
/**
* Batch-fetch reactions for a set of DM message IDs.
* Returns a map from dmMessageId to Reaction[].
*/
export function fetchDmReactionsForMessages(dmMessageIds: string[]): Map<string, Reaction[]> {
if (dmMessageIds.length === 0) return new Map();
const db = getDb();
const reactionRows = db.select()
.from(schema.dmReactions)
.where(inArray(schema.dmReactions.dmMessageId, dmMessageIds))
.all();
// Batch fetch users for reactions
const reactionUserIds = [...new Set(reactionRows.map(r => r.userId))];
const reactionUsers = reactionUserIds.length > 0
? db.select().from(schema.users).where(inArray(schema.users.id, reactionUserIds)).all()
: [];
const reactionUserMap = new Map(reactionUsers.map(u => [u.id, u]));
const map = new Map<string, Reaction[]>();
for (const r of reactionRows) {
const user = reactionUserMap.get(r.userId);
const reaction: Reaction = {
id: r.id,
messageId: r.dmMessageId,
userId: r.userId,
emoji: r.emoji,
createdAt: r.createdAt,
user: user ? sanitizeUser(user) : undefined,
};
if (!map.has(r.dmMessageId)) {
map.set(r.dmMessageId, []);
}
map.get(r.dmMessageId)!.push(reaction);
}
return map;
}
/**
* Pure transformer: builds a DmMessageWithUser from pre-fetched data.
*/
export function buildDmMessageWithUser(
message: typeof schema.dmMessages.$inferSelect,
user: typeof schema.users.$inferSelect,
attachmentRows: (typeof schema.attachments.$inferSelect)[],
reactions: Reaction[] = [],
replyTo: DmMessageWithUser | null = null,
): DmMessageWithUser {
return {
id: message.id,
dmChannelId: message.dmChannelId,
userId: message.userId,
replyToId: message.replyToId,
content: message.content,
editedAt: message.editedAt,
createdAt: message.createdAt,
user: sanitizeUser(user),
attachments: attachmentRows.map(a => ({
id: a.id,
messageId: a.dmMessageId ?? message.id,
filename: a.filename,
originalName: a.originalName,
mimetype: a.mimetype,
size: a.size,
createdAt: a.createdAt,
})),
reactions,
replyTo,
};
}
/**
* Fetches a DM message by ID and hydrates it with user, attachments, reactions, and replyTo.
*/
export function getDmMessageWithUser(dmMessageId: string): DmMessageWithUser | null {
const db = getDb();
const message = db.select().from(schema.dmMessages).where(eq(schema.dmMessages.id, dmMessageId)).get();
if (!message) return null;
const user = db.select().from(schema.users).where(eq(schema.users.id, message.userId)).get();
if (!user) return null;
const attachmentRows = db.select()
.from(schema.attachments)
.where(eq(schema.attachments.dmMessageId, dmMessageId))
.all();
const reactionsMap = fetchDmReactionsForMessages([dmMessageId]);
const reactions = reactionsMap.get(dmMessageId) ?? [];
let replyTo: DmMessageWithUser | null = null;
if (message.replyToId) {
const replyMsg = db.select().from(schema.dmMessages).where(eq(schema.dmMessages.id, message.replyToId)).get();
if (replyMsg) {
const replyUser = db.select().from(schema.users).where(eq(schema.users.id, replyMsg.userId)).get();
if (replyUser) {
replyTo = {
id: replyMsg.id,
dmChannelId: replyMsg.dmChannelId,
userId: replyMsg.userId,
replyToId: replyMsg.replyToId,
content: replyMsg.content,
editedAt: replyMsg.editedAt,
createdAt: replyMsg.createdAt,
user: sanitizeUser(replyUser),
attachments: [],
reactions: [],
};
}
}
}
return buildDmMessageWithUser(message, user, attachmentRows, reactions, replyTo);
}
/** /**
* Broadcasts a DM message to all members of a DM channel. * Broadcasts a DM message to all members of a DM channel.
* For members who have closed the channel (closed=1), also sends a * For members who have closed the channel (closed=1), also sends a
@@ -590,18 +707,64 @@ export async function dmRoutes(app: FastifyInstance): Promise<void> {
const users = db.select().from(schema.users).where(inArray(schema.users.id, userIds)).all(); const users = db.select().from(schema.users).where(inArray(schema.users.id, userIds)).all();
const userMap = new Map(users.map(u => [u.id, u])); const userMap = new Map(users.map(u => [u.id, u]));
// Batch fetch attachments by dmMessageId
const messageIds = messageRows.map(m => m.id);
const allAttachments = db.select()
.from(schema.attachments)
.where(inArray(schema.attachments.dmMessageId, messageIds))
.all();
const attachmentMap = new Map<string, (typeof schema.attachments.$inferSelect)[]>();
for (const att of allAttachments) {
const mid = att.dmMessageId ?? '';
if (!attachmentMap.has(mid)) attachmentMap.set(mid, []);
attachmentMap.get(mid)!.push(att);
}
// Batch fetch reactions
const reactionsMap = fetchDmReactionsForMessages(messageIds);
// Batch fetch reply-to messages
const replyToIds = messageRows
.map(m => m.replyToId)
.filter((id): id is string => id !== null && id !== undefined);
const uniqueReplyIds = [...new Set(replyToIds)];
const replyToMap = new Map<string, DmMessageWithUser>();
if (uniqueReplyIds.length > 0) {
const replyMessages = db.select()
.from(schema.dmMessages)
.where(inArray(schema.dmMessages.id, uniqueReplyIds))
.all();
const replyUserIds = [...new Set(replyMessages.map(m => m.userId))];
const replyUsers = replyUserIds.length > 0
? db.select().from(schema.users).where(inArray(schema.users.id, replyUserIds)).all()
: [];
const replyUserMap = new Map(replyUsers.map(u => [u.id, u]));
for (const rm of replyMessages) {
const rUser = replyUserMap.get(rm.userId);
if (!rUser) continue;
replyToMap.set(rm.id, {
id: rm.id,
dmChannelId: rm.dmChannelId,
userId: rm.userId,
replyToId: rm.replyToId,
content: rm.content,
editedAt: rm.editedAt,
createdAt: rm.createdAt,
user: sanitizeUser(rUser),
attachments: [],
reactions: [],
});
}
}
const messages: DmMessageWithUser[] = messageRows const messages: DmMessageWithUser[] = messageRows
.map(m => { .map(m => {
const user = userMap.get(m.userId); const user = userMap.get(m.userId);
if (!user) return null; if (!user) return null;
return { const reactions = reactionsMap.get(m.id) ?? [];
id: m.id, const replyTo = m.replyToId ? (replyToMap.get(m.replyToId) ?? null) : null;
dmChannelId: m.dmChannelId, return buildDmMessageWithUser(m, user, attachmentMap.get(m.id) ?? [], reactions, replyTo);
userId: m.userId,
content: m.content,
createdAt: m.createdAt,
user: sanitizeUser(user),
};
}) })
.filter((m): m is DmMessageWithUser => m !== null); .filter((m): m is DmMessageWithUser => m !== null);
@@ -613,14 +776,15 @@ export async function dmRoutes(app: FastifyInstance): Promise<void> {
preHandler: authenticate, preHandler: authenticate,
}, async (request, reply) => { }, async (request, reply) => {
const { id } = request.params; const { id } = request.params;
const { content } = request.body; const { content, attachments: attachmentIds, replyToId } = request.body;
if (!isDmMember(id, request.userId)) { if (!isDmMember(id, request.userId)) {
return reply.code(403).send({ error: 'You are not a member of this DM channel', statusCode: 403 }); return reply.code(403).send({ error: 'You are not a member of this DM channel', statusCode: 403 });
} }
if (!content || typeof content !== 'string' || content.trim().length === 0) { if ((!content || typeof content !== 'string' || content.trim().length === 0) &&
return reply.code(400).send({ error: 'Message content is required', statusCode: 400 }); (!attachmentIds || attachmentIds.length === 0)) {
return reply.code(400).send({ error: 'Message must have content or attachments', statusCode: 400 });
} }
const db = getDb(); const db = getDb();
@@ -631,23 +795,25 @@ export async function dmRoutes(app: FastifyInstance): Promise<void> {
id: messageId, id: messageId,
dmChannelId: id, dmChannelId: id,
userId: request.userId, userId: request.userId,
content: content.trim(), replyToId: replyToId || null,
content: content?.trim() || null,
createdAt: now, createdAt: now,
}).run(); }).run();
const user = db.select().from(schema.users).where(eq(schema.users.id, request.userId)).get(); // Link attachments to this DM message
if (!user) { if (attachmentIds && attachmentIds.length > 0) {
return reply.code(500).send({ error: 'User not found', statusCode: 500 }); for (const attId of attachmentIds) {
db.update(schema.attachments)
.set({ dmMessageId: messageId })
.where(eq(schema.attachments.id, attId))
.run();
}
} }
const message: DmMessageWithUser = { const message = getDmMessageWithUser(messageId);
id: messageId, if (!message) {
dmChannelId: id, return reply.code(500).send({ error: 'Failed to create message', statusCode: 500 });
userId: request.userId, }
content: content.trim(),
createdAt: now,
user: sanitizeUser(user),
};
// Broadcast to all DM members (including those who closed the channel) // Broadcast to all DM members (including those who closed the channel)
broadcastDmMessage(id, message); broadcastDmMessage(id, message);
@@ -683,21 +849,11 @@ export async function dmRoutes(app: FastifyInstance): Promise<void> {
.where(eq(schema.dmMessages.id, id)) .where(eq(schema.dmMessages.id, id))
.run(); .run();
const user = db.select().from(schema.users).where(eq(schema.users.id, request.userId)).get(); const updated = getDmMessageWithUser(id);
if (!user) { if (!updated) {
return reply.code(500).send({ error: 'User not found', statusCode: 500 }); return reply.code(500).send({ error: 'Failed to update message', statusCode: 500 });
} }
const updated: DmMessageWithUser = {
id: msg.id,
dmChannelId: msg.dmChannelId,
userId: msg.userId,
content: content.trim(),
editedAt: now,
createdAt: msg.createdAt,
user: sanitizeUser(user),
};
// Broadcast to all DM members // Broadcast to all DM members
const dmMembers = db.select() const dmMembers = db.select()
.from(schema.dmMembers) .from(schema.dmMembers)
+24 -27
View File
@@ -4,7 +4,7 @@ import { generateSnowflake } from '../utils/snowflake.js';
import { connectionManager } from './handler.js'; import { connectionManager } from './handler.js';
import type { VoiceRoom, DmRoomMeta, ServerRoomMeta } from './handler.js'; import type { VoiceRoom, DmRoomMeta, ServerRoomMeta } from './handler.js';
import { isMember, getChannelServerId, isDmMember } from '../utils/permissions.js'; import { isMember, getChannelServerId, isDmMember } from '../utils/permissions.js';
import { broadcastDmMessage } from '../routes/dm.js'; import { broadcastDmMessage, getDmMessageWithUser } from '../routes/dm.js';
import type { User, MessageWithUser, Attachment, DmMessageWithUser } from '@opencord/shared'; import type { User, MessageWithUser, Attachment, DmMessageWithUser } from '@opencord/shared';
function sanitizeUser(row: typeof schema.users.$inferSelect): User { function sanitizeUser(row: typeof schema.users.$inferSelect): User {
@@ -523,15 +523,20 @@ function handleVoiceStatus(event: Record<string, unknown>, userId: string): void
function handleDmMessageCreate(event: Record<string, unknown>, userId: string): void { function handleDmMessageCreate(event: Record<string, unknown>, userId: string): void {
const dmChannelId = event.dmChannelId as string; const dmChannelId = event.dmChannelId as string;
const content = event.content as string; const content = event.content as string | undefined;
const attachmentIds = event.attachments as string[] | undefined;
const replyToId = event.replyToId as string | undefined;
if (!dmChannelId || typeof dmChannelId !== 'string') { if (!dmChannelId || typeof dmChannelId !== 'string') {
connectionManager.sendToUser(userId, { type: 'error', message: 'dmChannelId is required' }); connectionManager.sendToUser(userId, { type: 'error', message: 'dmChannelId is required' });
return; return;
} }
if (!content || typeof content !== 'string' || content.trim().length === 0) { const hasContent = content && typeof content === 'string' && content.trim().length > 0;
connectionManager.sendToUser(userId, { type: 'error', message: 'content is required' }); const hasAttachments = attachmentIds && Array.isArray(attachmentIds) && attachmentIds.length > 0;
if (!hasContent && !hasAttachments) {
connectionManager.sendToUser(userId, { type: 'error', message: 'Message must have content or attachments' });
return; return;
} }
@@ -548,21 +553,23 @@ function handleDmMessageCreate(event: Record<string, unknown>, userId: string):
id: messageId, id: messageId,
dmChannelId, dmChannelId,
userId, userId,
content: content.trim(), replyToId: replyToId || null,
content: hasContent ? content.trim() : null,
createdAt: now, createdAt: now,
}).run(); }).run();
const user = db.select().from(schema.users).where(eq(schema.users.id, userId)).get(); // Link attachments to this DM message
if (!user) return; if (hasAttachments) {
for (const attId of attachmentIds) {
db.update(schema.attachments)
.set({ dmMessageId: messageId })
.where(eq(schema.attachments.id, attId))
.run();
}
}
const dmMessage: DmMessageWithUser = { const dmMessage = getDmMessageWithUser(messageId);
id: messageId, if (!dmMessage) return;
dmChannelId,
userId,
content: content.trim(),
createdAt: now,
user: sanitizeUser(user),
};
// Broadcast to all DM members (including those who closed the channel) // Broadcast to all DM members (including those who closed the channel)
broadcastDmMessage(dmChannelId, dmMessage); broadcastDmMessage(dmChannelId, dmMessage);
@@ -637,18 +644,8 @@ function handleDmMessageEdit(event: Record<string, unknown>, userId: string): vo
.where(eq(schema.dmMessages.id, messageId)) .where(eq(schema.dmMessages.id, messageId))
.run(); .run();
const user = db.select().from(schema.users).where(eq(schema.users.id, userId)).get(); const updated = getDmMessageWithUser(messageId);
if (!user) return; if (!updated) return;
const updated: DmMessageWithUser = {
id: msg.id,
dmChannelId: msg.dmChannelId,
userId: msg.userId,
content: content.trim(),
editedAt: now,
createdAt: msg.createdAt,
user: sanitizeUser(user),
};
const dmMembers = db.select() const dmMembers = db.select()
.from(schema.dmMembers) .from(schema.dmMembers)
+7 -5
View File
@@ -168,9 +168,9 @@ export interface DmMessage {
export interface DmMessageWithUser extends DmMessage { export interface DmMessageWithUser extends DmMessage {
user: User; user: User;
attachments?: Attachment[]; attachments: Attachment[];
reactions?: Reaction[]; reactions: Reaction[];
replyTo?: MessageWithUser | null; replyTo?: DmMessageWithUser | null;
} }
// ─── WebSocket Event Types ────────────────────────────────────────────────── // ─── WebSocket Event Types ──────────────────────────────────────────────────
@@ -185,7 +185,7 @@ export type ClientEvent =
| { type: 'presence_update'; status: 'online' | 'idle' | 'dnd' } | { type: 'presence_update'; status: 'online' | 'idle' | 'dnd' }
| { type: 'voice_join'; channelId: string } | { type: 'voice_join'; channelId: string }
| { type: 'voice_leave' } | { type: 'voice_leave' }
| { type: 'dm_message_create'; dmChannelId: string; content: string; replyToId?: string } | { type: 'dm_message_create'; dmChannelId: string; content?: string; attachments?: string[]; replyToId?: string }
| { type: 'dm_typing_start'; dmChannelId: string } | { type: 'dm_typing_start'; dmChannelId: string }
| { type: 'dm_message_edit'; messageId: string; content: string } | { type: 'dm_message_edit'; messageId: string; content: string }
| { type: 'dm_message_delete'; messageId: string } | { type: 'dm_message_delete'; messageId: string }
@@ -319,7 +319,9 @@ export interface AddDmMemberRequest {
} }
export interface CreateDmMessageRequest { export interface CreateDmMessageRequest {
content: string; content?: string;
attachments?: string[];
replyToId?: string;
} }
export interface PaginatedQuery { export interface PaginatedQuery {
+1 -1
View File
@@ -166,7 +166,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
try { try {
if (isDm) { if (isDm) {
await api.dm.sendMessage(channelId, { content }); await api.dm.sendMessage(channelId, { content, attachments: attachmentIds, replyToId });
} else { } else {
await api.channels.sendMessage(channelId, { content, attachments: attachmentIds, replyToId }); await api.channels.sendMessage(channelId, { content, attachments: attachmentIds, replyToId });
} }