diff --git a/apps/api/prisma/migrations/20260929200000_conversation_hidden_messages/migration.sql b/apps/api/prisma/migrations/20260929200000_conversation_hidden_messages/migration.sql new file mode 100644 index 0000000..f174b00 --- /dev/null +++ b/apps/api/prisma/migrations/20260929200000_conversation_hidden_messages/migration.sql @@ -0,0 +1,20 @@ +-- CreateTable +CREATE TABLE "ConversationHiddenMessage" ( + "id" TEXT NOT NULL, + "organizationId" TEXT NOT NULL, + "externalRoomId" TEXT NOT NULL, + "messageId" TEXT NOT NULL, + "hiddenByUserId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "ConversationHiddenMessage_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "ConversationHiddenMessage_organizationId_externalRoomId_idx" ON "ConversationHiddenMessage"("organizationId", "externalRoomId"); + +-- CreateIndex +CREATE UNIQUE INDEX "ConversationHiddenMessage_organizationId_externalRoomId_messageId_key" ON "ConversationHiddenMessage"("organizationId", "externalRoomId", "messageId"); + +-- AddForeignKey +ALTER TABLE "ConversationHiddenMessage" ADD CONSTRAINT "ConversationHiddenMessage_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 224968c..129ba3e 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -209,6 +209,7 @@ model Organization { leadImportJobs LeadImportJob[] conversationSettings ConversationSettings? conversationRooms ConversationRoom[] + conversationHiddenMessages ConversationHiddenMessage[] } model User { @@ -2095,3 +2096,17 @@ model ConversationRoom { @@unique([organizationId, dmUserMinId, dmUserMaxId]) @@index([organizationId, kind]) } + +/// پیام‌های حذف‌شده برای همه در CRM (سرویس چت خارجی حذف پایدار ندارد) +model ConversationHiddenMessage { + id String @id @default(cuid()) + organizationId String + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + externalRoomId String + messageId String + hiddenByUserId String + createdAt DateTime @default(now()) + + @@unique([organizationId, externalRoomId, messageId]) + @@index([organizationId, externalRoomId]) +} diff --git a/apps/api/src/conversations/conversations.controller.ts b/apps/api/src/conversations/conversations.controller.ts index 078b6a7..c775896 100644 --- a/apps/api/src/conversations/conversations.controller.ts +++ b/apps/api/src/conversations/conversations.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common'; +import { Body, Controller, Get, Post, Query, UseGuards } from '@nestjs/common'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { CurrentUser } from '../auth/current-user.decorator'; import { RequireOrgModule } from '../billing/org-module.decorator'; @@ -6,6 +6,7 @@ import { OrgModuleGuard } from '../billing/org-module.guard'; import { CreateConversationDmDto, CreateConversationGroupDto, + HideConversationMessagesDto, } from './dto'; import { ConversationsService } from './conversations.service'; @@ -61,4 +62,31 @@ export class ConversationsController { dto.memberUserIds, ); } + + @Post('messages/hide') + hideMessages( + @CurrentUser() user: { userId: string; organizationId: string; role: string }, + @Body() dto: HideConversationMessagesDto, + ) { + return this.conversations.hideMessages( + user.userId, + user.organizationId, + user.role, + dto.externalRoomId, + dto.messageIds, + ); + } + + @Get('messages/hidden') + listHidden( + @CurrentUser() user: { userId: string; organizationId: string; role: string }, + @Query('externalRoomId') externalRoomId: string, + ) { + return this.conversations.listHiddenMessageIds( + user.userId, + user.organizationId, + user.role, + externalRoomId || '', + ); + } } diff --git a/apps/api/src/conversations/conversations.service.ts b/apps/api/src/conversations/conversations.service.ts index 5d1cc16..2628986 100644 --- a/apps/api/src/conversations/conversations.service.ts +++ b/apps/api/src/conversations/conversations.service.ts @@ -443,6 +443,9 @@ export class ConversationsService { let preview = lastMsg ? String(lastMsg.content ?? lastMsg.text ?? lastMsg.body ?? '').trim() : ''; + if (/^__MENTO_HIDE__:/i.test(preview) || /MENTO_DEL/i.test(preview)) { + preview = ''; + } if (lastType === 'image' || lastType === 'photo' || lastType === 'img') { if (/(?:^|[\\/._-])vid-\d+/i.test(preview) || /\.(mp4|mov|m4v|mkv|avi|3gp)(?:\?|$)/i.test(preview)) { preview = 'ویدیو'; @@ -490,4 +493,54 @@ export class ConversationsService { peerUserId, }; } + + async hideMessages( + userId: string, + organizationId: string, + role: string, + externalRoomId: string, + messageIds: string[], + ): Promise<{ messageIds: string[] }> { + await this.loadAccess(userId, organizationId, role); + const roomId = externalRoomId.trim(); + if (!roomId) throw new BadRequestException('شناسه اتاق خالی است'); + const ids = [ + ...new Set( + messageIds + .map((id) => String(id || '').trim()) + .filter((id) => id.length > 0 && id.length <= 200), + ), + ].slice(0, 100); + if (!ids.length) throw new BadRequestException('شناسه پیام خالی است'); + + await this.prisma.conversationHiddenMessage.createMany({ + data: ids.map((messageId) => ({ + organizationId, + externalRoomId: roomId, + messageId, + hiddenByUserId: userId, + })), + skipDuplicates: true, + }); + + return { messageIds: ids }; + } + + async listHiddenMessageIds( + userId: string, + organizationId: string, + role: string, + externalRoomId: string, + ): Promise<{ messageIds: string[] }> { + await this.loadAccess(userId, organizationId, role); + const roomId = externalRoomId.trim(); + if (!roomId) throw new BadRequestException('شناسه اتاق خالی است'); + + const rows = await this.prisma.conversationHiddenMessage.findMany({ + where: { organizationId, externalRoomId: roomId }, + select: { messageId: true }, + take: 5000, + }); + return { messageIds: rows.map((r) => r.messageId) }; + } } diff --git a/apps/api/src/conversations/dto.ts b/apps/api/src/conversations/dto.ts index 84cf0d2..7c89ee4 100644 --- a/apps/api/src/conversations/dto.ts +++ b/apps/api/src/conversations/dto.ts @@ -49,3 +49,16 @@ export class CreateConversationGroupDto { @IsString({ each: true }) memberUserIds!: string[]; } + +export class HideConversationMessagesDto { + @IsString() + @MinLength(1) + @MaxLength(200) + externalRoomId!: string; + + @IsArray() + @ArrayMinSize(1) + @ArrayMaxSize(100) + @IsString({ each: true }) + messageIds!: string[]; +} diff --git a/apps/web/src/lib/useOrgChat.ts b/apps/web/src/lib/useOrgChat.ts index e49c2c7..0506220 100644 --- a/apps/web/src/lib/useOrgChat.ts +++ b/apps/web/src/lib/useOrgChat.ts @@ -37,18 +37,28 @@ const PAGE_SIZE = 50; const IMAGE_MAX_EDGE = 1600; const IMAGE_WEBP_QUALITY = 0.82; const MAX_UPLOAD_BYTES = 40 * 1024 * 1024; -/** Synced hard-delete marker (edit channel). Clients purge the message; never shown in UI. */ +/** Synced hard-delete marker (legacy edit attempts). */ const HARD_DELETE_MARKER = '__MENTO_DEL__'; +/** Realtime hide command delivered as a normal text message (external WS delivers text reliably). */ +const HIDE_CMD_PREFIX = '__MENTO_HIDE__:'; function isDeletedContent(content: string): boolean { const t = content.trim(); return ( /^(?:\[?\s*deleted\s*\]?|این پیام حذف شد|پیام حذف شد)$/i.test(t) || t.includes('MENTO_DEL') || - t === HARD_DELETE_MARKER + t === HARD_DELETE_MARKER || + t.startsWith(HIDE_CMD_PREFIX) ); } +function parseHideCommand(content: string): string | null { + const t = content.trim(); + if (!t.startsWith(HIDE_CMD_PREFIX)) return null; + const id = t.slice(HIDE_CMD_PREFIX.length).trim(); + return id || null; +} + function purgeMessageById(id: string) { const key = String(id || '').trim(); if (!key) return; @@ -56,6 +66,81 @@ function purgeMessageById(id: string) { if (next.length !== messages.value.length) messages.value = next; } +function purgeMessageIds(ids: Iterable) { + const set = new Set([...ids].map((id) => String(id || '').trim()).filter(Boolean)); + if (!set.size) return; + const next = messages.value.filter((m) => !set.has(m.id)); + if (next.length !== messages.value.length) messages.value = next; +} + +const hiddenIdsByRoom = new Map>(); +let hiddenPollTimer: ReturnType | null = null; + +function rememberHidden(roomId: string, ids: string[]) { + if (!roomId || !ids.length) return; + let set = hiddenIdsByRoom.get(roomId); + if (!set) { + set = new Set(); + hiddenIdsByRoom.set(roomId, set); + } + for (const id of ids) { + const key = String(id || '').trim(); + if (key) set.add(key); + } +} + +function isRememberedHidden(roomId: string, messageId: string): boolean { + return Boolean(hiddenIdsByRoom.get(roomId)?.has(messageId)); +} + +async function persistHiddenOnServer(externalRoomId: string, messageIds: string[]) { + const ids = [...new Set(messageIds.map((id) => String(id || '').trim()).filter(Boolean))]; + if (!externalRoomId || !ids.length) return ids; + rememberHidden(externalRoomId, ids); + purgeMessageIds(ids); + try { + await api.post('/conversations/messages/hide', { + externalRoomId, + messageIds: ids, + }); + } catch { + // local purge already applied; sync may retry via poll + } + return ids; +} + +async function fetchHiddenFromServer(externalRoomId: string): Promise { + if (!externalRoomId) return []; + try { + const { data } = await api.get<{ messageIds: string[] }>('/conversations/messages/hidden', { + params: { externalRoomId }, + }); + const ids = Array.isArray(data?.messageIds) ? data.messageIds : []; + rememberHidden(externalRoomId, ids); + return ids; + } catch { + return [...(hiddenIdsByRoom.get(externalRoomId) ?? [])]; + } +} + +function stopHiddenPoll() { + if (hiddenPollTimer) { + clearInterval(hiddenPollTimer); + hiddenPollTimer = null; + } +} + +function startHiddenPoll(roomId: string) { + stopHiddenPoll(); + if (!roomId) return; + hiddenPollTimer = setInterval(() => { + void (async () => { + const ids = await fetchHiddenFromServer(roomId); + if (ids.length) purgeMessageIds(ids); + })(); + }, 3000); +} + let socket: WebSocket | null = null; let sessionCache: ConversationSessionDto | null = null; let connectPromise: Promise | null = null; @@ -316,19 +401,25 @@ function normalizeIncoming( String(base.file_name ?? base.fileName ?? base.name ?? '').trim() || content.split('/').pop() || undefined; + const roomId = String(base.room_id ?? raw.room_id ?? roomFallback); const type = inferMediaType(declared, content, fileName); const isMedia = type !== 'text'; const mediaUrl = isMedia ? buildChatFileUrl(content, downloadUrl) : undefined; const deletedFlag = Boolean(base.deleted || base.is_deleted || base.isDeleted); - if (deletedFlag || isDeletedContent(content)) { - // Hard-delete: drop from history / ignore tombstones + const hideTarget = parseHideCommand(content); + if ( + deletedFlag || + isDeletedContent(content) || + hideTarget || + (id && isRememberedHidden(roomId, id)) + ) { return null; } const edited = Boolean(base.edited || base.is_edited || base.isEdited || base.edited_at); return { id: id || `tmp-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, - roomId: String(base.room_id ?? raw.room_id ?? roomFallback), + roomId, senderId: String( base.sender_id ?? base.user_id ?? base.from_user_id ?? raw.sender_id ?? '', ), @@ -625,12 +716,26 @@ function onSocketMessage(event: MessageEvent) { } const purgeId = extractPayloadId(parsed); const purgeContent = extractPayloadContent(parsed); + const hideTarget = parseHideCommand(purgeContent); + if (hideTarget) { + const roomId = String( + (parsed.room_id as string) || + ((parsed.message as Record | undefined)?.room_id as string) || + activeRoomId.value, + ); + const ids = [hideTarget, purgeId].filter(Boolean); + purgeMessageIds(ids); + rememberHidden(roomId, ids); + void persistHiddenOnServer(roomId, ids); + return; + } if (purgeId && isDeletedContent(purgeContent)) { purgeMessageById(purgeId); return; } const msg = normalizeIncoming(parsed, activeRoomId.value); if (!msg) return; + if (isRememberedHidden(msg.roomId, msg.id)) return; handleIncomingMessage(msg); } catch { // ignore @@ -791,9 +896,13 @@ export function useOrgChat() { async function loadMessages(roomId: string) { activeRoomId.value = roomId; loadingMessages.value = true; + startHiddenPoll(roomId); try { const session = await ensureSession(); await connectSocket(); + const hiddenIds = await fetchHiddenFromServer(roomId); + const hiddenSet = new Set(hiddenIds); + const params = new URLSearchParams({ api_key: new URL(session.wsUrl).searchParams.get('api_key') ?? '', room_id: roomId, @@ -806,16 +915,29 @@ export function useOrgChat() { if (!res.ok) throw new Error('messages failed'); const body = await res.json(); const list = Array.isArray(body) ? body : body?.data ?? []; + + // Collect hide-command targets from history so we filter even without CRM row yet + for (const item of list as Record[]) { + const rawContent = String( + item.content ?? item.text ?? item.body ?? '', + ).trim(); + const target = parseHideCommand(rawContent); + if (target) hiddenSet.add(target); + } + rememberHidden(roomId, [...hiddenSet]); + const myId = session.chatUserId; const normalized = (list .map((item: Record) => normalizeIncoming(item, roomId)) - .filter(Boolean) as ChatMessage[]).map((m) => { - if (m.senderId !== myId) return m; - return { - ...m, - delivery: (m.readAt ? 'read' : 'sent') as ChatDelivery, - }; - }); + .filter((m): m is ChatMessage => Boolean(m) && !hiddenSet.has(m!.id)) as ChatMessage[]).map( + (m) => { + if (m.senderId !== myId) return m; + return { + ...m, + delivery: (m.readAt ? 'read' : 'sent') as ChatDelivery, + }; + }, + ); messages.value = sortAsc(normalized); sendRead(roomId); } finally { @@ -1080,51 +1202,22 @@ export function useOrgChat() { await connectSocket(); if (!socket || socket.readyState !== WebSocket.OPEN) throw new Error('ws not open'); - // Remove locally immediately (no tombstone) - purgeMessageById(messageId); + // 1) Persist hide in CRM (survives refresh for everyone) + await persistHiddenOnServer(roomId, [messageId]); - // Sync to peers via edit channel (proven to broadcast) + best-effort delete + // 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({ - type: 'edit', - id: messageId, + id: controlId, + type: 'text', room_id: roomId, - content: HARD_DELETE_MARKER, + content: `${HIDE_CMD_PREFIX}${messageId}`, }), ); - socket.send( - JSON.stringify({ - type: 'delete', - id: messageId, - message_id: messageId, - room_id: roomId, - }), - ); - socket.send( - JSON.stringify({ - type: 'remove', - id: messageId, - message_id: messageId, - room_id: roomId, - }), - ); - - // Best-effort REST delete if the chat host supports it - try { - const session = await ensureSession(); - const apiKey = new URL(session.wsUrl).searchParams.get('api_key') ?? ''; - const params = new URLSearchParams({ - api_key: apiKey, - room_id: roomId, - user_id: session.chatUserId, - }); - void fetch(`${session.messagesBaseUrl}/api/messages/${encodeURIComponent(messageId)}?${params}`, { - method: 'DELETE', - headers: { Accept: 'application/json' }, - }).catch(() => undefined); - } catch { - // ignore - } + // Hide the control message itself so it never appears in history UI + void persistHiddenOnServer(roomId, [controlId]); const last = [...messages.value].reverse().find((m) => m.roomId === roomId); emitRoomLive({ @@ -1162,6 +1255,7 @@ export function useOrgChat() { } function clearActiveRoom() { + stopHiddenPoll(); activeRoomId.value = ''; messages.value = []; } @@ -1169,6 +1263,7 @@ export function useOrgChat() { function resetSession() { wantSocket = false; sessionCache = null; + stopHiddenPoll(); disconnectSocket(); messages.value = []; activeRoomId.value = '';