Skip to content
Merged
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
125 changes: 103 additions & 22 deletions app/(dashboard)/lists/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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

View check run for this annotation

Autter.dev / autter/review-gate

🟠 Medium · Missing linked tracker issue

This PR's title and body do not reference any tracker issue (GitHub `#123`, Jira/Linear `KEY-123`, or `Fixes/Closes/Resolves`). **References:** - https://docs.github.com/en/issues/tracking-your-work-with-issues/using-issues/linking-a-pull-request-to-an-issue — # Linking a pull request to an issue You can link a pull request or branch to an issue to show that a fix is in progress and to automatically close the issue when the pull request - https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/getting-started/helping-others-review-your-changes — # Helping others review your changes You can use pull requests to provide clear context for your changes and keep your team informed, improving collaboration and the quality of rev Suggested fix: In the PR description, add a reference to the tracker issue this change implements (GitHub `#123`, Jira/Linear `PROJ-456`, or a `Fixes/Closes/Resolves` marker). Reviewers anchor on `app/(dashboard)/lists/[id]/page.tsx` around line 33 need that context to understand why this change exists and what success looks like. Why it matters: reviewers and on-call engineers need the linked issue to understand the why behind a change months from now.
import { useToast } from '@/components/ui/use-toast'
import { DuplicatesTab } from '@/components/lists/DuplicatesTab'
import { EmailCheckerTab } from '@/components/lists/EmailCheckerTab'
Expand Down Expand Up @@ -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

View check run for this annotation

Autter.dev / autter/review-gate

🟠 Medium · Missing CODEOWNERS reviewer approval

No approving reviewer is recorded for the changed `ListDetailPage` path. The new `handleDeleteContact` action affects contact deletion and list refresh behavior. Blast radius — skipping this guardrail cascades to the downstream usage that depends on this file: functions `handleGdprExport`, `fetchList`, `ListDetailPage`, `handleToggleDoubleOptIn`, `handleAddContact`; scopes `app`; dependent files `react`, `next/navigation`, `next/link`, `date-fns`, `@/components/ui/tabs`, `@/components/ui/table`, `@/components/ui/input`, `@/components/ui/label`. **References:** - https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners — # About code owners You can use a CODEOWNERS file to define individuals or teams that are responsible for code in a repository. The people you choose as code owners must have write - https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/managing-a-branch-protection-rule — # Managing a branch protection rule You can create a branch protection rule to enforce certain workflows for one or more branches, such as requiring an approving review or passing Suggested fix: Request and obtain an approval from a reviewer matching this path's CODEOWNERS entry. Blast radius — skipping this guardrail cascades to the downstream usage that depends on this file: functions `handleGdprExport`, `fetchList`, `ListDetailPage`, `handleToggleDoubleOptIn`, `handleAddContact`; scopes `app`; dependent files `react`, `next/navigation`, `next/link`, `date-fns`, `@/components/ui/tabs`, `@/components/ui/table`, `@/components/ui/input`, `@/components/ui/label`.
const [deleting, setDeleting] = useState(false)

const { toast } = useToast()

async function handleGdprExport(contact: Contact) {
Expand Down Expand Up @@ -156,6 +160,34 @@
}
}

async function handleDeleteContact() {

Check warning on line 163 in app/(dashboard)/lists/[id]/page.tsx

View check run for this annotation

Autter.dev / autter/review-gate

🟠 Medium · Source changes without matching tests

`handleDeleteContact` and its confirmation flow were added without a sibling test file being added or modified. Regressions could affect contact deletion, pagination after deleting the last row, and list count refreshes in `ListDetailPage`. Blast radius — skipping this guardrail cascades to the downstream usage that depends on this file: functions `handleGdprExport`, `fetchList`, `ListDetailPage`, `handleToggleDoubleOptIn`, `handleAddContact`; scopes `app`; dependent files `react`, `next/navigation`, `next/link`, `date-fns`, `@/components/ui/tabs`, `@/components/ui/table`, `@/components/ui/input`, `@/components/ui/label`. **References:** - https://google.github.io/eng-practices/review/ — A code review is a process where someone other than the author(s) of a piece of code examines that code. At Google, we use code review to maintain the quality ... Missing: required - https://google.github.io/eng-practices/review/reviewer/standard.html — The primary purpose of code review is to make sure that the overall code health of Google's code base is improving over time. Missing: tests- tests behavior 2026 ( Suggested fix: Add or update a sibling test file covering the delete confirmation, successful and failed requests, refreshed list counts, and stepping back a page after deleting its last contact. Blast radius — skipping this guardrail cascades to the downstream usage that depends on this file: functions `handleGdprExport`, `fetchList`, `ListDetailPage`, `handleToggleDoubleOptIn`, `handleAddContact`; scopes `app`; dependent files `react`, `next/navigation`, `next/link`, `date-fns`, `@/components/ui/tabs`, `@/components/ui/table`, `@/components/ui/input`, `@/components/ui/label`.
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

View check run for this annotation

Autter.dev / autter/review-gate

💡 Suggestion · Broken invariant

Assumes: The rendered contacts count reliably determines whether deletion will empty the current page.. Counterexample (concurrent update): A list has 52 active contacts. Page 2 displays contacts A and B with limit 50. Another user deletes B after this page loads. Without refreshing, the first user deletes A. → The handler still sees contacts.length === 2, so it refreshes page 2 instead of decrementing the page. Only 50 contacts remain, leaving an empty table on page 2 although all remaining contacts are on page 1. Blast radius — this counterexample can fail the downstream usage that depends on this file: functions `handleGdprExport`, `fetchList`, `ListDetailPage`, `handleToggleDoubleOptIn`, `handleAddContact`; scopes `app`; dependent files `react`, `next/navigation`, `next/link`, `date-fns`, `@/components/ui/tabs`, `@/components/ui/table`, `@/components/ui/input`, `@/components/ui/label`. Suggested fix: Handle the concurrent update case at app/(dashboard)/lists/[id]/page.tsx:178. A list has 52 active contacts. Page 2 displays contacts A and B with limit 50. Another user deletes B after this page loads. Without refreshing, the first user deletes A. currently The handler still sees contacts.length === 2, so it refreshes page 2 instead of decrementing the page. Only 50 contacts remain, leaving an empty table on page 2 although all remaining contacts are on page 1. — add the guard / idempotency / transaction / bound that makes this case safe, then confirm the assumption "The rendered contacts count reliably determines whether deletion will empty the current page." holds. Blast radius — this counterexample can fail the downstream usage that depends on this file: functions `handleGdprExport`, `fetchList`, `ListDetailPage`, `handleToggleDoubleOptIn`, `handleAddContact`; scopes `app`; dependent files `react`, `next/navigation`, `next/link`, `date-fns`, `@/components/ui/tabs`, `@/components/ui/table`, `@/components/ui/input`, `@/components/ui/label`.
} else {
fetchContacts()

Check notice on line 180 in app/(dashboard)/lists/[id]/page.tsx

View check run for this annotation

Autter.dev / autter/review-gate

🟡 Low · Post-delete refresh failures leave stale UI or report a false deletion failure

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 shows `Failed to delete contact` even 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. Suggested fix: Separate the successful DELETE outcome from subsequent refreshes. Remove the deleted contact and update pagination/counts immediately, then handle contact/list refresh errors with a distinct refresh warning rather than reporting that deletion failed.

Copy link
Copy Markdown

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 shows Failed to delete contact even 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.

⚠ Downstream affected — if this fails, it cascades to the usage that depends on this file:

  • Dependent files: app/(dashboard)/lists/[id]/page.tsx, app/api/internal/contacts/[id]/route.ts
🛠 AI fix prompt (copy & paste into your coding agent)
Separate the successful DELETE outcome from subsequent refreshes. Remove the deleted contact and update pagination/counts immediately, then handle contact/list refresh errors with a distinct refresh warning rather than reporting that deletion failed.

Flagged by Autter security & observability checks.

}
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
Expand Down Expand Up @@ -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>
))
Expand Down Expand Up @@ -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) => {
Expand Down
34 changes: 34 additions & 0 deletions app/api/internal/contacts/[id]/route.ts
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

View check run for this annotation

Autter.dev / autter/review-gate

🟠 Medium · Source changes without matching tests

This source file was changed but no sibling test file is added or modified anywhere in the PR. **References:** - https://google.github.io/eng-practices/review/ — A code review is a process where someone other than the author(s) of a piece of code examines that code. At Google, we use code review to maintain the quality ... Missing: required - https://google.github.io/eng-practices/review/reviewer/standard.html — The primary purpose of code review is to make sure that the overall code health of Google's code base is improving over time. Missing: tests- tests behavior 2026 ( Suggested fix: Add or update a sibling test (`*.test.*`, `*_test.*`, or `__tests__/`) that exercises the new behavior in `app/api/internal/contacts/[id]/route.ts` around line 1. Cover the happy path AND at least one failure case; without a test, a regression here will only be caught in production.

Check notice on line 1 in app/api/internal/contacts/[id]/route.ts

View check run for this annotation

Autter.dev / autter/review-gate

💡 Suggestion · Biome: lint/style/useImportType

Some named imports are only used as types. Suggested fix: Fix the Biome `lint/style/useImportType` issue at app/api/internal/contacts/[id]/route.ts:1: Some named imports are only used as types.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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)
Fix the Biome `lint/style/useImportType` issue at app/api/internal/contacts/[id]/route.ts:1: Some named imports are only used as types.

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

View check run for this annotation

Autter.dev / autter/review-gate

🟠 Medium · Missing CODEOWNERS reviewer approval

No approving reviewer is recorded for the new `DELETE /api/internal/contacts/[id]` route, which calls `hardDeleteContact` and writes a contact deletion audit entry. **References:** - https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners — # About code owners You can use a CODEOWNERS file to define individuals or teams that are responsible for code in a repository. The people you choose as code owners must have write - https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/managing-a-branch-protection-rule — # Managing a branch protection rule You can create a branch protection rule to enforce certain workflows for one or more branches, such as requiring an approving review or passing Suggested fix: Request and obtain an approval from a reviewer matching this route's CODEOWNERS entry.
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

View check run for this annotation

Autter.dev / autter/review-gate

🟠 Medium · Unauthenticated contact deletion endpoint

This handler deletes a contact without checking the caller’s session or authorization. If internal API routes are not protected by middleware, anyone who can reach this endpoint can permanently delete contacts and their history by guessing an ID. _Flagged by the Release Notes Curator agent._ Suggested fix: Require an authenticated session and verify the caller is authorized to manage the contact before calling hardDeleteContact. Return a 401 or 403 JSON error when access is denied, and add a test confirming an unauthenticated request cannot delete a contact.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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 hardDeleteContact. That helper loads every send for the contact and then every related event before cascading the deletion, so repeated requests for contacts with large histories can drive unbounded database reads and delete work from one authenticated session. The repository's explicit limiter is attached by withApiAuth for API-key routes, and public form submissions use an IP token bucket; neither covers this internal route. Add an appropriately keyed limit for this expensive operation before merge.

⚠ Downstream affected — if this fails, it cascades to the usage that depends on this file:

  • Dependent files: app/api/internal/contacts/[id]/route.ts, lib/gdpr/index.ts, middleware.ts, lib/api-auth.ts
🛠 AI fix prompt (copy & paste into your coding agent)
Protect DELETE /api/internal/contacts/[id] with a server-side per-authenticated-user rate limit appropriate for destructive, history-dependent work, and reject over-limit requests before calling hardDeleteContact.

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

View check run for this annotation

Autter.dev / autter/review-gate

🟠 Medium · PII in logs

`DELETE /api/internal/contacts/[id]` passes the contact email to `logAudit`, storing it in audit logs accessible to log readers. This exposes contact PII for every deletion handled by this endpoint. **References:** - https://cheatsheetseries.owasp.org/cheatsheets/File_Upload_Cheat_Sheet.html — Website with the collection of all the cheat sheets of the project. - https://cheatsheetseries.owasp.org/cheatsheets/Vulnerability_Disclosure_Cheat_Sheet.html — This cheat sheet is intended to provide guidance on the vulnerability disclosure process for both security researchers and organizations. - https://gdpr-info.eu/art-5-gdpr/ — Personal data shall be: processed lawfully, fairly and in a transparent manner in relation to the data subject ('lawfulness, fairness and transparency'); ... Missing: logging | Sho Suggested fix: Remove `email` from the `logAudit` metadata; retain only non-PII deletion details such as counts and the contact identifier if required.
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,
})
}
Loading