adding multi organisation connect

This commit is contained in:
Khalim Conn-Kowlessar 2026-05-07 12:32:20 +00:00
parent 49519a8d82
commit 5f0617b691
5 changed files with 321 additions and 145 deletions

View file

@ -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<string, unknown> = {};
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<string, unknown> = {};
self["values"] = vi.fn(() => Promise.resolve([]));
return self;
}
function makeDeleteChain() {
const self: Record<string, unknown> = {};
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);
});
});

View file

@ -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 });
}

View file

@ -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<typeof portfolioOrganisation, "select">;
export type NewPortfolioOrganisation = InferModel<typeof portfolioOrganisation, "insert">;

View file

@ -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<OrgSummary | null> {
async function fetchLinkedOrgs(portfolioId: string): Promise<OrgSummary[]> {
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<OrgSummary[]> {
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<OrgSummary | null>(null);
const [selectedOrgId, setSelectedOrgId] = useState<string | null>(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 (
<div className="rounded-xl border border-brandblue/15 bg-white shadow-sm mt-4 overflow-hidden">
{/* Header */}
<div className="flex items-center gap-3 px-5 py-4 border-b border-gray-100 bg-brandlightblue/20">
<div className="p-2 rounded-lg bg-brandblue/10">
<Building2 className="h-4 w-4 text-brandblue" />
</div>
<div>
<p className="text-sm font-semibold text-brandblue">Organisation Link</p>
<p className="text-xs text-gray-500 mt-0.5">
Connect this portfolio to an organisation to enable live project tracking
</p>
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-100 bg-brandlightblue/20">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-brandblue/10">
<Building2 className="h-4 w-4 text-brandblue" />
</div>
<div>
<p className="text-sm font-semibold text-brandblue">Organisation Links</p>
<p className="text-xs text-gray-500 mt-0.5">
Connect this portfolio to one or more organisations to enable live project tracking
</p>
</div>
</div>
<Button
size="sm"
className="h-8 text-xs bg-brandblue hover:bg-brandmidblue"
onClick={openAdd}
>
<PlusCircle className="h-3.5 w-3.5 mr-1.5" />
Add Organisation
</Button>
</div>
{/* Body */}
<div className="px-5 py-4">
{loadingCurrent ? (
<div className="px-5 py-4 space-y-2">
{loadingLinked ? (
<div className="h-10 bg-gray-100 rounded-lg animate-pulse w-48" />
) : currentOrg ? (
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-3">
<CheckCircle2 className="h-5 w-5 text-emerald-500 shrink-0" />
<div>
<p className="text-sm font-semibold text-gray-800">{currentOrg.name ?? "Unnamed organisation"}</p>
<p className="text-xs text-gray-400 mt-0.5">
Connected · HubSpot ID: {currentOrg.hubspotCompanyId ?? "—"}
</p>
</div>
) : linkedOrgs.length === 0 ? (
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-gray-100 flex items-center justify-center">
<Building2 className="h-4 w-4 text-gray-400" />
</div>
<div className="flex items-center gap-2">
<Button
size="sm"
variant="outline"
className="h-8 text-xs border-brandblue/20 text-brandblue hover:bg-brandlightblue/30"
onClick={() => {
setConnectOpen(true);
setSelectedOrgId(null);
setConfirmed(false);
setSearchQuery("");
}}
>
<Link2 className="h-3.5 w-3.5 mr-1.5" />
Change
</Button>
<p className="text-sm text-gray-500">No organisations linked</p>
</div>
) : (
linkedOrgs.map((org) => (
<div key={org.id} className="flex items-center justify-between gap-4 py-1">
<div className="flex items-center gap-3">
<CheckCircle2 className="h-5 w-5 text-emerald-500 shrink-0" />
<div>
<p className="text-sm font-semibold text-gray-800">{org.name ?? "Unnamed organisation"}</p>
<p className="text-xs text-gray-400 mt-0.5">
Connected · HubSpot ID: {org.hubspotCompanyId ?? "—"}
</p>
</div>
</div>
<Button
size="sm"
variant="outline"
className="h-8 text-xs border-red-200 text-red-600 hover:bg-red-50"
onClick={() => setDisconnectOpen(true)}
onClick={() => setDisconnectTarget(org)}
>
<Link2Off className="h-3.5 w-3.5 mr-1.5" />
Disconnect
</Button>
</div>
</div>
) : (
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-gray-100 flex items-center justify-center">
<Building2 className="h-4 w-4 text-gray-400" />
</div>
<p className="text-sm text-gray-500">No organisation linked</p>
</div>
<Button
size="sm"
className="h-8 text-xs bg-brandblue hover:bg-brandmidblue"
onClick={() => {
setConnectOpen(true);
setSelectedOrgId(null);
setConfirmed(false);
setSearchQuery("");
}}
>
<Link2 className="h-3.5 w-3.5 mr-1.5" />
Connect Organisation
</Button>
</div>
))
)}
</div>
{/* ── Connect modal ─────────────────────────────────────────────── */}
<Dialog open={connectOpen} onOpenChange={(v) => { setConnectOpen(v); if (!v) { setSelectedOrgId(null); setConfirmed(false); setSearchQuery(""); } }}>
{/* ── Add Organisation modal ─────────────────────────────────────────────── */}
<Dialog open={addOpen} onOpenChange={(v) => { setAddOpen(v); if (!v) { setSelectedOrgId(null); setConfirmed(false); setSearchQuery(""); } }}>
<DialogContent className="max-w-md">
<DialogTitle className="text-brandblue">Connect Organisation</DialogTitle>
<DialogTitle className="text-brandblue">Add Organisation</DialogTitle>
{/* Search */}
<div className="relative mt-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400 pointer-events-none" />
<Input
@ -192,14 +178,13 @@ export default function OrganisationLinkCard({ portfolioId }: { portfolioId: str
/>
</div>
{/* Org list */}
<div className="mt-2 max-h-56 overflow-y-auto rounded-lg border border-gray-200 divide-y divide-gray-100">
{loadingOrgs ? (
<div className="p-4 text-sm text-gray-400 text-center">Loading</div>
) : filteredOrgs.length === 0 ? (
<div className="p-4 text-sm text-gray-400 text-center">No organisations found</div>
) : availableOrgs.length === 0 ? (
<div className="p-4 text-sm text-gray-400 text-center">No organisations available</div>
) : (
filteredOrgs.map((org) => (
availableOrgs.map((org) => (
<button
key={org.id}
onClick={() => setSelectedOrgId(org.id)}
@ -218,7 +203,6 @@ export default function OrganisationLinkCard({ portfolioId }: { portfolioId: str
)}
</div>
{/* Warning */}
<div className="flex items-start gap-2.5 p-3 rounded-lg bg-amber-50 border border-amber-200 mt-1">
<AlertTriangle className="h-4 w-4 text-amber-500 mt-0.5 shrink-0" />
<p className="text-xs text-amber-700 leading-relaxed">
@ -226,7 +210,6 @@ export default function OrganisationLinkCard({ portfolioId }: { portfolioId: str
</p>
</div>
{/* Confirmation checkbox */}
<label className="flex items-center gap-2.5 cursor-pointer select-none mt-1">
<input
type="checkbox"
@ -238,7 +221,7 @@ export default function OrganisationLinkCard({ portfolioId }: { portfolioId: str
</label>
<DialogFooter className="mt-2">
<Button variant="outline" onClick={() => setConnectOpen(false)} className="text-sm">
<Button variant="outline" onClick={() => setAddOpen(false)} className="text-sm">
Cancel
</Button>
<Button
@ -252,21 +235,21 @@ export default function OrganisationLinkCard({ portfolioId }: { portfolioId: str
</DialogContent>
</Dialog>
{/* ── Disconnect confirm dialog ──────────────────────────────────── */}
<Dialog open={disconnectOpen} onOpenChange={setDisconnectOpen}>
{/* ── Disconnect confirm dialog ──────────────────────────────────────────── */}
<Dialog open={!!disconnectTarget} onOpenChange={(v) => { if (!v) setDisconnectTarget(null); }}>
<DialogContent className="max-w-sm">
<DialogTitle className="text-gray-800">Disconnect organisation?</DialogTitle>
<p className="text-sm text-gray-600 leading-relaxed">
Are you sure you want to disconnect{" "}
<strong>{currentOrg?.name ?? "this organisation"}</strong>?
Live project tracking data will no longer be visible to portfolio viewers.
<strong>{disconnectTarget?.name ?? "this organisation"}</strong>?
Live project tracking data for this organisation will no longer be visible to portfolio viewers.
</p>
<DialogFooter className="mt-2">
<Button variant="outline" onClick={() => setDisconnectOpen(false)} className="text-sm">
<Button variant="outline" onClick={() => setDisconnectTarget(null)} className="text-sm">
Cancel
</Button>
<Button
onClick={() => disconnectMutation.mutate()}
onClick={() => disconnectTarget && disconnectMutation.mutate(disconnectTarget.id)}
disabled={disconnectMutation.isPending}
className="bg-red-600 hover:bg-red-700 text-white text-sm"
>

View file

@ -101,13 +101,12 @@ export default async function LiveReportingPage(props: {
redirect("/");
}
// Look up the linked organisation for this portfolio
// Look up all linked organisations for this portfolio
const link = await db
.select({ hubspotCompanyId: organisation.hubspotCompanyId })
.from(portfolioOrganisation)
.innerJoin(organisation, eq(portfolioOrganisation.organisationId, organisation.id))
.where(eq(portfolioOrganisation.portfolioId, BigInt(portfolioId)))
.limit(1);
.where(eq(portfolioOrganisation.portfolioId, BigInt(portfolioId)));
const pageHeader = (
<div className="mb-6">
@ -119,7 +118,9 @@ export default async function LiveReportingPage(props: {
</div>
);
if (!link.length || !link[0].hubspotCompanyId) {
const companyIds = link.map((l) => l.hubspotCompanyId).filter((id): id is string => !!id);
if (companyIds.length === 0) {
return (
<div className="max-w-7xl mx-auto px-6 pb-10 space-y-4">
{pageHeader}
@ -141,8 +142,6 @@ export default async function LiveReportingPage(props: {
);
}
const companyId = link[0].hubspotCompanyId;
const rawDeals = await db
.select({
deal: hubspotDealData,
@ -152,7 +151,7 @@ export default async function LiveReportingPage(props: {
.from(hubspotDealData)
.leftJoin(coordinatorUser, eq(hubspotDealData.coordinator, coordinatorUser.hubspotOwnerId))
.leftJoin(designerUser, eq(hubspotDealData.designer, designerUser.hubspotOwnerId))
.where(eq(hubspotDealData.companyId, companyId));
.where(inArray(hubspotDealData.companyId, companyIds));
const deals = rawDeals.map(mapDbRowToHubspotDeal);
const trackerData = computeLiveTrackerData(deals);