From 19139b62533b365cd383cdcc11405f300e9010fe Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 6 May 2026 20:37:30 +0000 Subject: [PATCH] Updated survey request UI for Devon County Council --- skills-lock.json | 89 ++++++ .../survey-requests/route.test.ts | 176 ++++++++++++ .../[portfolioId]/survey-requests/route.ts | 79 ++++-- src/app/db/schema/survey_requests.ts | 2 +- src/app/lib/dealPropertyUpdate.test.ts | 20 +- src/app/lib/dealPropertyUpdate.ts | 4 +- src/app/lib/hubspot/dealSync.test.ts | 35 ++- src/app/lib/hubspot/dealSync.ts | 10 +- .../live/PropertyDetailDrawer.tsx | 267 +++--------------- .../your-projects/live/[dealId]/DealPage.tsx | 13 +- 10 files changed, 424 insertions(+), 271 deletions(-) create mode 100644 skills-lock.json create mode 100644 src/app/api/portfolio/[portfolioId]/survey-requests/route.test.ts diff --git a/skills-lock.json b/skills-lock.json new file mode 100644 index 00000000..e56778a2 --- /dev/null +++ b/skills-lock.json @@ -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" + } + } +} diff --git a/src/app/api/portfolio/[portfolioId]/survey-requests/route.test.ts b/src/app/api/portfolio/[portfolioId]/survey-requests/route.test.ts new file mode 100644 index 00000000..8db83b0a --- /dev/null +++ b/src/app/api/portfolio/[portfolioId]/survey-requests/route.test.ts @@ -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 = {}; + 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"); + }); +}); diff --git a/src/app/api/portfolio/[portfolioId]/survey-requests/route.ts b/src/app/api/portfolio/[portfolioId]/survey-requests/route.ts index 8190fa93..7b2c02bc 100644 --- a/src/app/api/portfolio/[portfolioId]/survey-requests/route.ts +++ b/src/app/api/portfolio/[portfolioId]/survey-requests/route.ts @@ -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({ diff --git a/src/app/db/schema/survey_requests.ts b/src/app/db/schema/survey_requests.ts index 815d18f1..55001d00 100644 --- a/src/app/db/schema/survey_requests.ts +++ b/src/app/db/schema/survey_requests.ts @@ -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" }) diff --git a/src/app/lib/dealPropertyUpdate.test.ts b/src/app/lib/dealPropertyUpdate.test.ts index 2f092b58..4c2b34ce 100644 --- a/src/app/lib/dealPropertyUpdate.test.ts +++ b/src/app/lib/dealPropertyUpdate.test.ts @@ -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 () => { diff --git a/src/app/lib/dealPropertyUpdate.ts b/src/app/lib/dealPropertyUpdate.ts index 5aaf1226..23360064 100644 --- a/src/app/lib/dealPropertyUpdate.ts +++ b/src/app/lib/dealPropertyUpdate.ts @@ -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, domna_survey_date: { schema: isoDateSchema, allowedRoles: APPROVER_ROLES, - hubspotProperty: "domna_survey_date", + hubspotProperty: "osmosis_survey_date", dbColumn: hubspotDealData.domnaSurveyDate, toHubspot: dateToHubspot, } satisfies DealPropertyFieldDef, diff --git a/src/app/lib/hubspot/dealSync.test.ts b/src/app/lib/hubspot/dealSync.test.ts index 2ca92a08..4d5aa472 100644 --- a/src/app/lib/hubspot/dealSync.test.ts +++ b/src/app/lib/hubspot/dealSync.test.ts @@ -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" }); + }); +}); diff --git a/src/app/lib/hubspot/dealSync.ts b/src/app/lib/hubspot/dealSync.ts index 8b08c890..26a0497f 100644 --- a/src/app/lib/hubspot/dealSync.ts +++ b/src/app/lib/hubspot/dealSync.ts @@ -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) { diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyDetailDrawer.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyDetailDrawer.tsx index 721ece34..a76adf80 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyDetailDrawer.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyDetailDrawer.tsx @@ -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(SURVEY_TYPES[0].value); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(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

Loading…

; } @@ -437,6 +444,11 @@ export function SurveyRequestSection({

{error}

)} + {/* Empty state */} + {!pending && fulfilled.length === 0 && ( +

No surveys requested

+ )} + {/* Pending badge */} {pending && (
Survey Requested -

{pending.notes}

+

{surveyTypeLabel(pending.surveyType)}

Requested by{" "} {pending.requestedByEmail} @@ -455,27 +467,29 @@ export function SurveyRequestSection({

)} - {/* Request form — only shown when no pending request */} - {canRequest && !pending && ( + {/* Request form — shown to approvers when no pending request */} + {canEdit && !pending && (
-

- Request a survey from Domna. Notes will be sent to the coordination team. -

-