mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-06-30 12:55:02 +00:00
Updated survey request UI for Devon County Council
This commit is contained in:
parent
70374d56f0
commit
19139b6253
10 changed files with 424 additions and 271 deletions
89
skills-lock.json
Normal file
89
skills-lock.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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" })
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -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" });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue