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
7 changes: 6 additions & 1 deletion app/(app)/companies/[id]/deals/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -41,13 +42,14 @@ export default async function DealsTab({ params }: { params: Promise<{ id: strin
{h}
</th>
))}
<th className="w-8 px-3 py-2" />
</tr>
</thead>
<tbody>
{rows.map((d) => (
<tr
key={d.id}
className="border-b border-border-subtle transition hover:bg-surface-hover"
className="group border-b border-border-subtle transition hover:bg-surface-hover"
>
<td className="px-3 py-2.5 font-medium text-text">{d.name}</td>
<td className="px-3 py-2.5">
Expand All @@ -62,6 +64,9 @@ export default async function DealsTab({ params }: { params: Promise<{ id: strin
<td className="px-3 py-2.5 text-[11px] text-text-muted">
{relativeTime(d.expectedCloseDate)}
</td>
<td className="px-3 py-2.5">
<DeleteDealButton dealId={d.id} dealName={d.name} />
</td>
</tr>
))}
</tbody>
Expand Down
2 changes: 2 additions & 0 deletions app/(app)/companies/[id]/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -64,6 +65,7 @@ export default async function CompanyDetailLayout({
</div>
</div>
</div>
<DeleteCompanyButton companyId={company.id} companyName={company.name} />
</div>
<CompanyTabs companyId={company.id} />
</div>
Expand Down
26 changes: 26 additions & 0 deletions app/(app)/companies/[id]/notes/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -39,3 +40,28 @@ export async function addCompanyNoteAction(formData: FormData): Promise<void> {
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 };
}
30 changes: 30 additions & 0 deletions app/(app)/companies/[id]/notes/delete-note-button.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
<button
type="button"
aria-label="Delete note"
onClick={() => setOpen(true)}
className="rounded p-1 text-text-subtle opacity-0 transition group-hover:opacity-100 hover:bg-surface-hover hover:text-destructive"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
<DeleteConfirmDialog
open={open}
onOpenChange={setOpen}
title="Delete note?"
description="This note will be permanently deleted. This cannot be undone."
onConfirm={() => deleteNoteAction({ noteId })}
/>
</>
);
}
6 changes: 5 additions & 1 deletion app/(app)/companies/[id]/notes/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }> }) {
Expand All @@ -23,12 +24,15 @@ export default async function NotesTab({ params }: { params: Promise<{ id: strin
{rows.map((n) => (
<li
key={n.id}
className="rounded-lg border border-border bg-surface px-4 py-3"
className="group rounded-lg border border-border bg-surface px-4 py-3"
>
<div className="flex items-center gap-2 text-[12px]">
<Avatar name={n.author === "agent" ? "Claude" : "User"} size={22} />
<span className="font-medium capitalize text-text">{n.author}</span>
<span className="text-text-subtle">{relativeTime(n.createdAt)}</span>
<span className="ml-auto">
<DeleteNoteButton noteId={n.id} />
</span>
</div>
<p className="mt-2 whitespace-pre-wrap text-[13px] leading-relaxed text-text-muted">
{n.body}
Expand Down
30 changes: 17 additions & 13 deletions app/(app)/companies/[id]/people/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -37,7 +38,7 @@ export default async function PeopleTab({ params }: { params: Promise<{ id: stri
{rows.map((p) => (
<tr
key={p.id}
className="cursor-pointer border-b border-border-subtle transition hover:bg-surface-hover"
className="group cursor-pointer border-b border-border-subtle transition hover:bg-surface-hover"
>
<td className="px-3 py-2.5">
<Link
Expand All @@ -64,18 +65,21 @@ export default async function PeopleTab({ params }: { params: Promise<{ id: stri
{relativeTime(p.lastInteractionAt)}
</td>
<td className="px-3 py-2.5 text-right">
{p.linkedinUrl ? (
<a
href={p.linkedinUrl}
target="_blank"
rel="noreferrer"
className="inline-flex text-text-subtle opacity-50 hover:opacity-100"
>
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
<path d="M3.5 2A1.5 1.5 0 102 3.5 1.5 1.5 0 003.5 2zm-1 3.5h2V14h-2zm3.5 0h2v1.4h.03A2.4 2.4 0 0110.2 5c2.2 0 2.6 1.45 2.6 3.33V14h-2V8.8c0-.8-.01-1.83-1.11-1.83s-1.29.87-1.29 1.77V14H6V5.5z" />
</svg>
</a>
) : null}
<div className="inline-flex items-center gap-1.5">
{p.linkedinUrl ? (
<a
href={p.linkedinUrl}
target="_blank"
rel="noreferrer"
className="inline-flex text-text-subtle opacity-50 hover:opacity-100"
>
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
<path d="M3.5 2A1.5 1.5 0 102 3.5 1.5 1.5 0 003.5 2zm-1 3.5h2V14h-2zm3.5 0h2v1.4h.03A2.4 2.4 0 0110.2 5c2.2 0 2.6 1.45 2.6 3.33V14h-2V8.8c0-.8-.01-1.83-1.11-1.83s-1.29.87-1.29 1.77V14H6V5.5z" />
</svg>
</a>
) : null}
<DeletePersonButton personId={p.id} personName={p.name} />
</div>
</td>
</tr>
))}
Expand Down
32 changes: 32 additions & 0 deletions app/(app)/companies/[id]/signals/actions.ts
Original file line number Diff line number Diff line change
@@ -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 };
}
40 changes: 40 additions & 0 deletions app/(app)/companies/[id]/signals/delete-signal-button.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
<button
type="button"
aria-label="Delete signal"
onClick={() => setOpen(true)}
className="rounded p-1 text-text-subtle opacity-0 transition group-hover:opacity-100 hover:bg-surface-hover hover:text-destructive"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
<DeleteConfirmDialog
open={open}
onOpenChange={setOpen}
title="Delete signal?"
description={
<>
&ldquo;{signalTitle}&rdquo; will be permanently deleted. This cannot be undone.
</>
}
onConfirm={() => deleteSignalAction({ signalId })}
/>
</>
);
}
12 changes: 8 additions & 4 deletions app/(app)/companies/[id]/signals/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -22,17 +23,20 @@ export default async function SignalsTab({ params }: { params: Promise<{ id: str
return (
<ol className="px-6 py-5">
{rows.map((s) => (
<li key={s.id} className="flex gap-3.5 border-b border-border-subtle py-3.5">
<li key={s.id} className="group flex gap-3.5 border-b border-border-subtle py-3.5">
<div
className="mt-1 h-3 w-[3px] shrink-0 rounded"
style={{ background: "oklch(0.65 0.12 250)" }}
/>
<div className="flex-1">
<div className="flex items-baseline justify-between gap-3">
<h4 className="text-[13px] font-medium text-text">{s.title}</h4>
<span className="shrink-0 text-[11px] text-text-subtle">
{relativeTime(s.occurredAt)}
</span>
<div className="flex shrink-0 items-center gap-1.5">
<span className="text-[11px] text-text-subtle">
{relativeTime(s.occurredAt)}
</span>
<DeleteSignalButton signalId={s.id} signalTitle={s.title} />
</div>
</div>
<div className="mt-0.5 text-[10px] uppercase tracking-wider text-text-subtle">
{s.type.replace(/_/g, " ")}
Expand Down
70 changes: 70 additions & 0 deletions app/(app)/companies/actions.ts
Original file line number Diff line number Diff line change
@@ -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");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Missing revalidatePath for deleted company's detail page

deleteCompanyAction calls revalidatePath("/companies") which in Next.js App Router defaults to page-level revalidation and only invalidates the /companies list page — it does not clear cached RSC payloads for child routes like /companies/${companyId}. After deletion, navigating to the deleted company's URL could serve a stale cached page. Add revalidatePath(\/companies/${companyId}`)or pass'layout'as the second argument torevalidatePath("/companies", "layout")` to ensure the detail-page cache is purged.

revalidatePath("/deals");
revalidatePath("/meetings");
revalidatePath("/tasks");
revalidatePath("/people");
revalidatePath("/");
return { ok: true };
}
Loading
Loading