From d481947843938e558bf03a9361d97cdba25d15a1 Mon Sep 17 00:00:00 2001 From: ghwmelite-dotcom Date: Wed, 1 Jul 2026 23:26:45 +0000 Subject: [PATCH] feat(journal,accounts): in-app delete for journal trades + account purge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the need for raw production SQL to clean up data. All paths are ownership-scoped (a user can only ever touch their own rows). Journal: - DELETE /journal/trades/:accountId — filter-aware bulk delete (from/to date, symbol, direction, session); no filters = clear the account's journal. Returns the deleted count. - DELETE /journal/trades/:accountId/:dealTicket — single trade. - JournalPage: a Delete action opens a modal with "Before a date" (e.g. everything before 2026-06-01) and "Clear entire journal" (typed confirm) modes, plus a per-row trash button. Account hard-delete: - DELETE /accounts/:id/purge — atomic batch that removes the account and every row referencing it (journal_trades, symbol_mappings, follower_config, prop_rules, daily_stats, blocked_trades, firm_templates, signals, executions, provider_profiles, marketplace_subscriptions), then the account. Distinct from the existing soft deactivate. - Delete Account modal gains a gated "permanently erase" path (type the account name to confirm). Stores gain deleteTrades/deleteTrade and purgeAccount. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/web/src/pages/AccountsPage.tsx | 76 +++++++- apps/web/src/pages/JournalPage.tsx | 217 ++++++++++++++++++++- apps/web/src/stores/accounts.ts | 11 ++ apps/web/src/stores/journal.ts | 28 +++ workers/api-gateway/src/routes/accounts.ts | 39 ++++ workers/api-gateway/src/routes/journal.ts | 73 +++++++ 6 files changed, 425 insertions(+), 19 deletions(-) diff --git a/apps/web/src/pages/AccountsPage.tsx b/apps/web/src/pages/AccountsPage.tsx index 6e5ff1a..5ab5842 100644 --- a/apps/web/src/pages/AccountsPage.tsx +++ b/apps/web/src/pages/AccountsPage.tsx @@ -604,28 +604,53 @@ function DeleteConfirmModal({ account: Account | null; }) { const deleteAccount = useAccountsStore((s) => s.deleteAccount); + const purgeAccount = useAccountsStore((s) => s.purgeAccount); const [isDeleting, setIsDeleting] = useState(false); + const [isPurging, setIsPurging] = useState(false); + const [purgeMode, setPurgeMode] = useState(false); + const [confirmText, setConfirmText] = useState(''); + const [error, setError] = useState(null); + + const close = () => { + setPurgeMode(false); + setConfirmText(''); + setError(null); + onClose(); + }; - const handleDelete = async () => { + const handleDeactivate = async () => { if (!account) return; setIsDeleting(true); await deleteAccount(account.id); setIsDeleting(false); - onClose(); + close(); + }; + + const handlePurge = async () => { + if (!account) return; + if (confirmText.trim() !== account.alias) { + setError(`Type the account name "${account.alias}" exactly to confirm.`); + return; + } + setError(null); + setIsPurging(true); + const ok = await purgeAccount(account.id); + setIsPurging(false); + if (ok) close(); + else setError('Could not permanently delete. Please try again.'); }; if (!account) return null; return ( - +

- Are you sure you want to delete{' '} - {account.alias}? This action - cannot be undone. + Deactivate {account.alias}? It stops + trading and is removed from your list; its data is retained.

{account.role === 'master' && (

@@ -636,14 +661,47 @@ function DeleteConfirmModal({

- -
+ +
+ {!purgeMode ? ( + + ) : ( +
+

+ Permanent delete. This erases the + account and all its data — journal trades, signals, executions, + symbol mappings and settings. It cannot be undone or restored. +

+

+ Type {account.alias} to confirm: +

+ setConfirmText(e.target.value)} + placeholder={account.alias} + /> + {error &&

{error}

} + +
+ )} +
); diff --git a/apps/web/src/pages/JournalPage.tsx b/apps/web/src/pages/JournalPage.tsx index c13d817..6fb071c 100644 --- a/apps/web/src/pages/JournalPage.tsx +++ b/apps/web/src/pages/JournalPage.tsx @@ -1,11 +1,14 @@ -import { useEffect, useRef, useCallback } from 'react'; +import { useEffect, useRef, useCallback, useState } from 'react'; import { useNavigate } from 'react-router-dom'; -import { BookOpen } from 'lucide-react'; +import { BookOpen, Trash2, AlertTriangle } from 'lucide-react'; import { useAccountsStore } from '@/stores/accounts'; import { useJournalStore, type JournalFilters } from '@/stores/journal'; import { EquityCurve } from '@/components/journal/EquityCurve'; import { TradeFilters } from '@/components/journal/TradeFilters'; import { Select } from '@/components/ui/Select'; +import { Button } from '@/components/ui/Button'; +import { Input } from '@/components/ui/Input'; +import { Modal } from '@/components/ui/Modal'; /* ------------------------------------------------------------------ */ /* Formatting helpers */ @@ -73,7 +76,7 @@ function SkeletonRow({ delay = 0 }: { delay?: number }) { className="border-b border-terminal-border/50 animate-fade-in-up" style={{ animationDelay: `${delay}ms` }} > - {Array.from({ length: 8 }).map((_, j) => ( + {Array.from({ length: 9 }).map((_, j) => (
@@ -105,13 +108,28 @@ export function JournalPage() { fetchAll, fetchTrades, fetchMoreTrades, + deleteTrade, } = useJournalStore(); + const [deleteOpen, setDeleteOpen] = useState(false); + const setSelectedAccountId = useCallback( (id: string) => useJournalStore.setState({ selectedAccountId: id }), [], ); + const selectedAccount = accounts.find((a) => a.id === selectedAccountId); + + const handleDeleteRow = useCallback( + async (dealTicket: number) => { + if (!selectedAccountId) return; + if (!window.confirm('Delete this trade from your journal? This cannot be undone.')) return; + await deleteTrade(selectedAccountId, dealTicket); + fetchAll(selectedAccountId); + }, + [selectedAccountId, deleteTrade, fetchAll], + ); + // Infinite scroll sentinel const sentinelRef = useRef(null); const loadingMoreRef = useRef(false); @@ -189,13 +207,26 @@ export function JournalPage() {

Trade Journal

-
- setSelectedAccountId(e.target.value)} + /> +
+ {selectedAccountId && ( + + )}
@@ -425,6 +456,7 @@ export function JournalPage() { Profit Pips Duration + Actions @@ -484,6 +516,19 @@ export function JournalPage() { {formatDuration(trade.duration_seconds)} + + + ); })} @@ -502,6 +547,16 @@ export function JournalPage() { )} + + setDeleteOpen(false)} + accountId={selectedAccountId} + accountAlias={selectedAccount?.alias ?? ''} + onDeleted={() => { + if (selectedAccountId) fetchAll(selectedAccountId); + }} + /> ); } @@ -510,6 +565,148 @@ export function JournalPage() { /* Sub-components */ /* ------------------------------------------------------------------ */ +function DeleteJournalModal({ + open, + onClose, + accountId, + accountAlias, + onDeleted, +}: { + open: boolean; + onClose: () => void; + accountId: string | null; + accountAlias: string; + onDeleted: () => void; +}) { + const deleteTrades = useJournalStore((s) => s.deleteTrades); + const [mode, setMode] = useState<'date' | 'all'>('date'); + const [beforeDate, setBeforeDate] = useState(''); + const [confirmText, setConfirmText] = useState(''); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [result, setResult] = useState(null); + + const close = () => { + setMode('date'); + setBeforeDate(''); + setConfirmText(''); + setError(null); + setResult(null); + setBusy(false); + onClose(); + }; + + const handleDelete = async () => { + if (!accountId) return; + setError(null); + let filters: JournalFilters | undefined; + if (mode === 'date') { + if (!beforeDate) { + setError('Pick a cutoff date first.'); + return; + } + const cutoff = Math.floor(new Date(`${beforeDate}T00:00:00Z`).getTime() / 1000); + filters = { to: cutoff - 1 }; + } else { + if (confirmText.trim() !== accountAlias) { + setError(`Type the account name "${accountAlias}" exactly to confirm.`); + return; + } + filters = undefined; + } + setBusy(true); + const deleted = await deleteTrades(accountId, filters); + setBusy(false); + if (deleted === null) { + setError('Delete failed. Please try again.'); + return; + } + setResult(`Deleted ${deleted} trade${deleted === 1 ? '' : 's'}.`); + onDeleted(); + }; + + return ( + + {result ? ( +
+
+ {result} +
+ +
+ ) : ( +
+
+ + +
+ + {mode === 'date' ? ( +
+

+ Permanently delete every trade dated before this day: +

+ setBeforeDate(e.target.value)} /> +

+ Cutoff is interpreted in UTC. Example: pick 2026-06-01 to remove + everything before June 2026. +

+
+ ) : ( +
+
+ +

+ This permanently deletes every trade for{' '} + {accountAlias}. This cannot be undone. +

+
+

+ Type {accountAlias} to confirm: +

+ setConfirmText(e.target.value)} + placeholder={accountAlias} + /> +
+ )} + + {error &&

{error}

} + +
+ + +
+
+ )} +
+ ); +} + function MiniStat({ label, value, diff --git a/apps/web/src/stores/accounts.ts b/apps/web/src/stores/accounts.ts index cdfa824..60ddac6 100644 --- a/apps/web/src/stores/accounts.ts +++ b/apps/web/src/stores/accounts.ts @@ -52,6 +52,7 @@ interface AccountsState { master_account_id?: string; }) => Promise; deleteAccount: (id: string) => Promise; + purgeAccount: (id: string) => Promise; } export const useAccountsStore = create()((set, get) => ({ @@ -87,4 +88,14 @@ export const useAccountsStore = create()((set, get) => ({ } return false; }, + + purgeAccount: async (id) => { + const res = await api.del<{ purged: boolean }>(`/accounts/${id}/purge`); + if (!res.error) { + set({ accounts: get().accounts.filter((a) => a.id !== id) }); + return true; + } + set({ error: res.error?.message ?? 'Failed to permanently delete account' }); + return false; + }, })); diff --git a/apps/web/src/stores/journal.ts b/apps/web/src/stores/journal.ts index 576dfa5..c9153b3 100644 --- a/apps/web/src/stores/journal.ts +++ b/apps/web/src/stores/journal.ts @@ -100,6 +100,8 @@ interface JournalState { fetchTradeDetail: (accountId: string, dealTicket: number) => Promise; fetchAll: (accountId: string) => Promise; setFilters: (filters: Partial) => void; + deleteTrades: (accountId: string, filters?: JournalFilters) => Promise; + deleteTrade: (accountId: string, dealTicket: number) => Promise; reset: () => void; } @@ -248,6 +250,32 @@ export const useJournalStore = create()((set, get) => ({ set({ filters: { ...get().filters, ...filters } }); }, + deleteTrades: async (accountId: string, filters?: JournalFilters) => { + const qs = filters ? buildDateParams(filters) : ''; + // buildDateParams only carries from/to; add the remaining filters too. + const params = new URLSearchParams(qs.startsWith('?') ? qs.slice(1) : qs); + if (filters?.symbol) params.set('symbol', filters.symbol); + if (filters?.direction) params.set('direction', filters.direction); + if (filters?.session_tag) params.set('session_tag', filters.session_tag); + const query = params.toString(); + const res = await api.del<{ deleted: number }>( + `/journal/trades/${accountId}${query ? `?${query}` : ''}`, + ); + if (res.data) return res.data.deleted; + set({ error: res.error?.message ?? 'Failed to delete trades' }); + return null; + }, + + deleteTrade: async (accountId: string, dealTicket: number) => { + const res = await api.del<{ deleted: number }>(`/journal/trades/${accountId}/${dealTicket}`); + if (!res.error) { + set({ trades: get().trades.filter((t) => t.deal_ticket !== dealTicket) }); + return true; + } + set({ error: res.error?.message ?? 'Failed to delete trade' }); + return false; + }, + reset: () => { set({ ...initialState }); }, diff --git a/workers/api-gateway/src/routes/accounts.ts b/workers/api-gateway/src/routes/accounts.ts index 3b7d260..d45e617 100644 --- a/workers/api-gateway/src/routes/accounts.ts +++ b/workers/api-gateway/src/routes/accounts.ts @@ -568,6 +568,45 @@ accounts.delete('/:id', async (c) => { return c.json({ data: { id: accountId, deactivated: true }, error: null }); }); +// ── DELETE /accounts/:id/purge — Hard delete account + all its data ── +// Unlike the soft delete above (which only sets is_active = false), this +// permanently removes the account and every row that references it. Runs +// as an atomic batch: if any statement fails, nothing is deleted. +accounts.delete('/:id/purge', async (c) => { + const userId = c.get('userId'); + const accountId = c.req.param('id'); + + // Verify ownership first — a user may only purge their own account. + const owned = await c.env.DB.prepare('SELECT id FROM accounts WHERE id = ? AND user_id = ?') + .bind(accountId, userId) + .first(); + + if (!owned) { + return c.json( + { data: null, error: { code: 'NOT_FOUND', message: 'Account not found' } }, + 404, + ); + } + + const db = c.env.DB; + await db.batch([ + db.prepare('DELETE FROM journal_trades WHERE account_id = ?').bind(accountId), + db.prepare('DELETE FROM symbol_mappings WHERE account_id = ?').bind(accountId), + db.prepare('DELETE FROM follower_config WHERE account_id = ?').bind(accountId), + db.prepare('DELETE FROM prop_rules WHERE account_id = ?').bind(accountId), + db.prepare('DELETE FROM daily_stats WHERE account_id = ?').bind(accountId), + db.prepare('DELETE FROM blocked_trades WHERE account_id = ?').bind(accountId), + db.prepare('DELETE FROM firm_templates WHERE account_id = ?').bind(accountId), + db.prepare('DELETE FROM signals WHERE master_account_id = ?').bind(accountId), + db.prepare('DELETE FROM executions WHERE follower_account_id = ?').bind(accountId), + db.prepare('DELETE FROM provider_profiles WHERE master_account_id = ?').bind(accountId), + db.prepare('DELETE FROM marketplace_subscriptions WHERE follower_account_id = ?').bind(accountId), + db.prepare('DELETE FROM accounts WHERE id = ? AND user_id = ?').bind(accountId, userId), + ]); + + return c.json({ data: { id: accountId, purged: true }, error: null }); +}); + // ── POST /accounts/:id/regenerate-keys ────────────────────────── accounts.post('/:id/regenerate-keys', async (c) => { const userId = c.get('userId'); diff --git a/workers/api-gateway/src/routes/journal.ts b/workers/api-gateway/src/routes/journal.ts index 5611260..d2eb944 100644 --- a/workers/api-gateway/src/routes/journal.ts +++ b/workers/api-gateway/src/routes/journal.ts @@ -467,3 +467,76 @@ journal.get('/stats/:accountId/daily', async (c) => { error: null, }); }); + +// ── DELETE /trades/:accountId — Bulk delete (filter-aware) ────── +// No filters = clear the entire journal for the account. Same filter +// params as the GET list, so "delete what you're viewing" works. Scoped +// to an owned account; a user can only ever touch their own trades. + +journal.delete('/trades/:accountId', async (c) => { + const accountId = c.req.param('accountId'); + const userId = c.get('userId'); + + const owns = await verifyAccountOwnership(c.env.DB, accountId, userId); + if (!owns) { + return c.json( + { data: null, error: { code: 'FORBIDDEN', message: 'Account not found or not owned by user' } }, + 403, + ); + } + + const symbol = c.req.query('symbol'); + const direction = c.req.query('direction'); + const sessionTag = c.req.query('session_tag'); + const magicNumber = c.req.query('magic_number'); + const from = c.req.query('from'); + const to = c.req.query('to'); + + const conditions: string[] = ['account_id = ?']; + const bindings: unknown[] = [accountId]; + if (symbol) { conditions.push('symbol = ?'); bindings.push(symbol); } + if (direction) { conditions.push('direction = ?'); bindings.push(direction); } + if (sessionTag) { conditions.push('session_tag = ?'); bindings.push(sessionTag); } + if (magicNumber) { conditions.push('magic_number = ?'); bindings.push(parseInt(magicNumber, 10)); } + if (from) { conditions.push('time >= ?'); bindings.push(parseInt(from, 10)); } + if (to) { conditions.push('time <= ?'); bindings.push(parseInt(to, 10)); } + + const result = await c.env.DB.prepare( + `DELETE FROM journal_trades WHERE ${conditions.join(' AND ')}`, + ) + .bind(...bindings) + .run(); + + return c.json({ data: { deleted: result.meta.changes }, error: null }); +}); + +// ── DELETE /trades/:accountId/:dealTicket — Single trade ──────── + +journal.delete('/trades/:accountId/:dealTicket', async (c) => { + const accountId = c.req.param('accountId'); + const dealTicket = parseInt(c.req.param('dealTicket'), 10); + const userId = c.get('userId'); + + const owns = await verifyAccountOwnership(c.env.DB, accountId, userId); + if (!owns) { + return c.json( + { data: null, error: { code: 'FORBIDDEN', message: 'Account not found or not owned by user' } }, + 403, + ); + } + + const result = await c.env.DB.prepare( + 'DELETE FROM journal_trades WHERE account_id = ? AND deal_ticket = ?', + ) + .bind(accountId, dealTicket) + .run(); + + if (result.meta.changes === 0) { + return c.json( + { data: null, error: { code: 'NOT_FOUND', message: 'Trade not found' } }, + 404, + ); + } + + return c.json({ data: { deleted: result.meta.changes }, error: null }); +});