From 53d6cc1c996b5d88948451323da43832d963548b Mon Sep 17 00:00:00 2001 From: KingArthur000 Date: Sun, 23 Aug 2026 07:15:38 +0000 Subject: [PATCH 1/2] feat(chats): delete a conversation from the chat 3-dot menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clearing a chat previously meant going into the database by hand. The chat window's 3-dot menu now offers two admin-only actions: * Delete chat history — removes every chat_history row, its reactions and the read marker for one (wa_number, contact_number) pair, and unlinks the conversation's stored media. The saved contact (name, tags, custom fields, assignment) is kept, so the chat simply reopens empty. * Delete contact & chat — the same sweep, plus the contacts row. Backed by a new admin-gated DELETE /api/chat-history. The row deletes run in one transaction; media paths are read inside it but unlinked only after the commit, since a rollback can undo a DELETE but nothing can undo an unlink. Unlinking goes through a resolveInMediaDir guard so a bad DB-stored path can never reach outside MEDIA_DIR — the same containment the media read route applies. Every deletion is recorded via auditLog. The route is gated on role rather than assertContactAccess deliberately: "is this your conversation?" is the right question for reading and replying, but the wrong one for erasing. Deals and automation_executions are left intact — they are CRM history, not chat history. No schema change; all five tables touched already exist. Co-Authored-By: Claude Opus 5 Signed-off-by: KingArthur000 --- CHANGELOG.md | 7 ++ backend/src/routes/messages.js | 103 ++++++++++++++++++++++++- frontend/src/api.js | 4 + frontend/src/components/ChatWindow.jsx | 86 ++++++++++++++++++++- frontend/src/components/ChatsPage.jsx | 9 +++ 5 files changed, 207 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e370a07..07bdb5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- **Delete a chat** — the 3-dot menu inside an open conversation now offers + *Delete chat history* (wipes every message, reaction and stored media for that + contact, keeping their name, tags and assignment) and *Delete contact & chat* + (also removes the saved contact record). Admin-only, behind a confirm dialog, + and recorded in the audit log. Deals and automation history are left intact. + ## [1.2.1] - 2026-06-17 ### Fixed diff --git a/backend/src/routes/messages.js b/backend/src/routes/messages.js index aa4a4bb..eca0898 100644 --- a/backend/src/routes/messages.js +++ b/backend/src/routes/messages.js @@ -11,7 +11,7 @@ const { uploadMedia } = require('../integrations/metaSend'); const { markAccountHealth, classifyMetaError } = require('../services/accountHealth'); const storage = require('../util/pgStorage'); const { syncMediaToAccount } = require('./mediaLibrary'); -const { assertWaAccess, assertContactAccess } = require('../middleware/access'); +const { assertWaAccess, assertContactAccess, auditLog } = require('../middleware/access'); const { isAdmin } = require('../permissions'); const { canonicalizeMime, chatKindFor, CHAT_TYPES_MSG } = require('../util/metaMime'); const ExcelJS = require('exceljs'); @@ -83,6 +83,17 @@ function persistOutboundMedia({ accountPhoneDigits, messageId, buffer, ext }) { return { absPath, size: buffer.length }; } +/** + * Only ever touch files that really live under MEDIA_DIR. media_storage_path is + * DB-sourced, so a traversal value there must never reach the wider filesystem. + * Mirrors the same guard the media-streaming route applies on read. + */ +function resolveInMediaDir(p) { + if (!p) return null; + const resolved = path.resolve(p); + return resolved.startsWith(path.resolve(MEDIA_DIR) + path.sep) ? resolved : null; +} + const router = Router(); const SERVICE_WINDOW_SECONDS = 24 * 3600; @@ -595,6 +606,96 @@ router.delete('/contact', async (req, res) => { } }); +// DELETE /api/chat-history?waNumber=xxx&contactNumber=xxx[&withContact=1] +// Admin-only, irreversible. Wipes one conversation, keyed on the same +// (wa_number, contact_number) pair everything else in this file is keyed on: +// its chat_history rows, their reactions, and the read marker that drives the +// unread badge. Media owned by those messages is unlinked from MEDIA_DIR — +// those files are per-message copies (see persistOutboundMedia), never shared +// with the media library, so removing them cannot orphan anything else. +// +// withContact=1 also drops the saved contact record (name / profile_name / tags +// / custom fields / assignment) — the "Delete contact & chat" menu item. Deals +// and automation_executions are deliberately left alone: they are CRM history, +// not chat history. +router.delete('/chat-history', async (req, res) => { + // Destructive and unrecoverable, so it is gated on role rather than on + // conversation access — a BDA can read and reply to their chats, not erase them. + if (!isAdmin(req.user)) { + return res.status(403).json({ error: 'Admin access required' }); + } + const waNumber = String(req.query.waNumber || req.body?.waNumber || '').replace(/\D/g, ''); + const contactNumber = String(req.query.contactNumber || req.body?.contactNumber || '').replace(/\D/g, ''); + const withContact = String(req.query.withContact ?? req.body?.withContact ?? '') === '1'; + if (!waNumber || !contactNumber) { + return res.status(400).json({ error: 'waNumber and contactNumber required' }); + } + + const client = await pool.connect(); + let mediaPaths = []; + let deletedMessages = 0; + let deletedContact = 0; + try { + await client.query('BEGIN'); + // Read the media paths first — once the rows are gone there is no way back + // to them, and the files would linger on the volume forever. + const media = await client.query( + `SELECT media_storage_path FROM coexistence.chat_history + WHERE wa_number = $1 AND contact_number = $2 AND media_storage_path IS NOT NULL`, + [waNumber, contactNumber] + ); + mediaPaths = media.rows.map(r => r.media_storage_path); + + const del = await client.query( + `DELETE FROM coexistence.chat_history WHERE wa_number = $1 AND contact_number = $2`, + [waNumber, contactNumber] + ); + deletedMessages = del.rowCount; + + await client.query( + `DELETE FROM coexistence.message_reactions WHERE wa_number = $1 AND contact_number = $2`, + [waNumber, contactNumber] + ); + await client.query( + `DELETE FROM coexistence.conversation_reads WHERE wa_number = $1 AND contact_number = $2`, + [waNumber, contactNumber] + ); + if (withContact) { + const c = await client.query( + `DELETE FROM coexistence.contacts WHERE wa_number = $1 AND contact_number = $2`, + [waNumber, contactNumber] + ); + deletedContact = c.rowCount; + } + await client.query('COMMIT'); + } catch (err) { + await client.query('ROLLBACK').catch(() => {}); + console.error('[messages] DELETE /chat-history error:', err.message); + return res.status(500).json({ error: 'Failed to delete chat history' }); + } finally { + client.release(); + } + + // Best-effort file cleanup. The DB commit above is the source of truth, so a + // failed unlink must not fail the request — it only leaves an orphan blob. + let filesRemoved = 0; + for (const p of mediaPaths) { + const abs = resolveInMediaDir(p); + if (!abs) continue; + try { fs.unlinkSync(abs); filesRemoved++; } catch {} + } + + await auditLog({ + actor: req.user, + action: withContact ? 'chat.delete_with_contact' : 'chat.delete_history', + targetType: 'conversation', + targetId: `${waNumber}:${contactNumber}`, + payload: { deletedMessages, deletedContact, filesRemoved }, + }); + + res.json({ ok: true, deletedMessages, deletedContact, filesRemoved }); +}); + // GET /api/saved-contacts?waNumber=xxx router.get('/saved-contacts', async (req, res) => { try { diff --git a/frontend/src/api.js b/frontend/src/api.js index aba5472..b43591c 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -48,6 +48,10 @@ export const api = { req(`/saved-contacts?waNumber=${encodeURIComponent(waNumber)}`), deleteContact: (waNumber, contactNumber) => req(`/contact?waNumber=${encodeURIComponent(waNumber)}&contactNumber=${encodeURIComponent(contactNumber)}`, { method: 'DELETE' }), + // Wipe a conversation (admin only). withContact:true also removes the saved + // contact record — the two 3-dot menu items in ChatWindow map onto this flag. + deleteChatHistory: (waNumber, contactNumber, { withContact = false } = {}) => + req(`/chat-history?waNumber=${encodeURIComponent(waNumber)}&contactNumber=${encodeURIComponent(contactNumber)}${withContact ? '&withContact=1' : ''}`, { method: 'DELETE' }), // Change a contact's phone number — migrates the conversation + history across // every table keyed on (wa_number, contact_number), transactionally. changeContactNumber: (waNumber, oldNumber, newNumber) => diff --git a/frontend/src/components/ChatWindow.jsx b/frontend/src/components/ChatWindow.jsx index 26d8cad..79b13ea 100644 --- a/frontend/src/components/ChatWindow.jsx +++ b/frontend/src/components/ChatWindow.jsx @@ -7,9 +7,26 @@ import { C, FONT, MONO, maskPhone, darkenColor } from '../constants.js'; import MessageBubble, { quoteSnippet } from './MessageBubble.jsx'; import MaskedNumber from './MaskedNumber.jsx'; import { CustomFieldEditor } from './CustomFieldInputs.jsx'; +import DeleteConfirmModal from './DeleteConfirmModal.jsx'; // Monotonic delivery lifecycle — mirror of the backend STATUS_RANK. Used to // merge a live SSE tick onto the polled status without ever downgrading. +// api.js throws `Error(" ")`. Unwrap the server's friendly +// message so the UI never shows an HTTP code or a JSON blob. Same shape as the +// prettyError used by the agent editor. +function prettyError(e) { + if (!e) return 'Unknown error'; + const msg = e.message || String(e); + try { + const m = msg.match(/^\d+\s+(.+)$/); + if (m) { + const body = JSON.parse(m[1]); + if (body && body.error) return body.error; + } + } catch { /* fall through */ } + return msg; +} + const STATUS_RANK = { sending: 0, sent: 1, delivered: 2, read: 3, played: 3, failed: 2 }; const higherStatus = (a, b) => ((STATUS_RANK[b] ?? -1) > (STATUS_RANK[a] ?? -1) ? b : a); @@ -80,7 +97,7 @@ function ForwardModal({ waNumber, message, onClose }) { ); } -export default function ChatWindow({ waNumber, contactNumber, onContactSaved }) { +export default function ChatWindow({ waNumber, contactNumber, onContactSaved, user, onChatDeleted }) { const [page, setPage] = useState(1); const [limit] = useState(50); const [search, setSearch] = useState(''); @@ -109,6 +126,12 @@ export default function ChatWindow({ waNumber, contactNumber, onContactSaved }) const [headerSaving, setHeaderSaving] = useState(false); const tagMenuRef = useRef(null); const assignMenuRef = useRef(null); + // Chat deletion (admin only): null | 'history' | 'contact' — drives which + // confirm copy the shared modal shows and which flag the API call sends. + const isAdmin = user?.role === 'admin'; + const [deleteMode, setDeleteMode] = useState(null); + const [deleting, setDeleting] = useState(false); + const [deleteError, setDeleteError] = useState(null); const fetchMessages = useCallback(() => { return api.messages({ @@ -776,6 +799,25 @@ export default function ChatWindow({ waNumber, contactNumber, onContactSaved }) setMenuOpen(false); }; + // Delete this conversation. 'history' clears the messages and leaves the + // contact (name/tags/assignment) in place; 'contact' also removes the saved + // contact record. Either way the parent unselects the chat, because the + // window it is showing no longer exists. + const handleDeleteChat = async () => { + if (!deleteMode) return; + setDeleting(true); + setDeleteError(null); + try { + await api.deleteChatHistory(waNumber, contactNumber, { withContact: deleteMode === 'contact' }); + setDeleteMode(null); + onChatDeleted?.({ contactNumber, contactRemoved: deleteMode === 'contact' }); + } catch (err) { + setDeleteError(prettyError(err)); + } finally { + setDeleting(false); + } + }; + const headerIconBtn = { width: 32, height: 32, borderRadius: '50%', border: 'none', background: 'transparent', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', @@ -1038,6 +1080,29 @@ export default function ChatWindow({ waNumber, contactNumber, onContactSaved }) > Export chat + {/* Destructive actions are admin-only and mirror the backend + role gate on DELETE /api/chat-history. */} + {isAdmin && ( + <> +
+ + + + )}
)} @@ -1498,6 +1563,25 @@ export default function ChatWindow({ waNumber, contactNumber, onContactSaved }) onSend={handleSendLibraryMedia} /> )} + + + {deleteMode === 'contact' + ? <>This permanently deletes every message with {contactName || `+${maskPhone(contactNumber)}`} on this WhatsApp number, along with their saved name, tags, custom fields and assignment. + : <>This permanently deletes every message with {contactName || `+${maskPhone(contactNumber)}`} on this WhatsApp number, including any photos, voice notes and documents. The contact’s name, tags and assignment are kept.} +
This cannot be undone.
+ {deleteError && ( +
{deleteError}
+ )} + + } + confirmText={deleting ? 'Deleting\u2026' : (deleteMode === 'contact' ? 'Delete contact & chat' : 'Delete history')} + onConfirm={deleting ? () => {} : handleDeleteChat} + onCancel={() => { if (!deleting) { setDeleteMode(null); setDeleteError(null); } }} + /> ); } diff --git a/frontend/src/components/ChatsPage.jsx b/frontend/src/components/ChatsPage.jsx index b3c6ad7..f6f9ee2 100644 --- a/frontend/src/components/ChatsPage.jsx +++ b/frontend/src/components/ChatsPage.jsx @@ -67,6 +67,13 @@ export default function ChatsPage({ subParts = [], navigate, user }) { const selectNumber = (n) => { setSelectedNumber(n); setSelectedContact(null); }; const selectContact = (c) => setSelectedContact(c); + // A deleted conversation can't stay open — drop the selection and refetch the + // contact list so the row's last-message preview (or the row itself) updates. + const handleChatDeleted = useCallback(() => { + setSelectedContact(null); + setContactRefreshKey(k => k + 1); + }, []); + // Drag the divider to resize the contacts list; the chat window (flex:1) takes the rest. const startResize = useCallback((e) => { e.preventDefault(); @@ -149,7 +156,9 @@ export default function ChatsPage({ subParts = [], navigate, user }) { key={`${selectedNumber}-${selectedContact}`} waNumber={selectedNumber} contactNumber={selectedContact} + user={user} onContactSaved={() => setContactRefreshKey(k => k + 1)} + onChatDeleted={handleChatDeleted} /> ) : (
Date: Sun, 23 Aug 2026 08:25:08 +0000 Subject: [PATCH 2/2] feat(chats): erase all chat content, and delete from the chat list too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two follow-ups to the initial chat-deletion feature, mirroring what now runs in the internal repo. 1. The sweep was incomplete — text survived a "delete". Now also removed: * agent_runs — final_reply is the text the AI agent actually sent this contact, and agent_run_steps (the full prompt / tool-call / output transcript) cascades off the run. Keyed on wa_account_id, so the account is resolved from the wa_number first. * webhook_events — the raw Meta payloads, which carry verbatim message text and media ids. Leaving these behind meant a deleted chat was still fully readable in the webhook log. Matched by jsonpath on the four places a contact's number appears in Meta's envelope (messages.from, statuses.recipient_id, contacts.wa_id, message_echoes.to) so a different contact's events are never caught. 2. Deletion is reachable from the chat list, not just inside an open chat. Each contact row gets a hover kebab with the same two actions. The row was a - {/* Destructive actions are admin-only and mirror the backend - role gate on DELETE /api/chat-history. */} - {isAdmin && ( - <> + {/* Anyone who can open this conversation may delete it — the + backend applies the same assertContactAccess check. */} + <>
- - )} +
)}
diff --git a/frontend/src/components/ChatsPage.jsx b/frontend/src/components/ChatsPage.jsx index f6f9ee2..fee38c6 100644 --- a/frontend/src/components/ChatsPage.jsx +++ b/frontend/src/components/ChatsPage.jsx @@ -136,6 +136,7 @@ export default function ChatsPage({ subParts = [], navigate, user }) { onSelectContact={selectContact} refreshKey={contactRefreshKey} user={user} + onChatDeleted={handleChatDeleted} /> {/* Drag handle: resize contacts list ⇄ chat window */}
api.contacts(waNumber, '30d'), 30000); + // Per-row chat actions: which row's kebab menu is open, which row is hovered + // (the kebab only appears on hover so the list stays clean), and the pending + // delete — { contactNumber, mode } where mode is 'history' | 'contact'. + const [menuFor, setMenuFor] = useState(null); + const [hoverRow, setHoverRow] = useState(null); + const [pendingDelete, setPendingDelete] = useState(null); + const [deleting, setDeleting] = useState(false); + const [deleteError, setDeleteError] = useState(null); + + // Any click outside a row menu dismisses it. + useEffect(() => { + if (!menuFor) return; + const onDown = () => setMenuFor(null); + document.addEventListener('mousedown', onDown); + return () => document.removeEventListener('mousedown', onDown); + }, [menuFor]); + + const runDelete = async () => { + if (!pendingDelete) return; + setDeleting(true); + setDeleteError(null); + try { + await api.deleteChatHistory(waNumber, pendingDelete.contactNumber, { + withContact: pendingDelete.mode === 'contact', + }); + const wasOpen = selectedContact === pendingDelete.contactNumber; + setPendingDelete(null); + refetch(); + if (wasOpen) onChatDeleted?.(); + } catch (err) { + const m = (err?.message || '').match(/^\d+\s+(.+)$/); + let msg = err?.message || 'Failed to delete chat'; + try { if (m) msg = JSON.parse(m[1]).error || msg; } catch { /* keep raw */ } + setDeleteError(msg); + } finally { + setDeleting(false); + } + }; + // Tag taxonomy for the filter dropdown. useEffect(() => { api.categories.list().then(setCategories).catch(() => {}); @@ -124,9 +164,14 @@ export default function ContactList({ waNumber, width = 380, selectedContact, on const unread = Number(c.unread_count) || 0; return ( -
)} - + + {/* Row actions — same two deletions as the in-chat 3-dot menu, so + a chat can be cleared without opening it. Only rendered on + hover (or while its menu is open) to keep the list quiet. */} + {(hoverRow === c.contact_number || menuFor === c.contact_number) && ( +
+
e.stopPropagation()} + onClick={e => { + e.stopPropagation(); + setMenuFor(m => (m === c.contact_number ? null : c.contact_number)); + }} + onKeyDown={e => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); e.stopPropagation(); + setMenuFor(m => (m === c.contact_number ? null : c.contact_number)); + } + }} + style={{ + width: 26, height: 26, borderRadius: '50%', cursor: 'pointer', + display: 'flex', alignItems: 'center', justifyContent: 'center', + color: C.textSecondary, background: 'transparent', + }} + > + +
+ {menuFor === c.contact_number && ( +
e.stopPropagation()} + onMouseDown={e => e.stopPropagation()} + style={{ + position: 'absolute', top: '100%', right: 0, + background: 'var(--c-cardBg)', borderRadius: 10, + boxShadow: C.shadowLg, border: `1px solid ${C.border}`, + minWidth: 200, zIndex: 60, overflow: 'hidden', fontFamily: FONT, + }} + > + {[ + { mode: 'history', label: 'Delete chat history' }, + { mode: 'contact', label: 'Delete contact & chat' }, + ].map(item => ( +
{ + setMenuFor(null); + setDeleteError(null); + setPendingDelete({ contactNumber: c.contact_number, mode: item.mode, name: displayName }); + }} + style={{ + display: 'flex', alignItems: 'center', gap: 10, + padding: '10px 14px', cursor: 'pointer', + fontSize: 13, fontWeight: 500, color: C.primary, + }} + onMouseEnter={e => { e.currentTarget.style.background = '#FEF2F2'; }} + onMouseLeave={e => { e.currentTarget.style.background = 'transparent'; }} + > + {item.label} +
+ ))} +
+ )} +
+ )} + ); - })}, + })} {contacts.length === 0 && !loading && (
@@ -232,6 +345,23 @@ export default function ContactList({ waNumber, width = 380, selectedContact, on
)} + + + {pendingDelete?.mode === 'contact' + ? <>This permanently deletes every message with {pendingDelete?.name} on this WhatsApp number, along with their saved name, tags, custom fields and assignment. + : <>This permanently deletes every message with {pendingDelete?.name} on this WhatsApp number, including any photos, voice notes and documents. The contact’s name, tags and assignment are kept.} +
This cannot be undone.
+ {deleteError &&
{deleteError}
} + + } + confirmText={deleting ? 'Deleting…' : (pendingDelete?.mode === 'contact' ? 'Delete contact & chat' : 'Delete history')} + onConfirm={deleting ? () => {} : runDelete} + onCancel={() => { if (!deleting) { setPendingDelete(null); setDeleteError(null); } }} + /> ); }