From b6adf310fc535f16cc55c0bf51dfcd06abfe5b3c Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Tue, 24 Feb 2026 00:08:45 +0100 Subject: [PATCH] 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. --- packages/server/src/routes/dm.ts | 228 ++++++++++++++++++++++----- packages/server/src/ws/events.ts | 51 +++--- packages/shared/src/types.ts | 12 +- packages/web/src/stores/chatStore.ts | 2 +- 4 files changed, 224 insertions(+), 69 deletions(-) diff --git a/packages/server/src/routes/dm.ts b/packages/server/src/routes/dm.ts index 812948cf..35fcbc2e 100644 --- a/packages/server/src/routes/dm.ts +++ b/packages/server/src/routes/dm.ts @@ -14,6 +14,8 @@ import type { CreateDmMessageRequest, AddDmMemberRequest, PaginatedQuery, + Attachment, + Reaction, } from '@opencord/shared'; 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 { + 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(); + 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. * For members who have closed the channel (closed=1), also sends a @@ -590,18 +707,64 @@ export async function dmRoutes(app: FastifyInstance): Promise { const users = db.select().from(schema.users).where(inArray(schema.users.id, userIds)).all(); 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(); + 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(); + 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 .map(m => { const user = userMap.get(m.userId); if (!user) return null; - return { - id: m.id, - dmChannelId: m.dmChannelId, - userId: m.userId, - content: m.content, - createdAt: m.createdAt, - user: sanitizeUser(user), - }; + const reactions = reactionsMap.get(m.id) ?? []; + const replyTo = m.replyToId ? (replyToMap.get(m.replyToId) ?? null) : null; + return buildDmMessageWithUser(m, user, attachmentMap.get(m.id) ?? [], reactions, replyTo); }) .filter((m): m is DmMessageWithUser => m !== null); @@ -613,14 +776,15 @@ export async function dmRoutes(app: FastifyInstance): Promise { preHandler: authenticate, }, async (request, reply) => { const { id } = request.params; - const { content } = request.body; + const { content, attachments: attachmentIds, replyToId } = request.body; if (!isDmMember(id, request.userId)) { 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) { - return reply.code(400).send({ error: 'Message content is required', statusCode: 400 }); + if ((!content || typeof content !== 'string' || content.trim().length === 0) && + (!attachmentIds || attachmentIds.length === 0)) { + return reply.code(400).send({ error: 'Message must have content or attachments', statusCode: 400 }); } const db = getDb(); @@ -631,23 +795,25 @@ export async function dmRoutes(app: FastifyInstance): Promise { id: messageId, dmChannelId: id, userId: request.userId, - content: content.trim(), + replyToId: replyToId || null, + content: content?.trim() || null, createdAt: now, }).run(); - const user = db.select().from(schema.users).where(eq(schema.users.id, request.userId)).get(); - if (!user) { - return reply.code(500).send({ error: 'User not found', statusCode: 500 }); + // Link attachments to this DM message + if (attachmentIds && attachmentIds.length > 0) { + for (const attId of attachmentIds) { + db.update(schema.attachments) + .set({ dmMessageId: messageId }) + .where(eq(schema.attachments.id, attId)) + .run(); + } } - const message: DmMessageWithUser = { - id: messageId, - dmChannelId: id, - userId: request.userId, - content: content.trim(), - createdAt: now, - user: sanitizeUser(user), - }; + const message = getDmMessageWithUser(messageId); + if (!message) { + return reply.code(500).send({ error: 'Failed to create message', statusCode: 500 }); + } // Broadcast to all DM members (including those who closed the channel) broadcastDmMessage(id, message); @@ -683,21 +849,11 @@ export async function dmRoutes(app: FastifyInstance): Promise { .where(eq(schema.dmMessages.id, id)) .run(); - const user = db.select().from(schema.users).where(eq(schema.users.id, request.userId)).get(); - if (!user) { - return reply.code(500).send({ error: 'User not found', statusCode: 500 }); + const updated = getDmMessageWithUser(id); + if (!updated) { + 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 const dmMembers = db.select() .from(schema.dmMembers) diff --git a/packages/server/src/ws/events.ts b/packages/server/src/ws/events.ts index 11876cfa..151cba24 100644 --- a/packages/server/src/ws/events.ts +++ b/packages/server/src/ws/events.ts @@ -4,7 +4,7 @@ import { generateSnowflake } from '../utils/snowflake.js'; import { connectionManager } from './handler.js'; import type { VoiceRoom, DmRoomMeta, ServerRoomMeta } from './handler.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'; function sanitizeUser(row: typeof schema.users.$inferSelect): User { @@ -523,15 +523,20 @@ function handleVoiceStatus(event: Record, userId: string): void function handleDmMessageCreate(event: Record, userId: string): void { 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') { connectionManager.sendToUser(userId, { type: 'error', message: 'dmChannelId is required' }); return; } - if (!content || typeof content !== 'string' || content.trim().length === 0) { - connectionManager.sendToUser(userId, { type: 'error', message: 'content is required' }); + const hasContent = content && typeof content === 'string' && content.trim().length > 0; + 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; } @@ -548,21 +553,23 @@ function handleDmMessageCreate(event: Record, userId: string): id: messageId, dmChannelId, userId, - content: content.trim(), + replyToId: replyToId || null, + content: hasContent ? content.trim() : null, createdAt: now, }).run(); - const user = db.select().from(schema.users).where(eq(schema.users.id, userId)).get(); - if (!user) return; + // Link attachments to this DM message + if (hasAttachments) { + for (const attId of attachmentIds) { + db.update(schema.attachments) + .set({ dmMessageId: messageId }) + .where(eq(schema.attachments.id, attId)) + .run(); + } + } - const dmMessage: DmMessageWithUser = { - id: messageId, - dmChannelId, - userId, - content: content.trim(), - createdAt: now, - user: sanitizeUser(user), - }; + const dmMessage = getDmMessageWithUser(messageId); + if (!dmMessage) return; // Broadcast to all DM members (including those who closed the channel) broadcastDmMessage(dmChannelId, dmMessage); @@ -637,18 +644,8 @@ function handleDmMessageEdit(event: Record, userId: string): vo .where(eq(schema.dmMessages.id, messageId)) .run(); - const user = db.select().from(schema.users).where(eq(schema.users.id, userId)).get(); - if (!user) 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 updated = getDmMessageWithUser(messageId); + if (!updated) return; const dmMembers = db.select() .from(schema.dmMembers) diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index eec964c5..82fa6e38 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -168,9 +168,9 @@ export interface DmMessage { export interface DmMessageWithUser extends DmMessage { user: User; - attachments?: Attachment[]; - reactions?: Reaction[]; - replyTo?: MessageWithUser | null; + attachments: Attachment[]; + reactions: Reaction[]; + replyTo?: DmMessageWithUser | null; } // ─── WebSocket Event Types ────────────────────────────────────────────────── @@ -185,7 +185,7 @@ export type ClientEvent = | { type: 'presence_update'; status: 'online' | 'idle' | 'dnd' } | { type: 'voice_join'; channelId: string } | { 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_message_edit'; messageId: string; content: string } | { type: 'dm_message_delete'; messageId: string } @@ -319,7 +319,9 @@ export interface AddDmMemberRequest { } export interface CreateDmMessageRequest { - content: string; + content?: string; + attachments?: string[]; + replyToId?: string; } export interface PaginatedQuery { diff --git a/packages/web/src/stores/chatStore.ts b/packages/web/src/stores/chatStore.ts index d82275eb..09c424fd 100644 --- a/packages/web/src/stores/chatStore.ts +++ b/packages/web/src/stores/chatStore.ts @@ -166,7 +166,7 @@ export const useChatStore = create((set, get) => ({ try { if (isDm) { - await api.dm.sendMessage(channelId, { content }); + await api.dm.sendMessage(channelId, { content, attachments: attachmentIds, replyToId }); } else { await api.channels.sendMessage(channelId, { content, attachments: attachmentIds, replyToId }); }