Updated survey request UI for Devon County Council

This commit is contained in:
Khalim Conn-Kowlessar 2026-05-06 20:37:30 +00:00
parent 70374d56f0
commit 19139b6253
10 changed files with 424 additions and 271 deletions

89
skills-lock.json Normal file
View file

@ -0,0 +1,89 @@
{
"version": 1,
"skills": {
"caveman": {
"source": "mattpocock/skills",
"sourceType": "github",
"skillPath": "skills/productivity/caveman/SKILL.md",
"computedHash": "934433479903febc585bf6deb5f0cebc63137e3f86b7babe0aab1ecb94d6d7a4"
},
"diagnose": {
"source": "mattpocock/skills",
"sourceType": "github",
"skillPath": "skills/engineering/diagnose/SKILL.md",
"computedHash": "15939a26f86edec2d4862042b8564e5a062cb81d04e047a0cea6305c8830b5f5"
},
"grill-me": {
"source": "mattpocock/skills",
"sourceType": "github",
"skillPath": "skills/productivity/grill-me/SKILL.md",
"computedHash": "784f0dbb7403b0f00324bce9a112f715342777a0daee7bbb7385f9c6f0a170ea"
},
"grill-with-docs": {
"source": "mattpocock/skills",
"sourceType": "github",
"skillPath": "skills/engineering/grill-with-docs/SKILL.md",
"computedHash": "31a5b1ae116558bf7d3f633f442835f54bd7645923d4f45c7823e52a97317666"
},
"improve-codebase-architecture": {
"source": "mattpocock/skills",
"sourceType": "github",
"skillPath": "skills/engineering/improve-codebase-architecture/SKILL.md",
"computedHash": "c77b86b4332919499608f9af1880074e1fec65a59b95c70c27a9f39cd137865e"
},
"ralph-loop": {
"source": "Hestia-Homes/agentic-toolkit",
"sourceType": "github",
"skillPath": "skills/engineering/ralph-loop/SKILL.md",
"computedHash": "6d45d44d84abf566d0f298af6b7d710e5f6ebaecb5a06c31fedacd20085ae88d"
},
"setup-matt-pocock-skills": {
"source": "mattpocock/skills",
"sourceType": "github",
"skillPath": "skills/engineering/setup-matt-pocock-skills/SKILL.md",
"computedHash": "3a32f8f1ed8160c9d286a2aabe88ee9b884c6f3f88a7a6c47b7d5d552c959587"
},
"tdd": {
"source": "mattpocock/skills",
"sourceType": "github",
"skillPath": "skills/engineering/tdd/SKILL.md",
"computedHash": "15a7b5e36383ebadb2dec5e586679e55e9663d292da418926b8da6fc0ef27d84"
},
"to-issues": {
"source": "mattpocock/skills",
"sourceType": "github",
"skillPath": "skills/engineering/to-issues/SKILL.md",
"computedHash": "73a91f30784523aa59ec9b02769576ebfc738e2cd5ad8f6441076031f0a5d5ac"
},
"to-prd": {
"source": "mattpocock/skills",
"sourceType": "github",
"skillPath": "skills/engineering/to-prd/SKILL.md",
"computedHash": "fd8c259f9c44eff08e29a1a2fc71a806a3568d279a55387a361f78620b10f2aa"
},
"to-project": {
"source": "Hestia-Homes/agentic-toolkit",
"sourceType": "github",
"skillPath": "skills/engineering/to-project/SKILL.md",
"computedHash": "59daf039ac699a44a9416f8ec403b83d4166e05489959e127746231ff8be4e12"
},
"triage": {
"source": "mattpocock/skills",
"sourceType": "github",
"skillPath": "skills/engineering/triage/SKILL.md",
"computedHash": "2b6efb6da12d92551772fcc04acf331f4e0e6f7bd9d4cb23ce0b301e0b128feb"
},
"write-a-skill": {
"source": "mattpocock/skills",
"sourceType": "github",
"skillPath": "skills/productivity/write-a-skill/SKILL.md",
"computedHash": "b44d8aab2ead83c716e01af4c9a24ccc4575ce70ad58ec4f1749fb88c9cc82ba"
},
"zoom-out": {
"source": "mattpocock/skills",
"sourceType": "github",
"skillPath": "skills/engineering/zoom-out/SKILL.md",
"computedHash": "8357aeaece3b709c442eab67e64b86844e05e2f1ea95b109565eba50b6def36e"
}
}
}

View file

@ -0,0 +1,176 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
// ── Hoisted mocks ─────────────────────────────────────────────────────────────
const {
mockGetServerSession,
mockSyncSurveyRequestToHubSpot,
mockDbSelect,
mockDbInsert,
} = vi.hoisted(() => ({
mockGetServerSession: vi.fn(),
mockSyncSurveyRequestToHubSpot: vi.fn(),
mockDbSelect: vi.fn(),
mockDbInsert: vi.fn(),
}));
vi.mock("next-auth", () => ({ getServerSession: mockGetServerSession }));
vi.mock("@/app/api/auth/[...nextauth]/authOptions", () => ({
AuthOptions: {},
}));
vi.mock("@/app/lib/hubspot/dealSync", () => ({
syncSurveyRequestToHubSpot: mockSyncSurveyRequestToHubSpot,
}));
vi.mock("drizzle-orm", () => ({
and: vi.fn((...args: unknown[]) => ({ $and: args })),
eq: vi.fn((a: unknown, b: unknown) => ({ $eq: [a, b] })),
desc: vi.fn((col: unknown) => ({ $desc: col })),
}));
vi.mock("@/app/db/schema/survey_requests", () => ({
surveyRequests: {
id: {}, hubspotDealId: {}, portfolioId: {}, notes: {},
surveyType: {}, status: {}, requestedBy: {}, requestedAt: {}, fulfilledAt: {},
},
}));
vi.mock("@/app/db/schema/portfolio", () => ({
portfolioUsers: { portfolioId: {}, userId: {}, role: {} },
portfolioCapabilities: { portfolioId: {}, userId: {}, capability: {} },
}));
vi.mock("@/app/db/schema/users", () => ({
user: { id: {}, email: {} },
}));
vi.mock("@/app/db/db", () => ({
db: {
get select() { return mockDbSelect; },
get insert() { return mockDbInsert; },
},
}));
// ── Helpers ───────────────────────────────────────────────────────────────────
function makeSelectChain(limitResult: unknown[], directResult: unknown[] = []) {
const self: Record<string, unknown> = {};
self["then"] = (resolve: (v: unknown) => unknown, reject: (e: unknown) => unknown) =>
Promise.resolve(directResult).then(resolve, reject);
self["from"] = vi.fn(() => self);
self["innerJoin"] = vi.fn(() => self);
self["where"] = vi.fn(() => self);
self["orderBy"] = vi.fn(() => self);
self["limit"] = vi.fn(() => Promise.resolve(limitResult));
return self;
}
function makeInsertChain(returningResult: unknown[] = []) {
const returning = vi.fn(() => Promise.resolve(returningResult));
const values = vi.fn(() => ({ returning }));
return { values };
}
function makeRequest(body: unknown, portfolioId = "5") {
const req = new NextRequest(
`http://localhost/api/portfolio/${portfolioId}/survey-requests`,
{
method: "POST",
body: JSON.stringify(body),
headers: { "content-type": "application/json" },
},
);
return { req, params: Promise.resolve({ portfolioId }) };
}
// ── Subject under test ────────────────────────────────────────────────────────
import { POST } from "./route";
describe("POST /survey-requests", () => {
beforeEach(() => {
vi.clearAllMocks();
mockSyncSurveyRequestToHubSpot.mockResolvedValue({ ok: true });
});
it("returns 401 when unauthenticated", async () => {
mockGetServerSession.mockResolvedValue(null);
const { req, params } = makeRequest({ hubspotDealId: "deal-1", surveyType: "technical_building_survey" });
const res = await POST(req, { params });
expect(res.status).toBe(401);
});
it("returns 403 when user lacks approver capability", async () => {
mockGetServerSession.mockResolvedValue({ user: { email: "write@test.com" } });
// user lookup
mockDbSelect.mockImplementationOnce(() => makeSelectChain([{ id: 1n, email: "write@test.com" }]));
// portfolio role check
mockDbSelect.mockImplementationOnce(() => makeSelectChain([{ role: "write" }]));
// capability check — no rows (directResult), so not an approver
mockDbSelect.mockImplementationOnce(() => makeSelectChain([], []));
const { req, params } = makeRequest({ hubspotDealId: "deal-1", surveyType: "technical_building_survey" });
const res = await POST(req, { params });
expect(res.status).toBe(403);
const json = await res.json();
expect(json.error).toMatch(/approver/i);
});
it("returns 409 when a pending request already exists for the deal", async () => {
mockGetServerSession.mockResolvedValue({ user: { email: "approver@test.com" } });
mockDbSelect.mockImplementationOnce(() => makeSelectChain([{ id: 2n, email: "approver@test.com" }]));
mockDbSelect.mockImplementationOnce(() => makeSelectChain([{ role: "admin" }]));
// capability rows come back via directResult (no .limit() on that query)
mockDbSelect.mockImplementationOnce(() => makeSelectChain([], [{ capability: "approver" }]));
// pending check — returns a pending row
mockDbSelect.mockImplementationOnce(() => makeSelectChain([{ id: 99n, status: "pending" }]));
const { req, params } = makeRequest({ hubspotDealId: "deal-1", surveyType: "technical_building_survey" });
const res = await POST(req, { params });
expect(res.status).toBe(409);
const json = await res.json();
expect(json.error).toMatch(/pending/i);
});
it("creates the request with surveyType and syncs to HubSpot", async () => {
const insertedAt = new Date("2026-05-06T10:00:00.000Z");
mockGetServerSession.mockResolvedValue({ user: { email: "approver@test.com" } });
mockDbSelect.mockImplementationOnce(() => makeSelectChain([{ id: 2n, email: "approver@test.com" }]));
mockDbSelect.mockImplementationOnce(() => makeSelectChain([{ role: "admin" }]));
mockDbSelect.mockImplementationOnce(() => makeSelectChain([], [{ capability: "approver" }]));
// no pending request
mockDbSelect.mockImplementationOnce(() => makeSelectChain([]));
// insert returning
mockDbInsert.mockImplementationOnce(() =>
makeInsertChain([{ id: 42n, requestedAt: insertedAt }])
);
const { req, params } = makeRequest({ hubspotDealId: "deal-abc", surveyType: "technical_building_survey" });
const res = await POST(req, { params });
expect(res.status).toBe(200);
const json = await res.json();
expect(json.ok).toBe(true);
expect(json.id).toBe("42");
expect(json.hubspotSync).toBe("ok");
expect(mockSyncSurveyRequestToHubSpot).toHaveBeenCalledWith({
hubspotDealId: "deal-abc",
surveyType: "technical_building_survey",
requestedAt: insertedAt,
});
});
it("returns hubspotSync: failed but still 200 when HubSpot fails", async () => {
const insertedAt = new Date();
mockGetServerSession.mockResolvedValue({ user: { email: "approver@test.com" } });
mockDbSelect.mockImplementationOnce(() => makeSelectChain([{ id: 2n, email: "approver@test.com" }]));
mockDbSelect.mockImplementationOnce(() => makeSelectChain([{ role: "admin" }]));
mockDbSelect.mockImplementationOnce(() => makeSelectChain([], [{ capability: "approver" }]));
mockDbSelect.mockImplementationOnce(() => makeSelectChain([]));
mockDbInsert.mockImplementationOnce(() =>
makeInsertChain([{ id: 43n, requestedAt: insertedAt }])
);
mockSyncSurveyRequestToHubSpot.mockResolvedValue({ ok: false, error: "HubSpot sync failed" });
const { req, params } = makeRequest({ hubspotDealId: "deal-abc", surveyType: "technical_building_survey" });
const res = await POST(req, { params });
expect(res.status).toBe(200);
const json = await res.json();
expect(json.ok).toBe(true);
expect(json.hubspotSync).toBe("failed");
expect(json.hubspotError).toBe("HubSpot sync failed");
});
});

View file

@ -1,7 +1,7 @@
import { db } from "@/app/db/db";
import { NextRequest, NextResponse } from "next/server";
import { surveyRequests } from "@/app/db/schema/survey_requests";
import { portfolioUsers } from "@/app/db/schema/portfolio";
import { portfolioUsers, portfolioCapabilities } from "@/app/db/schema/portfolio";
import { user } from "@/app/db/schema/users";
import { and, eq, desc } from "drizzle-orm";
import { z } from "zod";
@ -9,8 +9,6 @@ import { getServerSession } from "next-auth";
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { syncSurveyRequestToHubSpot } from "@/app/lib/hubspot/dealSync";
const WRITE_ROLES = ["creator", "admin", "write"] as const;
async function getRequestingUser(email: string) {
const rows = await db
.select({ id: user.id, email: user.email })
@ -20,7 +18,7 @@ async function getRequestingUser(email: string) {
return rows[0] ?? null;
}
async function getUserRole(portfolioId: bigint, userId: bigint) {
async function hasPortfolioRole(portfolioId: bigint, userId: bigint) {
const rows = await db
.select({ role: portfolioUsers.role })
.from(portfolioUsers)
@ -31,11 +29,38 @@ async function getUserRole(portfolioId: bigint, userId: bigint) {
),
)
.limit(1);
return rows[0]?.role ?? null;
return !!rows[0]?.role;
}
async function hasApproverCapability(portfolioId: bigint, userId: bigint) {
const rows = await db
.select({ capability: portfolioCapabilities.capability })
.from(portfolioCapabilities)
.where(
and(
eq(portfolioCapabilities.portfolioId, portfolioId),
eq(portfolioCapabilities.userId, userId),
),
);
return rows.map((r) => r.capability).includes("approver");
}
async function getPendingRequest(hubspotDealId: string, portfolioId: bigint) {
const rows = await db
.select({ id: surveyRequests.id, status: surveyRequests.status })
.from(surveyRequests)
.where(
and(
eq(surveyRequests.hubspotDealId, hubspotDealId),
eq(surveyRequests.portfolioId, portfolioId),
eq(surveyRequests.status, "pending"),
),
)
.limit(1);
return rows[0] ?? null;
}
// GET /api/portfolio/[portfolioId]/survey-requests?dealId=xxx
// Returns all survey requests for a deal, most recent first.
export async function GET(
req: NextRequest,
props: { params: Promise<{ portfolioId: string }> },
@ -57,7 +82,7 @@ export async function GET(
.select({
id: surveyRequests.id,
hubspotDealId: surveyRequests.hubspotDealId,
notes: surveyRequests.notes,
surveyType: surveyRequests.surveyType,
status: surveyRequests.status,
requestedAt: surveyRequests.requestedAt,
fulfilledAt: surveyRequests.fulfilledAt,
@ -77,7 +102,7 @@ export async function GET(
const requests = rows.map((r) => ({
id: String(r.id),
hubspotDealId: r.hubspotDealId,
notes: r.notes,
surveyType: r.surveyType ?? null,
status: r.status,
requestedByEmail: r.requestedByEmail,
requestedAt: r.requestedAt?.toISOString() ?? null,
@ -93,11 +118,11 @@ export async function GET(
const postSchema = z.object({
hubspotDealId: z.string().min(1),
notes: z.string().min(1, "Notes are required"),
surveyType: z.string().min(1),
});
// POST /api/portfolio/[portfolioId]/survey-requests
// Submit a new survey request — requires write+ role.
// Submit a new survey request — requires approver capability.
export async function POST(
req: NextRequest,
props: { params: Promise<{ portfolioId: string }> },
@ -114,10 +139,17 @@ export async function POST(
return NextResponse.json({ error: "User not found" }, { status: 401 });
}
const role = await getUserRole(BigInt(portfolioId), requestingUser.id);
if (!role || !WRITE_ROLES.includes(role as (typeof WRITE_ROLES)[number])) {
const pid = BigInt(portfolioId);
const isMember = await hasPortfolioRole(pid, requestingUser.id);
if (!isMember) {
return NextResponse.json({ error: "No portfolio access" }, { status: 403 });
}
const isApprover = await hasApproverCapability(pid, requestingUser.id);
if (!isApprover) {
return NextResponse.json(
{ error: "Write access required to submit a survey request" },
{ error: "Approver capability required to submit a survey request" },
{ status: 403 },
);
}
@ -134,24 +166,33 @@ export async function POST(
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
}
const { hubspotDealId, notes } = parsed.data;
const { hubspotDealId, surveyType } = parsed.data;
const existing = await getPendingRequest(hubspotDealId, pid);
if (existing) {
return NextResponse.json(
{ error: "A pending survey request already exists for this deal" },
{ status: 409 },
);
}
try {
const [inserted] = await db
.insert(surveyRequests)
.values({
hubspotDealId,
portfolioId: BigInt(portfolioId),
notes,
portfolioId: pid,
notes: "",
surveyType,
status: "pending",
requestedBy: requestingUser.id,
})
.returning({ id: surveyRequests.id });
.returning({ id: surveyRequests.id, requestedAt: surveyRequests.requestedAt });
const hubspotResult = await syncSurveyRequestToHubSpot({
hubspotDealId,
notes,
requestedByEmail: requestingUser.email,
surveyType,
requestedAt: inserted.requestedAt,
});
return NextResponse.json({

View file

@ -20,8 +20,8 @@ export const surveyRequests = pgTable(
portfolioId: bigint("portfolio_id", { mode: "bigint" })
.notNull()
.references(() => portfolio.id),
// Free-text notes from the requester describing what survey is needed.
notes: text("notes").notNull(),
surveyType: text("survey_type"),
// 'pending' | 'fulfilled'
status: text("status").notNull().default("pending"),
requestedBy: bigint("requested_by", { mode: "bigint" })

View file

@ -75,10 +75,10 @@ describe("DEAL_PROPERTY_FIELDS registry", () => {
"property_halted_reason",
);
expect(DEAL_PROPERTY_FIELDS.domna_survey_type.hubspotProperty).toBe(
"domna_survey_type",
"osmosis_survey_required",
);
expect(DEAL_PROPERTY_FIELDS.domna_survey_date.hubspotProperty).toBe(
"domna_survey_date",
"osmosis_survey_date",
);
});
@ -369,8 +369,8 @@ describe("applyDealPropertyUpdate", () => {
expect((dbValues.domnaSurveyDate as Date).toISOString()).toBe(surveyIso);
const props = pushHubspot.mock.calls[0][0].properties;
expect(props.domna_survey_type).toBe(surveyType);
expect(props.domna_survey_date).toBe(
expect(props.osmosis_survey_required).toBe(surveyType);
expect(props.osmosis_survey_date).toBe(
String(new Date(surveyIso).getTime()),
);
});
@ -407,8 +407,8 @@ describe("applyDealPropertyUpdate", () => {
expect(typeOnlyDb.domnaSurveyType).toBe("Standard");
expect("domnaSurveyDate" in typeOnlyDb).toBe(false);
const typeOnlyProps = pushHubspotType.mock.calls[0][0].properties;
expect(typeOnlyProps.domna_survey_type).toBe("Standard");
expect("domna_survey_date" in typeOnlyProps).toBe(false);
expect(typeOnlyProps.osmosis_survey_required).toBe("Standard");
expect("osmosis_survey_date" in typeOnlyProps).toBe(false);
// Setting only the date — type column is untouched.
const updateDbDate = vi.fn().mockResolvedValue(undefined);
@ -427,10 +427,10 @@ describe("applyDealPropertyUpdate", () => {
expect(dateOnlyDb.domnaSurveyDate).toBeInstanceOf(Date);
expect("domnaSurveyType" in dateOnlyDb).toBe(false);
const dateOnlyProps = pushHubspotDate.mock.calls[0][0].properties;
expect(dateOnlyProps.domna_survey_date).toBe(
expect(dateOnlyProps.osmosis_survey_date).toBe(
String(new Date(surveyIso).getTime()),
);
expect("domna_survey_type" in dateOnlyProps).toBe(false);
expect("osmosis_survey_required" in dateOnlyProps).toBe(false);
});
it("clears both domna fields to null when explicitly cleared", async () => {
@ -453,8 +453,8 @@ describe("applyDealPropertyUpdate", () => {
expect(dbValues.domnaSurveyType).toBeNull();
expect(dbValues.domnaSurveyDate).toBeNull();
const props = pushHubspot.mock.calls[0][0].properties;
expect(props.domna_survey_type).toBe("");
expect(props.domna_survey_date).toBe("");
expect(props.osmosis_survey_required).toBe("");
expect(props.osmosis_survey_date).toBe("");
});
it("surfaces HubSpot push failures back to the caller", async () => {

View file

@ -149,14 +149,14 @@ export const DEAL_PROPERTY_FIELDS = {
domna_survey_type: {
schema: stringOrNullSchema,
allowedRoles: APPROVER_ROLES,
hubspotProperty: "domna_survey_type",
hubspotProperty: "osmosis_survey_required",
dbColumn: hubspotDealData.domnaSurveyType,
toHubspot: stringToHubspot,
} satisfies DealPropertyFieldDef<string | null>,
domna_survey_date: {
schema: isoDateSchema,
allowedRoles: APPROVER_ROLES,
hubspotProperty: "domna_survey_date",
hubspotProperty: "osmosis_survey_date",
dbColumn: hubspotDealData.domnaSurveyDate,
toHubspot: dateToHubspot,
} satisfies DealPropertyFieldDef<Date | null>,

View file

@ -10,7 +10,7 @@ vi.mock("./client", () => ({
}),
}));
import { syncMeasuresFieldToHubSpot } from "./dealSync";
import { syncMeasuresFieldToHubSpot, syncSurveyRequestToHubSpot } from "./dealSync";
describe("syncMeasuresFieldToHubSpot", () => {
beforeEach(() => {
@ -122,3 +122,36 @@ describe("syncMeasuresFieldToHubSpot", () => {
});
});
});
describe("syncSurveyRequestToHubSpot", () => {
beforeEach(() => {
updateMock.mockReset();
});
it("writes survey type to osmosis_survey_required and date to osmosis_survey_date", async () => {
updateMock.mockResolvedValueOnce(undefined);
const requestedAt = new Date("2026-05-06T10:00:00.000Z");
const result = await syncSurveyRequestToHubSpot({
hubspotDealId: "deal-99",
surveyType: "technical_building_survey",
requestedAt,
});
expect(result).toEqual({ ok: true });
expect(updateMock).toHaveBeenCalledWith("deal-99", {
properties: {
osmosis_survey_required: "technical_building_survey",
osmosis_survey_date: "2026-05-06T10:00:00.000Z",
},
});
});
it("returns ok: false with error message on HubSpot failure", async () => {
updateMock.mockRejectedValueOnce(new Error("HubSpot 400"));
const result = await syncSurveyRequestToHubSpot({
hubspotDealId: "deal-99",
surveyType: "technical_building_survey",
requestedAt: new Date(),
});
expect(result).toEqual({ ok: false, error: "HubSpot sync failed" });
});
});

View file

@ -187,14 +187,16 @@ export async function syncMeasuresFieldToHubSpot(params: {
export async function syncSurveyRequestToHubSpot(params: {
hubspotDealId: string;
notes: string;
requestedByEmail: string;
surveyType: string;
requestedAt: Date;
}): Promise<{ ok: boolean; error?: string }> {
try {
const client = getHubSpotClient();
const log = `Survey requested by: ${params.requestedByEmail}\nNotes: ${params.notes}`;
await client.crm.deals.basicApi.update(params.hubspotDealId, {
properties: { survey_request_log: log },
properties: {
osmosis_survey_required: params.surveyType,
osmosis_survey_date: params.requestedAt.toISOString(),
},
});
return { ok: true };
} catch (err) {

View file

@ -359,12 +359,16 @@ export function RemovalRequestSection({
}
// -----------------------------------------------------------------------
// Survey request section — write-role users can request a survey from Domna
// Survey request section — approver-only type-select flow
// -----------------------------------------------------------------------
const SURVEY_TYPES = [
{ value: "technical_building_survey", label: "Technical Building Survey" },
] as const;
type SurveyRequestRecord = {
id: string;
hubspotDealId: string;
notes: string;
surveyType: string | null;
status: string;
requestedByEmail: string;
requestedAt: string | null;
@ -374,19 +378,17 @@ type SurveyRequestRecord = {
export function SurveyRequestSection({
dealId,
portfolioId,
userRole,
canEdit,
}: {
dealId: string;
portfolioId: string;
userRole: string;
canEdit: boolean;
}) {
const queryClient = useQueryClient();
const [notes, setNotes] = useState("");
const [selectedType, setSelectedType] = useState<string>(SURVEY_TYPES[0].value);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const canRequest = WRITE_ROLES.includes(userRole as (typeof WRITE_ROLES)[number]);
const { data, isLoading } = useQuery<{ requests: SurveyRequestRecord[] }>({
queryKey: ["surveyRequests", portfolioId, dealId],
queryFn: async () => {
@ -400,16 +402,17 @@ export function SurveyRequestSection({
});
const pending = data?.requests?.find((r) => r.status === "pending") ?? null;
const fulfilled = (data?.requests ?? []).filter((r) => r.status === "fulfilled");
async function handleSubmit() {
if (!notes.trim() || submitting) return;
if (submitting) return;
setSubmitting(true);
setError(null);
try {
const res = await fetch(`/api/portfolio/${portfolioId}/survey-requests`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ hubspotDealId: dealId, notes: notes.trim() }),
body: JSON.stringify({ hubspotDealId: dealId, surveyType: selectedType }),
});
if (!res.ok) {
const json = await res.json().catch(() => ({}));
@ -418,15 +421,19 @@ export function SurveyRequestSection({
}
const json = (await res.json()) as { ok: boolean; hubspotSync?: string; hubspotError?: string };
if (json.hubspotSync === "failed") {
setError(json.hubspotError ?? "Saved locally — HubSpot sync failed");
setError(json.hubspotError ? `Saved locally — HubSpot sync failed: ${json.hubspotError}` : "Saved locally — HubSpot sync failed");
}
setNotes("");
queryClient.invalidateQueries({ queryKey: ["surveyRequests", portfolioId, dealId] });
} finally {
setSubmitting(false);
}
}
function surveyTypeLabel(value: string | null) {
if (!value) return value;
return SURVEY_TYPES.find((t) => t.value === value)?.label ?? value;
}
if (isLoading) {
return <p className="text-xs text-gray-400 py-2">Loading</p>;
}
@ -437,6 +444,11 @@ export function SurveyRequestSection({
<p className="text-xs text-red-600 bg-red-50 px-3 py-2 rounded-lg">{error}</p>
)}
{/* Empty state */}
{!pending && fulfilled.length === 0 && (
<p className="text-xs text-gray-400 py-1">No surveys requested</p>
)}
{/* Pending badge */}
{pending && (
<div
@ -446,7 +458,7 @@ export function SurveyRequestSection({
<span className="text-xs font-semibold text-amber-700 bg-amber-100 px-2 py-0.5 rounded-full border border-amber-200">
Survey Requested
</span>
<p className="text-xs text-gray-700 leading-relaxed">{pending.notes}</p>
<p className="text-xs text-gray-700 font-medium">{surveyTypeLabel(pending.surveyType)}</p>
<p className="text-[11px] text-gray-400">
Requested by{" "}
<span className="font-medium text-gray-600">{pending.requestedByEmail}</span>
@ -455,27 +467,29 @@ export function SurveyRequestSection({
</div>
)}
{/* Request form — only shown when no pending request */}
{canRequest && !pending && (
{/* Request form — shown to approvers when no pending request */}
{canEdit && !pending && (
<div className="space-y-2">
<p className="text-xs text-gray-500">
Request a survey from Domna. Notes will be sent to the coordination team.
</p>
<textarea
data-testid="survey-request-notes"
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-xs text-gray-800 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-200 focus:border-blue-300 resize-none"
placeholder="Describe the survey needed…"
rows={3}
value={notes}
onChange={(e) => setNotes(e.target.value)}
disabled={submitting}
/>
<label className="flex flex-col gap-1">
<span className="text-xs text-gray-500 font-medium">Survey type</span>
<select
data-testid="survey-request-type-select"
value={selectedType}
onChange={(e) => setSelectedType(e.target.value)}
disabled={submitting}
className="rounded-lg border border-gray-200 px-3 py-1.5 text-xs text-gray-800 focus:outline-none focus:ring-2 focus:ring-blue-200 focus:border-blue-300 bg-white"
>
{SURVEY_TYPES.map((t) => (
<option key={t.value} value={t.value}>{t.label}</option>
))}
</select>
</label>
<div className="flex justify-end">
<button
data-testid="survey-request-submit"
type="button"
onClick={handleSubmit}
disabled={!notes.trim() || submitting}
disabled={submitting}
className="text-xs font-medium px-3 py-1.5 rounded-lg bg-brandblue text-white hover:bg-brandmidblue disabled:opacity-50 transition-colors"
>
{submitting ? "Submitting…" : "Request Survey"}
@ -485,13 +499,13 @@ export function SurveyRequestSection({
)}
{/* Past fulfilled requests */}
{(data?.requests ?? []).filter((r) => r.status === "fulfilled").length > 0 && (
{fulfilled.length > 0 && (
<div className="space-y-1.5 pt-1">
<p className="text-[10px] font-semibold uppercase tracking-wide text-gray-400">Past Requests</p>
{data!.requests.filter((r) => r.status === "fulfilled").map((r) => (
{fulfilled.map((r) => (
<div key={r.id} className="rounded-lg border border-emerald-100 bg-emerald-50/50 px-3 py-2.5 space-y-1">
<span className="text-[10px] font-semibold text-emerald-700">Fulfilled</span>
<p className="text-xs text-gray-700">{r.notes}</p>
<p className="text-xs text-gray-700 font-medium">{surveyTypeLabel(r.surveyType)}</p>
<p className="text-[11px] text-gray-400">
By <span className="font-medium text-gray-600">{r.requestedByEmail}</span>
{r.requestedAt && ` · ${formatDateTime(r.requestedAt)}`}
@ -1101,185 +1115,6 @@ export function HaltedEditor({
);
}
// -----------------------------------------------------------------------
// Domna section — editable for approvers (issue #256)
// -----------------------------------------------------------------------
interface DomnaEditorProps {
dealId: string;
portfolioId: string;
initialSurveyType: string | null;
initialSurveyDate: Date | string | null;
/** True when the user has the approver capability on this portfolio. */
canEdit: boolean;
}
export function DomnaEditor({
dealId,
portfolioId,
initialSurveyType,
initialSurveyDate,
canEdit,
}: DomnaEditorProps) {
const initialType = initialSurveyType ?? "";
const initialDate = useMemo(
() => toDateInputValue(initialSurveyDate),
[initialSurveyDate],
);
const [typeValue, setTypeValue] = useState(initialType);
const [dateValue, setDateValue] = useState(initialDate);
const [savedType, setSavedType] = useState(initialType);
const [savedDate, setSavedDate] = useState(initialDate);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
// Reset state when the drawer switches deals.
useEffect(() => {
setTypeValue(initialType);
setSavedType(initialType);
}, [initialType]);
useEffect(() => {
setDateValue(initialDate);
setSavedDate(initialDate);
}, [initialDate]);
const dirty = typeValue !== savedType || dateValue !== savedDate;
async function handleSave() {
if (!dirty) return;
setSubmitting(true);
setError(null);
const fields: Record<string, string | null> = {};
if (typeValue !== savedType) {
fields.domna_survey_type = typeValue.trim() === "" ? null : typeValue;
}
if (dateValue !== savedDate) {
fields.domna_survey_date = dateInputToIso(dateValue);
}
// Optimistic update.
const prevType = savedType;
const prevDate = savedDate;
setSavedType(typeValue);
setSavedDate(dateValue);
try {
const res = await fetch(
`/api/portfolio/${portfolioId}/deal-properties`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ dealId, fields }),
},
);
if (!res.ok) {
setSavedType(prevType);
setSavedDate(prevDate);
setTypeValue(prevType);
setDateValue(prevDate);
const json = await res.json().catch(() => ({}));
setError(
typeof json.error === "string"
? json.error
: "Failed to update Domna survey",
);
return;
}
const json = (await res.json()) as {
results: Record<string, { ok: boolean; error?: string }>;
hubspotSync: "ok" | "failed" | "skipped";
hubspotError?: string;
};
const fieldErrors = Object.entries(json.results ?? {})
.filter(([, r]) => !r.ok)
.map(([k, r]) => `${k}: ${r.error ?? "rejected"}`);
if (fieldErrors.length > 0) {
setError(fieldErrors.join("; "));
} else if (json.hubspotSync === "failed") {
setError(
json.hubspotError
? `Saved locally — HubSpot sync failed: ${json.hubspotError}`
: "Saved locally — HubSpot sync failed",
);
}
} catch (err) {
setSavedType(prevType);
setSavedDate(prevDate);
setTypeValue(prevType);
setDateValue(prevDate);
setError(err instanceof Error ? err.message : "Failed to update Domna survey");
} finally {
setSubmitting(false);
}
}
if (!canEdit) {
return (
<div className="divide-y divide-gray-50">
<InfoRow label="Domna Survey Type" value={initialSurveyType} />
<InfoRow
label="Domna Survey Date"
value={formatDate(initialSurveyDate)}
/>
</div>
);
}
return (
<div className="space-y-3">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<label className="flex flex-col gap-1">
<span className="text-xs text-gray-500 font-medium">
Domna Survey Type
</span>
<input
type="text"
data-testid="domna-survey-type-input"
value={typeValue}
onChange={(e) => setTypeValue(e.target.value)}
disabled={submitting}
placeholder="e.g. Standard, Detailed"
className="rounded-lg border border-gray-200 px-3 py-1.5 text-xs text-gray-800 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-200 focus:border-blue-300"
/>
</label>
<label className="flex flex-col gap-1">
<span className="text-xs text-gray-500 font-medium">
Domna Survey Date
</span>
<input
type="date"
data-testid="domna-survey-date-input"
value={dateValue}
onChange={(e) => setDateValue(e.target.value)}
disabled={submitting}
className="rounded-lg border border-gray-200 px-3 py-1.5 text-xs text-gray-800 focus:outline-none focus:ring-2 focus:ring-blue-200 focus:border-blue-300"
/>
</label>
</div>
<div className="flex items-center justify-between gap-3">
<p className="text-[11px] text-gray-400">
Free text leave blank to clear. Changes sync to HubSpot.
</p>
<button
type="button"
data-testid="domna-save-button"
onClick={handleSave}
disabled={!dirty || submitting}
className="text-xs font-medium px-3 py-1.5 rounded-lg bg-brandblue text-white hover:bg-brandmidblue disabled:opacity-50 transition-colors"
>
{submitting ? "Saving…" : "Save Domna Survey"}
</button>
</div>
{error && (
<p
data-testid="domna-error"
className="text-xs text-red-600 bg-red-50 px-3 py-2 rounded-lg"
>
{error}
</p>
)}
</div>
);
}
// -----------------------------------------------------------------------
// PIBI measure selector — approver-only multi-select listing all measures
@ -2321,18 +2156,6 @@ export default function PropertyDetailDrawer({
data-testid="drawer-tab-panel-survey-admin"
className={`px-6 py-5 space-y-6 ${activeTab === "survey-admin" ? "block" : "hidden"}`}
>
{/* Domna survey */}
<div>
<SectionHeader id="domna" label={SECTION_TITLES.domna} />
<DomnaEditor
dealId={deal.dealId}
portfolioId={portfolioId}
initialSurveyType={deal.domnaSurveyType ?? null}
initialSurveyDate={deal.domnaSurveyDate}
canEdit={userCapability.includes("approver")}
/>
</div>
{/* Halted */}
<div>
<SectionHeader id="halted" label={SECTION_TITLES.halted} />
@ -2353,7 +2176,7 @@ export default function PropertyDetailDrawer({
<SurveyRequestSection
dealId={deal.dealId}
portfolioId={portfolioId}
userRole={userRole}
canEdit={userCapability.includes("approver")}
/>
</div>

View file

@ -30,7 +30,6 @@ import {
ApprovalLogSection,
PibiDatesEditor,
PibiMeasureSelector,
DomnaEditor,
HaltedEditor,
SurveyRequestSection,
RemovalRequestSection,
@ -353,16 +352,6 @@ export default function DealPage({
<div
className={`p-5 space-y-6 ${activeTab === "survey-admin" ? "block" : "hidden"}`}
>
<div>
<SectionHeader id="domna" label={SECTION_TITLES.domna} />
<DomnaEditor
dealId={deal.dealId}
portfolioId={portfolioId}
initialSurveyType={deal.domnaSurveyType ?? null}
initialSurveyDate={deal.domnaSurveyDate}
canEdit={isApprover}
/>
</div>
<div>
<SectionHeader id="halted" label={SECTION_TITLES.halted} />
<HaltedEditor
@ -380,7 +369,7 @@ export default function DealPage({
<SurveyRequestSection
dealId={deal.dealId}
portfolioId={portfolioId}
userRole={userRole}
canEdit={isApprover}
/>
</div>
<div className="border-t border-gray-100 pt-4">