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
+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 = '';