diff --git a/CHANGELOG.md b/CHANGELOG.md index e370a07..082af17 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,20 @@ 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). Available both from the open chat and + from a hover menu on each row of the chat list, behind a confirm dialog, and + recorded in the audit log. + + The wipe covers everything holding that conversation's content: messages, + reactions, the read marker, stored media on disk, the AI agent's run history + and step transcripts, and the raw Meta webhook payloads (which carry the + verbatim message text). Deals and automation history are left intact — they + are CRM history, not chat history. + ## [1.2.1] - 2026-06-17 ### Fixed diff --git a/backend/src/routes/messages.js b/backend/src/routes/messages.js index aa4a4bb..0ab0019 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,131 @@ router.delete('/contact', async (req, res) => { } }); +// DELETE /api/chat-history?waNumber=xxx&contactNumber=xxx[&withContact=1] +// 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) => { + 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' }); + } + // Anyone who can open the conversation may erase it; admins bypass as always. + // Still fully audited below, so a deletion is always attributable. + if (!(await assertContactAccess(req, res, waNumber, contactNumber))) return; + + const client = await pool.connect(); + let mediaPaths = []; + let deletedMessages = 0; + let deletedContact = 0; + let deletedAgentRuns = 0; + let deletedWebhookEvents = 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] + ); + // AI-agent traces for this conversation. agent_runs.final_reply holds the + // text the agent actually sent this contact, and agent_run_steps (the full + // prompt / tool-call / output transcript) cascades off the run — so text + // would survive a chat wipe if we stopped at chat_history. These key on + // wa_account_id, not wa_number, hence the account lookup. + const acct = await client.query( + `SELECT id FROM coexistence.whatsapp_accounts + WHERE regexp_replace(display_phone_number, '\\D', '', 'g') = $1`, + [waNumber] + ); + const accountIds = acct.rows.map(r => r.id); + if (accountIds.length > 0) { + const runs = await client.query( + `DELETE FROM coexistence.agent_runs + WHERE wa_account_id = ANY($1::bigint[]) AND contact_number = $2`, + [accountIds, contactNumber] + ); + deletedAgentRuns = runs.rowCount; + } + + // Raw Meta webhook payloads. These carry the verbatim message text and the + // media ids, so leaving them behind means a "deleted" chat is still fully + // readable in the webhook log. Matched on the four places a contact's + // number appears in Meta's envelope; jsonpath keeps it precise so we never + // catch a different contact's events. + const waEvents = await client.query( + `DELETE FROM coexistence.webhook_events + WHERE payload @? ('$.entry[*].changes[*].value.messages[*].from ? (@ == "' || $1 || '")')::jsonpath + OR payload @? ('$.entry[*].changes[*].value.statuses[*].recipient_id ? (@ == "' || $1 || '")')::jsonpath + OR payload @? ('$.entry[*].changes[*].value.contacts[*].wa_id ? (@ == "' || $1 || '")')::jsonpath + OR payload @? ('$.entry[*].changes[*].value.message_echoes[*].to ? (@ == "' || $1 || '")')::jsonpath`, + [contactNumber] + ); + deletedWebhookEvents = waEvents.rowCount; + + 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, deletedAgentRuns, deletedWebhookEvents }, + }); + + res.json({ ok: true, deletedMessages, deletedContact, filesRemoved, deletedAgentRuns, deletedWebhookEvents }); +}); + // 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..4e5ccf3 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,11 @@ export default function ChatWindow({ waNumber, contactNumber, onContactSaved }) const [headerSaving, setHeaderSaving] = useState(false); const tagMenuRef = useRef(null); const assignMenuRef = useRef(null); + // Chat deletion: null | 'history' | 'contact' — drives which confirm copy the + // shared modal shows and which flag the API call sends. + const [deleteMode, setDeleteMode] = useState(null); + const [deleting, setDeleting] = useState(false); + const [deleteError, setDeleteError] = useState(null); const fetchMessages = useCallback(() => { return api.messages({ @@ -776,6 +798,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 +1079,27 @@ export default function ChatWindow({ waNumber, contactNumber, onContactSaved }) > Export chat + {/* Anyone who can open this conversation may delete it — the + backend applies the same assertContactAccess check. */} + <> +
+ + +
)} @@ -1498,6 +1560,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..fee38c6 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(); @@ -129,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 */}
setContactRefreshKey(k => k + 1)} + onChatDeleted={handleChatDeleted} /> ) : (
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); } }} + /> ); }