From 298b3316b476d0b583e83765f6245cd24e6cadd8 Mon Sep 17 00:00:00 2001 From: Mahdi Date: Wed, 30 Sep 2026 09:19:36 +0330 Subject: [PATCH] Fixed message deletion error and layout changes for the message input box. --- .../migration.sql | 8 + apps/api/prisma/schema.prisma | 7 +- .../conversations/conversations.controller.ts | 1 + .../conversations/conversations.service.ts | 15 +- apps/api/src/conversations/dto.ts | 6 + apps/web/src/lib/useOrgChat.ts | 66 +++++--- apps/web/src/views/ConversationsView.vue | 149 ++++++++++++------ 7 files changed, 176 insertions(+), 76 deletions(-) create mode 100644 apps/api/prisma/migrations/20260930080000_conversation_hidden_scope/migration.sql diff --git a/apps/api/prisma/migrations/20260930080000_conversation_hidden_scope/migration.sql b/apps/api/prisma/migrations/20260930080000_conversation_hidden_scope/migration.sql new file mode 100644 index 0000000..ec86639 --- /dev/null +++ b/apps/api/prisma/migrations/20260930080000_conversation_hidden_scope/migration.sql @@ -0,0 +1,8 @@ +-- AlterTable +ALTER TABLE "ConversationHiddenMessage" ADD COLUMN "scope" TEXT NOT NULL DEFAULT 'all'; + +-- DropIndex +DROP INDEX "ConversationHiddenMessage_organizationId_externalRoomId_messageId_key"; + +-- CreateIndex +CREATE UNIQUE INDEX "ConversationHiddenMessage_organizationId_externalRoomId_messageId_scope_hiddenByUserId_key" ON "ConversationHiddenMessage"("organizationId", "externalRoomId", "messageId", "scope", "hiddenByUserId"); diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 129ba3e..a8152ee 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -2097,7 +2097,8 @@ model ConversationRoom { @@index([organizationId, kind]) } -/// پیام‌های حذف‌شده برای همه در CRM (سرویس چت خارجی حذف پایدار ندارد) +/// پیام‌های حذف‌شده در CRM (سرویس چت خارجی حذف پایدار ندارد) +/// scope=all → برای همه؛ scope=self → فقط برای hiddenByUserId model ConversationHiddenMessage { id String @id @default(cuid()) organizationId String @@ -2105,8 +2106,10 @@ model ConversationHiddenMessage { externalRoomId String messageId String hiddenByUserId String + /// all = حذف برای همه | self = حذف فقط برای کاربر + scope String @default("all") createdAt DateTime @default(now()) - @@unique([organizationId, externalRoomId, messageId]) + @@unique([organizationId, externalRoomId, messageId, scope, hiddenByUserId]) @@index([organizationId, externalRoomId]) } diff --git a/apps/api/src/conversations/conversations.controller.ts b/apps/api/src/conversations/conversations.controller.ts index c775896..4319064 100644 --- a/apps/api/src/conversations/conversations.controller.ts +++ b/apps/api/src/conversations/conversations.controller.ts @@ -74,6 +74,7 @@ export class ConversationsController { user.role, dto.externalRoomId, dto.messageIds, + dto.forEveryone !== false, ); } diff --git a/apps/api/src/conversations/conversations.service.ts b/apps/api/src/conversations/conversations.service.ts index 2628986..4ae69b2 100644 --- a/apps/api/src/conversations/conversations.service.ts +++ b/apps/api/src/conversations/conversations.service.ts @@ -500,7 +500,8 @@ export class ConversationsService { role: string, externalRoomId: string, messageIds: string[], - ): Promise<{ messageIds: string[] }> { + forEveryone = true, + ): Promise<{ messageIds: string[]; scope: 'all' | 'self' }> { await this.loadAccess(userId, organizationId, role); const roomId = externalRoomId.trim(); if (!roomId) throw new BadRequestException('شناسه اتاق خالی است'); @@ -513,17 +514,19 @@ export class ConversationsService { ].slice(0, 100); if (!ids.length) throw new BadRequestException('شناسه پیام خالی است'); + const scope = forEveryone ? 'all' : 'self'; await this.prisma.conversationHiddenMessage.createMany({ data: ids.map((messageId) => ({ organizationId, externalRoomId: roomId, messageId, hiddenByUserId: userId, + scope, })), skipDuplicates: true, }); - return { messageIds: ids }; + return { messageIds: ids, scope }; } async listHiddenMessageIds( @@ -537,10 +540,14 @@ export class ConversationsService { if (!roomId) throw new BadRequestException('شناسه اتاق خالی است'); const rows = await this.prisma.conversationHiddenMessage.findMany({ - where: { organizationId, externalRoomId: roomId }, + where: { + organizationId, + externalRoomId: roomId, + OR: [{ scope: 'all' }, { scope: 'self', hiddenByUserId: userId }], + }, select: { messageId: true }, take: 5000, }); - return { messageIds: rows.map((r) => r.messageId) }; + return { messageIds: [...new Set(rows.map((r) => r.messageId))] }; } } diff --git a/apps/api/src/conversations/dto.ts b/apps/api/src/conversations/dto.ts index 7c89ee4..d02b1ef 100644 --- a/apps/api/src/conversations/dto.ts +++ b/apps/api/src/conversations/dto.ts @@ -1,5 +1,6 @@ import { IsArray, + IsBoolean, IsOptional, IsString, MaxLength, @@ -61,4 +62,9 @@ export class HideConversationMessagesDto { @ArrayMaxSize(100) @IsString({ each: true }) messageIds!: string[]; + + /** true = حذف برای همه؛ false = فقط برای کاربر جاری */ + @IsOptional() + @IsBoolean() + forEveryone?: boolean; } diff --git a/apps/web/src/lib/useOrgChat.ts b/apps/web/src/lib/useOrgChat.ts index e206378..d974bde 100644 --- a/apps/web/src/lib/useOrgChat.ts +++ b/apps/web/src/lib/useOrgChat.ts @@ -93,7 +93,11 @@ function isRememberedHidden(roomId: string, messageId: string): boolean { return Boolean(hiddenIdsByRoom.get(roomId)?.has(messageId)); } -async function persistHiddenOnServer(externalRoomId: string, messageIds: string[]) { +async function persistHiddenOnServer( + externalRoomId: string, + messageIds: string[], + forEveryone = true, +) { const ids = [...new Set(messageIds.map((id) => String(id || '').trim()).filter(Boolean))]; if (!externalRoomId || !ids.length) return ids; rememberHidden(externalRoomId, ids); @@ -102,6 +106,7 @@ async function persistHiddenOnServer(externalRoomId: string, messageIds: string[ await api.post('/conversations/messages/hide', { externalRoomId, messageIds: ids, + forEveryone, }); } catch { // local purge already applied; sync may retry via poll @@ -726,7 +731,7 @@ function onSocketMessage(event: MessageEvent) { const ids = [hideTarget, purgeId].filter(Boolean); purgeMessageIds(ids); rememberHidden(roomId, ids); - void persistHiddenOnServer(roomId, ids); + void persistHiddenOnServer(roomId, ids, true); return; } if (purgeId && isDeletedContent(purgeContent)) { @@ -1196,27 +1201,34 @@ export function useOrgChat() { }); } - async function deleteMessage(roomId: string, messageId: string) { + async function deleteMessage( + roomId: string, + messageId: string, + opts?: { forEveryone?: boolean }, + ) { if (!messageId) return; - await connectSocket(); - if (!socket || socket.readyState !== WebSocket.OPEN) throw new Error('ws not open'); + const forEveryone = opts?.forEveryone !== false; - // 1) Persist hide in CRM (survives refresh for everyone) - await persistHiddenOnServer(roomId, [messageId]); + // 1) Persist hide in CRM + await persistHiddenOnServer(roomId, [messageId], forEveryone); - // 2) Realtime: send a normal text command (text delivery is reliable on chat WS) - const controlId = crypto.randomUUID(); - rememberHidden(roomId, [controlId]); - socket.send( - JSON.stringify({ - id: controlId, - type: 'text', - room_id: roomId, - content: `${HIDE_CMD_PREFIX}${messageId}`, - }), - ); - // Hide the control message itself so it never appears in history UI - void persistHiddenOnServer(roomId, [controlId]); + // 2) Realtime sync only when deleting for everyone + if (forEveryone) { + await connectSocket(); + if (socket && socket.readyState === WebSocket.OPEN) { + const controlId = crypto.randomUUID(); + rememberHidden(roomId, [controlId]); + socket.send( + JSON.stringify({ + id: controlId, + type: 'text', + room_id: roomId, + content: `${HIDE_CMD_PREFIX}${messageId}`, + }), + ); + void persistHiddenOnServer(roomId, [controlId], true); + } + } const last = [...messages.value].reverse().find((m) => m.roomId === roomId); emitRoomLive({ @@ -1227,10 +1239,16 @@ export function useOrgChat() { }); } - async function deleteMessages(roomId: string, messageIds: string[]) { - const ids = [...new Set(messageIds.filter(Boolean))]; - for (const id of ids) { - await deleteMessage(roomId, id); + async function deleteMessages( + roomId: string, + items: Array<{ id: string; forEveryone?: boolean }>, + ) { + const seen = new Set(); + for (const item of items) { + const id = String(item.id || '').trim(); + if (!id || seen.has(id)) continue; + seen.add(id); + await deleteMessage(roomId, id, { forEveryone: item.forEveryone !== false }); } } diff --git a/apps/web/src/views/ConversationsView.vue b/apps/web/src/views/ConversationsView.vue index 93b4829..10da298 100644 --- a/apps/web/src/views/ConversationsView.vue +++ b/apps/web/src/views/ConversationsView.vue @@ -46,7 +46,7 @@ const recording = ref(false); const recordSecs = ref(0); const lightboxUrl = ref(''); const fileInputEl = ref(null); -const draftInputEl = ref(null); +const draftInputEl = ref(null); const emojiOpen = ref(false); const editingMessageId = ref(null); const msgMenu = ref<{ id: string; x: number; y: number } | null>(null); @@ -194,11 +194,19 @@ function selectedMessages(): ChatMessage[] { async function deleteSelected() { if (!activeRoom.value || !selectedIds.value.length) return; - const n = selectedIds.value.length; - if (!window.confirm(n > 1 ? `${n} پیام حذف شوند؟` : 'این پیام حذف شود؟')) return; - const ids = [...selectedIds.value]; + const list = selectedMessages(); + if (!list.length) return; + const mineCount = list.filter((m) => isMineMessage(m)).length; + const otherCount = list.length - mineCount; + const parts: string[] = []; + if (mineCount) parts.push(`${mineCount} پیام خودتان برای همه`); + if (otherCount) parts.push(`${otherCount} پیام دریافتی فقط برای شما`); + if (!window.confirm(`حذف شود؟\n${parts.join('\n')}`)) return; try { - await deleteMessages(activeRoom.value.externalRoomId, ids); + await deleteMessages( + activeRoom.value.externalRoomId, + list.map((m) => ({ id: m.id, forEveryone: isMineMessage(m) })), + ); exitSelectMode(); } catch (e) { error.value = e instanceof Error ? e.message : 'حذف پیام ناموفق بود.'; @@ -266,15 +274,49 @@ function startEditMessage(msg: ChatMessage) { async function confirmDeleteMessage(msg: ChatMessage) { msgMenu.value = null; if (!activeRoom.value || msg.optimistic || msg.deleted) return; - if (!window.confirm('این پیام برای همه حذف شود؟')) return; + const mine = isMineMessage(msg); + const ok = window.confirm( + mine + ? 'این پیام برای همه حذف شود؟' + : 'این پیام فقط برای شما حذف شود؟ (برای طرف مقابل باقی می‌ماند)', + ); + if (!ok) return; try { - await deleteMessage(activeRoom.value.externalRoomId, msg.id); + await deleteMessage(activeRoom.value.externalRoomId, msg.id, { + forEveryone: mine, + }); if (editingMessageId.value === msg.id) cancelEdit(); } catch (e) { error.value = e instanceof Error ? e.message : 'حذف پیام ناموفق بود.'; } } +function onComposerKeydown(event: KeyboardEvent) { + if (event.key !== 'Enter') return; + if (event.ctrlKey || event.metaKey) { + // Ctrl/Cmd+Enter → newline + event.preventDefault(); + const el = draftInputEl.value; + if (!el) { + draft.value += '\n'; + return; + } + const start = el.selectionStart ?? draft.value.length; + const end = el.selectionEnd ?? start; + const next = draft.value.slice(0, start) + '\n' + draft.value.slice(end); + draft.value = next; + nextTick(() => { + const pos = start + 1; + el.focus(); + el.setSelectionRange(pos, pos); + }); + return; + } + if (event.shiftKey) return; + event.preventDefault(); + void submitMessage(); +} + function openMsgMenu(msg: ChatMessage, event: MouseEvent | TouchEvent) { if (msg.deleted || msg.optimistic) return; if (selectMode.value) { @@ -966,38 +1008,6 @@ onUnmounted(() => { accept="image/*,video/*,audio/*,.pdf,.doc,.docx,.xls,.xlsx,.zip,.rar,.txt" @change="onFilePicked" /> - -
- - -
+
+