diff --git a/src/app/api/portfolio/[portfolioId]/organisation/route.test.ts b/src/app/api/portfolio/[portfolioId]/organisation/route.test.ts new file mode 100644 index 00000000..a59d9b22 --- /dev/null +++ b/src/app/api/portfolio/[portfolioId]/organisation/route.test.ts @@ -0,0 +1,181 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +// ── Hoisted mocks ───────────────────────────────────────────────────────────── +const { + mockGetServerSession, + mockDbSelect, + mockDbInsert, + mockDbDelete, +} = vi.hoisted(() => ({ + mockGetServerSession: vi.fn(), + mockDbSelect: vi.fn(), + mockDbInsert: vi.fn(), + mockDbDelete: vi.fn(), +})); + +vi.mock("next-auth", () => ({ getServerSession: mockGetServerSession })); +vi.mock("@/app/api/auth/[...nextauth]/authOptions", () => ({ AuthOptions: {} })); +vi.mock("drizzle-orm", () => ({ + and: vi.fn((...args: unknown[]) => ({ $and: args })), + eq: vi.fn((a: unknown, b: unknown) => ({ $eq: [a, b] })), + inArray: vi.fn((col: unknown, vals: unknown) => ({ $inArray: [col, vals] })), +})); +vi.mock("@/app/db/schema/portfolio_organisation", () => ({ + portfolioOrganisation: { + portfolioId: {}, + organisationId: {}, + id: {}, + }, +})); +vi.mock("@/app/db/schema/organisation", () => ({ + organisation: { id: {}, name: {}, hubspotCompanyId: {} }, +})); +vi.mock("@/app/db/db", () => ({ + db: { + get select() { return mockDbSelect; }, + get insert() { return mockDbInsert; }, + get delete() { return mockDbDelete; }, + }, +})); + +// ── Chain builders ──────────────────────────────────────────────────────────── +function makeSelectChain(rows: unknown[]) { + const self: Record = {}; + self["then"] = (resolve: (v: unknown) => unknown, reject: (e: unknown) => unknown) => + Promise.resolve(rows).then(resolve, reject); + self["from"] = vi.fn(() => self); + self["innerJoin"] = vi.fn(() => self); + self["leftJoin"] = vi.fn(() => self); + self["where"] = vi.fn(() => self); + self["limit"] = vi.fn(() => Promise.resolve(rows)); + return self; +} + +function makeInsertChain() { + const self: Record = {}; + self["values"] = vi.fn(() => Promise.resolve([])); + return self; +} + +function makeDeleteChain() { + const self: Record = {}; + self["where"] = vi.fn(() => Promise.resolve([])); + return self; +} + +function makeParams(portfolioId = "42") { + return Promise.resolve({ portfolioId }); +} + +function makeRequest(method: string, body?: unknown, portfolioId = "42") { + return new NextRequest( + `http://localhost/api/portfolio/${portfolioId}/organisation`, + { + method, + ...(body ? { body: JSON.stringify(body), headers: { "content-type": "application/json" } } : {}), + }, + ); +} + +// ── Subject under test ──────────────────────────────────────────────────────── +import { GET, POST, DELETE } from "./route"; + +describe("GET /portfolio/:id/organisation", () => { + beforeEach(() => vi.clearAllMocks()); + + it("returns empty array when no orgs linked", async () => { + mockDbSelect.mockImplementationOnce(() => makeSelectChain([])); + const res = await GET(makeRequest("GET"), { params: makeParams() }); + expect(res.status).toBe(200); + const json = await res.json(); + expect(json).toEqual([]); + }); + + it("returns all linked orgs as array", async () => { + const orgs = [ + { id: "org-1", name: "Alpha Housing", hubspotCompanyId: "hs-1" }, + { id: "org-2", name: "Beta Council", hubspotCompanyId: "hs-2" }, + ]; + mockDbSelect.mockImplementationOnce(() => makeSelectChain(orgs)); + const res = await GET(makeRequest("GET"), { params: makeParams() }); + expect(res.status).toBe(200); + const json = await res.json(); + expect(json).toHaveLength(2); + expect(json[0].name).toBe("Alpha Housing"); + expect(json[1].name).toBe("Beta Council"); + }); +}); + +describe("POST /portfolio/:id/organisation", () => { + beforeEach(() => vi.clearAllMocks()); + + it("returns 403 for non-Domna user", async () => { + mockGetServerSession.mockResolvedValue({ user: { email: "outsider@other.com" } }); + const res = await POST(makeRequest("POST", { organisationId: "org-1" }), { params: makeParams() }); + expect(res.status).toBe(403); + }); + + it("returns 400 when organisationId missing", async () => { + mockGetServerSession.mockResolvedValue({ user: { email: "admin@domna.homes" } }); + const res = await POST(makeRequest("POST", {}), { params: makeParams() }); + expect(res.status).toBe(400); + }); + + it("returns 409 when org already linked to this portfolio", async () => { + mockGetServerSession.mockResolvedValue({ user: { email: "admin@domna.homes" } }); + // existing link found + mockDbSelect.mockImplementationOnce(() => makeSelectChain([{ id: "link-1" }])); + const res = await POST(makeRequest("POST", { organisationId: "org-1" }), { params: makeParams() }); + expect(res.status).toBe(409); + }); + + it("adds org without removing existing links", async () => { + mockGetServerSession.mockResolvedValue({ user: { email: "admin@domna.homes" } }); + // no existing link for this org + mockDbSelect.mockImplementationOnce(() => makeSelectChain([])); + const insertChain = makeInsertChain(); + mockDbInsert.mockImplementationOnce(() => insertChain); + // return updated list + mockDbSelect.mockImplementationOnce(() => + makeSelectChain([ + { id: "org-1", name: "Alpha Housing", hubspotCompanyId: "hs-1" }, + { id: "org-2", name: "Beta Council", hubspotCompanyId: "hs-2" }, + ]), + ); + const res = await POST(makeRequest("POST", { organisationId: "org-2" }), { params: makeParams() }); + expect(res.status).toBe(200); + // insert called — no delete called + expect(mockDbDelete).not.toHaveBeenCalled(); + expect(insertChain.values).toHaveBeenCalled(); + const json = await res.json(); + expect(json).toHaveLength(2); + }); +}); + +describe("DELETE /portfolio/:id/organisation", () => { + beforeEach(() => vi.clearAllMocks()); + + it("returns 403 for non-Domna user", async () => { + mockGetServerSession.mockResolvedValue({ user: { email: "outsider@other.com" } }); + const res = await DELETE(makeRequest("DELETE", { organisationId: "org-1" }), { params: makeParams() }); + expect(res.status).toBe(403); + }); + + it("removes the specific org link", async () => { + mockGetServerSession.mockResolvedValue({ user: { email: "admin@domna.homes" } }); + const deleteChain = makeDeleteChain(); + mockDbDelete.mockImplementationOnce(() => deleteChain); + const res = await DELETE(makeRequest("DELETE", { organisationId: "org-1" }), { params: makeParams() }); + expect(res.status).toBe(200); + expect(deleteChain.where).toHaveBeenCalled(); + const json = await res.json(); + expect(json.success).toBe(true); + }); + + it("returns 400 when organisationId missing", async () => { + mockGetServerSession.mockResolvedValue({ user: { email: "admin@domna.homes" } }); + const res = await DELETE(makeRequest("DELETE", {}), { params: makeParams() }); + expect(res.status).toBe(400); + }); +}); diff --git a/src/app/api/portfolio/[portfolioId]/organisation/route.ts b/src/app/api/portfolio/[portfolioId]/organisation/route.ts index f6bf22f4..02f985f9 100644 --- a/src/app/api/portfolio/[portfolioId]/organisation/route.ts +++ b/src/app/api/portfolio/[portfolioId]/organisation/route.ts @@ -1,7 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { getServerSession } from "next-auth"; import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions"; -import { eq } from "drizzle-orm"; +import { and, eq } from "drizzle-orm"; import { db } from "@/app/db/db"; import { portfolioOrganisation } from "@/app/db/schema/portfolio_organisation"; import { organisation } from "@/app/db/schema/organisation"; @@ -10,14 +10,8 @@ function isDomnaUser(email: string | null | undefined): boolean { return !!email?.endsWith("@domna.homes"); } -// GET — fetch the current linked organisation for this portfolio -export async function GET( - _req: NextRequest, - { params }: { params: Promise<{ portfolioId: string }> }, -) { - const { portfolioId } = await params; - - const rows = await db +function linkedOrgsQuery(portfolioId: string) { + return db .select({ id: organisation.id, name: organisation.name, @@ -25,13 +19,20 @@ export async function GET( }) .from(portfolioOrganisation) .innerJoin(organisation, eq(portfolioOrganisation.organisationId, organisation.id)) - .where(eq(portfolioOrganisation.portfolioId, BigInt(portfolioId))) - .limit(1); - - return NextResponse.json(rows[0] ?? null); + .where(eq(portfolioOrganisation.portfolioId, BigInt(portfolioId))); } -// POST — connect an organisation to this portfolio (Domna only) +// GET — fetch all linked organisations for this portfolio +export async function GET( + _req: NextRequest, + { params }: { params: Promise<{ portfolioId: string }> }, +) { + const { portfolioId } = await params; + const rows = await linkedOrgsQuery(portfolioId); + return NextResponse.json(rows); +} + +// POST — add an organisation link (Domna only, rejects duplicates) export async function POST( req: NextRequest, { params }: { params: Promise<{ portfolioId: string }> }, @@ -43,40 +44,40 @@ export async function POST( const { portfolioId } = await params; const body = await req.json(); - const { organisationId } = body as { organisationId: string }; + const { organisationId } = body as { organisationId?: string }; if (!organisationId) { return NextResponse.json({ error: "organisationId required" }, { status: 400 }); } - // Upsert: delete any existing link then insert fresh - await db - .delete(portfolioOrganisation) - .where(eq(portfolioOrganisation.portfolioId, BigInt(portfolioId))); + // Reject if this org is already linked to this portfolio + const existing = await db + .select({ id: portfolioOrganisation.id }) + .from(portfolioOrganisation) + .where( + and( + eq(portfolioOrganisation.portfolioId, BigInt(portfolioId)), + eq(portfolioOrganisation.organisationId, organisationId), + ), + ) + .limit(1); + + if (existing.length > 0) { + return NextResponse.json({ error: "Organisation already linked" }, { status: 409 }); + } await db.insert(portfolioOrganisation).values({ portfolioId: BigInt(portfolioId), organisationId, }); - // Return the newly linked org - const rows = await db - .select({ - id: organisation.id, - name: organisation.name, - hubspotCompanyId: organisation.hubspotCompanyId, - }) - .from(portfolioOrganisation) - .innerJoin(organisation, eq(portfolioOrganisation.organisationId, organisation.id)) - .where(eq(portfolioOrganisation.portfolioId, BigInt(portfolioId))) - .limit(1); - - return NextResponse.json(rows[0] ?? null); + const rows = await linkedOrgsQuery(portfolioId); + return NextResponse.json(rows); } -// DELETE — disconnect the organisation from this portfolio (Domna only) +// DELETE — remove a specific organisation link (Domna only) export async function DELETE( - _req: NextRequest, + req: NextRequest, { params }: { params: Promise<{ portfolioId: string }> }, ) { const session = await getServerSession(AuthOptions); @@ -85,10 +86,21 @@ export async function DELETE( } const { portfolioId } = await params; + const body = await req.json().catch(() => ({})); + const { organisationId } = body as { organisationId?: string }; + + if (!organisationId) { + return NextResponse.json({ error: "organisationId required" }, { status: 400 }); + } await db .delete(portfolioOrganisation) - .where(eq(portfolioOrganisation.portfolioId, BigInt(portfolioId))); + .where( + and( + eq(portfolioOrganisation.portfolioId, BigInt(portfolioId)), + eq(portfolioOrganisation.organisationId, organisationId), + ), + ); return NextResponse.json({ success: true }); } diff --git a/src/app/db/schema/portfolio_organisation.ts b/src/app/db/schema/portfolio_organisation.ts index 246e3e49..84b1d0b3 100644 --- a/src/app/db/schema/portfolio_organisation.ts +++ b/src/app/db/schema/portfolio_organisation.ts @@ -1,4 +1,4 @@ -import { pgTable, bigint, uuid, timestamp } from "drizzle-orm/pg-core"; +import { pgTable, bigint, uuid, timestamp, unique } from "drizzle-orm/pg-core"; import { portfolio } from "./portfolio"; import { organisation } from "./organisation"; import { InferModel } from "drizzle-orm"; @@ -7,8 +7,7 @@ export const portfolioOrganisation = pgTable("portfolio_organisation", { id: uuid("id").defaultRandom().primaryKey(), portfolioId: bigint("portfolio_id", { mode: "bigint" }) .notNull() - .references(() => portfolio.id, { onDelete: "cascade" }) - .unique(), // one organisation per portfolio + .references(() => portfolio.id, { onDelete: "cascade" }), organisationId: uuid("organisation_id") .notNull() .references(() => organisation.id, { onDelete: "cascade" }), @@ -18,7 +17,9 @@ export const portfolioOrganisation = pgTable("portfolio_organisation", { updatedAt: timestamp("updated_at", { precision: 6, withTimezone: true }) .defaultNow() .notNull(), -}); +}, (t) => ({ + portfolioOrgUnique: unique().on(t.portfolioId, t.organisationId), +})); export type PortfolioOrganisation = InferModel; export type NewPortfolioOrganisation = InferModel; diff --git a/src/app/portfolio/[slug]/(portfolio)/settings/OrganisationLinkCard.tsx b/src/app/portfolio/[slug]/(portfolio)/settings/OrganisationLinkCard.tsx index 176047c3..0dc5590f 100644 --- a/src/app/portfolio/[slug]/(portfolio)/settings/OrganisationLinkCard.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/settings/OrganisationLinkCard.tsx @@ -2,7 +2,7 @@ import { useState, useMemo } from "react"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; -import { Building2, CheckCircle2, Link2, Link2Off, AlertTriangle, Search } from "lucide-react"; +import { Building2, Link2, Link2Off, AlertTriangle, Search, CheckCircle2, PlusCircle } from "lucide-react"; import { Button } from "@/app/shadcn_components/ui/button"; import { Input } from "@/app/shadcn_components/ui/input"; import { @@ -18,9 +18,9 @@ type OrgSummary = { hubspotCompanyId: string | null; }; -async function fetchCurrentOrg(portfolioId: string): Promise { +async function fetchLinkedOrgs(portfolioId: string): Promise { const res = await fetch(`/api/portfolio/${portfolioId}/organisation`); - if (!res.ok) throw new Error("Failed to fetch linked organisation"); + if (!res.ok) throw new Error("Failed to fetch linked organisations"); return res.json(); } @@ -33,25 +33,25 @@ async function fetchAllOrgs(): Promise { export default function OrganisationLinkCard({ portfolioId }: { portfolioId: string }) { const queryClient = useQueryClient(); - const [connectOpen, setConnectOpen] = useState(false); - const [disconnectOpen, setDisconnectOpen] = useState(false); + const [addOpen, setAddOpen] = useState(false); + const [disconnectTarget, setDisconnectTarget] = useState(null); const [selectedOrgId, setSelectedOrgId] = useState(null); const [searchQuery, setSearchQuery] = useState(""); const [confirmed, setConfirmed] = useState(false); - // Current linked org - const { data: currentOrg, isLoading: loadingCurrent } = useQuery({ - queryKey: ["portfolio-org", portfolioId], - queryFn: () => fetchCurrentOrg(portfolioId), + const { data: linkedOrgs = [], isLoading: loadingLinked } = useQuery({ + queryKey: ["portfolio-orgs", portfolioId], + queryFn: () => fetchLinkedOrgs(portfolioId), }); - // All orgs — only fetched when connect modal is open const { data: allOrgs = [], isLoading: loadingOrgs } = useQuery({ queryKey: ["all-organisations"], queryFn: fetchAllOrgs, - enabled: connectOpen, + enabled: addOpen, }); + const linkedOrgIds = useMemo(() => new Set(linkedOrgs.map((o) => o.id)), [linkedOrgs]); + const connectMutation = useMutation({ mutationFn: async (organisationId: string) => { const res = await fetch(`/api/portfolio/${portfolioId}/organisation`, { @@ -63,8 +63,8 @@ export default function OrganisationLinkCard({ portfolioId }: { portfolioId: str return res.json(); }, onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["portfolio-org", portfolioId] }); - setConnectOpen(false); + queryClient.invalidateQueries({ queryKey: ["portfolio-orgs", portfolioId] }); + setAddOpen(false); setSelectedOrgId(null); setConfirmed(false); setSearchQuery(""); @@ -72,116 +72,102 @@ export default function OrganisationLinkCard({ portfolioId }: { portfolioId: str }); const disconnectMutation = useMutation({ - mutationFn: async () => { + mutationFn: async (organisationId: string) => { const res = await fetch(`/api/portfolio/${portfolioId}/organisation`, { method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ organisationId }), }); if (!res.ok) throw new Error("Failed to disconnect organisation"); return res.json(); }, onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["portfolio-org", portfolioId] }); - setDisconnectOpen(false); + queryClient.invalidateQueries({ queryKey: ["portfolio-orgs", portfolioId] }); + setDisconnectTarget(null); }, }); - const filteredOrgs = useMemo( - () => - allOrgs.filter((o) => - (o.name ?? "").toLowerCase().includes(searchQuery.toLowerCase()), - ), - [allOrgs, searchQuery], + const availableOrgs = useMemo( + () => allOrgs.filter((o) => !linkedOrgIds.has(o.id) && (o.name ?? "").toLowerCase().includes(searchQuery.toLowerCase())), + [allOrgs, linkedOrgIds, searchQuery], ); const selectedOrg = allOrgs.find((o) => o.id === selectedOrgId) ?? null; + function openAdd() { + setAddOpen(true); + setSelectedOrgId(null); + setConfirmed(false); + setSearchQuery(""); + } + return (
{/* Header */} -
-
- -
-
-

Organisation Link

-

- Connect this portfolio to an organisation to enable live project tracking -

+
+
+
+ +
+
+

Organisation Links

+

+ Connect this portfolio to one or more organisations to enable live project tracking +

+
+
{/* Body */} -
- {loadingCurrent ? ( +
+ {loadingLinked ? (
- ) : currentOrg ? ( -
-
- -
-

{currentOrg.name ?? "Unnamed organisation"}

-

- Connected · HubSpot ID: {currentOrg.hubspotCompanyId ?? "—"} -

-
+ ) : linkedOrgs.length === 0 ? ( +
+
+
-
- +

No organisations linked

+
+ ) : ( + linkedOrgs.map((org) => ( +
+
+ +
+

{org.name ?? "Unnamed organisation"}

+

+ Connected · HubSpot ID: {org.hubspotCompanyId ?? "—"} +

+
+
-
- ) : ( -
-
-
- -
-

No organisation linked

-
- -
+ )) )}
- {/* ── Connect modal ─────────────────────────────────────────────── */} - { setConnectOpen(v); if (!v) { setSelectedOrgId(null); setConfirmed(false); setSearchQuery(""); } }}> + {/* ── Add Organisation modal ─────────────────────────────────────────────── */} + { setAddOpen(v); if (!v) { setSelectedOrgId(null); setConfirmed(false); setSearchQuery(""); } }}> - Connect Organisation + Add Organisation - {/* Search */}
- {/* Org list */}
{loadingOrgs ? (
Loading…
- ) : filteredOrgs.length === 0 ? ( -
No organisations found
+ ) : availableOrgs.length === 0 ? ( +
No organisations available
) : ( - filteredOrgs.map((org) => ( + availableOrgs.map((org) => (
- {/* Warning */}

@@ -226,7 +210,6 @@ export default function OrganisationLinkCard({ portfolioId }: { portfolioId: str

- {/* Confirmation checkbox */}
- {/* ── Disconnect confirm dialog ──────────────────────────────────── */} - + {/* ── Disconnect confirm dialog ──────────────────────────────────────────── */} + { if (!v) setDisconnectTarget(null); }}> Disconnect organisation?

Are you sure you want to disconnect{" "} - {currentOrg?.name ?? "this organisation"}? - Live project tracking data will no longer be visible to portfolio viewers. + {disconnectTarget?.name ?? "this organisation"}? + Live project tracking data for this organisation will no longer be visible to portfolio viewers.

-