Merge pull request #231 from Hestia-Homes/feature/installer-interaction

Feature/installer interaction
This commit is contained in:
Jun-te Kim 2026-04-20 12:29:24 +01:00 committed by GitHub
commit 5950db9e05
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 7277 additions and 59 deletions

View file

@ -10,6 +10,7 @@ import { and, eq, inArray, sql } from "drizzle-orm";
import { z } from "zod";
import { getServerSession } from "next-auth";
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { syncMeasureApprovalsToHubSpot } from "@/app/lib/hubspot/dealSync";
async function getRequestingUserId(email: string): Promise<bigint | null> {
const rows = await db
@ -204,6 +205,33 @@ export async function POST(
});
}
const affectedDealIds = [...new Set(body.changes.map((c) => c.hubspotDealId))];
for (const dealId of affectedDealIds) {
const approvalRows = await db
.select({
measureName: dealMeasureApprovals.measureName,
approvedByEmail: user.email,
})
.from(dealMeasureApprovals)
.leftJoin(user, eq(user.id, dealMeasureApprovals.approvedBy))
.where(
and(
eq(dealMeasureApprovals.hubspotDealId, dealId),
eq(dealMeasureApprovals.isApproved, true),
),
);
void syncMeasureApprovalsToHubSpot({
hubspotDealId: dealId,
approvedMeasures: approvalRows.map((r) => ({
measureName: r.measureName,
approvedByEmail: r.approvedByEmail ?? "unknown",
})),
actedByEmail: session.user.email,
actedAt: now,
});
}
return NextResponse.json({ success: true });
} catch (err) {
console.error("POST /approvals error:", err);

View file

@ -7,6 +7,7 @@ import { and, eq, desc } from "drizzle-orm";
import { z } from "zod";
import { getServerSession } from "next-auth";
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { syncRemovalRequestToHubSpot } from "@/app/lib/hubspot/dealSync";
const WRITE_ROLES = ["creator", "admin", "write"] as const;
@ -70,6 +71,7 @@ export async function GET(
.select({
id: propertyRemovalRequests.id,
hubspotDealId: propertyRemovalRequests.hubspotDealId,
type: propertyRemovalRequests.type,
reason: propertyRemovalRequests.reason,
status: propertyRemovalRequests.status,
requestedAt: propertyRemovalRequests.requestedAt,
@ -110,6 +112,7 @@ export async function GET(
return {
id: String(row.id),
hubspotDealId: row.hubspotDealId,
type: row.type,
reason: row.reason,
status: row.status,
requestedByEmail: row.requestedByEmail,
@ -130,6 +133,7 @@ export async function GET(
const postSchema = z.object({
hubspotDealId: z.string().min(1),
reason: z.string().min(1, "Reason is required"),
type: z.enum(["removal", "re_addition"]).default("removal"),
});
// POST /api/portfolio/[portfolioId]/removal-requests
@ -170,40 +174,71 @@ export async function POST(
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
}
const { hubspotDealId, reason } = parsed.data;
const { hubspotDealId, reason, type } = parsed.data;
try {
// Block if there's already a pending request for this deal
const existing = await db
.select({ id: propertyRemovalRequests.id })
// Fetch the most recent request for this deal to determine current state
const [latest] = await db
.select({
status: propertyRemovalRequests.status,
type: propertyRemovalRequests.type,
})
.from(propertyRemovalRequests)
.where(
and(
eq(propertyRemovalRequests.hubspotDealId, hubspotDealId),
eq(propertyRemovalRequests.portfolioId, BigInt(portfolioId)),
eq(propertyRemovalRequests.status, "pending"),
),
)
.orderBy(desc(propertyRemovalRequests.requestedAt))
.limit(1);
if (existing.length > 0) {
if (latest?.status === "pending") {
return NextResponse.json(
{ error: "A pending removal request already exists for this property" },
{ error: "A pending request already exists for this property" },
{ status: 409 },
);
}
if (type === "removal" && latest?.type === "removal" && latest?.status === "approved") {
return NextResponse.json(
{ error: "This property has already been removed" },
{ status: 409 },
);
}
if (type === "re_addition") {
const isRemoved =
(latest?.type === "removal" && latest?.status === "approved") ||
(latest?.type === "re_addition" && latest?.status === "declined");
if (!isRemoved) {
return NextResponse.json(
{ error: "This property is not currently removed" },
{ status: 409 },
);
}
}
const [inserted] = await db
.insert(propertyRemovalRequests)
.values({
hubspotDealId,
portfolioId: BigInt(portfolioId),
reason,
type,
status: "pending",
requestedBy: requestingUser.id,
})
.returning();
void syncRemovalRequestToHubSpot({
hubspotDealId,
type,
status: "pending",
reason,
requestedByEmail: requestingUser.email,
});
return NextResponse.json({ success: true, id: String(inserted.id) });
} catch (err) {
console.error("[removal-requests POST]", err);
@ -258,8 +293,16 @@ export async function PATCH(
try {
const target = await db
.select({ id: propertyRemovalRequests.id, status: propertyRemovalRequests.status })
.select({
id: propertyRemovalRequests.id,
type: propertyRemovalRequests.type,
status: propertyRemovalRequests.status,
hubspotDealId: propertyRemovalRequests.hubspotDealId,
reason: propertyRemovalRequests.reason,
requestedByEmail: user.email,
})
.from(propertyRemovalRequests)
.innerJoin(user, eq(user.id, propertyRemovalRequests.requestedBy))
.where(eq(propertyRemovalRequests.id, BigInt(requestId)))
.limit(1);
@ -283,6 +326,15 @@ export async function PATCH(
})
.where(eq(propertyRemovalRequests.id, BigInt(requestId)));
void syncRemovalRequestToHubSpot({
hubspotDealId: target[0].hubspotDealId,
type: (target[0].type ?? "removal") as "removal" | "re_addition",
status: action,
reason: target[0].reason,
requestedByEmail: target[0].requestedByEmail,
reviewedByEmail: requestingUser.email,
});
return NextResponse.json({ success: true });
} catch (err) {
console.error("[removal-requests PATCH]", err);

View file

@ -6,6 +6,7 @@ import { eq, inArray } from "drizzle-orm";
import { z } from "zod";
import { getServerSession } from "next-auth";
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { syncContractorDocUploadToHubSpot } from "@/app/lib/hubspot/dealSync";
// POST — record a contractor install document in uploaded_files (fileType optional — can be classified later)
export async function POST(req: NextRequest) {
@ -56,6 +57,12 @@ export async function POST(req: NextRequest) {
})
.returning({ id: uploadedFiles.id });
if (body.hubspotDealId) {
void syncContractorDocUploadToHubSpot({
hubspotDealId: body.hubspotDealId,
});
}
return NextResponse.json({ id: inserted.id.toString() }, { status: 201 });
} catch (err) {
console.error("POST /upload/contractor-install error:", err);

View file

@ -47,7 +47,7 @@ export default function AddNew({
className="
inline-flex items-center gap-1 px-4 py-2 rounded-md
bg-gray-50 text-gray-900 hover:bg-midblue hover:text-gray-100
transition-colors text-sm font-medium
transition-colors text-xs font-medium
"
>
<DocumentPlusIcon className="h-4 w-4 mr-2" />

View file

@ -31,7 +31,7 @@ export default function YourProjectsDropdown({
className="
inline-flex items-center gap-1 px-4 py-2 rounded-md
bg-gray-50 text-gray-900 hover:bg-midblue hover:text-gray-100
transition-colors text-sm font-medium
transition-colors text-xs font-medium
"
>
<RocketLaunchIcon className="h-4 w-4 mr-2" />

View file

@ -0,0 +1 @@
ALTER TABLE "property_removal_requests" ADD COLUMN "type" text DEFAULT 'removal' NOT NULL;

View file

@ -0,0 +1 @@
ALTER TABLE "property_removal_requests" ADD COLUMN IF NOT EXISTS "type" text NOT NULL DEFAULT 'removal';

File diff suppressed because it is too large Load diff

View file

@ -1261,6 +1261,13 @@
"when": 1776459924335,
"tag": "0179_mighty_cardiac",
"breakpoints": true
},
{
"idx": 180,
"version": "7",
"when": 1776683523665,
"tag": "0180_ambitious_jane_foster",
"breakpoints": true
}
]
}

View file

@ -21,6 +21,8 @@ export const propertyRemovalRequests = pgTable(
.notNull()
.references(() => portfolio.id),
reason: text("reason").notNull(),
// 'removal' | 're_addition'
type: text("type").notNull().default("removal"),
// 'pending' | 'approved' | 'declined'
status: text("status").notNull().default("pending"),
requestedBy: bigint("requested_by", { mode: "bigint" })

View file

@ -0,0 +1,14 @@
import { Client } from "@hubspot/api-client";
let _client: Client | null = null;
export function getHubSpotClient(): Client {
if (!_client) {
const accessToken = process.env.HUBSPOT_API_KEY;
if (!accessToken) {
throw new Error("HUBSPOT_API_KEY environment variable is not set");
}
_client = new Client({ accessToken });
}
return _client;
}

View file

@ -0,0 +1,121 @@
import { getHubSpotClient } from "./client";
export async function syncRemovalRequestToHubSpot(params: {
hubspotDealId: string;
type: "removal" | "re_addition";
status: "pending" | "approved" | "declined";
reason: string;
requestedByEmail: string;
reviewedByEmail?: string | null;
}): Promise<void> {
try {
const client = getHubSpotClient();
let statusLabel: string;
if (params.type === "removal") {
statusLabel =
params.status === "pending"
? "Removal Request In Progress"
: params.status === "approved"
? "Removed From Project"
: "";
} else {
statusLabel =
params.status === "pending"
? "Re-addition Requested"
: params.status === "approved"
? ""
: "Removed From Project";
}
let log = `Requested by: ${params.requestedByEmail}\nReason: ${params.reason}`;
if (params.reviewedByEmail) {
const action = params.status === "approved" ? "Approved" : "Declined";
log += `\n${action} by: ${params.reviewedByEmail}`;
}
await client.crm.deals.basicApi.update(params.hubspotDealId, {
properties: {
project_removal_status: statusLabel,
project_removal_request_log: log,
},
});
} catch (err) {
console.error("[HubSpot] syncRemovalRequestToHubSpot failed", {
dealId: params.hubspotDealId,
error: err,
});
}
}
export async function syncContractorDocUploadToHubSpot(params: {
hubspotDealId: string;
}): Promise<void> {
try {
const client = getHubSpotClient();
await client.crm.deals.basicApi.update(params.hubspotDealId, {
properties: {
contractor_document_upload_log: "Documents available - uploaded by contractor",
},
});
} catch (err) {
console.error("[HubSpot] syncContractorDocUploadToHubSpot failed", {
dealId: params.hubspotDealId,
error: err,
});
}
}
export async function syncMeasureApprovalsToHubSpot(params: {
hubspotDealId: string;
approvedMeasures: Array<{ measureName: string; approvedByEmail: string }>;
actedByEmail: string;
actedAt: Date;
}): Promise<void> {
const dateStr = params.actedAt.toLocaleString("en-GB", {
day: "2-digit",
month: "short",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
timeZone: "UTC",
timeZoneName: "short",
});
const log =
params.approvedMeasures.length === 0
? `All measures unapproved by ${params.actedByEmail} on ${dateStr}`
: [
`Approved measures (updated by ${params.actedByEmail} on ${dateStr}):`,
...params.approvedMeasures.map((m) => `- ${m.measureName} (approved by ${m.approvedByEmail})`),
].join("\r\n");
const maxAttempts = 3;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
const client = getHubSpotClient();
await client.crm.deals.basicApi.update(params.hubspotDealId, {
properties: {
client_measures_approval_log: log,
},
});
return;
} catch (err) {
const isReset =
err instanceof Error &&
"code" in err &&
(err as NodeJS.ErrnoException).code === "ECONNRESET";
if (isReset && attempt < maxAttempts) {
await new Promise((resolve) => setTimeout(resolve, 200 * attempt));
continue;
}
console.error("[HubSpot] syncMeasureApprovalsToHubSpot failed", {
dealId: params.hubspotDealId,
attempt,
error: err,
});
return;
}
}
}

View file

@ -73,7 +73,7 @@ export function BreakdownChart({
}
return rows;
}, [selected, epcBands, ageBands, propertyTypes, scenarioEpcBands, friendlyKeys.actual, friendlyKeys.estimated, friendlyKeys.scenario]);
}, [selected, epcBands, ageBands, propertyTypes, scenarioEpcBands]);
const categories =
selected === "epc"

View file

@ -1,4 +1,5 @@
import { UsersPermissionsCard } from "../UsersPermissionsCard";
import { CapabilitiesCard } from "../CapabilitiesCard";
export default async function UserAccessPage(props: {
params: Promise<{ slug: string }>;
@ -8,6 +9,7 @@ export default async function UserAccessPage(props: {
return (
<div>
<UsersPermissionsCard portfolioId={slug} />
<CapabilitiesCard portfolioId={slug} />
</div>
);
}

View file

@ -217,6 +217,8 @@ export default function LiveTracker({
data={currentProject?.allDeals ?? []}
onOpenDrawer={handleOpenDrawer}
docStatusMap={docStatusMap}
portfolioId={portfolioId}
userCapability={userCapability}
/>
</div>
</TabsContent>

View file

@ -2,7 +2,7 @@
import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { X, CheckCircle2, Circle, AlertTriangle, ChevronRight, ChevronDown, Trash2 } from "lucide-react";
import { X, CheckCircle2, Circle, AlertTriangle, ChevronRight, ChevronDown, Trash2, RotateCcw } from "lucide-react";
import {
Drawer,
DrawerClose,
@ -43,7 +43,7 @@ function RemovalRequestSection({
userCapability: PortfolioCapabilityType;
}) {
const queryClient = useQueryClient();
const [dialogOpen, setDialogOpen] = useState(false);
const [dialogType, setDialogType] = useState<"removal" | "re_addition" | null>(null);
const [reason, setReason] = useState("");
const [submitting, setSubmitting] = useState(false);
const [reviewing, setReviewing] = useState(false);
@ -64,26 +64,45 @@ function RemovalRequestSection({
staleTime: 30_000,
});
const pendingRequest = data?.requests?.find((r) => r.status === "pending") ?? null;
const latestResolvedRequest = data?.requests?.find((r) => r.status !== "pending") ?? null;
const latest = data?.requests?.[0] ?? null;
// Derive effective state from the most recent request
type EffectiveState = "active" | "pending_removal" | "removed" | "pending_re_addition";
const effectiveState: EffectiveState = (() => {
if (!latest) return "active";
if (latest.status === "pending") {
return latest.type === "re_addition" ? "pending_re_addition" : "pending_removal";
}
if (latest.type === "removal" && latest.status === "approved") return "removed";
if (latest.type === "re_addition" && latest.status === "declined") return "removed";
return "active";
})();
const pendingRequest = latest?.status === "pending" ? latest : null;
const latestResolvedRequest = latest?.status !== "pending" ? latest : null;
function closeDialog() {
setDialogType(null);
setReason("");
setError(null);
}
async function handleSubmit() {
if (!reason.trim()) return;
if (!reason.trim() || !dialogType) return;
setSubmitting(true);
setError(null);
try {
const res = await fetch(`/api/portfolio/${portfolioId}/removal-requests`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ hubspotDealId: dealId, reason: reason.trim() }),
body: JSON.stringify({ hubspotDealId: dealId, reason: reason.trim(), type: dialogType }),
});
if (!res.ok) {
const json = await res.json().catch(() => ({}));
setError(json.error ?? "Failed to submit request");
return;
}
setDialogOpen(false);
setReason("");
closeDialog();
queryClient.invalidateQueries({ queryKey: ["removalRequests", portfolioId, dealId] });
} finally {
setSubmitting(false);
@ -110,6 +129,13 @@ function RemovalRequestSection({
}
}
function resolvedLabel(req: RemovalRequest): string {
if (req.type === "re_addition") {
return req.status === "approved" ? "Re-addition Approved" : "Re-addition Declined";
}
return req.status === "approved" ? "Removal Approved" : "Removal Declined";
}
if (isLoading) {
return <p className="text-xs text-gray-400 py-2">Loading</p>;
}
@ -125,7 +151,7 @@ function RemovalRequestSection({
<div className="rounded-xl border border-amber-200 bg-amber-50 p-3.5 space-y-2">
<div className="flex items-center gap-2">
<span className="text-xs font-semibold text-amber-700 bg-amber-100 px-2 py-0.5 rounded-full border border-amber-200">
Pending Removal Request
{pendingRequest.type === "re_addition" ? "Pending Re-addition Request" : "Pending Removal Request"}
</span>
</div>
<p className="text-xs text-gray-700 leading-relaxed">{pendingRequest.reason}</p>
@ -134,7 +160,6 @@ function RemovalRequestSection({
{" · "}
{formatDateTime(pendingRequest.requestedAt)}
</p>
{/* Approver actions */}
{isApprover && (
<div className="flex gap-2 pt-1">
<button
@ -142,7 +167,7 @@ function RemovalRequestSection({
disabled={reviewing}
className="flex-1 text-xs font-medium px-3 py-1.5 rounded-lg bg-emerald-600 text-white hover:bg-emerald-700 disabled:opacity-50 transition-colors"
>
Approve Removal
{pendingRequest.type === "re_addition" ? "Approve Re-addition" : "Approve Removal"}
</button>
<button
onClick={() => handleReview(pendingRequest.id, "declined")}
@ -157,7 +182,7 @@ function RemovalRequestSection({
)}
{/* Most recent resolved request */}
{!pendingRequest && latestResolvedRequest && (
{latestResolvedRequest && (
<div className={`rounded-xl border p-3.5 space-y-1.5 ${
latestResolvedRequest.status === "approved"
? "border-emerald-200 bg-emerald-50"
@ -169,7 +194,7 @@ function RemovalRequestSection({
? "text-emerald-700 bg-emerald-100 border-emerald-200"
: "text-gray-600 bg-gray-100 border-gray-200"
}`}>
{latestResolvedRequest.status === "approved" ? "Removal Approved" : "Removal Declined"}
{resolvedLabel(latestResolvedRequest)}
</span>
</div>
<p className="text-xs text-gray-600 leading-relaxed">{latestResolvedRequest.reason}</p>
@ -188,50 +213,84 @@ function RemovalRequestSection({
</div>
)}
{/* Request button — only shown when no pending request exists */}
{/* Action buttons — shown when no pending request */}
{!pendingRequest && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-block w-full">
<button
onClick={() => { if (canRequest) setDialogOpen(true); }}
disabled={!canRequest}
className={`w-full flex items-center justify-center gap-2 text-xs font-medium px-3 py-2 rounded-lg border transition-colors ${
canRequest
? "border-red-200 text-red-600 hover:bg-red-50 bg-white"
: "border-gray-100 text-gray-300 bg-gray-50 cursor-not-allowed"
}`}
>
<Trash2 className="h-3.5 w-3.5" />
Request Removal from Project
</button>
</span>
</TooltipTrigger>
{!canRequest && (
<TooltipContent side="top" className="text-xs">
Not available with read-only permissions
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
<>
{effectiveState === "active" && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-block w-full">
<button
onClick={() => { if (canRequest) setDialogType("removal"); }}
disabled={!canRequest}
className={`w-full flex items-center justify-center gap-2 text-xs font-medium px-3 py-2 rounded-lg border transition-colors ${
canRequest
? "border-red-200 text-red-600 hover:bg-red-50 bg-white"
: "border-gray-100 text-gray-300 bg-gray-50 cursor-not-allowed"
}`}
>
<Trash2 className="h-3.5 w-3.5" />
Request Removal from Project
</button>
</span>
</TooltipTrigger>
{!canRequest && (
<TooltipContent side="top" className="text-xs">
Not available with read-only permissions
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
)}
{effectiveState === "removed" && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-block w-full">
<button
onClick={() => { if (canRequest) setDialogType("re_addition"); }}
disabled={!canRequest}
className={`w-full flex items-center justify-center gap-2 text-xs font-medium px-3 py-2 rounded-lg border transition-colors ${
canRequest
? "border-blue-200 text-blue-600 hover:bg-blue-50 bg-white"
: "border-gray-100 text-gray-300 bg-gray-50 cursor-not-allowed"
}`}
>
<RotateCcw className="h-3.5 w-3.5" />
Request Re-addition to Project
</button>
</span>
</TooltipTrigger>
{!canRequest && (
<TooltipContent side="top" className="text-xs">
Not available with read-only permissions
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
)}
</>
)}
{/* Reason dialog */}
<Dialog open={dialogOpen} onOpenChange={(v) => { if (!v) { setDialogOpen(false); setReason(""); setError(null); } }}>
{/* Shared dialog for removal and re-addition requests */}
<Dialog open={dialogType !== null} onOpenChange={(v) => { if (!v) closeDialog(); }}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="text-base font-semibold text-gray-800">
Request Removal from Project
{dialogType === "re_addition" ? "Request Re-addition to Project" : "Request Removal from Project"}
</DialogTitle>
</DialogHeader>
<div className="space-y-3 py-2">
<p className="text-xs text-gray-500 leading-relaxed">
Please provide a reason why this property should be removed from the project. This will be recorded for audit purposes.
{dialogType === "re_addition"
? "Please provide a reason why this property should be re-added to the project. This will be recorded for audit purposes."
: "Please provide a reason why this property should be removed from the project. This will be recorded for audit purposes."}
</p>
<textarea
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm text-gray-800 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-red-200 focus:border-red-300 resize-none"
placeholder="Reason for removal…"
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm text-gray-800 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-200 focus:border-blue-300 resize-none"
placeholder={dialogType === "re_addition" ? "Reason for re-addition…" : "Reason for removal…"}
rows={4}
value={reason}
onChange={(e) => setReason(e.target.value)}
@ -240,7 +299,7 @@ function RemovalRequestSection({
</div>
<DialogFooter className="gap-2">
<button
onClick={() => { setDialogOpen(false); setReason(""); setError(null); }}
onClick={closeDialog}
className="text-xs font-medium px-4 py-2 rounded-lg border border-gray-200 text-gray-600 hover:bg-gray-50 transition-colors"
>
Cancel
@ -248,7 +307,11 @@ function RemovalRequestSection({
<button
onClick={handleSubmit}
disabled={!reason.trim() || submitting}
className="text-xs font-medium px-4 py-2 rounded-lg bg-red-600 text-white hover:bg-red-700 disabled:opacity-50 transition-colors"
className={`text-xs font-medium px-4 py-2 rounded-lg text-white disabled:opacity-50 transition-colors ${
dialogType === "re_addition"
? "bg-blue-600 hover:bg-blue-700"
: "bg-red-600 hover:bg-red-700"
}`}
>
{submitting ? "Submitting…" : "Submit Request"}
</button>

View file

@ -375,7 +375,7 @@ export function computeOutcomeSlices(deals: ClassifiedDeal[]): OutcomeSlice[] {
// -----------------------------------------------------------------------
export function computeLiveTrackerData(
rawDeals: HubspotDeal[]
): Omit<LiveTrackerProps, "docStatusMap" | "userCapability" | "approvalsByDeal" | "portfolioId"> {
): Omit<LiveTrackerProps, "docStatusMap" | "userCapability" | "approvalsByDeal" | "portfolioId" | "userRole" | "userEmail"> {
// Classify all deals (add displayStage field)
const classified = classifyDeals(rawDeals);

View file

@ -175,6 +175,7 @@ export type ApprovalsByDeal = Record<string, string[]>;
export type RemovalRequest = {
id: string;
hubspotDealId: string;
type: "removal" | "re_addition";
status: "pending" | "approved" | "declined";
reason: string;
requestedByEmail: string;