Repository navigation
feat(lists): delete icon to remove contacts from a list #8
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -30,6 +30,7 @@ | |
| DropdownMenuItem, | ||
| DropdownMenuTrigger, | ||
| } from '@/components/ui/dropdown-menu' | ||
| import { Trash2 } from 'lucide-react' | ||
|
Check warning on line 33 in app/(dashboard)/lists/[id]/page.tsx
|
||
| import { useToast } from '@/components/ui/use-toast' | ||
| import { DuplicatesTab } from '@/components/lists/DuplicatesTab' | ||
| import { EmailCheckerTab } from '@/components/lists/EmailCheckerTab' | ||
|
|
@@ -103,6 +104,9 @@ | |
|
|
||
| const [togglingOptIn, setTogglingOptIn] = useState(false) | ||
|
|
||
| const [deleteTarget, setDeleteTarget] = useState<Contact | null>(null) | ||
|
Check warning on line 107 in app/(dashboard)/lists/[id]/page.tsx
|
||
| const [deleting, setDeleting] = useState(false) | ||
|
|
||
| const { toast } = useToast() | ||
|
|
||
| async function handleGdprExport(contact: Contact) { | ||
|
|
@@ -156,6 +160,34 @@ | |
| } | ||
| } | ||
|
|
||
| async function handleDeleteContact() { | ||
|
Check warning on line 163 in app/(dashboard)/lists/[id]/page.tsx
|
||
| if (!deleteTarget) return | ||
| setDeleting(true) | ||
| try { | ||
| const res = await fetch(`/api/internal/contacts/${deleteTarget.id}`, { | ||
| method: 'DELETE', | ||
| }) | ||
| if (!res.ok) { | ||
| const data = await res.json().catch(() => ({})) | ||
| toast({ title: data.error || 'Failed to delete contact', variant: 'destructive' }) | ||
| return | ||
| } | ||
| toast({ title: `${deleteTarget.email} removed from this list` }) | ||
| setDeleteTarget(null) | ||
| if (contacts.length === 1 && page > 1) { | ||
| setPage(page - 1) | ||
|
Check notice on line 178 in app/(dashboard)/lists/[id]/page.tsx
|
||
| } else { | ||
| fetchContacts() | ||
|
Check notice on line 180 in app/(dashboard)/lists/[id]/page.tsx
|
||
| } | ||
| const listRes = await fetch(`/api/internal/lists/${listId}`) | ||
| if (listRes.ok) setListInfo(await listRes.json()) | ||
| } catch { | ||
| toast({ title: 'Failed to delete contact', variant: 'destructive' }) | ||
| } finally { | ||
| setDeleting(false) | ||
| } | ||
| } | ||
|
|
||
| async function handleToggleDoubleOptIn() { | ||
| if (!listInfo) return | ||
| const next = !listInfo.requireDoubleOptIn | ||
|
|
@@ -577,28 +609,40 @@ | |
| {format(new Date(contact.createdAt), 'MMM d, yyyy')} | ||
| </TableCell> | ||
| <TableCell> | ||
| <DropdownMenu> | ||
| <DropdownMenuTrigger asChild> | ||
| <Button variant="ghost" size="sm" className="h-7 px-2"> | ||
| <span aria-hidden>...</span> | ||
| <span className="sr-only">Open contact actions</span> | ||
| </Button> | ||
| </DropdownMenuTrigger> | ||
| <DropdownMenuContent align="end"> | ||
| <DropdownMenuItem onSelect={() => handleGdprExport(contact)}> | ||
| Export GDPR data | ||
| </DropdownMenuItem> | ||
| <DropdownMenuItem | ||
| className="text-destructive focus:text-destructive" | ||
| onSelect={() => { | ||
| setGdprDeleteContact(contact) | ||
| setGdprConfirmEmail('') | ||
| }} | ||
| > | ||
| Delete (GDPR) | ||
| </DropdownMenuItem> | ||
| </DropdownMenuContent> | ||
| </DropdownMenu> | ||
| <div className="flex items-center justify-end gap-1"> | ||
| <Button | ||
| variant="ghost" | ||
| size="sm" | ||
| className="h-7 w-7 p-0 text-destructive hover:text-destructive" | ||
| disabled={deleting && deleteTarget?.id === contact.id} | ||
| onClick={() => setDeleteTarget(contact)} | ||
| > | ||
| <Trash2 className="h-4 w-4" aria-hidden /> | ||
| <span className="sr-only">Delete {contact.email}</span> | ||
| </Button> | ||
| <DropdownMenu> | ||
| <DropdownMenuTrigger asChild> | ||
| <Button variant="ghost" size="sm" className="h-7 px-2"> | ||
| <span aria-hidden>...</span> | ||
| <span className="sr-only">Open contact actions</span> | ||
| </Button> | ||
| </DropdownMenuTrigger> | ||
| <DropdownMenuContent align="end"> | ||
| <DropdownMenuItem onSelect={() => handleGdprExport(contact)}> | ||
| Export GDPR data | ||
| </DropdownMenuItem> | ||
| <DropdownMenuItem | ||
| className="text-destructive focus:text-destructive" | ||
| onSelect={() => { | ||
| setGdprDeleteContact(contact) | ||
| setGdprConfirmEmail('') | ||
| }} | ||
| > | ||
| Delete (GDPR) | ||
| </DropdownMenuItem> | ||
| </DropdownMenuContent> | ||
| </DropdownMenu> | ||
| </div> | ||
| </TableCell> | ||
| </TableRow> | ||
| )) | ||
|
|
@@ -659,6 +703,43 @@ | |
| </TabsContent> | ||
| </Tabs> | ||
|
|
||
| <Dialog | ||
| open={deleteTarget !== null} | ||
| onOpenChange={(open) => { | ||
| if (!open && !deleting) setDeleteTarget(null) | ||
| }} | ||
| > | ||
| <DialogContent> | ||
| <DialogHeader> | ||
| <DialogTitle>Delete contact</DialogTitle> | ||
| </DialogHeader> | ||
| <div className="space-y-3 text-sm"> | ||
| <p> | ||
| Delete <span className="font-mono">{deleteTarget?.email}</span> from this list? | ||
| </p> | ||
| <p className="text-muted-foreground"> | ||
| The contact and its send and engagement history are permanently removed. This cannot be undone. | ||
| </p> | ||
| </div> | ||
| <DialogFooter> | ||
| <Button | ||
| variant="outline" | ||
| onClick={() => setDeleteTarget(null)} | ||
| disabled={deleting} | ||
| > | ||
| Cancel | ||
| </Button> | ||
| <Button | ||
| variant="destructive" | ||
| disabled={deleting} | ||
| onClick={handleDeleteContact} | ||
| > | ||
| {deleting ? 'Deleting...' : 'Delete'} | ||
| </Button> | ||
| </DialogFooter> | ||
| </DialogContent> | ||
| </Dialog> | ||
|
|
||
| <Dialog | ||
| open={gdprDeleteContact !== null} | ||
| onOpenChange={(open) => { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,34 @@ | ||
| import { NextRequest, NextResponse } from 'next/server' | ||
|
Check warning on line 1 in app/api/internal/contacts/[id]/route.ts
|
||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟠 [deterministic] Biome: lint/style/useImportType — Risk: 55/100 Some named imports are only used as types. 🛠 AI fix prompt (copy & paste into your coding agent)Flagged by Autter security & observability checks. |
||
| import { hardDeleteContact } from '@/lib/gdpr' | ||
| import { auditFromSession, logAudit } from '@/lib/audit' | ||
|
|
||
| export async function DELETE( | ||
|
Check warning on line 5 in app/api/internal/contacts/[id]/route.ts
|
||
| req: NextRequest, | ||
| { params }: { params: { id: string } } | ||
| ) { | ||
| const result = await hardDeleteContact(params.id) | ||
|
Check warning on line 9 in app/api/internal/contacts/[id]/route.ts
|
||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟠 [ai] Unthrottled contact deletion can amplify database work — Risk: 52/100 This new DELETE route is session-authenticated by the global middleware, but it has no route-level rate limit and immediately calls
🛠 AI fix prompt (copy & paste into your coding agent)Flagged by Autter security & observability checks. |
||
|
|
||
| if (!result) { | ||
| return NextResponse.json({ error: 'Contact not found' }, { status: 404 }) | ||
| } | ||
|
|
||
| await logAudit( | ||
| await auditFromSession(req), | ||
| 'contact.delete', | ||
| { type: 'contact', id: params.id }, | ||
| { | ||
| email: result.email, | ||
|
Check warning on line 20 in app/api/internal/contacts/[id]/route.ts
|
||
| listId: result.listId, | ||
| sendCount: result.sendCount, | ||
| eventCount: result.eventCount, | ||
| }, | ||
| ) | ||
|
|
||
| return NextResponse.json({ | ||
| deleted: true, | ||
| id: params.id, | ||
| email: result.email, | ||
| sendCount: result.sendCount, | ||
| eventCount: result.eventCount, | ||
| }) | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟡 [ai] Post-delete refresh failures leave stale UI or report a false deletion failure — Risk: 32/100
Once DELETE succeeds, a failed contacts GET is swallowed by
fetchContacts()and leaves the deleted contact in the displayed array, while a thrown list-info refresh at line 182 reaches this handler's catch and showsFailed to delete contacteven though the destructive operation has already completed. In the latter case, the user is told deletion failed and may retry an irreversible operation; in the former, the deleted row remains actionable and a retry returns 404. Keep the delete result separate from refresh errors, remove the deleted row/update counts locally after success, and report refresh failure as a refresh issue rather than a delete failure.app/(dashboard)/lists/[id]/page.tsx,app/api/internal/contacts/[id]/route.ts🛠 AI fix prompt (copy & paste into your coding agent)
Flagged by Autter security & observability checks.