Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
138 changes: 137 additions & 1 deletion backend/src/routes/messages.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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 {
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) =>
Expand Down
83 changes: 82 additions & 1 deletion frontend/src/components/ChatWindow.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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("<status> <raw body>")`. 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);

Expand Down Expand Up @@ -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('');
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -1038,6 +1079,27 @@ export default function ChatWindow({ waNumber, contactNumber, onContactSaved })
>
<Download size={15} color={C.textSecondary} /><span>Export chat</span>
</button>
{/* Anyone who can open this conversation may delete it — the
backend applies the same assertContactAccess check. */}
<>
<div style={{ height: 1, background: C.border }} />
<button
onClick={() => { setDeleteMode('history'); setMenuOpen(false); }}
style={{ ...menuItemStyle, color: C.primary }}
onMouseEnter={e => { e.currentTarget.style.background = '#FEF2F2'; }}
onMouseLeave={e => { e.currentTarget.style.background = '#fff'; }}
>
<Trash2 size={15} color={C.primary} /><span>Delete chat history</span>
</button>
<button
onClick={() => { setDeleteMode('contact'); setMenuOpen(false); }}
style={{ ...menuItemStyle, color: C.primary }}
onMouseEnter={e => { e.currentTarget.style.background = '#FEF2F2'; }}
onMouseLeave={e => { e.currentTarget.style.background = '#fff'; }}
>
<Trash2 size={15} color={C.primary} /><span>Delete contact &amp; chat</span>
</button>
</>
</div>
)}
</div>
Expand Down Expand Up @@ -1498,6 +1560,25 @@ export default function ChatWindow({ waNumber, contactNumber, onContactSaved })
onSend={handleSendLibraryMedia}
/>
)}

<DeleteConfirmModal
open={!!deleteMode}
title={deleteMode === 'contact' ? 'Delete contact & chat' : 'Delete chat history'}
message={
<>
{deleteMode === 'contact'
? <>This permanently deletes every message with <b>{contactName || `+${maskPhone(contactNumber)}`}</b> on this WhatsApp number, along with their saved name, tags, custom fields and assignment.</>
: <>This permanently deletes every message with <b>{contactName || `+${maskPhone(contactNumber)}`}</b> on this WhatsApp number, including any photos, voice notes and documents. The contact’s name, tags and assignment are kept.</>}
<div style={{ marginTop: 10, color: C.primary, fontWeight: 600 }}>This cannot be undone.</div>
{deleteError && (
<div style={{ marginTop: 10, color: C.primary }}>{deleteError}</div>
)}
</>
}
confirmText={deleting ? 'Deleting\u2026' : (deleteMode === 'contact' ? 'Delete contact & chat' : 'Delete history')}
onConfirm={deleting ? () => {} : handleDeleteChat}
onCancel={() => { if (!deleting) { setDeleteMode(null); setDeleteError(null); } }}
/>
</div>
);
}
Expand Down
10 changes: 10 additions & 0 deletions frontend/src/components/ChatsPage.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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 */}
<div
Expand All @@ -149,7 +157,9 @@ export default function ChatsPage({ subParts = [], navigate, user }) {
key={`${selectedNumber}-${selectedContact}`}
waNumber={selectedNumber}
contactNumber={selectedContact}
user={user}
onContactSaved={() => setContactRefreshKey(k => k + 1)}
onChatDeleted={handleChatDeleted}
/>
) : (
<div style={{
Expand Down
Loading