Fixing the issue with complete deletion from the database

This commit is contained in:
2026-09-29 21:46:41 +03:30
parent 88dc7c5a1c
commit 5bb8e68521
6 changed files with 276 additions and 52 deletions
@@ -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;
+15
View File
@@ -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])
}
@@ -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 || '',
);
}
}
@@ -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) };
}
}
+13
View File
@@ -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[];
}
+146 -51
View File
@@ -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<string>) {
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<string, Set<string>>();
let hiddenPollTimer: ReturnType<typeof setInterval> | 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<string[]> {
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<void> | 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<string, unknown> | 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<string, unknown>[]) {
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<string, unknown>) => 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 = '';