Fixing the issue with complete deletion from the database
This commit is contained in:
@@ -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;
|
||||
@@ -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) };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 = '';
|
||||
|
||||
Reference in New Issue
Block a user