From 0c3eeabc270b6119a2873bd16023ee190e108188 Mon Sep 17 00:00:00 2001 From: Timmyy3000 Date: Tue, 12 May 2026 20:54:24 +0100 Subject: [PATCH 01/11] feat(ui): add shared DeleteConfirmDialog component Reusable shell wrapping base-ui Dialog for entity deletes. Per-entity dialogs become thin wrappers that supply preview text and an onConfirm server-action callback. Co-authored-by: Claude <81847+claude@users.noreply.github.com> --- components/delete-confirm-dialog.tsx | 85 ++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 components/delete-confirm-dialog.tsx diff --git a/components/delete-confirm-dialog.tsx b/components/delete-confirm-dialog.tsx new file mode 100644 index 0000000..0173d45 --- /dev/null +++ b/components/delete-confirm-dialog.tsx @@ -0,0 +1,85 @@ +"use client"; + +import { useState, useTransition, type ReactNode } from "react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; + +export type DeleteResult = { ok: true } | { ok: false; message: string }; + +export function DeleteConfirmDialog({ + open, + onOpenChange, + title, + description, + confirmLabel = "Delete", + onConfirm, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + title: string; + description: ReactNode; + confirmLabel?: string; + onConfirm: () => Promise; +}) { + const [error, setError] = useState(null); + const [pending, startTransition] = useTransition(); + + function onConfirmClick() { + setError(null); + startTransition(async () => { + const result = await onConfirm(); + if (!result.ok) { + setError(result.message); + return; + } + onOpenChange(false); + }); + } + + return ( + { + if (!next) setError(null); + onOpenChange(next); + }} + > + + + {title} + {description} + + {error ? ( +

{error}

+ ) : null} + + + + +
+
+ ); +} From 35c0fdb89a529fd6e46f540ede3e3a7b26b1de96 Mon Sep 17 00:00:00 2001 From: Timmyy3000 Date: Tue, 12 May 2026 20:57:10 +0100 Subject: [PATCH 02/11] feat(signals): UI delete for signals with audit Adds deleteSignal in lib/, server action, and a hover-trigger delete button on each signal row. Audit row written per delete. Co-authored-by: Claude <81847+claude@users.noreply.github.com> --- app/(app)/companies/[id]/signals/actions.ts | 32 +++++++++ .../[id]/signals/delete-signal-button.tsx | 40 +++++++++++ app/(app)/companies/[id]/signals/page.tsx | 12 ++-- lib/signals.ts | 20 ++++++ tests/signals.test.ts | 72 +++++++++++++++++++ 5 files changed, 172 insertions(+), 4 deletions(-) create mode 100644 app/(app)/companies/[id]/signals/actions.ts create mode 100644 app/(app)/companies/[id]/signals/delete-signal-button.tsx create mode 100644 lib/signals.ts create mode 100644 tests/signals.test.ts diff --git a/app/(app)/companies/[id]/signals/actions.ts b/app/(app)/companies/[id]/signals/actions.ts new file mode 100644 index 0000000..bb473a5 --- /dev/null +++ b/app/(app)/companies/[id]/signals/actions.ts @@ -0,0 +1,32 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { z } from "zod"; +import { db } from "@/db/client"; +import { recordAudit, userActor } from "@/lib/audit"; +import { requireOrgSession } from "@/lib/session"; +import { deleteSignal } from "@/lib/signals"; + +export async function deleteSignalAction(input: { + signalId: string; +}): Promise<{ ok: true } | { ok: false; message: string }> { + const session = await requireOrgSession(); + const signalId = z.string().uuid().parse(input.signalId); + + const result = await deleteSignal(db(), session.organizationId, signalId); + if (!result) return { ok: false, message: "Signal not found" }; + + await recordAudit(db(), { + organizationId: session.organizationId, + actor: userActor(session.user.id, session.user.name ?? null), + entityType: "signal", + entityId: signalId, + action: "delete", + changes: { before: result.before }, + }); + + revalidatePath(`/companies/${result.before.companyId}/signals`); + revalidatePath(`/companies/${result.before.companyId}`); + revalidatePath("/companies"); + return { ok: true }; +} diff --git a/app/(app)/companies/[id]/signals/delete-signal-button.tsx b/app/(app)/companies/[id]/signals/delete-signal-button.tsx new file mode 100644 index 0000000..42c2120 --- /dev/null +++ b/app/(app)/companies/[id]/signals/delete-signal-button.tsx @@ -0,0 +1,40 @@ +"use client"; + +import { Trash2 } from "lucide-react"; +import { useState } from "react"; +import { DeleteConfirmDialog } from "@/components/delete-confirm-dialog"; +import { deleteSignalAction } from "./actions"; + +export function DeleteSignalButton({ + signalId, + signalTitle, +}: { + signalId: string; + signalTitle: string; +}) { + const [open, setOpen] = useState(false); + + return ( + <> + + + “{signalTitle}” will be permanently deleted. This cannot be undone. + + } + onConfirm={() => deleteSignalAction({ signalId })} + /> + + ); +} diff --git a/app/(app)/companies/[id]/signals/page.tsx b/app/(app)/companies/[id]/signals/page.tsx index b727c5b..2f99809 100644 --- a/app/(app)/companies/[id]/signals/page.tsx +++ b/app/(app)/companies/[id]/signals/page.tsx @@ -2,6 +2,7 @@ import { desc, eq } from "drizzle-orm"; import { db } from "@/db/client"; import { signals } from "@/db/schema/signals"; import { relativeTime } from "@/lib/format"; +import { DeleteSignalButton } from "./delete-signal-button"; export default async function SignalsTab({ params }: { params: Promise<{ id: string }> }) { const { id } = await params; @@ -22,7 +23,7 @@ export default async function SignalsTab({ params }: { params: Promise<{ id: str return (
    {rows.map((s) => ( -
  1. +
  2. {s.title}

    - - {relativeTime(s.occurredAt)} - +
    + + {relativeTime(s.occurredAt)} + + +
    {s.type.replace(/_/g, " ")} diff --git a/lib/signals.ts b/lib/signals.ts new file mode 100644 index 0000000..ed81dea --- /dev/null +++ b/lib/signals.ts @@ -0,0 +1,20 @@ +import { and, eq } from "drizzle-orm"; +import type { Database } from "@/db/client"; +import { signals } from "@/db/schema/signals"; + +export type Signal = typeof signals.$inferSelect; + +export async function deleteSignal( + db: Database, + organizationId: string, + signalId: string, +): Promise<{ before: Signal } | null> { + const [before] = await db + .select() + .from(signals) + .where(and(eq(signals.id, signalId), eq(signals.organizationId, organizationId))) + .limit(1); + if (!before) return null; + await db.delete(signals).where(eq(signals.id, signalId)); + return { before }; +} diff --git a/tests/signals.test.ts b/tests/signals.test.ts new file mode 100644 index 0000000..7451794 --- /dev/null +++ b/tests/signals.test.ts @@ -0,0 +1,72 @@ +import { and, eq } from "drizzle-orm"; +import { beforeEach, describe, expect, test } from "bun:test"; +import { createDb } from "@/db/client"; +import { organization, user } from "@/db/schema/auth"; +import { auditLog } from "@/db/schema/audit"; +import { companies } from "@/db/schema/companies"; +import { signals } from "@/db/schema/signals"; +import { deleteSignal } from "@/lib/signals"; +import { recordAudit, userActor } from "@/lib/audit"; +import { resetDb } from "./setup"; + +const db = createDb(process.env.TEST_DATABASE_URL!); + +async function seed(orgId = "org_signals") { + await db.insert(organization).values({ id: orgId, name: "Signals", slug: orgId }); + const companyId = crypto.randomUUID(); + await db.insert(companies).values({ id: companyId, organizationId: orgId, name: "Acme" }); + const signalId = crypto.randomUUID(); + await db.insert(signals).values({ + id: signalId, + organizationId: orgId, + companyId, + type: "news", + title: "Acme raised $10M", + occurredAt: new Date(), + }); + return { orgId, companyId, signalId }; +} + +describe("lib/signals", () => { + beforeEach(async () => { + await resetDb(); + }); + + test("deleteSignal removes the row and returns the prior snapshot", async () => { + const { orgId, signalId } = await seed(); + const result = await deleteSignal(db, orgId, signalId); + expect(result?.before.id).toBe(signalId); + const remaining = await db.select().from(signals).where(eq(signals.id, signalId)); + expect(remaining).toHaveLength(0); + }); + + test("deleteSignal returns null for cross-org signals", async () => { + const { signalId } = await seed("org_a"); + await db.insert(organization).values({ id: "org_b", name: "B", slug: "org_b" }); + const result = await deleteSignal(db, "org_b", signalId); + expect(result).toBeNull(); + const remaining = await db.select().from(signals).where(eq(signals.id, signalId)); + expect(remaining).toHaveLength(1); + }); + + test("delete + audit fan-out leaves a delete audit row", async () => { + const { orgId, signalId } = await seed(); + await db.insert(user).values({ id: "u_alice", name: "Alice", email: "alice@example.com" }); + const result = await deleteSignal(db, orgId, signalId); + expect(result).not.toBeNull(); + await recordAudit(db, { + organizationId: orgId, + actor: userActor("u_alice", "Alice"), + entityType: "signal", + entityId: signalId, + action: "delete", + changes: { before: result!.before }, + }); + const rows = await db + .select() + .from(auditLog) + .where(and(eq(auditLog.entityType, "signal"), eq(auditLog.action, "delete"))); + expect(rows).toHaveLength(1); + expect(rows[0]!.entityId).toBe(signalId); + }); +}); From 46496fc53721b76d5f67192f3242d27b2160774b Mon Sep 17 00:00:00 2001 From: Timmyy3000 Date: Tue, 12 May 2026 20:58:31 +0100 Subject: [PATCH 03/11] feat(notes): UI delete for notes with audit Co-authored-by: Claude <81847+claude@users.noreply.github.com> --- app/(app)/companies/[id]/notes/actions.ts | 26 +++++++++++ .../[id]/notes/delete-note-button.tsx | 30 +++++++++++++ app/(app)/companies/[id]/notes/page.tsx | 6 ++- lib/notes.ts | 20 +++++++++ tests/notes.test.ts | 45 +++++++++++++++++++ 5 files changed, 126 insertions(+), 1 deletion(-) create mode 100644 app/(app)/companies/[id]/notes/delete-note-button.tsx create mode 100644 lib/notes.ts create mode 100644 tests/notes.test.ts diff --git a/app/(app)/companies/[id]/notes/actions.ts b/app/(app)/companies/[id]/notes/actions.ts index 49d027e..abeb823 100644 --- a/app/(app)/companies/[id]/notes/actions.ts +++ b/app/(app)/companies/[id]/notes/actions.ts @@ -5,6 +5,7 @@ import { z } from "zod"; import { db } from "@/db/client"; import { notes } from "@/db/schema/notes"; import { recordAudit, userActor } from "@/lib/audit"; +import { deleteNote } from "@/lib/notes"; import { requireOrgSession } from "@/lib/session"; const Schema = z.object({ @@ -39,3 +40,28 @@ export async function addCompanyNoteAction(formData: FormData): Promise { revalidatePath(`/companies/${companyId}/notes`); revalidatePath(`/companies/${companyId}`); } + +export async function deleteNoteAction(input: { + noteId: string; +}): Promise<{ ok: true } | { ok: false; message: string }> { + const session = await requireOrgSession(); + const noteId = z.string().uuid().parse(input.noteId); + + const result = await deleteNote(db(), session.organizationId, noteId); + if (!result) return { ok: false, message: "Note not found" }; + + await recordAudit(db(), { + organizationId: session.organizationId, + actor: userActor(session.user.id, session.user.name ?? null), + entityType: "note", + entityId: noteId, + action: "delete", + changes: { before: result.before }, + }); + + if (result.before.companyId) { + revalidatePath(`/companies/${result.before.companyId}/notes`); + revalidatePath(`/companies/${result.before.companyId}`); + } + return { ok: true }; +} diff --git a/app/(app)/companies/[id]/notes/delete-note-button.tsx b/app/(app)/companies/[id]/notes/delete-note-button.tsx new file mode 100644 index 0000000..c6be299 --- /dev/null +++ b/app/(app)/companies/[id]/notes/delete-note-button.tsx @@ -0,0 +1,30 @@ +"use client"; + +import { Trash2 } from "lucide-react"; +import { useState } from "react"; +import { DeleteConfirmDialog } from "@/components/delete-confirm-dialog"; +import { deleteNoteAction } from "./actions"; + +export function DeleteNoteButton({ noteId }: { noteId: string }) { + const [open, setOpen] = useState(false); + + return ( + <> + + deleteNoteAction({ noteId })} + /> + + ); +} diff --git a/app/(app)/companies/[id]/notes/page.tsx b/app/(app)/companies/[id]/notes/page.tsx index e36f4a8..0a74f22 100644 --- a/app/(app)/companies/[id]/notes/page.tsx +++ b/app/(app)/companies/[id]/notes/page.tsx @@ -3,6 +3,7 @@ import { db } from "@/db/client"; import { notes } from "@/db/schema/notes"; import { Avatar } from "@/components/avatar-init"; import { relativeTime } from "@/lib/format"; +import { DeleteNoteButton } from "./delete-note-button"; import { NoteComposer } from "./note-composer"; export default async function NotesTab({ params }: { params: Promise<{ id: string }> }) { @@ -23,12 +24,15 @@ export default async function NotesTab({ params }: { params: Promise<{ id: strin {rows.map((n) => (
  3. {n.author} {relativeTime(n.createdAt)} + + +

    {n.body} diff --git a/lib/notes.ts b/lib/notes.ts new file mode 100644 index 0000000..877527a --- /dev/null +++ b/lib/notes.ts @@ -0,0 +1,20 @@ +import { and, eq } from "drizzle-orm"; +import type { Database } from "@/db/client"; +import { notes } from "@/db/schema/notes"; + +export type Note = typeof notes.$inferSelect; + +export async function deleteNote( + db: Database, + organizationId: string, + noteId: string, +): Promise<{ before: Note } | null> { + const [before] = await db + .select() + .from(notes) + .where(and(eq(notes.id, noteId), eq(notes.organizationId, organizationId))) + .limit(1); + if (!before) return null; + await db.delete(notes).where(eq(notes.id, noteId)); + return { before }; +} diff --git a/tests/notes.test.ts b/tests/notes.test.ts new file mode 100644 index 0000000..ab4d464 --- /dev/null +++ b/tests/notes.test.ts @@ -0,0 +1,45 @@ +import { eq } from "drizzle-orm"; +import { beforeEach, describe, expect, test } from "bun:test"; +import { createDb } from "@/db/client"; +import { organization } from "@/db/schema/auth"; +import { companies } from "@/db/schema/companies"; +import { notes } from "@/db/schema/notes"; +import { deleteNote } from "@/lib/notes"; +import { resetDb } from "./setup"; + +const db = createDb(process.env.TEST_DATABASE_URL!); + +async function seed(orgId = "org_notes") { + await db.insert(organization).values({ id: orgId, name: "Notes", slug: orgId }); + const companyId = crypto.randomUUID(); + await db.insert(companies).values({ id: companyId, organizationId: orgId, name: "Acme" }); + const noteId = crypto.randomUUID(); + await db.insert(notes).values({ + id: noteId, + organizationId: orgId, + companyId, + body: "First contact email sent", + }); + return { orgId, noteId }; +} + +describe("lib/notes", () => { + beforeEach(async () => { + await resetDb(); + }); + + test("deleteNote removes the row and returns the prior snapshot", async () => { + const { orgId, noteId } = await seed(); + const result = await deleteNote(db, orgId, noteId); + expect(result?.before.id).toBe(noteId); + const remaining = await db.select().from(notes).where(eq(notes.id, noteId)); + expect(remaining).toHaveLength(0); + }); + + test("deleteNote returns null for cross-org notes", async () => { + const { noteId } = await seed("org_a"); + await db.insert(organization).values({ id: "org_b", name: "B", slug: "org_b" }); + const result = await deleteNote(db, "org_b", noteId); + expect(result).toBeNull(); + }); +}); From 5cc43f1d5889c7dea096bcc6a4d96f21dd71039c Mon Sep 17 00:00:00 2001 From: Timmyy3000 Date: Tue, 12 May 2026 21:00:14 +0100 Subject: [PATCH 04/11] feat(tasks): UI delete for tasks with audit Adds a trash-icon action to TaskActions, available regardless of task status (open / done / dismissed). Server action writes the audit row and revalidates list + company tabs. Co-authored-by: Claude <81847+claude@users.noreply.github.com> --- app/(app)/tasks/actions.ts | 29 ++++++++++++ components/task-actions.tsx | 88 +++++++++++++++++++++++++------------ components/task-row.tsx | 8 ++-- lib/tasks.ts | 20 +++++++++ tests/tasks.test.ts | 45 +++++++++++++++++++ 5 files changed, 156 insertions(+), 34 deletions(-) create mode 100644 lib/tasks.ts create mode 100644 tests/tasks.test.ts diff --git a/app/(app)/tasks/actions.ts b/app/(app)/tasks/actions.ts index 8ab19e7..530a79f 100644 --- a/app/(app)/tasks/actions.ts +++ b/app/(app)/tasks/actions.ts @@ -7,6 +7,7 @@ import { db } from "@/db/client"; import { tasks } from "@/db/schema/tasks"; import { diffChangedFields, recordAudit, userActor } from "@/lib/audit"; import { requireOrgSession } from "@/lib/session"; +import { deleteTask } from "@/lib/tasks"; const StatusSchema = z.enum(["open", "done", "dismissed"]); @@ -42,3 +43,31 @@ export async function setTaskStatusAction(formData: FormData): Promise { revalidatePath("/tasks"); revalidatePath("/companies"); } + +export async function deleteTaskAction(input: { + taskId: string; +}): Promise<{ ok: true } | { ok: false; message: string }> { + const session = await requireOrgSession(); + const taskId = z.string().uuid().parse(input.taskId); + + const result = await deleteTask(db(), session.organizationId, taskId); + if (!result) return { ok: false, message: "Task not found" }; + + await recordAudit(db(), { + organizationId: session.organizationId, + actor: userActor(session.user.id, session.user.name ?? null), + entityType: "task", + entityId: taskId, + action: "delete", + changes: { before: result.before }, + }); + + revalidatePath("/"); + revalidatePath("/tasks"); + revalidatePath("/companies"); + if (result.before.companyId) { + revalidatePath(`/companies/${result.before.companyId}/tasks`); + revalidatePath(`/companies/${result.before.companyId}`); + } + return { ok: true }; +} diff --git a/components/task-actions.tsx b/components/task-actions.tsx index e7ee2fb..5a0d74f 100644 --- a/components/task-actions.tsx +++ b/components/task-actions.tsx @@ -1,38 +1,68 @@ "use client"; -import { Check, X } from "lucide-react"; -import { setTaskStatusAction } from "@/app/(app)/tasks/actions"; +import { Check, Trash2, X } from "lucide-react"; +import { useState } from "react"; +import { deleteTaskAction, setTaskStatusAction } from "@/app/(app)/tasks/actions"; +import { DeleteConfirmDialog } from "@/components/delete-confirm-dialog"; import { Button } from "@/components/ui/button"; -export function TaskActions({ taskId }: { taskId: string }) { +export function TaskActions({ + taskId, + showStatusActions = true, +}: { + taskId: string; + showStatusActions?: boolean; +}) { + const [confirmOpen, setConfirmOpen] = useState(false); return (

    -
    - - - -
    -
    - - - -
    + {showStatusActions ? ( + <> +
    + + + +
    +
    + + + +
    + + ) : null} + + deleteTaskAction({ taskId })} + />
    ); } diff --git a/components/task-row.tsx b/components/task-row.tsx index 5976c92..9b87556 100644 --- a/components/task-row.tsx +++ b/components/task-row.tsx @@ -70,11 +70,9 @@ export function TaskRow({ task }: { task: TaskRowData }) { {relativeTime(task.dueDate)} - {task.status === "open" ? ( -
    - -
    - ) : null} +
    + +
    ); diff --git a/lib/tasks.ts b/lib/tasks.ts new file mode 100644 index 0000000..8660e57 --- /dev/null +++ b/lib/tasks.ts @@ -0,0 +1,20 @@ +import { and, eq } from "drizzle-orm"; +import type { Database } from "@/db/client"; +import { tasks } from "@/db/schema/tasks"; + +export type Task = typeof tasks.$inferSelect; + +export async function deleteTask( + db: Database, + organizationId: string, + taskId: string, +): Promise<{ before: Task } | null> { + const [before] = await db + .select() + .from(tasks) + .where(and(eq(tasks.id, taskId), eq(tasks.organizationId, organizationId))) + .limit(1); + if (!before) return null; + await db.delete(tasks).where(eq(tasks.id, taskId)); + return { before }; +} diff --git a/tests/tasks.test.ts b/tests/tasks.test.ts new file mode 100644 index 0000000..2f5a9af --- /dev/null +++ b/tests/tasks.test.ts @@ -0,0 +1,45 @@ +import { eq } from "drizzle-orm"; +import { beforeEach, describe, expect, test } from "bun:test"; +import { createDb } from "@/db/client"; +import { organization } from "@/db/schema/auth"; +import { companies } from "@/db/schema/companies"; +import { tasks } from "@/db/schema/tasks"; +import { deleteTask } from "@/lib/tasks"; +import { resetDb } from "./setup"; + +const db = createDb(process.env.TEST_DATABASE_URL!); + +async function seed(orgId = "org_tasks") { + await db.insert(organization).values({ id: orgId, name: "Tasks", slug: orgId }); + const companyId = crypto.randomUUID(); + await db.insert(companies).values({ id: companyId, organizationId: orgId, name: "Acme" }); + const taskId = crypto.randomUUID(); + await db.insert(tasks).values({ + id: taskId, + organizationId: orgId, + companyId, + title: "Follow up with Acme", + }); + return { orgId, taskId }; +} + +describe("lib/tasks", () => { + beforeEach(async () => { + await resetDb(); + }); + + test("deleteTask removes the row and returns the prior snapshot", async () => { + const { orgId, taskId } = await seed(); + const result = await deleteTask(db, orgId, taskId); + expect(result?.before.id).toBe(taskId); + const remaining = await db.select().from(tasks).where(eq(tasks.id, taskId)); + expect(remaining).toHaveLength(0); + }); + + test("deleteTask returns null for cross-org tasks", async () => { + const { taskId } = await seed("org_a"); + await db.insert(organization).values({ id: "org_b", name: "B", slug: "org_b" }); + const result = await deleteTask(db, "org_b", taskId); + expect(result).toBeNull(); + }); +}); From 73e31861baf75e159db8cfedc227952dfbfe3cdf Mon Sep 17 00:00:00 2001 From: Timmyy3000 Date: Tue, 12 May 2026 21:02:13 +0100 Subject: [PATCH 05/11] feat(meetings): UI delete for meetings with audit Co-authored-by: Claude <81847+claude@users.noreply.github.com> --- app/(app)/meetings/actions.ts | 31 +++++++++++++ app/(app)/meetings/delete-meeting-button.tsx | 43 ++++++++++++++++++ app/(app)/meetings/page.tsx | 7 ++- lib/meetings.ts | 20 +++++++++ tests/meetings.test.ts | 46 ++++++++++++++++++++ 5 files changed, 146 insertions(+), 1 deletion(-) create mode 100644 app/(app)/meetings/actions.ts create mode 100644 app/(app)/meetings/delete-meeting-button.tsx create mode 100644 lib/meetings.ts create mode 100644 tests/meetings.test.ts diff --git a/app/(app)/meetings/actions.ts b/app/(app)/meetings/actions.ts new file mode 100644 index 0000000..6ddc3ad --- /dev/null +++ b/app/(app)/meetings/actions.ts @@ -0,0 +1,31 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { z } from "zod"; +import { db } from "@/db/client"; +import { recordAudit, userActor } from "@/lib/audit"; +import { deleteMeeting } from "@/lib/meetings"; +import { requireOrgSession } from "@/lib/session"; + +export async function deleteMeetingAction(input: { + meetingId: string; +}): Promise<{ ok: true } | { ok: false; message: string }> { + const session = await requireOrgSession(); + const meetingId = z.string().uuid().parse(input.meetingId); + + const result = await deleteMeeting(db(), session.organizationId, meetingId); + if (!result) return { ok: false, message: "Meeting not found" }; + + await recordAudit(db(), { + organizationId: session.organizationId, + actor: userActor(session.user.id, session.user.name ?? null), + entityType: "meeting", + entityId: meetingId, + action: "delete", + changes: { before: result.before }, + }); + + revalidatePath("/meetings"); + revalidatePath(`/companies/${result.before.companyId}`); + return { ok: true }; +} diff --git a/app/(app)/meetings/delete-meeting-button.tsx b/app/(app)/meetings/delete-meeting-button.tsx new file mode 100644 index 0000000..3ac4f1a --- /dev/null +++ b/app/(app)/meetings/delete-meeting-button.tsx @@ -0,0 +1,43 @@ +"use client"; + +import { Trash2 } from "lucide-react"; +import { useState } from "react"; +import { DeleteConfirmDialog } from "@/components/delete-confirm-dialog"; +import { deleteMeetingAction } from "./actions"; + +export function DeleteMeetingButton({ + meetingId, + meetingTitle, +}: { + meetingId: string; + meetingTitle: string; +}) { + const [open, setOpen] = useState(false); + + return ( + <> + + + “{meetingTitle}” will be permanently deleted. This cannot be undone. + + } + onConfirm={() => deleteMeetingAction({ meetingId })} + /> + + ); +} diff --git a/app/(app)/meetings/page.tsx b/app/(app)/meetings/page.tsx index 0c08176..7b7a669 100644 --- a/app/(app)/meetings/page.tsx +++ b/app/(app)/meetings/page.tsx @@ -6,6 +6,7 @@ import { meetingAttendees, meetings } from "@/db/schema/meetings"; import { AvatarStack, CompanyLogo } from "@/components/avatar-init"; import { relativeTime } from "@/lib/format"; import { requireOrgSession } from "@/lib/session"; +import { DeleteMeetingButton } from "./delete-meeting-button"; const ROW_LIMIT = 100; @@ -84,13 +85,14 @@ export default async function MeetingsPage() { {h} ))} + {rows.map((m) => (
    @@ -131,6 +133,9 @@ export default async function MeetingsPage() {
    {m.summary ?? "—"}
    + + + ))} diff --git a/lib/meetings.ts b/lib/meetings.ts new file mode 100644 index 0000000..7ed2b18 --- /dev/null +++ b/lib/meetings.ts @@ -0,0 +1,20 @@ +import { and, eq } from "drizzle-orm"; +import type { Database } from "@/db/client"; +import { meetings } from "@/db/schema/meetings"; + +export type Meeting = typeof meetings.$inferSelect; + +export async function deleteMeeting( + db: Database, + organizationId: string, + meetingId: string, +): Promise<{ before: Meeting } | null> { + const [before] = await db + .select() + .from(meetings) + .where(and(eq(meetings.id, meetingId), eq(meetings.organizationId, organizationId))) + .limit(1); + if (!before) return null; + await db.delete(meetings).where(eq(meetings.id, meetingId)); + return { before }; +} diff --git a/tests/meetings.test.ts b/tests/meetings.test.ts new file mode 100644 index 0000000..ec5f949 --- /dev/null +++ b/tests/meetings.test.ts @@ -0,0 +1,46 @@ +import { eq } from "drizzle-orm"; +import { beforeEach, describe, expect, test } from "bun:test"; +import { createDb } from "@/db/client"; +import { organization } from "@/db/schema/auth"; +import { companies } from "@/db/schema/companies"; +import { meetings } from "@/db/schema/meetings"; +import { deleteMeeting } from "@/lib/meetings"; +import { resetDb } from "./setup"; + +const db = createDb(process.env.TEST_DATABASE_URL!); + +async function seed(orgId = "org_meetings") { + await db.insert(organization).values({ id: orgId, name: "Meetings", slug: orgId }); + const companyId = crypto.randomUUID(); + await db.insert(companies).values({ id: companyId, organizationId: orgId, name: "Acme" }); + const meetingId = crypto.randomUUID(); + await db.insert(meetings).values({ + id: meetingId, + organizationId: orgId, + companyId, + title: "Quarterly review", + scheduledAt: new Date(), + }); + return { orgId, meetingId }; +} + +describe("lib/meetings", () => { + beforeEach(async () => { + await resetDb(); + }); + + test("deleteMeeting removes the row and returns the prior snapshot", async () => { + const { orgId, meetingId } = await seed(); + const result = await deleteMeeting(db, orgId, meetingId); + expect(result?.before.id).toBe(meetingId); + const remaining = await db.select().from(meetings).where(eq(meetings.id, meetingId)); + expect(remaining).toHaveLength(0); + }); + + test("deleteMeeting returns null for cross-org meetings", async () => { + const { meetingId } = await seed("org_a"); + await db.insert(organization).values({ id: "org_b", name: "B", slug: "org_b" }); + const result = await deleteMeeting(db, "org_b", meetingId); + expect(result).toBeNull(); + }); +}); From 8ce808939fa261a2758cf71b3b297c83c864d645 Mon Sep 17 00:00:00 2001 From: Timmyy3000 Date: Tue, 12 May 2026 21:05:51 +0100 Subject: [PATCH 06/11] feat(deals): UI delete for deals with audit Adds delete button to kanban cards (with optimistic local removal), list view, and the company-detail deals tab. Co-authored-by: Claude <81847+claude@users.noreply.github.com> --- app/(app)/companies/[id]/deals/page.tsx | 7 ++- app/(app)/deals/actions.ts | 26 +++++++++++ app/(app)/deals/deals-view.tsx | 9 +++- app/(app)/deals/delete-deal-button.tsx | 60 +++++++++++++++++++++++++ app/(app)/deals/kanban.tsx | 46 ++++++++++++------- lib/deals.ts | 20 +++++++++ tests/deals.test.ts | 51 +++++++++++++++++++++ 7 files changed, 199 insertions(+), 20 deletions(-) create mode 100644 app/(app)/deals/delete-deal-button.tsx create mode 100644 lib/deals.ts create mode 100644 tests/deals.test.ts diff --git a/app/(app)/companies/[id]/deals/page.tsx b/app/(app)/companies/[id]/deals/page.tsx index a75cb15..0cc387d 100644 --- a/app/(app)/companies/[id]/deals/page.tsx +++ b/app/(app)/companies/[id]/deals/page.tsx @@ -3,6 +3,7 @@ import { db } from "@/db/client"; import { deals, stages } from "@/db/schema/deals"; import { StagePill } from "@/components/pills"; import { money, relativeTime } from "@/lib/format"; +import { DeleteDealButton } from "@/app/(app)/deals/delete-deal-button"; export default async function DealsTab({ params }: { params: Promise<{ id: string }> }) { const { id } = await params; @@ -41,13 +42,14 @@ export default async function DealsTab({ params }: { params: Promise<{ id: strin {h} ))} + {rows.map((d) => ( {d.name} @@ -62,6 +64,9 @@ export default async function DealsTab({ params }: { params: Promise<{ id: strin {relativeTime(d.expectedCloseDate)} + + + ))} diff --git a/app/(app)/deals/actions.ts b/app/(app)/deals/actions.ts index d8ecd27..1003f82 100644 --- a/app/(app)/deals/actions.ts +++ b/app/(app)/deals/actions.ts @@ -6,6 +6,7 @@ import { z } from "zod"; import { db } from "@/db/client"; import { deals, stages } from "@/db/schema/deals"; import { diffChangedFields, recordAudit, userActor } from "@/lib/audit"; +import { deleteDeal } from "@/lib/deals"; import { requireOrgSession } from "@/lib/session"; const Schema = z.object({ @@ -53,3 +54,28 @@ export async function moveDealStageAction(input: { revalidatePath("/deals"); return { ok: true }; } + +export async function deleteDealAction(input: { + dealId: string; +}): Promise<{ ok: true } | { ok: false; message: string }> { + const session = await requireOrgSession(); + const dealId = z.string().uuid().parse(input.dealId); + + const result = await deleteDeal(db(), session.organizationId, dealId); + if (!result) return { ok: false, message: "Deal not found" }; + + await recordAudit(db(), { + organizationId: session.organizationId, + actor: userActor(session.user.id, session.user.name ?? null), + entityType: "deal", + entityId: dealId, + action: "delete", + changes: { before: result.before }, + }); + + revalidatePath("/deals"); + revalidatePath(`/companies/${result.before.companyId}/deals`); + revalidatePath(`/companies/${result.before.companyId}`); + revalidatePath("/companies"); + return { ok: true }; +} diff --git a/app/(app)/deals/deals-view.tsx b/app/(app)/deals/deals-view.tsx index 05499a4..438ce30 100644 --- a/app/(app)/deals/deals-view.tsx +++ b/app/(app)/deals/deals-view.tsx @@ -5,6 +5,7 @@ import { useMemo, useState } from "react"; import { CompanyLogo } from "@/components/avatar-init"; import { StagePill, TemperaturePill } from "@/components/pills"; import { money, relativeTime } from "@/lib/format"; +import { DeleteDealButton } from "./delete-deal-button"; import { KanbanBoard, type KanbanDeal, type KanbanStage } from "./kanban"; type DealRow = KanbanDeal; @@ -191,13 +192,14 @@ export function DealsView({ Temp + {filtered.map((d) => ( + + + ))} {filtered.length === 0 ? ( No deals match this filter. diff --git a/app/(app)/deals/delete-deal-button.tsx b/app/(app)/deals/delete-deal-button.tsx new file mode 100644 index 0000000..7e5f793 --- /dev/null +++ b/app/(app)/deals/delete-deal-button.tsx @@ -0,0 +1,60 @@ +"use client"; + +import { Trash2 } from "lucide-react"; +import { useState, type MouseEvent } from "react"; +import { DeleteConfirmDialog } from "@/components/delete-confirm-dialog"; +import { deleteDealAction } from "./actions"; + +export function DeleteDealButton({ + dealId, + dealName, + className, + onDeleted, +}: { + dealId: string; + dealName: string; + className?: string; + onDeleted?: () => void; +}) { + const [open, setOpen] = useState(false); + + function onClick(e: MouseEvent) { + e.preventDefault(); + e.stopPropagation(); + setOpen(true); + } + + async function onConfirm() { + const result = await deleteDealAction({ dealId }); + if (result.ok) onDeleted?.(); + return result; + } + + return ( + <> + + + “{dealName}” will be permanently deleted. Notes and tasks attached to + this deal will also be removed. This cannot be undone. + + } + onConfirm={onConfirm} + /> + + ); +} diff --git a/app/(app)/deals/kanban.tsx b/app/(app)/deals/kanban.tsx index e2dc87f..8300d16 100644 --- a/app/(app)/deals/kanban.tsx +++ b/app/(app)/deals/kanban.tsx @@ -7,6 +7,7 @@ import { StagePill, TemperaturePill } from "@/components/pills"; import { money, relativeTime } from "@/lib/format"; import { cn } from "@/lib/utils"; import { moveDealStageAction } from "./actions"; +import { DeleteDealButton } from "./delete-deal-button"; export type KanbanDeal = { id: string; @@ -107,29 +108,40 @@ export function KanbanBoard({
    ) : ( list.map((d) => ( - onDragStart(e, d.id)} - onDragEnd={onDragEnd} className={cn( - "block cursor-grab rounded-md border border-border bg-card/40 p-3 transition hover:bg-card/70 active:cursor-grabbing", + "group relative rounded-md border border-border bg-card/40 transition hover:bg-card/70", draggingId === d.id ? "opacity-40" : "", )} > -
    - {d.companyName} - + onDragStart(e, d.id)} + onDragEnd={onDragEnd} + className="block cursor-grab p-3 active:cursor-grabbing" + > +
    + {d.companyName} + +
    +
    {d.name}
    +
    + {money(d.value)} + + {relativeTime(d.stageEnteredAt)} + +
    + +
    + setDeals((cur) => cur.filter((x) => x.id !== d.id))} + />
    -
    {d.name}
    -
    - {money(d.value)} - - {relativeTime(d.stageEnteredAt)} - -
    - +
    )) )} diff --git a/lib/deals.ts b/lib/deals.ts new file mode 100644 index 0000000..66df625 --- /dev/null +++ b/lib/deals.ts @@ -0,0 +1,20 @@ +import { and, eq } from "drizzle-orm"; +import type { Database } from "@/db/client"; +import { deals } from "@/db/schema/deals"; + +export type Deal = typeof deals.$inferSelect; + +export async function deleteDeal( + db: Database, + organizationId: string, + dealId: string, +): Promise<{ before: Deal } | null> { + const [before] = await db + .select() + .from(deals) + .where(and(eq(deals.id, dealId), eq(deals.organizationId, organizationId))) + .limit(1); + if (!before) return null; + await db.delete(deals).where(eq(deals.id, dealId)); + return { before }; +} diff --git a/tests/deals.test.ts b/tests/deals.test.ts new file mode 100644 index 0000000..06ffc89 --- /dev/null +++ b/tests/deals.test.ts @@ -0,0 +1,51 @@ +import { eq } from "drizzle-orm"; +import { beforeEach, describe, expect, test } from "bun:test"; +import { createDb } from "@/db/client"; +import { organization } from "@/db/schema/auth"; +import { companies } from "@/db/schema/companies"; +import { deals, pipelines, stages } from "@/db/schema/deals"; +import { deleteDeal } from "@/lib/deals"; +import { resetDb } from "./setup"; + +const db = createDb(process.env.TEST_DATABASE_URL!); + +async function seed(orgId = "org_deals") { + await db.insert(organization).values({ id: orgId, name: "Deals", slug: orgId }); + const companyId = crypto.randomUUID(); + await db.insert(companies).values({ id: companyId, organizationId: orgId, name: "Acme" }); + const pipelineId = crypto.randomUUID(); + await db.insert(pipelines).values({ id: pipelineId, organizationId: orgId, name: "Sales" }); + const stageId = crypto.randomUUID(); + await db.insert(stages).values({ id: stageId, pipelineId, name: "Lead", order: 0 }); + const dealId = crypto.randomUUID(); + await db.insert(deals).values({ + id: dealId, + organizationId: orgId, + companyId, + pipelineId, + stageId, + name: "Acme Q3 deal", + }); + return { orgId, dealId }; +} + +describe("lib/deals", () => { + beforeEach(async () => { + await resetDb(); + }); + + test("deleteDeal removes the row and returns the prior snapshot", async () => { + const { orgId, dealId } = await seed(); + const result = await deleteDeal(db, orgId, dealId); + expect(result?.before.id).toBe(dealId); + const remaining = await db.select().from(deals).where(eq(deals.id, dealId)); + expect(remaining).toHaveLength(0); + }); + + test("deleteDeal returns null for cross-org deals", async () => { + const { dealId } = await seed("org_a"); + await db.insert(organization).values({ id: "org_b", name: "B", slug: "org_b" }); + const result = await deleteDeal(db, "org_b", dealId); + expect(result).toBeNull(); + }); +}); From 3faeafea464271112ccb1bbe0217c8022682fee8 Mon Sep 17 00:00:00 2001 From: Timmyy3000 Date: Tue, 12 May 2026 21:08:41 +0100 Subject: [PATCH 07/11] feat(people): UI delete for people with cascade preview and audit Person delete cascades to tasks/notes/meeting_attendees and nulls signals.person_id. Confirm dialog fetches counts before destruction; action writes per-child audit rows in addition to the parent. Co-authored-by: Claude <81847+claude@users.noreply.github.com> --- app/(app)/people/[id]/page.tsx | 15 ++- app/(app)/people/actions.ts | 65 ++++++++++++ app/(app)/people/delete-person-button.tsx | 116 ++++++++++++++++++++++ app/(app)/people/page.tsx | 30 +++--- lib/people.ts | 78 +++++++++++++++ tests/people.test.ts | 99 ++++++++++++++++++ 6 files changed, 387 insertions(+), 16 deletions(-) create mode 100644 app/(app)/people/actions.ts create mode 100644 app/(app)/people/delete-person-button.tsx create mode 100644 lib/people.ts create mode 100644 tests/people.test.ts diff --git a/app/(app)/people/[id]/page.tsx b/app/(app)/people/[id]/page.tsx index 897bfa0..390ad64 100644 --- a/app/(app)/people/[id]/page.tsx +++ b/app/(app)/people/[id]/page.tsx @@ -12,6 +12,7 @@ import { EngagementPill, PersonaPill } from "@/components/pills"; import { TaskRow, type TaskRowData } from "@/components/task-row"; import { relativeTime } from "@/lib/format"; import { requireOrgSession } from "@/lib/session"; +import { DeletePersonButton } from "../delete-person-button"; export default async function PersonDetailPage({ params, @@ -70,9 +71,10 @@ export default async function PersonDetailPage({ / {person.name} -
    - -
    +
    +
    + +

    ) : null}

    +
    +
    diff --git a/app/(app)/people/actions.ts b/app/(app)/people/actions.ts new file mode 100644 index 0000000..ceb0837 --- /dev/null +++ b/app/(app)/people/actions.ts @@ -0,0 +1,65 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { z } from "zod"; +import { db } from "@/db/client"; +import { recordAudit, userActor } from "@/lib/audit"; +import { deletePerson, previewPersonDelete, type PersonCascadeCounts } from "@/lib/people"; +import { requireOrgSession } from "@/lib/session"; + +export async function previewPersonDeleteAction(input: { + personId: string; +}): Promise<{ ok: true; data: PersonCascadeCounts } | { ok: false; message: string }> { + const session = await requireOrgSession(); + const personId = z.string().uuid().parse(input.personId); + const data = await previewPersonDelete(db(), session.organizationId, personId); + if (!data) return { ok: false, message: "Person not found" }; + return { ok: true, data }; +} + +export async function deletePersonAction(input: { + personId: string; +}): Promise<{ ok: true } | { ok: false; message: string }> { + const session = await requireOrgSession(); + const personId = z.string().uuid().parse(input.personId); + + const result = await deletePerson(db(), session.organizationId, personId); + if (!result) return { ok: false, message: "Person not found" }; + + const actor = userActor(session.user.id, session.user.name ?? null); + for (const taskId of result.children.taskIds) { + await recordAudit(db(), { + organizationId: session.organizationId, + actor, + entityType: "task", + entityId: taskId, + action: "delete", + changes: { before: { personId } }, + }); + } + for (const noteId of result.children.noteIds) { + await recordAudit(db(), { + organizationId: session.organizationId, + actor, + entityType: "note", + entityId: noteId, + action: "delete", + changes: { before: { personId } }, + }); + } + await recordAudit(db(), { + organizationId: session.organizationId, + actor, + entityType: "person", + entityId: personId, + action: "delete", + changes: { before: result.before }, + }); + + revalidatePath("/people"); + if (result.before.companyId) { + revalidatePath(`/companies/${result.before.companyId}/people`); + revalidatePath(`/companies/${result.before.companyId}`); + } + return { ok: true }; +} diff --git a/app/(app)/people/delete-person-button.tsx b/app/(app)/people/delete-person-button.tsx new file mode 100644 index 0000000..f7b8c06 --- /dev/null +++ b/app/(app)/people/delete-person-button.tsx @@ -0,0 +1,116 @@ +"use client"; + +import { Trash2 } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { useEffect, useState, type MouseEvent } from "react"; +import { DeleteConfirmDialog } from "@/components/delete-confirm-dialog"; +import { Button } from "@/components/ui/button"; +import { deletePersonAction, previewPersonDeleteAction } from "./actions"; + +type Counts = { tasks: number; notes: number; meetingAttendances: number }; + +export function DeletePersonButton({ + personId, + personName, + variant = "row", + redirectTo, +}: { + personId: string; + personName: string; + variant?: "row" | "header"; + redirectTo?: string; +}) { + const router = useRouter(); + const [open, setOpen] = useState(false); + const [counts, setCounts] = useState(null); + + useEffect(() => { + if (!open) return; + let cancelled = false; + setCounts(null); + void previewPersonDeleteAction({ personId }).then((res) => { + if (cancelled) return; + if (res.ok) setCounts(res.data); + }); + return () => { + cancelled = true; + }; + }, [open, personId]); + + function stop(e: MouseEvent) { + e.preventDefault(); + e.stopPropagation(); + } + + async function onConfirm() { + const result = await deletePersonAction({ personId }); + if (result.ok && redirectTo) router.push(redirectTo); + return result; + } + + const description = counts + ? buildDescription(personName, counts) + : `Counting related items for ${personName}…`; + + if (variant === "header") { + return ( + <> + + + + ); + } + + return ( + <> + + + + ); +} + +function buildDescription(name: string, c: Counts): string { + const parts: string[] = []; + if (c.tasks) parts.push(`${c.tasks} task${c.tasks === 1 ? "" : "s"}`); + if (c.notes) parts.push(`${c.notes} note${c.notes === 1 ? "" : "s"}`); + if (c.meetingAttendances) + parts.push( + `${c.meetingAttendances} meeting attendance${ + c.meetingAttendances === 1 ? "" : "s" + }`, + ); + if (parts.length === 0) { + return `${name} will be permanently deleted. Signals attributed to them will remain but lose the person link.`; + } + return `${name} will be permanently deleted along with ${parts.join(", ")}. Signals attributed to them will remain but lose the person link.`; +} diff --git a/app/(app)/people/page.tsx b/app/(app)/people/page.tsx index 66fa05c..6999a88 100644 --- a/app/(app)/people/page.tsx +++ b/app/(app)/people/page.tsx @@ -7,6 +7,7 @@ import { AvatarSquare, CompanyLogo } from "@/components/avatar-init"; import { EngagementPill, LifecyclePill, PersonaPill } from "@/components/pills"; import { relativeTime } from "@/lib/format"; import { requireOrgSession } from "@/lib/session"; +import { DeletePersonButton } from "./delete-person-button"; const ROW_LIMIT = 200; @@ -82,7 +83,7 @@ export default async function PeoplePage() { {rows.slice(0, ROW_LIMIT).map((p) => ( @@ -113,18 +114,21 @@ export default async function PeoplePage() { {p.email ?? "—"} - {p.linkedinUrl ? ( - - - - - - ) : null} +
    + {p.linkedinUrl ? ( + + + + + + ) : null} + +
    ))} diff --git a/lib/people.ts b/lib/people.ts new file mode 100644 index 0000000..2c26c76 --- /dev/null +++ b/lib/people.ts @@ -0,0 +1,78 @@ +import { and, count, eq } from "drizzle-orm"; +import type { Database } from "@/db/client"; +import { people } from "@/db/schema/companies"; +import { meetingAttendees } from "@/db/schema/meetings"; +import { notes } from "@/db/schema/notes"; +import { tasks } from "@/db/schema/tasks"; + +export type Person = typeof people.$inferSelect; + +export type PersonCascadeChildren = { + taskIds: string[]; + noteIds: string[]; +}; + +export type PersonCascadeCounts = { + tasks: number; + notes: number; + meetingAttendances: number; +}; + +async function fetchPerson( + db: Database, + organizationId: string, + personId: string, +): Promise { + const [row] = await db + .select() + .from(people) + .where(and(eq(people.id, personId), eq(people.organizationId, organizationId))) + .limit(1); + return row ?? null; +} + +export async function previewPersonDelete( + db: Database, + organizationId: string, + personId: string, +): Promise { + const exists = await fetchPerson(db, organizationId, personId); + if (!exists) return null; + const [taskCount, noteCount, attendanceCount] = await Promise.all([ + db.select({ n: count() }).from(tasks).where(eq(tasks.personId, personId)), + db.select({ n: count() }).from(notes).where(eq(notes.personId, personId)), + db + .select({ n: count() }) + .from(meetingAttendees) + .where(eq(meetingAttendees.personId, personId)), + ]); + return { + tasks: taskCount[0]?.n ?? 0, + notes: noteCount[0]?.n ?? 0, + meetingAttendances: attendanceCount[0]?.n ?? 0, + }; +} + +export async function deletePerson( + db: Database, + organizationId: string, + personId: string, +): Promise<{ before: Person; children: PersonCascadeChildren } | null> { + const before = await fetchPerson(db, organizationId, personId); + if (!before) return null; + + return db.transaction(async (tx) => { + const [taskIds, noteIds] = await Promise.all([ + tx.select({ id: tasks.id }).from(tasks).where(eq(tasks.personId, personId)), + tx.select({ id: notes.id }).from(notes).where(eq(notes.personId, personId)), + ]); + await tx.delete(people).where(eq(people.id, personId)); + return { + before, + children: { + taskIds: taskIds.map((r) => r.id), + noteIds: noteIds.map((r) => r.id), + }, + }; + }); +} diff --git a/tests/people.test.ts b/tests/people.test.ts new file mode 100644 index 0000000..a7aba37 --- /dev/null +++ b/tests/people.test.ts @@ -0,0 +1,99 @@ +import { eq } from "drizzle-orm"; +import { beforeEach, describe, expect, test } from "bun:test"; +import { createDb } from "@/db/client"; +import { organization } from "@/db/schema/auth"; +import { companies, people } from "@/db/schema/companies"; +import { meetingAttendees, meetings } from "@/db/schema/meetings"; +import { notes } from "@/db/schema/notes"; +import { signals } from "@/db/schema/signals"; +import { tasks } from "@/db/schema/tasks"; +import { deletePerson, previewPersonDelete } from "@/lib/people"; +import { resetDb } from "./setup"; + +const db = createDb(process.env.TEST_DATABASE_URL!); + +async function seed(orgId = "org_people") { + await db.insert(organization).values({ id: orgId, name: "People", slug: orgId }); + const companyId = crypto.randomUUID(); + await db.insert(companies).values({ id: companyId, organizationId: orgId, name: "Acme" }); + const personId = crypto.randomUUID(); + await db.insert(people).values({ + id: personId, + organizationId: orgId, + companyId, + name: "Alice Engineer", + }); + await db.insert(tasks).values([ + { id: crypto.randomUUID(), organizationId: orgId, personId, title: "Send recap" }, + { id: crypto.randomUUID(), organizationId: orgId, personId, title: "Schedule call" }, + ]); + await db.insert(notes).values({ + id: crypto.randomUUID(), + organizationId: orgId, + personId, + body: "Loves Postgres", + }); + const meetingId = crypto.randomUUID(); + await db.insert(meetings).values({ + id: meetingId, + organizationId: orgId, + companyId, + title: "Kickoff", + scheduledAt: new Date(), + }); + await db.insert(meetingAttendees).values({ meetingId, personId }); + const signalId = crypto.randomUUID(); + await db.insert(signals).values({ + id: signalId, + organizationId: orgId, + companyId, + personId, + type: "email", + title: "Replied to outreach", + occurredAt: new Date(), + }); + return { orgId, personId, signalId }; +} + +describe("lib/people", () => { + beforeEach(async () => { + await resetDb(); + }); + + test("previewPersonDelete counts cascading children correctly", async () => { + const { orgId, personId } = await seed(); + const preview = await previewPersonDelete(db, orgId, personId); + expect(preview).toEqual({ tasks: 2, notes: 1, meetingAttendances: 1 }); + }); + + test("previewPersonDelete returns null for cross-org person", async () => { + const { personId } = await seed("org_a"); + await db.insert(organization).values({ id: "org_b", name: "B", slug: "org_b" }); + const preview = await previewPersonDelete(db, "org_b", personId); + expect(preview).toBeNull(); + }); + + test("deletePerson cascades tasks/notes/attendances and returns child IDs", async () => { + const { orgId, personId, signalId } = await seed(); + const result = await deletePerson(db, orgId, personId); + expect(result).not.toBeNull(); + expect(result!.children.taskIds).toHaveLength(2); + expect(result!.children.noteIds).toHaveLength(1); + + expect(await db.select().from(people).where(eq(people.id, personId))).toHaveLength(0); + expect(await db.select().from(tasks).where(eq(tasks.personId, personId))).toHaveLength(0); + expect(await db.select().from(notes).where(eq(notes.personId, personId))).toHaveLength(0); + + const signalRow = await db.select().from(signals).where(eq(signals.id, signalId)); + expect(signalRow).toHaveLength(1); + expect(signalRow[0]!.personId).toBeNull(); + }); + + test("deletePerson returns null for cross-org person", async () => { + const { personId } = await seed("org_a"); + await db.insert(organization).values({ id: "org_b", name: "B", slug: "org_b" }); + const result = await deletePerson(db, "org_b", personId); + expect(result).toBeNull(); + expect(await db.select().from(people).where(eq(people.id, personId))).toHaveLength(1); + }); +}); From 3cbfdd9b5c91e9811cae27a2af754c2569560af1 Mon Sep 17 00:00:00 2001 From: Timmyy3000 Date: Tue, 12 May 2026 21:15:08 +0100 Subject: [PATCH 08/11] feat(companies): UI delete for companies with cascade preview and audit Company delete cascades to deals, signals, meetings, notes, and tasks in a transaction; nullifies people.company_id. Detail-page header shows a Delete button; confirm dialog fetches and displays per-table counts before confirmation. Audit fans out one row per cascaded child plus the parent. Co-authored-by: Claude <81847+claude@users.noreply.github.com> --- app/(app)/companies/[id]/layout.tsx | 2 + app/(app)/companies/actions.ts | 70 +++++++++++ app/(app)/companies/delete-company-button.tsx | 84 +++++++++++++ app/(app)/people/delete-person-button.tsx | 1 - lib/companies.ts | 92 ++++++++++++++ tests/companies.test.ts | 119 ++++++++++++++++++ 6 files changed, 367 insertions(+), 1 deletion(-) create mode 100644 app/(app)/companies/actions.ts create mode 100644 app/(app)/companies/delete-company-button.tsx create mode 100644 lib/companies.ts create mode 100644 tests/companies.test.ts diff --git a/app/(app)/companies/[id]/layout.tsx b/app/(app)/companies/[id]/layout.tsx index 99cc490..a652d6b 100644 --- a/app/(app)/companies/[id]/layout.tsx +++ b/app/(app)/companies/[id]/layout.tsx @@ -6,6 +6,7 @@ import { TemperaturePill } from "@/components/pills"; import { CompanyTabs } from "./tabs"; import { getCompanyById } from "@/lib/data"; import { requireOrgSession } from "@/lib/session"; +import { DeleteCompanyButton } from "../delete-company-button"; export default async function CompanyDetailLayout({ children, @@ -64,6 +65,7 @@ export default async function CompanyDetailLayout({
    + diff --git a/app/(app)/companies/actions.ts b/app/(app)/companies/actions.ts new file mode 100644 index 0000000..9a1ac3e --- /dev/null +++ b/app/(app)/companies/actions.ts @@ -0,0 +1,70 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { z } from "zod"; +import { db } from "@/db/client"; +import { recordAudit, userActor, type EntityType } from "@/lib/audit"; +import { + deleteCompany, + previewCompanyDelete, + type CompanyCascadeCounts, +} from "@/lib/companies"; +import { requireOrgSession } from "@/lib/session"; + +export async function previewCompanyDeleteAction(input: { + companyId: string; +}): Promise<{ ok: true; data: CompanyCascadeCounts } | { ok: false; message: string }> { + const session = await requireOrgSession(); + const companyId = z.string().uuid().parse(input.companyId); + const data = await previewCompanyDelete(db(), session.organizationId, companyId); + if (!data) return { ok: false, message: "Company not found" }; + return { ok: true, data }; +} + +export async function deleteCompanyAction(input: { + companyId: string; +}): Promise<{ ok: true } | { ok: false; message: string }> { + const session = await requireOrgSession(); + const companyId = z.string().uuid().parse(input.companyId); + + const result = await deleteCompany(db(), session.organizationId, companyId); + if (!result) return { ok: false, message: "Company not found" }; + + const actor = userActor(session.user.id, session.user.name ?? null); + const beforeStub = { companyId }; + const fanouts: Array<{ entityType: EntityType; ids: string[] }> = [ + { entityType: "deal", ids: result.children.dealIds }, + { entityType: "signal", ids: result.children.signalIds }, + { entityType: "meeting", ids: result.children.meetingIds }, + { entityType: "note", ids: result.children.noteIds }, + { entityType: "task", ids: result.children.taskIds }, + ]; + for (const { entityType, ids } of fanouts) { + for (const id of ids) { + await recordAudit(db(), { + organizationId: session.organizationId, + actor, + entityType, + entityId: id, + action: "delete", + changes: { before: beforeStub }, + }); + } + } + await recordAudit(db(), { + organizationId: session.organizationId, + actor, + entityType: "company", + entityId: companyId, + action: "delete", + changes: { before: result.before }, + }); + + revalidatePath("/companies"); + revalidatePath("/deals"); + revalidatePath("/meetings"); + revalidatePath("/tasks"); + revalidatePath("/people"); + revalidatePath("/"); + return { ok: true }; +} diff --git a/app/(app)/companies/delete-company-button.tsx b/app/(app)/companies/delete-company-button.tsx new file mode 100644 index 0000000..7a7fb7e --- /dev/null +++ b/app/(app)/companies/delete-company-button.tsx @@ -0,0 +1,84 @@ +"use client"; + +import { Trash2 } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { useEffect, useState } from "react"; +import { DeleteConfirmDialog } from "@/components/delete-confirm-dialog"; +import { Button } from "@/components/ui/button"; +import { deleteCompanyAction, previewCompanyDeleteAction } from "./actions"; + +type Counts = { + deals: number; + signals: number; + meetings: number; + notes: number; + tasks: number; +}; + +export function DeleteCompanyButton({ + companyId, + companyName, +}: { + companyId: string; + companyName: string; +}) { + const router = useRouter(); + const [open, setOpen] = useState(false); + const [counts, setCounts] = useState(null); + + useEffect(() => { + if (!open) return; + let cancelled = false; + void previewCompanyDeleteAction({ companyId }).then((res) => { + if (cancelled) return; + if (res.ok) setCounts(res.data); + }); + return () => { + cancelled = true; + }; + }, [open, companyId]); + + async function onConfirm() { + const result = await deleteCompanyAction({ companyId }); + if (result.ok) router.push("/companies"); + return result; + } + + const description = counts + ? buildDescription(companyName, counts) + : `Counting related items for ${companyName}…`; + + return ( + <> + + + + ); +} + +function buildDescription(name: string, c: Counts): string { + const parts: string[] = []; + if (c.deals) parts.push(`${c.deals} deal${c.deals === 1 ? "" : "s"}`); + if (c.signals) parts.push(`${c.signals} signal${c.signals === 1 ? "" : "s"}`); + if (c.meetings) parts.push(`${c.meetings} meeting${c.meetings === 1 ? "" : "s"}`); + if (c.notes) parts.push(`${c.notes} note${c.notes === 1 ? "" : "s"}`); + if (c.tasks) parts.push(`${c.tasks} task${c.tasks === 1 ? "" : "s"}`); + if (parts.length === 0) { + return `${name} will be permanently deleted. People at ${name} will remain but lose the company link.`; + } + return `${name} will be permanently deleted along with ${parts.join(", ")}. People at ${name} will remain but lose the company link. This cannot be undone.`; +} diff --git a/app/(app)/people/delete-person-button.tsx b/app/(app)/people/delete-person-button.tsx index f7b8c06..f81d1b8 100644 --- a/app/(app)/people/delete-person-button.tsx +++ b/app/(app)/people/delete-person-button.tsx @@ -27,7 +27,6 @@ export function DeletePersonButton({ useEffect(() => { if (!open) return; let cancelled = false; - setCounts(null); void previewPersonDeleteAction({ personId }).then((res) => { if (cancelled) return; if (res.ok) setCounts(res.data); diff --git a/lib/companies.ts b/lib/companies.ts new file mode 100644 index 0000000..6ca6e0d --- /dev/null +++ b/lib/companies.ts @@ -0,0 +1,92 @@ +import { and, count, eq } from "drizzle-orm"; +import type { Database } from "@/db/client"; +import { companies } from "@/db/schema/companies"; +import { deals } from "@/db/schema/deals"; +import { meetings } from "@/db/schema/meetings"; +import { notes } from "@/db/schema/notes"; +import { signals } from "@/db/schema/signals"; +import { tasks } from "@/db/schema/tasks"; + +export type Company = typeof companies.$inferSelect; + +export type CompanyCascadeChildren = { + dealIds: string[]; + signalIds: string[]; + meetingIds: string[]; + noteIds: string[]; + taskIds: string[]; +}; + +export type CompanyCascadeCounts = { + deals: number; + signals: number; + meetings: number; + notes: number; + tasks: number; +}; + +async function fetchCompany( + db: Database, + organizationId: string, + companyId: string, +): Promise { + const [row] = await db + .select() + .from(companies) + .where(and(eq(companies.id, companyId), eq(companies.organizationId, organizationId))) + .limit(1); + return row ?? null; +} + +export async function previewCompanyDelete( + db: Database, + organizationId: string, + companyId: string, +): Promise { + const exists = await fetchCompany(db, organizationId, companyId); + if (!exists) return null; + const [dealRows, signalRows, meetingRows, noteRows, taskRows] = await Promise.all([ + db.select({ n: count() }).from(deals).where(eq(deals.companyId, companyId)), + db.select({ n: count() }).from(signals).where(eq(signals.companyId, companyId)), + db.select({ n: count() }).from(meetings).where(eq(meetings.companyId, companyId)), + db.select({ n: count() }).from(notes).where(eq(notes.companyId, companyId)), + db.select({ n: count() }).from(tasks).where(eq(tasks.companyId, companyId)), + ]); + return { + deals: dealRows[0]?.n ?? 0, + signals: signalRows[0]?.n ?? 0, + meetings: meetingRows[0]?.n ?? 0, + notes: noteRows[0]?.n ?? 0, + tasks: taskRows[0]?.n ?? 0, + }; +} + +export async function deleteCompany( + db: Database, + organizationId: string, + companyId: string, +): Promise<{ before: Company; children: CompanyCascadeChildren } | null> { + const before = await fetchCompany(db, organizationId, companyId); + if (!before) return null; + + return db.transaction(async (tx) => { + const [dealIds, signalIds, meetingIds, noteIds, taskIds] = await Promise.all([ + tx.select({ id: deals.id }).from(deals).where(eq(deals.companyId, companyId)), + tx.select({ id: signals.id }).from(signals).where(eq(signals.companyId, companyId)), + tx.select({ id: meetings.id }).from(meetings).where(eq(meetings.companyId, companyId)), + tx.select({ id: notes.id }).from(notes).where(eq(notes.companyId, companyId)), + tx.select({ id: tasks.id }).from(tasks).where(eq(tasks.companyId, companyId)), + ]); + await tx.delete(companies).where(eq(companies.id, companyId)); + return { + before, + children: { + dealIds: dealIds.map((r) => r.id), + signalIds: signalIds.map((r) => r.id), + meetingIds: meetingIds.map((r) => r.id), + noteIds: noteIds.map((r) => r.id), + taskIds: taskIds.map((r) => r.id), + }, + }; + }); +} diff --git a/tests/companies.test.ts b/tests/companies.test.ts new file mode 100644 index 0000000..e2612cc --- /dev/null +++ b/tests/companies.test.ts @@ -0,0 +1,119 @@ +import { eq } from "drizzle-orm"; +import { beforeEach, describe, expect, test } from "bun:test"; +import { createDb } from "@/db/client"; +import { organization } from "@/db/schema/auth"; +import { companies, people } from "@/db/schema/companies"; +import { deals, pipelines, stages } from "@/db/schema/deals"; +import { meetings } from "@/db/schema/meetings"; +import { notes } from "@/db/schema/notes"; +import { signals } from "@/db/schema/signals"; +import { tasks } from "@/db/schema/tasks"; +import { deleteCompany, previewCompanyDelete } from "@/lib/companies"; +import { resetDb } from "./setup"; + +const db = createDb(process.env.TEST_DATABASE_URL!); + +async function seed(orgId = "org_companies") { + await db.insert(organization).values({ id: orgId, name: "Cos", slug: orgId }); + const companyId = crypto.randomUUID(); + await db.insert(companies).values({ id: companyId, organizationId: orgId, name: "Acme" }); + + const pipelineId = crypto.randomUUID(); + await db.insert(pipelines).values({ id: pipelineId, organizationId: orgId, name: "Sales" }); + const stageId = crypto.randomUUID(); + await db.insert(stages).values({ id: stageId, pipelineId, name: "Lead", order: 0 }); + await db.insert(deals).values([ + { id: crypto.randomUUID(), organizationId: orgId, companyId, pipelineId, stageId, name: "D1" }, + { id: crypto.randomUUID(), organizationId: orgId, companyId, pipelineId, stageId, name: "D2" }, + ]); + + await db.insert(signals).values({ + id: crypto.randomUUID(), + organizationId: orgId, + companyId, + type: "news", + title: "Funding", + occurredAt: new Date(), + }); + + await db.insert(meetings).values({ + id: crypto.randomUUID(), + organizationId: orgId, + companyId, + title: "Kickoff", + scheduledAt: new Date(), + }); + + await db.insert(notes).values({ + id: crypto.randomUUID(), + organizationId: orgId, + companyId, + body: "First call notes", + }); + + await db.insert(tasks).values({ + id: crypto.randomUUID(), + organizationId: orgId, + companyId, + title: "Send proposal", + }); + + const personId = crypto.randomUUID(); + await db.insert(people).values({ + id: personId, + organizationId: orgId, + companyId, + name: "Alice", + }); + + return { orgId, companyId, personId }; +} + +describe("lib/companies", () => { + beforeEach(async () => { + await resetDb(); + }); + + test("previewCompanyDelete counts cascading children correctly", async () => { + const { orgId, companyId } = await seed(); + const preview = await previewCompanyDelete(db, orgId, companyId); + expect(preview).toEqual({ deals: 2, signals: 1, meetings: 1, notes: 1, tasks: 1 }); + }); + + test("previewCompanyDelete returns null for cross-org company", async () => { + const { companyId } = await seed("org_a"); + await db.insert(organization).values({ id: "org_b", name: "B", slug: "org_b" }); + const preview = await previewCompanyDelete(db, "org_b", companyId); + expect(preview).toBeNull(); + }); + + test("deleteCompany cascades children and returns their IDs; people.company_id nullified", async () => { + const { orgId, companyId, personId } = await seed(); + const result = await deleteCompany(db, orgId, companyId); + expect(result).not.toBeNull(); + expect(result!.children.dealIds).toHaveLength(2); + expect(result!.children.signalIds).toHaveLength(1); + expect(result!.children.meetingIds).toHaveLength(1); + expect(result!.children.noteIds).toHaveLength(1); + expect(result!.children.taskIds).toHaveLength(1); + + expect(await db.select().from(companies).where(eq(companies.id, companyId))).toHaveLength(0); + expect(await db.select().from(deals).where(eq(deals.companyId, companyId))).toHaveLength(0); + expect(await db.select().from(signals).where(eq(signals.companyId, companyId))).toHaveLength(0); + expect(await db.select().from(meetings).where(eq(meetings.companyId, companyId))).toHaveLength(0); + expect(await db.select().from(notes).where(eq(notes.companyId, companyId))).toHaveLength(0); + expect(await db.select().from(tasks).where(eq(tasks.companyId, companyId))).toHaveLength(0); + + const personRow = await db.select().from(people).where(eq(people.id, personId)); + expect(personRow).toHaveLength(1); + expect(personRow[0]!.companyId).toBeNull(); + }); + + test("deleteCompany returns null for cross-org company", async () => { + const { companyId } = await seed("org_a"); + await db.insert(organization).values({ id: "org_b", name: "B", slug: "org_b" }); + const result = await deleteCompany(db, "org_b", companyId); + expect(result).toBeNull(); + expect(await db.select().from(companies).where(eq(companies.id, companyId))).toHaveLength(1); + }); +}); From 5ed7ea1f6ff8e670514256afb8326efce40ee46d Mon Sep 17 00:00:00 2001 From: Timmyy3000 Date: Tue, 12 May 2026 21:16:25 +0100 Subject: [PATCH 09/11] feat(companies): wire DeletePersonButton into the people tab Per-row delete on the company-detail people tab, matching the global /people index. Tasks tab already uses TaskRow which handles delete. Co-authored-by: Claude <81847+claude@users.noreply.github.com> --- app/(app)/companies/[id]/people/page.tsx | 30 ++++++++++++++---------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/app/(app)/companies/[id]/people/page.tsx b/app/(app)/companies/[id]/people/page.tsx index 7b21591..cf4ea4a 100644 --- a/app/(app)/companies/[id]/people/page.tsx +++ b/app/(app)/companies/[id]/people/page.tsx @@ -5,6 +5,7 @@ import { people } from "@/db/schema/companies"; import { AvatarSquare } from "@/components/avatar-init"; import { EngagementPill, PersonaPill } from "@/components/pills"; import { relativeTime } from "@/lib/format"; +import { DeletePersonButton } from "@/app/(app)/people/delete-person-button"; export default async function PeopleTab({ params }: { params: Promise<{ id: string }> }) { const { id } = await params; @@ -37,7 +38,7 @@ export default async function PeopleTab({ params }: { params: Promise<{ id: stri {rows.map((p) => ( - {p.linkedinUrl ? ( - - - - - - ) : null} +
    + {p.linkedinUrl ? ( + + + + + + ) : null} + +
    ))} From acfd2331ea113bf0dfc8766d4ca3a35f25a67c22 Mon Sep 17 00:00:00 2001 From: Timmyy3000 Date: Tue, 12 May 2026 21:55:46 +0100 Subject: [PATCH 10/11] fix(deletes): address four P2 findings from enkii 1. kanban: call router.refresh() after onDeleted so the local optimistic filter doesn't diverge from server state. 2. people: drop meetingAttendances from preview counts. The meeting_attendees join rows aren't in audit ENTITY_TYPES and weren't being collected or audited during the cascade, so surfacing a count the audit trail can't account for was misleading. 3. companies: move org-membership fetch inside the delete transaction to close the TOCTOU defense-in-depth gap. 4. people: same TOCTOU fix as companies. Co-authored-by: Claude <81847+claude@users.noreply.github.com> --- app/(app)/deals/kanban.tsx | 5 +++- app/(app)/people/delete-person-button.tsx | 8 +---- lib/companies.ts | 27 +++++++---------- lib/people.ts | 36 ++++++++--------------- tests/people.test.ts | 2 +- 5 files changed, 29 insertions(+), 49 deletions(-) diff --git a/app/(app)/deals/kanban.tsx b/app/(app)/deals/kanban.tsx index 8300d16..598a09a 100644 --- a/app/(app)/deals/kanban.tsx +++ b/app/(app)/deals/kanban.tsx @@ -138,7 +138,10 @@ export function KanbanBoard({ setDeals((cur) => cur.filter((x) => x.id !== d.id))} + onDeleted={() => { + setDeals((cur) => cur.filter((x) => x.id !== d.id)); + router.refresh(); + }} /> diff --git a/app/(app)/people/delete-person-button.tsx b/app/(app)/people/delete-person-button.tsx index f81d1b8..4e5914f 100644 --- a/app/(app)/people/delete-person-button.tsx +++ b/app/(app)/people/delete-person-button.tsx @@ -7,7 +7,7 @@ import { DeleteConfirmDialog } from "@/components/delete-confirm-dialog"; import { Button } from "@/components/ui/button"; import { deletePersonAction, previewPersonDeleteAction } from "./actions"; -type Counts = { tasks: number; notes: number; meetingAttendances: number }; +type Counts = { tasks: number; notes: number }; export function DeletePersonButton({ personId, @@ -102,12 +102,6 @@ function buildDescription(name: string, c: Counts): string { const parts: string[] = []; if (c.tasks) parts.push(`${c.tasks} task${c.tasks === 1 ? "" : "s"}`); if (c.notes) parts.push(`${c.notes} note${c.notes === 1 ? "" : "s"}`); - if (c.meetingAttendances) - parts.push( - `${c.meetingAttendances} meeting attendance${ - c.meetingAttendances === 1 ? "" : "s" - }`, - ); if (parts.length === 0) { return `${name} will be permanently deleted. Signals attributed to them will remain but lose the person link.`; } diff --git a/lib/companies.ts b/lib/companies.ts index 6ca6e0d..1c7f27b 100644 --- a/lib/companies.ts +++ b/lib/companies.ts @@ -25,25 +25,16 @@ export type CompanyCascadeCounts = { tasks: number; }; -async function fetchCompany( +export async function previewCompanyDelete( db: Database, organizationId: string, companyId: string, -): Promise { - const [row] = await db - .select() +): Promise { + const [exists] = await db + .select({ id: companies.id }) .from(companies) .where(and(eq(companies.id, companyId), eq(companies.organizationId, organizationId))) .limit(1); - return row ?? null; -} - -export async function previewCompanyDelete( - db: Database, - organizationId: string, - companyId: string, -): Promise { - const exists = await fetchCompany(db, organizationId, companyId); if (!exists) return null; const [dealRows, signalRows, meetingRows, noteRows, taskRows] = await Promise.all([ db.select({ n: count() }).from(deals).where(eq(deals.companyId, companyId)), @@ -66,10 +57,14 @@ export async function deleteCompany( organizationId: string, companyId: string, ): Promise<{ before: Company; children: CompanyCascadeChildren } | null> { - const before = await fetchCompany(db, organizationId, companyId); - if (!before) return null; - return db.transaction(async (tx) => { + const [before] = await tx + .select() + .from(companies) + .where(and(eq(companies.id, companyId), eq(companies.organizationId, organizationId))) + .limit(1); + if (!before) return null; + const [dealIds, signalIds, meetingIds, noteIds, taskIds] = await Promise.all([ tx.select({ id: deals.id }).from(deals).where(eq(deals.companyId, companyId)), tx.select({ id: signals.id }).from(signals).where(eq(signals.companyId, companyId)), diff --git a/lib/people.ts b/lib/people.ts index 2c26c76..7a27e98 100644 --- a/lib/people.ts +++ b/lib/people.ts @@ -1,7 +1,6 @@ import { and, count, eq } from "drizzle-orm"; import type { Database } from "@/db/client"; import { people } from "@/db/schema/companies"; -import { meetingAttendees } from "@/db/schema/meetings"; import { notes } from "@/db/schema/notes"; import { tasks } from "@/db/schema/tasks"; @@ -15,41 +14,26 @@ export type PersonCascadeChildren = { export type PersonCascadeCounts = { tasks: number; notes: number; - meetingAttendances: number; }; -async function fetchPerson( +export async function previewPersonDelete( db: Database, organizationId: string, personId: string, -): Promise { - const [row] = await db - .select() +): Promise { + const [exists] = await db + .select({ id: people.id }) .from(people) .where(and(eq(people.id, personId), eq(people.organizationId, organizationId))) .limit(1); - return row ?? null; -} - -export async function previewPersonDelete( - db: Database, - organizationId: string, - personId: string, -): Promise { - const exists = await fetchPerson(db, organizationId, personId); if (!exists) return null; - const [taskCount, noteCount, attendanceCount] = await Promise.all([ + const [taskCount, noteCount] = await Promise.all([ db.select({ n: count() }).from(tasks).where(eq(tasks.personId, personId)), db.select({ n: count() }).from(notes).where(eq(notes.personId, personId)), - db - .select({ n: count() }) - .from(meetingAttendees) - .where(eq(meetingAttendees.personId, personId)), ]); return { tasks: taskCount[0]?.n ?? 0, notes: noteCount[0]?.n ?? 0, - meetingAttendances: attendanceCount[0]?.n ?? 0, }; } @@ -58,10 +42,14 @@ export async function deletePerson( organizationId: string, personId: string, ): Promise<{ before: Person; children: PersonCascadeChildren } | null> { - const before = await fetchPerson(db, organizationId, personId); - if (!before) return null; - return db.transaction(async (tx) => { + const [before] = await tx + .select() + .from(people) + .where(and(eq(people.id, personId), eq(people.organizationId, organizationId))) + .limit(1); + if (!before) return null; + const [taskIds, noteIds] = await Promise.all([ tx.select({ id: tasks.id }).from(tasks).where(eq(tasks.personId, personId)), tx.select({ id: notes.id }).from(notes).where(eq(notes.personId, personId)), diff --git a/tests/people.test.ts b/tests/people.test.ts index a7aba37..cac7d55 100644 --- a/tests/people.test.ts +++ b/tests/people.test.ts @@ -63,7 +63,7 @@ describe("lib/people", () => { test("previewPersonDelete counts cascading children correctly", async () => { const { orgId, personId } = await seed(); const preview = await previewPersonDelete(db, orgId, personId); - expect(preview).toEqual({ tasks: 2, notes: 1, meetingAttendances: 1 }); + expect(preview).toEqual({ tasks: 2, notes: 1 }); }); test("previewPersonDelete returns null for cross-org person", async () => { From b4da3039e57f8de905f161c1f1d42687c3be90c6 Mon Sep 17 00:00:00 2001 From: Timmyy3000 Date: Tue, 12 May 2026 22:20:17 +0100 Subject: [PATCH 11/11] fix(deletes): address second round of enkii P2 findings 1. leaf deletes: collapse SELECT-then-DELETE into a single atomic DELETE...WHERE id = ? AND organization_id = ? RETURNING *. Closes the TOCTOU defense-in-depth gap on signals, notes, tasks, meetings, and deals. 2. deals: revalidate /tasks after delete (FK cascade removes tasks). 3. people: revalidate /tasks after delete (same cascade). Co-authored-by: Claude <81847+claude@users.noreply.github.com> --- app/(app)/deals/actions.ts | 1 + app/(app)/people/actions.ts | 1 + lib/deals.ts | 9 +++------ lib/meetings.ts | 9 +++------ lib/notes.ts | 9 +++------ lib/signals.ts | 9 +++------ lib/tasks.ts | 9 +++------ 7 files changed, 17 insertions(+), 30 deletions(-) diff --git a/app/(app)/deals/actions.ts b/app/(app)/deals/actions.ts index 1003f82..41734a7 100644 --- a/app/(app)/deals/actions.ts +++ b/app/(app)/deals/actions.ts @@ -74,6 +74,7 @@ export async function deleteDealAction(input: { }); revalidatePath("/deals"); + revalidatePath("/tasks"); revalidatePath(`/companies/${result.before.companyId}/deals`); revalidatePath(`/companies/${result.before.companyId}`); revalidatePath("/companies"); diff --git a/app/(app)/people/actions.ts b/app/(app)/people/actions.ts index ceb0837..41bad5f 100644 --- a/app/(app)/people/actions.ts +++ b/app/(app)/people/actions.ts @@ -57,6 +57,7 @@ export async function deletePersonAction(input: { }); revalidatePath("/people"); + revalidatePath("/tasks"); if (result.before.companyId) { revalidatePath(`/companies/${result.before.companyId}/people`); revalidatePath(`/companies/${result.before.companyId}`); diff --git a/lib/deals.ts b/lib/deals.ts index 66df625..e1a921e 100644 --- a/lib/deals.ts +++ b/lib/deals.ts @@ -10,11 +10,8 @@ export async function deleteDeal( dealId: string, ): Promise<{ before: Deal } | null> { const [before] = await db - .select() - .from(deals) + .delete(deals) .where(and(eq(deals.id, dealId), eq(deals.organizationId, organizationId))) - .limit(1); - if (!before) return null; - await db.delete(deals).where(eq(deals.id, dealId)); - return { before }; + .returning(); + return before ? { before } : null; } diff --git a/lib/meetings.ts b/lib/meetings.ts index 7ed2b18..d5a74e0 100644 --- a/lib/meetings.ts +++ b/lib/meetings.ts @@ -10,11 +10,8 @@ export async function deleteMeeting( meetingId: string, ): Promise<{ before: Meeting } | null> { const [before] = await db - .select() - .from(meetings) + .delete(meetings) .where(and(eq(meetings.id, meetingId), eq(meetings.organizationId, organizationId))) - .limit(1); - if (!before) return null; - await db.delete(meetings).where(eq(meetings.id, meetingId)); - return { before }; + .returning(); + return before ? { before } : null; } diff --git a/lib/notes.ts b/lib/notes.ts index 877527a..09fec17 100644 --- a/lib/notes.ts +++ b/lib/notes.ts @@ -10,11 +10,8 @@ export async function deleteNote( noteId: string, ): Promise<{ before: Note } | null> { const [before] = await db - .select() - .from(notes) + .delete(notes) .where(and(eq(notes.id, noteId), eq(notes.organizationId, organizationId))) - .limit(1); - if (!before) return null; - await db.delete(notes).where(eq(notes.id, noteId)); - return { before }; + .returning(); + return before ? { before } : null; } diff --git a/lib/signals.ts b/lib/signals.ts index ed81dea..ebbdaba 100644 --- a/lib/signals.ts +++ b/lib/signals.ts @@ -10,11 +10,8 @@ export async function deleteSignal( signalId: string, ): Promise<{ before: Signal } | null> { const [before] = await db - .select() - .from(signals) + .delete(signals) .where(and(eq(signals.id, signalId), eq(signals.organizationId, organizationId))) - .limit(1); - if (!before) return null; - await db.delete(signals).where(eq(signals.id, signalId)); - return { before }; + .returning(); + return before ? { before } : null; } diff --git a/lib/tasks.ts b/lib/tasks.ts index 8660e57..521bd23 100644 --- a/lib/tasks.ts +++ b/lib/tasks.ts @@ -10,11 +10,8 @@ export async function deleteTask( taskId: string, ): Promise<{ before: Task } | null> { const [before] = await db - .select() - .from(tasks) + .delete(tasks) .where(and(eq(tasks.id, taskId), eq(tasks.organizationId, organizationId))) - .limit(1); - if (!before) return null; - await db.delete(tasks).where(eq(tasks.id, taskId)); - return { before }; + .returning(); + return before ? { before } : null; }