diff --git a/src/app/api/live-tracking/property-documents/route.ts b/src/app/api/live-tracking/property-documents/route.ts
index 912dba8..81081a4 100644
--- a/src/app/api/live-tracking/property-documents/route.ts
+++ b/src/app/api/live-tracking/property-documents/route.ts
@@ -31,6 +31,7 @@ export async function GET(req: Request) {
fileType: uploadedFiles.fileType,
uprn: uploadedFiles.uprn,
landlordPropertyId: uploadedFiles.landlordPropertyId,
+ measureName: uploadedFiles.measureName,
})
.from(uploadedFiles)
.where(condition);
@@ -43,6 +44,7 @@ export async function GET(req: Request) {
s3UploadTimestamp: row.s3UploadTimestamp.toISOString(),
uprn: row.uprn !== null ? String(row.uprn) : null,
landlordPropertyId: row.landlordPropertyId,
+ measureName: row.measureName ?? null,
}));
return NextResponse.json(documents);
diff --git a/src/app/api/portfolio/[portfolioId]/approvals/route.ts b/src/app/api/portfolio/[portfolioId]/approvals/route.ts
new file mode 100644
index 0000000..c4b3933
--- /dev/null
+++ b/src/app/api/portfolio/[portfolioId]/approvals/route.ts
@@ -0,0 +1,215 @@
+import { db } from "@/app/db/db";
+import { NextRequest, NextResponse } from "next/server";
+import {
+ dealMeasureApprovals,
+ dealMeasureApprovalEvents,
+} from "@/app/db/schema/approvals";
+import { portfolioCapabilities } from "@/app/db/schema/portfolio";
+import { user } from "@/app/db/schema/users";
+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";
+
+async function getRequestingUserId(email: string): Promise {
+ const rows = await db
+ .select({ id: user.id })
+ .from(user)
+ .where(eq(user.email, email))
+ .limit(1);
+ return rows[0]?.id ?? null;
+}
+
+async function hasApproverCapability(
+ portfolioId: bigint,
+ userId: bigint,
+): Promise {
+ const rows = await db
+ .select({ id: portfolioCapabilities.id })
+ .from(portfolioCapabilities)
+ .where(
+ and(
+ eq(portfolioCapabilities.portfolioId, portfolioId),
+ eq(portfolioCapabilities.userId, userId),
+ eq(portfolioCapabilities.capability, "approver"),
+ ),
+ )
+ .limit(1);
+ return rows.length > 0;
+}
+
+// GET — return currently approved measures per deal, and optionally the audit event log
+// Query params:
+// dealIds comma-separated HubSpot deal IDs (required)
+// include "events" to also return the audit log
+export async function GET(
+ req: NextRequest,
+ props: { params: Promise<{ portfolioId: string }> },
+) {
+ const url = new URL(req.url);
+ const dealIdsParam = url.searchParams.get("dealIds");
+ const includeEvents = url.searchParams.get("include") === "events";
+
+ if (!dealIdsParam) {
+ return NextResponse.json(includeEvents ? { approved: {}, events: [] } : {});
+ }
+
+ const dealIds = dealIdsParam.split(",").filter(Boolean);
+ if (dealIds.length === 0) {
+ return NextResponse.json(includeEvents ? { approved: {}, events: [] } : {});
+ }
+
+ try {
+ // Current approved measures
+ const approvalRows = await db
+ .select({
+ hubspotDealId: dealMeasureApprovals.hubspotDealId,
+ measureName: dealMeasureApprovals.measureName,
+ approvedByEmail: user.email,
+ approvedByName: user.firstName,
+ approvedAt: dealMeasureApprovals.approvedAt,
+ })
+ .from(dealMeasureApprovals)
+ .leftJoin(user, eq(user.id, dealMeasureApprovals.approvedBy))
+ .where(
+ and(
+ inArray(dealMeasureApprovals.hubspotDealId, dealIds),
+ eq(dealMeasureApprovals.isApproved, true),
+ ),
+ );
+
+ const approved: Record = {};
+ for (const row of approvalRows) {
+ (approved[row.hubspotDealId] ??= []).push(row.measureName);
+ }
+
+ if (!includeEvents) {
+ return NextResponse.json(approved);
+ }
+
+ // Audit event log
+ const eventRows = await db
+ .select({
+ id: dealMeasureApprovalEvents.id,
+ hubspotDealId: dealMeasureApprovalEvents.hubspotDealId,
+ measureName: dealMeasureApprovalEvents.measureName,
+ action: dealMeasureApprovalEvents.action,
+ actedByEmail: user.email,
+ actedByName: user.firstName,
+ actedAt: dealMeasureApprovalEvents.actedAt,
+ })
+ .from(dealMeasureApprovalEvents)
+ .leftJoin(user, eq(user.id, dealMeasureApprovalEvents.actedBy))
+ .where(inArray(dealMeasureApprovalEvents.hubspotDealId, dealIds))
+ .orderBy(dealMeasureApprovalEvents.actedAt);
+
+ const events = eventRows.map((e) => ({
+ id: e.id.toString(),
+ hubspotDealId: e.hubspotDealId,
+ measureName: e.measureName,
+ action: e.action,
+ actedByEmail: e.actedByEmail ?? "",
+ actedByName: e.actedByName ?? null,
+ actedAt: e.actedAt.toISOString(),
+ }));
+
+ return NextResponse.json({ approved, events });
+ } catch (err) {
+ console.error("GET /approvals error:", err);
+ return NextResponse.json(
+ { error: "Failed to fetch approvals" },
+ { status: 500 },
+ );
+ }
+}
+
+// POST — apply explicit approve/unapprove changes, updating current state + audit log
+// Body: { changes: [{ hubspotDealId, measureName, approved: boolean }] }
+export async function POST(
+ req: NextRequest,
+ props: { params: Promise<{ portfolioId: string }> },
+) {
+ const session = await getServerSession(AuthOptions);
+ if (!session?.user?.email) {
+ return NextResponse.json({ error: "Unauthorised" }, { status: 401 });
+ }
+
+ const { portfolioId } = await props.params;
+ const pId = BigInt(portfolioId);
+
+ const userId = await getRequestingUserId(session.user.email);
+ if (!userId) {
+ return NextResponse.json({ error: "User not found" }, { status: 404 });
+ }
+
+ const isApprover = await hasApproverCapability(pId, userId);
+ if (!isApprover) {
+ return NextResponse.json({ error: "Forbidden" }, { status: 403 });
+ }
+
+ const bodySchema = z.object({
+ changes: z.array(
+ z.object({
+ hubspotDealId: z.string(),
+ measureName: z.string(),
+ approved: z.boolean(),
+ }),
+ ),
+ });
+
+ let body: z.infer;
+ try {
+ body = bodySchema.parse(await req.json());
+ } catch {
+ return NextResponse.json({ error: "Invalid body" }, { status: 400 });
+ }
+
+ if (body.changes.length === 0) {
+ return NextResponse.json({ success: true });
+ }
+
+ try {
+ const now = new Date();
+
+ for (const change of body.changes) {
+ // 1. Upsert current state
+ await db
+ .insert(dealMeasureApprovals)
+ .values({
+ hubspotDealId: change.hubspotDealId,
+ measureName: change.measureName,
+ isApproved: change.approved,
+ approvedBy: userId,
+ approvedAt: now,
+ })
+ .onConflictDoUpdate({
+ target: [
+ dealMeasureApprovals.hubspotDealId,
+ dealMeasureApprovals.measureName,
+ ],
+ set: {
+ isApproved: change.approved,
+ approvedBy: userId,
+ approvedAt: now,
+ },
+ });
+
+ // 2. Append to audit log
+ await db.insert(dealMeasureApprovalEvents).values({
+ hubspotDealId: change.hubspotDealId,
+ measureName: change.measureName,
+ action: change.approved ? "approved" : "unapproved",
+ actedBy: userId,
+ actedAt: now,
+ });
+ }
+
+ return NextResponse.json({ success: true });
+ } catch (err) {
+ console.error("POST /approvals error:", err);
+ return NextResponse.json(
+ { error: "Failed to save approvals" },
+ { status: 500 },
+ );
+ }
+}
diff --git a/src/app/api/portfolio/[portfolioId]/capabilities/route.ts b/src/app/api/portfolio/[portfolioId]/capabilities/route.ts
new file mode 100644
index 0000000..8f7f8b0
--- /dev/null
+++ b/src/app/api/portfolio/[portfolioId]/capabilities/route.ts
@@ -0,0 +1,171 @@
+import { db } from "@/app/db/db";
+import { NextRequest, NextResponse } from "next/server";
+import {
+ portfolioUsers,
+ portfolioCapabilities,
+ PortfolioCapabilityType,
+} from "@/app/db/schema/portfolio";
+import { user } from "@/app/db/schema/users";
+import { and, eq } from "drizzle-orm";
+import { z } from "zod";
+import { getServerSession } from "next-auth";
+import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
+
+const CAPABILITY_OPTIONS = ["approver", "contractor"] as const;
+
+async function getRequestingUserRole(portfolioId: bigint, email: string) {
+ const rows = await db
+ .select({ role: portfolioUsers.role })
+ .from(portfolioUsers)
+ .innerJoin(user, eq(user.id, portfolioUsers.userId))
+ .where(
+ and(
+ eq(portfolioUsers.portfolioId, portfolioId),
+ eq(user.email, email),
+ ),
+ )
+ .limit(1);
+ return rows[0]?.role ?? null;
+}
+
+// GET — list all capability assignments for this portfolio
+export async function GET(
+ _req: NextRequest,
+ props: { params: Promise<{ portfolioId: string }> },
+) {
+ const { portfolioId } = await props.params;
+
+ try {
+ const rows = await db
+ .select({
+ id: portfolioCapabilities.id,
+ userId: portfolioCapabilities.userId,
+ capability: portfolioCapabilities.capability,
+ name: user.firstName,
+ email: user.email,
+ })
+ .from(portfolioCapabilities)
+ .leftJoin(user, eq(user.id, portfolioCapabilities.userId))
+ .where(eq(portfolioCapabilities.portfolioId, BigInt(portfolioId)));
+
+ return NextResponse.json(
+ rows.map((r) => ({
+ id: r.id?.toString(),
+ userId: r.userId?.toString(),
+ capability: r.capability,
+ name: r.name ?? null,
+ email: r.email ?? "",
+ })),
+ );
+ } catch (err) {
+ console.error("GET /capabilities error:", err);
+ return NextResponse.json(
+ { error: "Failed to fetch capabilities" },
+ { status: 500 },
+ );
+ }
+}
+
+// POST — assign a capability to a user
+export async function POST(
+ req: NextRequest,
+ props: { params: Promise<{ portfolioId: string }> },
+) {
+ const session = await getServerSession(AuthOptions);
+ if (!session?.user?.email) {
+ return NextResponse.json({ error: "Unauthorised" }, { status: 401 });
+ }
+
+ const { portfolioId } = await props.params;
+ const pId = BigInt(portfolioId);
+
+ const requestingRole = await getRequestingUserRole(pId, session.user.email);
+ if (requestingRole !== "admin" && requestingRole !== "creator") {
+ return NextResponse.json({ error: "Forbidden" }, { status: 403 });
+ }
+
+ const bodySchema = z.object({
+ userId: z.string(),
+ capability: z.enum(CAPABILITY_OPTIONS),
+ });
+
+ let body: z.infer;
+ try {
+ body = bodySchema.parse(await req.json());
+ } catch {
+ return NextResponse.json({ error: "Invalid body" }, { status: 400 });
+ }
+
+ try {
+ await db
+ .insert(portfolioCapabilities)
+ .values({
+ portfolioId: pId,
+ userId: BigInt(body.userId),
+ capability: body.capability as PortfolioCapabilityType,
+ })
+ .onConflictDoNothing();
+
+ return NextResponse.json({ success: true }, { status: 200 });
+ } catch (err) {
+ console.error("POST /capabilities error:", err);
+ return NextResponse.json(
+ { error: "Failed to assign capability" },
+ { status: 500 },
+ );
+ }
+}
+
+// DELETE — remove a capability from a user
+export async function DELETE(
+ req: NextRequest,
+ props: { params: Promise<{ portfolioId: string }> },
+) {
+ const session = await getServerSession(AuthOptions);
+ if (!session?.user?.email) {
+ return NextResponse.json({ error: "Unauthorised" }, { status: 401 });
+ }
+
+ const { portfolioId } = await props.params;
+ const pId = BigInt(portfolioId);
+
+ const requestingRole = await getRequestingUserRole(pId, session.user.email);
+ if (requestingRole !== "admin" && requestingRole !== "creator") {
+ return NextResponse.json({ error: "Forbidden" }, { status: 403 });
+ }
+
+ const bodySchema = z.object({
+ userId: z.string(),
+ capability: z.enum(CAPABILITY_OPTIONS),
+ });
+
+ let body: z.infer;
+ try {
+ body = bodySchema.parse(await req.json());
+ } catch {
+ return NextResponse.json({ error: "Invalid body" }, { status: 400 });
+ }
+
+ try {
+ await db
+ .delete(portfolioCapabilities)
+ .where(
+ and(
+ eq(portfolioCapabilities.portfolioId, pId),
+ eq(portfolioCapabilities.userId, BigInt(body.userId)),
+ eq(
+ portfolioCapabilities.capability,
+ body.capability as PortfolioCapabilityType,
+ ),
+ ),
+ );
+
+ return NextResponse.json({ success: true }, { status: 200 });
+ } catch (err) {
+ console.error("DELETE /capabilities error:", err);
+ return NextResponse.json(
+ { error: "Failed to remove capability" },
+ { status: 500 },
+ );
+ }
+}
diff --git a/src/app/api/portfolio/[portfolioId]/removal-requests/route.ts b/src/app/api/portfolio/[portfolioId]/removal-requests/route.ts
new file mode 100644
index 0000000..5b7aa0a
--- /dev/null
+++ b/src/app/api/portfolio/[portfolioId]/removal-requests/route.ts
@@ -0,0 +1,291 @@
+import { db } from "@/app/db/db";
+import { NextRequest, NextResponse } from "next/server";
+import { propertyRemovalRequests } from "@/app/db/schema/removal_requests";
+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";
+import { getServerSession } from "next-auth";
+import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
+
+const WRITE_ROLES = ["creator", "admin", "write"] as const;
+
+async function getRequestingUser(email: string) {
+ const rows = await db
+ .select({ id: user.id, email: user.email })
+ .from(user)
+ .where(eq(user.email, email))
+ .limit(1);
+ return rows[0] ?? null;
+}
+
+async function getUserRole(portfolioId: bigint, userId: bigint) {
+ const rows = await db
+ .select({ role: portfolioUsers.role })
+ .from(portfolioUsers)
+ .where(
+ and(
+ eq(portfolioUsers.portfolioId, portfolioId),
+ eq(portfolioUsers.userId, userId),
+ ),
+ )
+ .limit(1);
+ return rows[0]?.role ?? null;
+}
+
+async function hasApproverCapability(portfolioId: bigint, userId: bigint) {
+ const rows = await db
+ .select({ id: portfolioCapabilities.id })
+ .from(portfolioCapabilities)
+ .where(
+ and(
+ eq(portfolioCapabilities.portfolioId, portfolioId),
+ eq(portfolioCapabilities.userId, userId),
+ eq(portfolioCapabilities.capability, "approver"),
+ ),
+ )
+ .limit(1);
+ return rows.length > 0;
+}
+
+// GET /api/portfolio/[portfolioId]/removal-requests?dealId=xxx
+// Returns the most recent removal request for this deal (all statuses)
+export async function GET(
+ req: NextRequest,
+ props: { params: Promise<{ portfolioId: string }> },
+) {
+ const { portfolioId } = await props.params;
+ const dealId = req.nextUrl.searchParams.get("dealId");
+
+ if (!dealId) {
+ return NextResponse.json({ error: "dealId required" }, { status: 400 });
+ }
+
+ try {
+ const requester = user;
+ const reviewer = { ...user } as typeof user;
+
+ // Fetch all requests for this deal, most recent first, joining requester and reviewer emails
+ const rows = await db
+ .select({
+ id: propertyRemovalRequests.id,
+ hubspotDealId: propertyRemovalRequests.hubspotDealId,
+ reason: propertyRemovalRequests.reason,
+ status: propertyRemovalRequests.status,
+ requestedAt: propertyRemovalRequests.requestedAt,
+ reviewedAt: propertyRemovalRequests.reviewedAt,
+ requestedByEmail: user.email,
+ })
+ .from(propertyRemovalRequests)
+ .innerJoin(user, eq(user.id, propertyRemovalRequests.requestedBy))
+ .where(
+ and(
+ eq(propertyRemovalRequests.hubspotDealId, dealId),
+ eq(propertyRemovalRequests.portfolioId, BigInt(portfolioId)),
+ ),
+ )
+ .orderBy(desc(propertyRemovalRequests.requestedAt))
+ .limit(10);
+
+ // For rows with a reviewer, fetch their email separately
+ const requests = await Promise.all(
+ rows.map(async (row) => {
+ // Find the full row to get reviewedBy
+ const fullRow = await db
+ .select({ reviewedBy: propertyRemovalRequests.reviewedBy })
+ .from(propertyRemovalRequests)
+ .where(eq(propertyRemovalRequests.id, row.id))
+ .limit(1);
+
+ let reviewedByEmail: string | null = null;
+ if (fullRow[0]?.reviewedBy) {
+ const reviewerRow = await db
+ .select({ email: user.email })
+ .from(user)
+ .where(eq(user.id, fullRow[0].reviewedBy))
+ .limit(1);
+ reviewedByEmail = reviewerRow[0]?.email ?? null;
+ }
+
+ return {
+ id: String(row.id),
+ hubspotDealId: row.hubspotDealId,
+ reason: row.reason,
+ status: row.status,
+ requestedByEmail: row.requestedByEmail,
+ requestedAt: row.requestedAt?.toISOString() ?? null,
+ reviewedByEmail,
+ reviewedAt: row.reviewedAt?.toISOString() ?? null,
+ };
+ }),
+ );
+
+ return NextResponse.json({ requests });
+ } catch (err) {
+ console.error("[removal-requests GET]", err);
+ return NextResponse.json({ error: "Internal server error" }, { status: 500 });
+ }
+}
+
+const postSchema = z.object({
+ hubspotDealId: z.string().min(1),
+ reason: z.string().min(1, "Reason is required"),
+});
+
+// POST /api/portfolio/[portfolioId]/removal-requests
+// Submit a new removal request — requires write+ role
+export async function POST(
+ req: NextRequest,
+ props: { params: Promise<{ portfolioId: string }> },
+) {
+ const { portfolioId } = await props.params;
+ const session = await getServerSession(AuthOptions);
+
+ if (!session?.user?.email) {
+ return NextResponse.json({ error: "Unauthorised" }, { status: 401 });
+ }
+
+ const requestingUser = await getRequestingUser(session.user.email);
+ if (!requestingUser) {
+ 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])) {
+ return NextResponse.json(
+ { error: "Write access required to submit a removal request" },
+ { status: 403 },
+ );
+ }
+
+ let body: unknown;
+ try {
+ body = await req.json();
+ } catch {
+ return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
+ }
+
+ const parsed = postSchema.safeParse(body);
+ if (!parsed.success) {
+ return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
+ }
+
+ const { hubspotDealId, reason } = parsed.data;
+
+ try {
+ // Block if there's already a pending request for this deal
+ const existing = await db
+ .select({ id: propertyRemovalRequests.id })
+ .from(propertyRemovalRequests)
+ .where(
+ and(
+ eq(propertyRemovalRequests.hubspotDealId, hubspotDealId),
+ eq(propertyRemovalRequests.portfolioId, BigInt(portfolioId)),
+ eq(propertyRemovalRequests.status, "pending"),
+ ),
+ )
+ .limit(1);
+
+ if (existing.length > 0) {
+ return NextResponse.json(
+ { error: "A pending removal request already exists for this property" },
+ { status: 409 },
+ );
+ }
+
+ const [inserted] = await db
+ .insert(propertyRemovalRequests)
+ .values({
+ hubspotDealId,
+ portfolioId: BigInt(portfolioId),
+ reason,
+ status: "pending",
+ requestedBy: requestingUser.id,
+ })
+ .returning();
+
+ return NextResponse.json({ success: true, id: String(inserted.id) });
+ } catch (err) {
+ console.error("[removal-requests POST]", err);
+ return NextResponse.json({ error: "Internal server error" }, { status: 500 });
+ }
+}
+
+const patchSchema = z.object({
+ requestId: z.number().int().positive(),
+ action: z.enum(["approved", "declined"]),
+});
+
+// PATCH /api/portfolio/[portfolioId]/removal-requests
+// Approve or decline a pending request — requires approver capability
+export async function PATCH(
+ req: NextRequest,
+ props: { params: Promise<{ portfolioId: string }> },
+) {
+ const { portfolioId } = await props.params;
+ const session = await getServerSession(AuthOptions);
+
+ if (!session?.user?.email) {
+ return NextResponse.json({ error: "Unauthorised" }, { status: 401 });
+ }
+
+ const requestingUser = await getRequestingUser(session.user.email);
+ if (!requestingUser) {
+ return NextResponse.json({ error: "User not found" }, { status: 401 });
+ }
+
+ const isApprover = await hasApproverCapability(BigInt(portfolioId), requestingUser.id);
+ if (!isApprover) {
+ return NextResponse.json(
+ { error: "Approver capability required to review a removal request" },
+ { status: 403 },
+ );
+ }
+
+ let body: unknown;
+ try {
+ body = await req.json();
+ } catch {
+ return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
+ }
+
+ const parsed = patchSchema.safeParse(body);
+ if (!parsed.success) {
+ return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
+ }
+
+ const { requestId, action } = parsed.data;
+
+ try {
+ const target = await db
+ .select({ id: propertyRemovalRequests.id, status: propertyRemovalRequests.status })
+ .from(propertyRemovalRequests)
+ .where(eq(propertyRemovalRequests.id, BigInt(requestId)))
+ .limit(1);
+
+ if (!target.length) {
+ return NextResponse.json({ error: "Request not found" }, { status: 404 });
+ }
+
+ if (target[0].status !== "pending") {
+ return NextResponse.json(
+ { error: "Only pending requests can be reviewed" },
+ { status: 409 },
+ );
+ }
+
+ await db
+ .update(propertyRemovalRequests)
+ .set({
+ status: action,
+ reviewedBy: requestingUser.id,
+ reviewedAt: new Date(),
+ })
+ .where(eq(propertyRemovalRequests.id, BigInt(requestId)));
+
+ return NextResponse.json({ success: true });
+ } catch (err) {
+ console.error("[removal-requests PATCH]", err);
+ return NextResponse.json({ error: "Internal server error" }, { status: 500 });
+ }
+}
diff --git a/src/app/api/upload/contractor-install/route.ts b/src/app/api/upload/contractor-install/route.ts
new file mode 100644
index 0000000..c3c3fa0
--- /dev/null
+++ b/src/app/api/upload/contractor-install/route.ts
@@ -0,0 +1,111 @@
+import { db } from "@/app/db/db";
+import { NextRequest, NextResponse } from "next/server";
+import { uploadedFiles } from "@/app/db/schema/uploaded_files";
+import { user } from "@/app/db/schema/users";
+import { eq, inArray } from "drizzle-orm";
+import { z } from "zod";
+import { getServerSession } from "next-auth";
+import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
+
+// POST — record a contractor install document in uploaded_files (fileType optional — can be classified later)
+export async function POST(req: NextRequest) {
+ const session = await getServerSession(AuthOptions);
+ if (!session?.user?.email) {
+ return NextResponse.json({ error: "Unauthorised" }, { status: 401 });
+ }
+
+ const bodySchema = z.object({
+ s3FileKey: z.string(),
+ s3FileBucket: z.string(),
+ fileType: z.string().optional(), // optional — null means unclassified
+ measureName: z.string().optional(),
+ uprn: z.string().optional(),
+ hubspotDealId: z.string().optional(),
+ landlordPropertyId: z.string().optional(),
+ });
+
+ let body: z.infer;
+ try {
+ body = bodySchema.parse(await req.json());
+ } catch {
+ return NextResponse.json({ error: "Invalid body" }, { status: 400 });
+ }
+
+ try {
+ const userRow = await db
+ .select({ id: user.id })
+ .from(user)
+ .where(eq(user.email, session.user.email))
+ .limit(1);
+
+ const uploadedBy = userRow[0]?.id ?? null;
+
+ const [inserted] = await db
+ .insert(uploadedFiles)
+ .values({
+ s3FileBucket: body.s3FileBucket,
+ s3FileKey: body.s3FileKey,
+ s3UploadTimestamp: new Date(),
+ fileType: (body.fileType as any) ?? null,
+ source: "contractor",
+ measureName: body.measureName ?? null,
+ uploadedBy: uploadedBy ?? undefined,
+ uprn: body.uprn ? BigInt(body.uprn) : undefined,
+ hubsotDealId: body.hubspotDealId ?? null,
+ landlordPropertyId: body.landlordPropertyId ?? null,
+ })
+ .returning({ id: uploadedFiles.id });
+
+ return NextResponse.json({ id: inserted.id.toString() }, { status: 201 });
+ } catch (err) {
+ console.error("POST /upload/contractor-install error:", err);
+ return NextResponse.json({ error: "Failed to record upload" }, { status: 500 });
+ }
+}
+
+// PATCH — update fileType and measureName for previously unclassified uploads
+export async function PATCH(req: NextRequest) {
+ const session = await getServerSession(AuthOptions);
+ if (!session?.user?.email) {
+ return NextResponse.json({ error: "Unauthorised" }, { status: 401 });
+ }
+
+ const bodySchema = z.object({
+ updates: z.array(
+ z.object({
+ id: z.string(),
+ fileType: z.string(),
+ measureName: z.string().optional(),
+ }),
+ ),
+ });
+
+ let body: z.infer;
+ try {
+ body = bodySchema.parse(await req.json());
+ } catch {
+ return NextResponse.json({ error: "Invalid body" }, { status: 400 });
+ }
+
+ if (body.updates.length === 0) {
+ return NextResponse.json({ success: true });
+ }
+
+ try {
+ // Update each record individually (small batches — no bulk update without raw SQL)
+ for (const update of body.updates) {
+ await db
+ .update(uploadedFiles)
+ .set({
+ fileType: update.fileType as any,
+ measureName: update.measureName ?? null,
+ })
+ .where(eq(uploadedFiles.id, BigInt(update.id)));
+ }
+
+ return NextResponse.json({ success: true });
+ } catch (err) {
+ console.error("PATCH /upload/contractor-install error:", err);
+ return NextResponse.json({ error: "Failed to update classifications" }, { status: 500 });
+ }
+}
diff --git a/src/app/db/migrations/0178_parched_midnight.sql b/src/app/db/migrations/0178_parched_midnight.sql
new file mode 100644
index 0000000..026250e
--- /dev/null
+++ b/src/app/db/migrations/0178_parched_midnight.sql
@@ -0,0 +1,27 @@
+CREATE TABLE "property_removal_requests" (
+ "id" bigserial PRIMARY KEY NOT NULL,
+ "hubspot_deal_id" text NOT NULL,
+ "portfolio_id" bigint NOT NULL,
+ "reason" text NOT NULL,
+ "status" text DEFAULT 'pending' NOT NULL,
+ "requested_by" bigint NOT NULL,
+ "requested_at" timestamp with time zone DEFAULT now() NOT NULL,
+ "reviewed_by" bigint,
+ "reviewed_at" timestamp with time zone
+);
+--> statement-breakpoint
+ALTER TABLE "installed_measure" ALTER COLUMN "measure_type" SET DATA TYPE text;--> statement-breakpoint
+DROP TYPE "public"."measure_type";--> statement-breakpoint
+CREATE TYPE "public"."measure_type" AS ENUM('air_source_heat_pump', 'boiler_upgrade', 'high_heat_retention_storage_heaters', 'secondary_heating', 'roomstat_programmer_trvs', 'time_temperature_zone_control', 'cylinder_thermostat', 'cavity_wall_insulation', 'extension_cavity_wall_insulation', 'external_wall_insulation', 'internal_wall_insulation', 'loft_insulation', 'flat_roof_insulation', 'room_roof_insulation', 'solid_floor_insulation', 'suspended_floor_insulation', 'double_glazing', 'secondary_glazing', 'draught_proofing', 'mechanical_ventilation', 'low_energy_lighting', 'solar_pv', 'hot_water_tank_insulation', 'sealing_open_fireplace');--> statement-breakpoint
+ALTER TABLE "installed_measure" ALTER COLUMN "measure_type" SET DATA TYPE "public"."measure_type" USING "measure_type"::"public"."measure_type";--> statement-breakpoint
+ALTER TABLE "uploaded_files" ALTER COLUMN "file_type" SET DATA TYPE text;--> statement-breakpoint
+DROP TYPE "public"."file_type";--> statement-breakpoint
+CREATE TYPE "public"."file_type" AS ENUM('photo_pack', 'site_note', 'rd_sap_site_note', 'pas_2023_ventilation', 'pas_2023_condition', 'pas_significance', 'par_photo_pack', 'pas_2023_property', 'pas_2023_occupancy', 'ecmk_site_note', 'ecmk_rd_sap_site_note', 'ecmk_survey_xml', 'pre_photo', 'mid_photo', 'post_photo', 'loft_hatch_photo', 'dmev_photos', 'door_undercut_photos', 'trickle_vent_photos', 'pre_installation_building_inspection', 'point_of_work_risk_assessment', 'claim_of_compliance', 'mcs_compliance_certificate', 'certificate_of_conformity', 'minor_works_electrical_certificate', 'trustmark_licence_numbers', 'operative_competency', 'ventilation_assessment_checklist', 'anemometer_readings', 'commissioning_records', 'part_f_ventilation_document', 'handover_pack', 'insurance_guarantee', 'workmanship_warranty', 'g98_notification', 'installer_qualifications', 'installer_feedback', 'contractor_other');--> statement-breakpoint
+ALTER TABLE "uploaded_files" ALTER COLUMN "file_type" SET DATA TYPE "public"."file_type" USING "file_type"::"public"."file_type";--> statement-breakpoint
+ALTER TABLE "property_removal_requests" ADD CONSTRAINT "property_removal_requests_portfolio_id_portfolio_id_fk" FOREIGN KEY ("portfolio_id") REFERENCES "public"."portfolio"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "property_removal_requests" ADD CONSTRAINT "property_removal_requests_requested_by_user_id_fk" FOREIGN KEY ("requested_by") REFERENCES "public"."user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "property_removal_requests" ADD CONSTRAINT "property_removal_requests_reviewed_by_user_id_fk" FOREIGN KEY ("reviewed_by") REFERENCES "public"."user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
+CREATE INDEX "idx_removal_requests_deal_id" ON "property_removal_requests" USING btree ("hubspot_deal_id");--> statement-breakpoint
+CREATE INDEX "idx_removal_requests_portfolio_id" ON "property_removal_requests" USING btree ("portfolio_id");--> statement-breakpoint
+ALTER TABLE "bulk_address_uploads" DROP COLUMN "task_id";--> statement-breakpoint
+ALTER TABLE "bulk_address_uploads" DROP COLUMN "combined_output_s3_uri";
\ No newline at end of file
diff --git a/src/app/db/migrations/meta/0178_snapshot.json b/src/app/db/migrations/meta/0178_snapshot.json
new file mode 100644
index 0000000..d380b0d
--- /dev/null
+++ b/src/app/db/migrations/meta/0178_snapshot.json
@@ -0,0 +1,6898 @@
+{
+ "id": "eed32c53-4a51-451e-9898-5b2bd962bae7",
+ "prevId": "1099360c-ef3d-4381-ac80-28cb4475b1c7",
+ "version": "7",
+ "dialect": "postgresql",
+ "tables": {
+ "public.postcode_search": {
+ "name": "postcode_search",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "postcode": {
+ "name": "postcode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "result_data": {
+ "name": "result_data",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "last_updated_at": {
+ "name": "last_updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "postcode_search_postcode_unique": {
+ "name": "postcode_search_postcode_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "postcode"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.deal_measure_approval_events": {
+ "name": "deal_measure_approval_events",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "bigserial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "hubspot_deal_id": {
+ "name": "hubspot_deal_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "measure_name": {
+ "name": "measure_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "action": {
+ "name": "action",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "acted_by": {
+ "name": "acted_by",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "acted_at": {
+ "name": "acted_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "idx_deal_measure_events_deal_id": {
+ "name": "idx_deal_measure_events_deal_id",
+ "columns": [
+ {
+ "expression": "hubspot_deal_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_deal_measure_events_acted_at": {
+ "name": "idx_deal_measure_events_acted_at",
+ "columns": [
+ {
+ "expression": "acted_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "deal_measure_approval_events_acted_by_user_id_fk": {
+ "name": "deal_measure_approval_events_acted_by_user_id_fk",
+ "tableFrom": "deal_measure_approval_events",
+ "tableTo": "user",
+ "columnsFrom": [
+ "acted_by"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.deal_measure_approvals": {
+ "name": "deal_measure_approvals",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "bigserial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "hubspot_deal_id": {
+ "name": "hubspot_deal_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "measure_name": {
+ "name": "measure_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "is_approved": {
+ "name": "is_approved",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "approved_by": {
+ "name": "approved_by",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "approved_at": {
+ "name": "approved_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "idx_deal_measure_approvals_deal_id": {
+ "name": "idx_deal_measure_approvals_deal_id",
+ "columns": [
+ {
+ "expression": "hubspot_deal_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "deal_measure_approvals_approved_by_user_id_fk": {
+ "name": "deal_measure_approvals_approved_by_user_id_fk",
+ "tableFrom": "deal_measure_approvals",
+ "tableTo": "user",
+ "columnsFrom": [
+ "approved_by"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "uq_deal_measure": {
+ "name": "uq_deal_measure",
+ "nullsNotDistinct": false,
+ "columns": [
+ "hubspot_deal_id",
+ "measure_name"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.bulk_address_uploads": {
+ "name": "bulk_address_uploads",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "portfolio_id": {
+ "name": "portfolio_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "s3_bucket": {
+ "name": "s3_bucket",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "s3_key": {
+ "name": "s3_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "filename": {
+ "name": "filename",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'ready_for_processing'"
+ },
+ "source_headers": {
+ "name": "source_headers",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'"
+ },
+ "column_mapping": {
+ "name": "column_mapping",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.aspect_condition": {
+ "name": "aspect_condition",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "bigserial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "element_id": {
+ "name": "element_id",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "aspect_type": {
+ "name": "aspect_type",
+ "type": "aspect_type",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "aspect_instance": {
+ "name": "aspect_instance",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "quantity": {
+ "name": "quantity",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "install_date": {
+ "name": "install_date",
+ "type": "date",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "renewal_year": {
+ "name": "renewal_year",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "comments": {
+ "name": "comments",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "aspect_condition_element_id_element_id_fk": {
+ "name": "aspect_condition_element_id_element_id_fk",
+ "tableFrom": "aspect_condition",
+ "tableTo": "element",
+ "columnsFrom": [
+ "element_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.element": {
+ "name": "element",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "bigserial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "survey_id": {
+ "name": "survey_id",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "element_type": {
+ "name": "element_type",
+ "type": "element_type",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "element_instance": {
+ "name": "element_instance",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "element_survey_id_property_condition_survey_id_fk": {
+ "name": "element_survey_id_property_condition_survey_id_fk",
+ "tableFrom": "element",
+ "tableTo": "property_condition_survey",
+ "columnsFrom": [
+ "survey_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.property_condition_survey": {
+ "name": "property_condition_survey",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "bigserial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "uprn": {
+ "name": "uprn",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "date": {
+ "name": "date",
+ "type": "date",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "source": {
+ "name": "source",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.hubspot_company_data": {
+ "name": "hubspot_company_data",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "company_name": {
+ "name": "company_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "group_id": {
+ "name": "group_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp (6) with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp (6) with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.hubspot_deal_data": {
+ "name": "hubspot_deal_data",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "deal_id": {
+ "name": "deal_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "dealname": {
+ "name": "dealname",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "dealstage": {
+ "name": "dealstage",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "project_code": {
+ "name": "project_code",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "landlord_property_id": {
+ "name": "landlord_property_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "listing_id": {
+ "name": "listing_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "uprn": {
+ "name": "uprn",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "outcome": {
+ "name": "outcome",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "outcome_notes": {
+ "name": "outcome_notes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "major_condition_issue_description": {
+ "name": "major_condition_issue_description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "major_condition_issue_photos": {
+ "name": "major_condition_issue_photos",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "major_condition_issue_evidence_s3_url": {
+ "name": "major_condition_issue_evidence_s3_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "coordination_status": {
+ "name": "coordination_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "design_status": {
+ "name": "design_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "pashub_link": {
+ "name": "pashub_link",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "sharepoint_link": {
+ "name": "sharepoint_link",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "dampmould_growth": {
+ "name": "dampmould_growth",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "pre_sap": {
+ "name": "pre_sap",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "coordinator": {
+ "name": "coordinator",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "mtp_completion_date": {
+ "name": "mtp_completion_date",
+ "type": "timestamp (6) with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "mtp_re_model_completion_date": {
+ "name": "mtp_re_model_completion_date",
+ "type": "timestamp (6) with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "ioe_v3_completion_date": {
+ "name": "ioe_v3_completion_date",
+ "type": "timestamp (6) with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "proposed_measures": {
+ "name": "proposed_measures",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "approved_package": {
+ "name": "approved_package",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "designer": {
+ "name": "designer",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "design_type": {
+ "name": "design_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "design_completion_date": {
+ "name": "design_completion_date",
+ "type": "timestamp (6) with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "actual_measures_installed": {
+ "name": "actual_measures_installed",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "installer": {
+ "name": "installer",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "installer_handover": {
+ "name": "installer_handover",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "lodgement_status": {
+ "name": "lodgement_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "measures_lodgement_date": {
+ "name": "measures_lodgement_date",
+ "type": "timestamp (6) with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "lodgement_date": {
+ "name": "lodgement_date",
+ "type": "timestamp (6) with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "expected_commencement_date": {
+ "name": "expected_commencement_date",
+ "type": "timestamp (6) with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "coordination_comments": {
+ "name": "coordination_comments",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "surveyor": {
+ "name": "surveyor",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "damp_mould_and_repairs_comments": {
+ "name": "damp_mould_and_repairs_comments",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "confirmed_survey_date": {
+ "name": "confirmed_survey_date",
+ "type": "timestamp (6) with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "confirmed_survey_time": {
+ "name": "confirmed_survey_time",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "surveyed_date": {
+ "name": "surveyed_date",
+ "type": "timestamp (6) with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp (6) with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp (6) with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.property_status_tracker": {
+ "name": "property_status_tracker",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "hubspot_deal_id": {
+ "name": "hubspot_deal_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "property_id": {
+ "name": "property_id",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "portfolio_id": {
+ "name": "portfolio_id",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp (6) with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp (6) with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "property_status_tracker_property_id_property_id_fk": {
+ "name": "property_status_tracker_property_id_property_id_fk",
+ "tableFrom": "property_status_tracker",
+ "tableTo": "property",
+ "columnsFrom": [
+ "property_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "property_status_tracker_portfolio_id_portfolio_id_fk": {
+ "name": "property_status_tracker_portfolio_id_portfolio_id_fk",
+ "tableFrom": "property_status_tracker",
+ "tableTo": "portfolio",
+ "columnsFrom": [
+ "portfolio_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.energy_assessments": {
+ "name": "energy_assessments",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "bigserial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "uprn": {
+ "name": "uprn",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "uprn_source": {
+ "name": "uprn_source",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "property_type": {
+ "name": "property_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "building_reference_number": {
+ "name": "building_reference_number",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "current_energy_efficiency": {
+ "name": "current_energy_efficiency",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "current_energy_rating": {
+ "name": "current_energy_rating",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "address1": {
+ "name": "address1",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "address2": {
+ "name": "address2",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "address3": {
+ "name": "address3",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "posttown": {
+ "name": "posttown",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "postcode": {
+ "name": "postcode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "address": {
+ "name": "address",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "county": {
+ "name": "county",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "constituency": {
+ "name": "constituency",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "constituency_label": {
+ "name": "constituency_label",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "low_energy_fixed_light_count": {
+ "name": "low_energy_fixed_light_count",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "construction_age_band": {
+ "name": "construction_age_band",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "mainheat_energy_eff": {
+ "name": "mainheat_energy_eff",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "windows_env_eff": {
+ "name": "windows_env_eff",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "lighting_energy_eff": {
+ "name": "lighting_energy_eff",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "environment_impact_potential": {
+ "name": "environment_impact_potential",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "mainheatcont_description": {
+ "name": "mainheatcont_description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "sheating_energy_eff": {
+ "name": "sheating_energy_eff",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "local_authority": {
+ "name": "local_authority",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "local_authority_label": {
+ "name": "local_authority_label",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "fixed_lighting_outlets_count": {
+ "name": "fixed_lighting_outlets_count",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "energy_tariff": {
+ "name": "energy_tariff",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "mechanical_ventilation": {
+ "name": "mechanical_ventilation",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "solar_water_heating_flag": {
+ "name": "solar_water_heating_flag",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "co2_emissions_potential": {
+ "name": "co2_emissions_potential",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "number_heated_rooms": {
+ "name": "number_heated_rooms",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "floor_description": {
+ "name": "floor_description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "energy_consumption_potential": {
+ "name": "energy_consumption_potential",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "built_form": {
+ "name": "built_form",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "number_open_fireplaces": {
+ "name": "number_open_fireplaces",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "windows_description": {
+ "name": "windows_description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "glazed_area": {
+ "name": "glazed_area",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "inspection_date": {
+ "name": "inspection_date",
+ "type": "timestamp (6) with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "mains_gas_flag": {
+ "name": "mains_gas_flag",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "co2_emiss_curr_per_floor_area": {
+ "name": "co2_emiss_curr_per_floor_area",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "heat_loss_corridor": {
+ "name": "heat_loss_corridor",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "unheated_corridor_length": {
+ "name": "unheated_corridor_length",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "flat_storey_count": {
+ "name": "flat_storey_count",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "roof_energy_eff": {
+ "name": "roof_energy_eff",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "total_floor_area": {
+ "name": "total_floor_area",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "environment_impact_current": {
+ "name": "environment_impact_current",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "roof_description": {
+ "name": "roof_description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "floor_energy_eff": {
+ "name": "floor_energy_eff",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "number_habitable_rooms": {
+ "name": "number_habitable_rooms",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "hot_water_env_eff": {
+ "name": "hot_water_env_eff",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "mainheatc_energy_eff": {
+ "name": "mainheatc_energy_eff",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "main_fuel": {
+ "name": "main_fuel",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "lighting_env_eff": {
+ "name": "lighting_env_eff",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "windows_energy_eff": {
+ "name": "windows_energy_eff",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "floor_env_eff": {
+ "name": "floor_env_eff",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "sheating_env_eff": {
+ "name": "sheating_env_eff",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "lighting_description": {
+ "name": "lighting_description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "roof_env_eff": {
+ "name": "roof_env_eff",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "walls_energy_eff": {
+ "name": "walls_energy_eff",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "photo_supply": {
+ "name": "photo_supply",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "lighting_cost_potential": {
+ "name": "lighting_cost_potential",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "mainheat_env_eff": {
+ "name": "mainheat_env_eff",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "multi_glaze_proportion": {
+ "name": "multi_glaze_proportion",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "main_heating_controls": {
+ "name": "main_heating_controls",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "flat_top_storey": {
+ "name": "flat_top_storey",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "secondheat_description": {
+ "name": "secondheat_description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "walls_env_eff": {
+ "name": "walls_env_eff",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "transaction_type": {
+ "name": "transaction_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "extension_count": {
+ "name": "extension_count",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "mainheatc_env_eff": {
+ "name": "mainheatc_env_eff",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "lmk_key": {
+ "name": "lmk_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "wind_turbine_count": {
+ "name": "wind_turbine_count",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "tenure": {
+ "name": "tenure",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "floor_level": {
+ "name": "floor_level",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "potential_energy_efficiency": {
+ "name": "potential_energy_efficiency",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "potential_energy_rating": {
+ "name": "potential_energy_rating",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "hot_water_energy_eff": {
+ "name": "hot_water_energy_eff",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "low_energy_lighting": {
+ "name": "low_energy_lighting",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "walls_description": {
+ "name": "walls_description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "hotwater_description": {
+ "name": "hotwater_description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "co2_emissions_current": {
+ "name": "co2_emissions_current",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "heating_cost_current": {
+ "name": "heating_cost_current",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "heating_cost_potential": {
+ "name": "heating_cost_potential",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "hot_water_cost_current": {
+ "name": "hot_water_cost_current",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "hot_water_cost_potential": {
+ "name": "hot_water_cost_potential",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "lighting_cost_current": {
+ "name": "lighting_cost_current",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "energy_consumption_current": {
+ "name": "energy_consumption_current",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "lodgement_date": {
+ "name": "lodgement_date",
+ "type": "date",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "lodgement_datetime": {
+ "name": "lodgement_datetime",
+ "type": "timestamp (6)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "mainheat_description": {
+ "name": "mainheat_description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "floor_height": {
+ "name": "floor_height",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "glazed_type": {
+ "name": "glazed_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "file_location": {
+ "name": "file_location",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "surveyor_name": {
+ "name": "surveyor_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "surveyor_company": {
+ "name": "surveyor_company",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "space_heating_kwh": {
+ "name": "space_heating_kwh",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "water_heating_kwh": {
+ "name": "water_heating_kwh",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "number_of_doors": {
+ "name": "number_of_doors",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "number_of_insulated_doors": {
+ "name": "number_of_insulated_doors",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "number_of_floors": {
+ "name": "number_of_floors",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "insulation_wall_area": {
+ "name": "insulation_wall_area",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "heat_loss_perimeter": {
+ "name": "heat_loss_perimeter",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "party_wall_length": {
+ "name": "party_wall_length",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "perimeter": {
+ "name": "perimeter",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "rooms_with_bath_and_or_shower": {
+ "name": "rooms_with_bath_and_or_shower",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "rooms_with_mixer_shower_no_bath": {
+ "name": "rooms_with_mixer_shower_no_bath",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "room_with_bath_and_mixer_shower": {
+ "name": "room_with_bath_and_mixer_shower",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "percent_draftproofed": {
+ "name": "percent_draftproofed",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "has_hot_water_cylinder": {
+ "name": "has_hot_water_cylinder",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "cylinder_insulation_type": {
+ "name": "cylinder_insulation_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "cylinder_insulation_thickness": {
+ "name": "cylinder_insulation_thickness",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "cylinder_thermostat": {
+ "name": "cylinder_thermostat",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "main_dwelling_ground_floor_area": {
+ "name": "main_dwelling_ground_floor_area",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "number_of_windows": {
+ "name": "number_of_windows",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "windows_area": {
+ "name": "windows_area",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.energy_assessment_documents": {
+ "name": "energy_assessment_documents",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "bigserial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "uprn": {
+ "name": "uprn",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "energy_assessment_id": {
+ "name": "energy_assessment_id",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "document_type": {
+ "name": "document_type",
+ "type": "document_type",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "document_location": {
+ "name": "document_location",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "uploaded_at": {
+ "name": "uploaded_at",
+ "type": "timestamp (6) with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "scenario_id": {
+ "name": "scenario_id",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "energy_assessment_documents_energy_assessment_id_energy_assessments_id_fk": {
+ "name": "energy_assessment_documents_energy_assessment_id_energy_assessments_id_fk",
+ "tableFrom": "energy_assessment_documents",
+ "tableTo": "energy_assessments",
+ "columnsFrom": [
+ "energy_assessment_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "energy_assessment_documents_scenario_id_energy_assessment_scenarios_id_fk": {
+ "name": "energy_assessment_documents_scenario_id_energy_assessment_scenarios_id_fk",
+ "tableFrom": "energy_assessment_documents",
+ "tableTo": "energy_assessment_scenarios",
+ "columnsFrom": [
+ "scenario_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.energy_assessment_scenarios": {
+ "name": "energy_assessment_scenarios",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "bigserial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "scenario_name": {
+ "name": "scenario_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "energy_assessment_id": {
+ "name": "energy_assessment_id",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "energy_assessment_scenarios_energy_assessment_id_energy_assessments_id_fk": {
+ "name": "energy_assessment_scenarios_energy_assessment_id_energy_assessments_id_fk",
+ "tableFrom": "energy_assessment_scenarios",
+ "tableTo": "energy_assessments",
+ "columnsFrom": [
+ "energy_assessment_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.epc_store": {
+ "name": "epc_store",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "uprn": {
+ "name": "uprn",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "epc_api_created_at": {
+ "name": "epc_api_created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "epc_api": {
+ "name": "epc_api",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "epc_page_created_at": {
+ "name": "epc_page_created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "epc_page": {
+ "name": "epc_page",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "epc_page_rrn": {
+ "name": "epc_page_rrn",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "uq_epc_store_uprn": {
+ "name": "uq_epc_store_uprn",
+ "columns": [
+ {
+ "expression": "uprn",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.files_from_surveyor": {
+ "name": "files_from_surveyor",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "portfolio_id": {
+ "name": "portfolio_id",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "property_id": {
+ "name": "property_id",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "s3_json_url": {
+ "name": "s3_json_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "uploaded_at": {
+ "name": "uploaded_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "files_from_surveyor_portfolio_id_portfolio_id_fk": {
+ "name": "files_from_surveyor_portfolio_id_portfolio_id_fk",
+ "tableFrom": "files_from_surveyor",
+ "tableTo": "portfolio",
+ "columnsFrom": [
+ "portfolio_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "files_from_surveyor_property_id_property_id_fk": {
+ "name": "files_from_surveyor_property_id_property_id_fk",
+ "tableFrom": "files_from_surveyor",
+ "tableTo": "property",
+ "columnsFrom": [
+ "property_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.funding_package": {
+ "name": "funding_package",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "bigserial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "plan_id": {
+ "name": "plan_id",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "scheme": {
+ "name": "scheme",
+ "type": "scheme",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "project_funding": {
+ "name": "project_funding",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "total_uplift": {
+ "name": "total_uplift",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "full_project_score": {
+ "name": "full_project_score",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "partial_project_score": {
+ "name": "partial_project_score",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "uplift_project_score": {
+ "name": "uplift_project_score",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "funding_package_plan_id_plan_id_fk": {
+ "name": "funding_package_plan_id_plan_id_fk",
+ "tableFrom": "funding_package",
+ "tableTo": "plan",
+ "columnsFrom": [
+ "plan_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.funding_package_measures": {
+ "name": "funding_package_measures",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "bigserial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "funding_package_id": {
+ "name": "funding_package_id",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "measure": {
+ "name": "measure",
+ "type": "type",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "material_id": {
+ "name": "material_id",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "innovation_uplift": {
+ "name": "innovation_uplift",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "partial_project_score": {
+ "name": "partial_project_score",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "uplift_project_score": {
+ "name": "uplift_project_score",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "funding_package_measures_funding_package_id_funding_package_id_fk": {
+ "name": "funding_package_measures_funding_package_id_funding_package_id_fk",
+ "tableFrom": "funding_package_measures",
+ "tableTo": "funding_package",
+ "columnsFrom": [
+ "funding_package_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "funding_package_measures_material_id_material_id_fk": {
+ "name": "funding_package_measures_material_id_material_id_fk",
+ "tableFrom": "funding_package_measures",
+ "tableTo": "material",
+ "columnsFrom": [
+ "material_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.inspections": {
+ "name": "inspections",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "bigserial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "property_id": {
+ "name": "property_id",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "archetype": {
+ "name": "archetype",
+ "type": "inspection_archetype",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "archetype_2": {
+ "name": "archetype_2",
+ "type": "inspection_archetype_2",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "wall_construction": {
+ "name": "wall_construction",
+ "type": "inspections_wall_construction",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "insulation": {
+ "name": "insulation",
+ "type": "inspections_wall_insulation",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "insulation_material": {
+ "name": "insulation_material",
+ "type": "inspections_insulation_material",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "borescoped": {
+ "name": "borescoped",
+ "type": "inspection_borescoped",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "roof_orientation": {
+ "name": "roof_orientation",
+ "type": "inspections_roof_orientation",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "tile_hung": {
+ "name": "tile_hung",
+ "type": "inspections_tile_hung",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "rendered": {
+ "name": "rendered",
+ "type": "inspections_rendered",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "cladding": {
+ "name": "cladding",
+ "type": "inspections_cladding",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "access_issues": {
+ "name": "access_issues",
+ "type": "inspections_access_issues",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "notes": {
+ "name": "notes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "surveyor_name": {
+ "name": "surveyor_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "uploaded_at": {
+ "name": "uploaded_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "inspections_property_id_property_id_fk": {
+ "name": "inspections_property_id_property_id_fk",
+ "tableFrom": "inspections",
+ "tableTo": "property",
+ "columnsFrom": [
+ "property_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.material": {
+ "name": "material",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "bigserial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "type": {
+ "name": "type",
+ "type": "type",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "depth": {
+ "name": "depth",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "depth_unit": {
+ "name": "depth_unit",
+ "type": "depth_unit",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "cost_unit": {
+ "name": "cost_unit",
+ "type": "cost_unit",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "r_value_per_mm": {
+ "name": "r_value_per_mm",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "r_value_unit": {
+ "name": "r_value_unit",
+ "type": "r_value_unit",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "thermal_conductivity": {
+ "name": "thermal_conductivity",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "thermal_conductivity_unit": {
+ "name": "thermal_conductivity_unit",
+ "type": "thermal_conductivity_unit",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "link": {
+ "name": "link",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "is_active": {
+ "name": "is_active",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "prime_material_cost": {
+ "name": "prime_material_cost",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "material_cost": {
+ "name": "material_cost",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "labour_cost": {
+ "name": "labour_cost",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "labour_hours_per_unit": {
+ "name": "labour_hours_per_unit",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "plant_cost": {
+ "name": "plant_cost",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "total_cost": {
+ "name": "total_cost",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "cost": {
+ "name": "cost",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "notes": {
+ "name": "notes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "is_installer_quote": {
+ "name": "is_installer_quote",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false,
+ "default": false
+ },
+ "innovation_rate": {
+ "name": "innovation_rate",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false,
+ "default": 0
+ },
+ "size": {
+ "name": "size",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "size_unit": {
+ "name": "size_unit",
+ "type": "size_unit",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "includes_scaffolding": {
+ "name": "includes_scaffolding",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false,
+ "default": false
+ },
+ "includes_battery": {
+ "name": "includes_battery",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false,
+ "default": false
+ },
+ "battery_size": {
+ "name": "battery_size",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.organisation": {
+ "name": "organisation",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp (6) with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp (6) with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "hubspot_company_id": {
+ "name": "hubspot_company_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.portfolio_organisation": {
+ "name": "portfolio_organisation",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "portfolio_id": {
+ "name": "portfolio_id",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "organisation_id": {
+ "name": "organisation_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp (6) with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp (6) with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "portfolio_organisation_portfolio_id_portfolio_id_fk": {
+ "name": "portfolio_organisation_portfolio_id_portfolio_id_fk",
+ "tableFrom": "portfolio_organisation",
+ "tableTo": "portfolio",
+ "columnsFrom": [
+ "portfolio_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "portfolio_organisation_organisation_id_organisation_id_fk": {
+ "name": "portfolio_organisation_organisation_id_organisation_id_fk",
+ "tableFrom": "portfolio_organisation",
+ "tableTo": "organisation",
+ "columnsFrom": [
+ "organisation_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "portfolio_organisation_portfolio_id_unique": {
+ "name": "portfolio_organisation_portfolio_id_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "portfolio_id"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.portfolio": {
+ "name": "portfolio",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "bigserial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "budget": {
+ "name": "budget",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "status": {
+ "name": "status",
+ "type": "status",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "goal": {
+ "name": "goal",
+ "type": "goal",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "cost": {
+ "name": "cost",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "number_of_properties": {
+ "name": "number_of_properties",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "co2_equivalent_savings": {
+ "name": "co2_equivalent_savings",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "energy_savings": {
+ "name": "energy_savings",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "energy_cost_savings": {
+ "name": "energy_cost_savings",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "property_valuation_increase": {
+ "name": "property_valuation_increase",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "rental_yield_increase": {
+ "name": "rental_yield_increase",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "total_work_hours": {
+ "name": "total_work_hours",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "labour_days": {
+ "name": "labour_days",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp (6) with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp (6) with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "epc_breakdown_pre_retrofit": {
+ "name": "epc_breakdown_pre_retrofit",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "epc_breakdown_post_retrofit": {
+ "name": "epc_breakdown_post_retrofit",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "n_units_to_retrofit": {
+ "name": "n_units_to_retrofit",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "co2_per_unit_pre_retrofit": {
+ "name": "co2_per_unit_pre_retrofit",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "co2_per_unit_post_retrofit": {
+ "name": "co2_per_unit_post_retrofit",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "energy_bill_per_unit_pre_retrofit": {
+ "name": "energy_bill_per_unit_pre_retrofit",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "energy_bill_per_unit_post_retrofit": {
+ "name": "energy_bill_per_unit_post_retrofit",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "energy_consumption_per_unit_pre_retrofit": {
+ "name": "energy_consumption_per_unit_pre_retrofit",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "energy_consumption_per_unit_post_retrofit": {
+ "name": "energy_consumption_per_unit_post_retrofit",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "valuation_improvement_per_unit": {
+ "name": "valuation_improvement_per_unit",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "cost_per_unit": {
+ "name": "cost_per_unit",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "cost_per_co2_saved": {
+ "name": "cost_per_co2_saved",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "cost_per_sap_point": {
+ "name": "cost_per_sap_point",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "valuation_return_on_investment": {
+ "name": "valuation_return_on_investment",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.portfolio_capabilities": {
+ "name": "portfolio_capabilities",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "bigserial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "portfolio_id": {
+ "name": "portfolio_id",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "capability": {
+ "name": "capability",
+ "type": "portfolio_capability",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp (6) with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp (6) with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "portfolio_capabilities_user_id_user_id_fk": {
+ "name": "portfolio_capabilities_user_id_user_id_fk",
+ "tableFrom": "portfolio_capabilities",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "portfolio_capabilities_portfolio_id_portfolio_id_fk": {
+ "name": "portfolio_capabilities_portfolio_id_portfolio_id_fk",
+ "tableFrom": "portfolio_capabilities",
+ "tableTo": "portfolio",
+ "columnsFrom": [
+ "portfolio_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "portfolio_capabilities_user_id_portfolio_id_capability_unique": {
+ "name": "portfolio_capabilities_user_id_portfolio_id_capability_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "user_id",
+ "portfolio_id",
+ "capability"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.portfolioUsers": {
+ "name": "portfolioUsers",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "bigserial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "portfolio_id": {
+ "name": "portfolio_id",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "role": {
+ "name": "role",
+ "type": "role",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp (6) with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp (6) with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "portfolioUsers_user_id_user_id_fk": {
+ "name": "portfolioUsers_user_id_user_id_fk",
+ "tableFrom": "portfolioUsers",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "portfolioUsers_portfolio_id_portfolio_id_fk": {
+ "name": "portfolioUsers_portfolio_id_portfolio_id_fk",
+ "tableFrom": "portfolioUsers",
+ "tableTo": "portfolio",
+ "columnsFrom": [
+ "portfolio_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.non_intrusive_survey": {
+ "name": "non_intrusive_survey",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "bigserial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "uprn": {
+ "name": "uprn",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "survey_date": {
+ "name": "survey_date",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "surveyor": {
+ "name": "surveyor",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.non_intrusive_survey_notes": {
+ "name": "non_intrusive_survey_notes",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "bigserial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "survey_id": {
+ "name": "survey_id",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "note": {
+ "name": "note",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "non_intrusive_survey_notes_survey_id_non_intrusive_survey_id_fk": {
+ "name": "non_intrusive_survey_notes_survey_id_non_intrusive_survey_id_fk",
+ "tableFrom": "non_intrusive_survey_notes",
+ "tableTo": "non_intrusive_survey",
+ "columnsFrom": [
+ "survey_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.property": {
+ "name": "property",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "bigserial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "portfolio_id": {
+ "name": "portfolio_id",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "creation_status": {
+ "name": "creation_status",
+ "type": "creation_status",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "uprn": {
+ "name": "uprn",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "landlord_property_id": {
+ "name": "landlord_property_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "building_reference_number": {
+ "name": "building_reference_number",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "status": {
+ "name": "status",
+ "type": "status",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "address": {
+ "name": "address",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "postcode": {
+ "name": "postcode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "has_pre_condition_report": {
+ "name": "has_pre_condition_report",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "has_recommendations": {
+ "name": "has_recommendations",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "property_type": {
+ "name": "property_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "built_form": {
+ "name": "built_form",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "local_authority": {
+ "name": "local_authority",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "constituency": {
+ "name": "constituency",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "number_of_rooms": {
+ "name": "number_of_rooms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "year_built": {
+ "name": "year_built",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "tenure": {
+ "name": "tenure",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "current_epc_rating": {
+ "name": "current_epc_rating",
+ "type": "epc",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "current_sap_points": {
+ "name": "current_sap_points",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "current_valuation": {
+ "name": "current_valuation",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "installed_measures_sap_point_adjustment": {
+ "name": "installed_measures_sap_point_adjustment",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "is_sap_points_adjusted_for_installed_measures": {
+ "name": "is_sap_points_adjusted_for_installed_measures",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false,
+ "default": false
+ },
+ "original_sap_points": {
+ "name": "original_sap_points",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "lodged_sap_points": {
+ "name": "lodged_sap_points",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "lodged_epc_rating": {
+ "name": "lodged_epc_rating",
+ "type": "epc",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "uq_property_portfolio_uprn": {
+ "name": "uq_property_portfolio_uprn",
+ "columns": [
+ {
+ "expression": "portfolio_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "uprn",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "where": "\"property\".\"uprn\" IS NOT NULL",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "property_portfolio_id_portfolio_id_fk": {
+ "name": "property_portfolio_id_portfolio_id_fk",
+ "tableFrom": "property",
+ "tableTo": "portfolio",
+ "columnsFrom": [
+ "portfolio_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.property_details_epc": {
+ "name": "property_details_epc",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "bigserial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "property_id": {
+ "name": "property_id",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "portfolio_id": {
+ "name": "portfolio_id",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "full_address": {
+ "name": "full_address",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "lodgement_date": {
+ "name": "lodgement_date",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "is_expired": {
+ "name": "is_expired",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "total_floor_area": {
+ "name": "total_floor_area",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "walls": {
+ "name": "walls",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "walls_rating": {
+ "name": "walls_rating",
+ "type": "smallint",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "roof": {
+ "name": "roof",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "roof_rating": {
+ "name": "roof_rating",
+ "type": "smallint",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "floor": {
+ "name": "floor",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "floor_rating": {
+ "name": "floor_rating",
+ "type": "smallint",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "windows": {
+ "name": "windows",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "windows_rating": {
+ "name": "windows_rating",
+ "type": "smallint",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "heating": {
+ "name": "heating",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "heating_rating": {
+ "name": "heating_rating",
+ "type": "smallint",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "heating_controls": {
+ "name": "heating_controls",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "heating_controls_rating": {
+ "name": "heating_controls_rating",
+ "type": "smallint",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "hot_water": {
+ "name": "hot_water",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "hot_water_rating": {
+ "name": "hot_water_rating",
+ "type": "smallint",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "lighting": {
+ "name": "lighting",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "lighting_rating": {
+ "name": "lighting_rating",
+ "type": "smallint",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "mainfuel": {
+ "name": "mainfuel",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "ventilation": {
+ "name": "ventilation",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "solar_pv": {
+ "name": "solar_pv",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "solar_hot_water": {
+ "name": "solar_hot_water",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "wind_turbine": {
+ "name": "wind_turbine",
+ "type": "smallint",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "floor_height": {
+ "name": "floor_height",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "number_heated_rooms": {
+ "name": "number_heated_rooms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "heat_loss_corridor": {
+ "name": "heat_loss_corridor",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "unheated_corridor_length": {
+ "name": "unheated_corridor_length",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "number_of_open_fireplaces": {
+ "name": "number_of_open_fireplaces",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "number_of_extensions": {
+ "name": "number_of_extensions",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "number_of_storeys": {
+ "name": "number_of_storeys",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "mains_gas": {
+ "name": "mains_gas",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "energy_tariff": {
+ "name": "energy_tariff",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "primary_energy_consumption": {
+ "name": "primary_energy_consumption",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "co2_emissions": {
+ "name": "co2_emissions",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "current_energy_demand": {
+ "name": "current_energy_demand",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "current_energy_demand_heating_hotwater": {
+ "name": "current_energy_demand_heating_hotwater",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "estimated": {
+ "name": "estimated",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false,
+ "default": false
+ },
+ "sap_05_overwritten": {
+ "name": "sap_05_overwritten",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false,
+ "default": false
+ },
+ "sap_05_score": {
+ "name": "sap_05_score",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "sap_05_epc_rating": {
+ "name": "sap_05_epc_rating",
+ "type": "epc",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "heating_cost_current": {
+ "name": "heating_cost_current",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "hot_water_cost_current": {
+ "name": "hot_water_cost_current",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "lighting_cost_current": {
+ "name": "lighting_cost_current",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "appliances_cost_current": {
+ "name": "appliances_cost_current",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "gas_standing_charge": {
+ "name": "gas_standing_charge",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "electricity_standing_charge": {
+ "name": "electricity_standing_charge",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "original_co2_emissions": {
+ "name": "original_co2_emissions",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "original_primary_energy_consumption": {
+ "name": "original_primary_energy_consumption",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "original_current_energy_demand": {
+ "name": "original_current_energy_demand",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "original_current_energy_demand_heating_hotwater": {
+ "name": "original_current_energy_demand_heating_hotwater",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "installed_measures_co2_adjustment": {
+ "name": "installed_measures_co2_adjustment",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "installed_measures_energy_demand_adjustment": {
+ "name": "installed_measures_energy_demand_adjustment",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "installed_measures_total_energy_bill_adjustment": {
+ "name": "installed_measures_total_energy_bill_adjustment",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "installed_measures_heat_demand_adjustment": {
+ "name": "installed_measures_heat_demand_adjustment",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "is_epc_adjusted_for_installed_measures": {
+ "name": "is_epc_adjusted_for_installed_measures",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false,
+ "default": false
+ },
+ "lodged_co2_emissions": {
+ "name": "lodged_co2_emissions",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "lodged_heat_demand": {
+ "name": "lodged_heat_demand",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "has_been_remodelled": {
+ "name": "has_been_remodelled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false,
+ "default": false
+ },
+ "environment_impact_current": {
+ "name": "environment_impact_current",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "uq_property_details_epc_property_portfolio": {
+ "name": "uq_property_details_epc_property_portfolio",
+ "columns": [
+ {
+ "expression": "property_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "portfolio_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "property_details_epc_property_id_property_id_fk": {
+ "name": "property_details_epc_property_id_property_id_fk",
+ "tableFrom": "property_details_epc",
+ "tableTo": "property",
+ "columnsFrom": [
+ "property_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "property_details_epc_portfolio_id_portfolio_id_fk": {
+ "name": "property_details_epc_portfolio_id_portfolio_id_fk",
+ "tableFrom": "property_details_epc",
+ "tableTo": "portfolio",
+ "columnsFrom": [
+ "portfolio_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.property_details_meter": {
+ "name": "property_details_meter",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "bigserial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "uprn": {
+ "name": "uprn",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "energy_supplier": {
+ "name": "energy_supplier",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "gas_supplier": {
+ "name": "gas_supplier",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "meter_reading_total": {
+ "name": "meter_reading_total",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "meter_reading_electricity": {
+ "name": "meter_reading_electricity",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "meter_reading_gas": {
+ "name": "meter_reading_gas",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.property_details_spatial": {
+ "name": "property_details_spatial",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "bigserial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "uprn": {
+ "name": "uprn",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "x_coordinate": {
+ "name": "x_coordinate",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "y_coordinate": {
+ "name": "y_coordinate",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "latitude": {
+ "name": "latitude",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "longitude": {
+ "name": "longitude",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "conservation_status": {
+ "name": "conservation_status",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "is_listed_building": {
+ "name": "is_listed_building",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "is_heritage_building": {
+ "name": "is_heritage_building",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "uq_property_details_spatial_uprn": {
+ "name": "uq_property_details_spatial_uprn",
+ "columns": [
+ {
+ "expression": "uprn",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.property_targets": {
+ "name": "property_targets",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "bigserial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "property_id": {
+ "name": "property_id",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "portfolio_id": {
+ "name": "portfolio_id",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "epc": {
+ "name": "epc",
+ "type": "epc",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "heat_demand": {
+ "name": "heat_demand",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "property_targets_property_id_property_id_fk": {
+ "name": "property_targets_property_id_property_id_fk",
+ "tableFrom": "property_targets",
+ "tableTo": "property",
+ "columnsFrom": [
+ "property_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "property_targets_portfolio_id_portfolio_id_fk": {
+ "name": "property_targets_portfolio_id_portfolio_id_fk",
+ "tableFrom": "property_targets",
+ "tableTo": "portfolio",
+ "columnsFrom": [
+ "portfolio_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.installed_measure": {
+ "name": "installed_measure",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "bigserial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "uprn": {
+ "name": "uprn",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "measure_type": {
+ "name": "measure_type",
+ "type": "measure_type",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "installed_at": {
+ "name": "installed_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ },
+ "sap_points": {
+ "name": "sap_points",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "carbon_savings": {
+ "name": "carbon_savings",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "kwh_savings": {
+ "name": "kwh_savings",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "bill_savings": {
+ "name": "bill_savings",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "heat_demand_savings": {
+ "name": "heat_demand_savings",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "source": {
+ "name": "source",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "is_active": {
+ "name": "is_active",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ }
+ },
+ "indexes": {
+ "idx_installed_measure_uprn": {
+ "name": "idx_installed_measure_uprn",
+ "columns": [
+ {
+ "expression": "uprn",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_installed_measure_uprn_active": {
+ "name": "idx_installed_measure_uprn_active",
+ "columns": [
+ {
+ "expression": "uprn",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "where": "\"installed_measure\".\"is_active\" = true",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_installed_measure_measure_type": {
+ "name": "idx_installed_measure_measure_type",
+ "columns": [
+ {
+ "expression": "measure_type",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_installed_measure_uprn_measure": {
+ "name": "idx_installed_measure_uprn_measure",
+ "columns": [
+ {
+ "expression": "uprn",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "measure_type",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "where": "\"installed_measure\".\"is_active\" = true",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.plan": {
+ "name": "plan",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "bigserial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "portfolio_id": {
+ "name": "portfolio_id",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "property_id": {
+ "name": "property_id",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "scenario_id": {
+ "name": "scenario_id",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "is_default": {
+ "name": "is_default",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "valuation_increase_lower_bound": {
+ "name": "valuation_increase_lower_bound",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "valuation_increase_upper_bound": {
+ "name": "valuation_increase_upper_bound",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "valuation_increase_average": {
+ "name": "valuation_increase_average",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "post_sap_points": {
+ "name": "post_sap_points",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "post_epc_rating": {
+ "name": "post_epc_rating",
+ "type": "epc",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "post_co2_emissions": {
+ "name": "post_co2_emissions",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "co2_savings": {
+ "name": "co2_savings",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "post_energy_bill": {
+ "name": "post_energy_bill",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "energy_bill_savings": {
+ "name": "energy_bill_savings",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "post_energy_consumption": {
+ "name": "post_energy_consumption",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "energy_consumption_savings": {
+ "name": "energy_consumption_savings",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "valuation_post_retrofit": {
+ "name": "valuation_post_retrofit",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "valuation_increase": {
+ "name": "valuation_increase",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "cost_of_works": {
+ "name": "cost_of_works",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "contingency_cost": {
+ "name": "contingency_cost",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "plan_type": {
+ "name": "plan_type",
+ "type": "plan_type",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "idx_plan_portfolio_scenario": {
+ "name": "idx_plan_portfolio_scenario",
+ "columns": [
+ {
+ "expression": "portfolio_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "scenario_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_plan_latest_per_property": {
+ "name": "idx_plan_latest_per_property",
+ "columns": [
+ {
+ "expression": "portfolio_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "scenario_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "property_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": false,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "plan_portfolio_id_portfolio_id_fk": {
+ "name": "plan_portfolio_id_portfolio_id_fk",
+ "tableFrom": "plan",
+ "tableTo": "portfolio",
+ "columnsFrom": [
+ "portfolio_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "plan_property_id_property_id_fk": {
+ "name": "plan_property_id_property_id_fk",
+ "tableFrom": "plan",
+ "tableTo": "property",
+ "columnsFrom": [
+ "property_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "plan_scenario_id_scenario_id_fk": {
+ "name": "plan_scenario_id_scenario_id_fk",
+ "tableFrom": "plan",
+ "tableTo": "scenario",
+ "columnsFrom": [
+ "scenario_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.plan_recommendations": {
+ "name": "plan_recommendations",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "bigserial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "plan_id": {
+ "name": "plan_id",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "recommendation_id": {
+ "name": "recommendation_id",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "idx_plan_recommendations_plan_id": {
+ "name": "idx_plan_recommendations_plan_id",
+ "columns": [
+ {
+ "expression": "plan_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_plan_recommendations_plan_rec": {
+ "name": "idx_plan_recommendations_plan_rec",
+ "columns": [
+ {
+ "expression": "plan_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "recommendation_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "plan_recommendations_plan_id_plan_id_fk": {
+ "name": "plan_recommendations_plan_id_plan_id_fk",
+ "tableFrom": "plan_recommendations",
+ "tableTo": "plan",
+ "columnsFrom": [
+ "plan_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "plan_recommendations_recommendation_id_recommendation_id_fk": {
+ "name": "plan_recommendations_recommendation_id_recommendation_id_fk",
+ "tableFrom": "plan_recommendations",
+ "tableTo": "recommendation",
+ "columnsFrom": [
+ "recommendation_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.recommendation": {
+ "name": "recommendation",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "bigserial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "property_id": {
+ "name": "property_id",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "measure_type": {
+ "name": "measure_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "estimated_cost": {
+ "name": "estimated_cost",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "contingency_cost": {
+ "name": "contingency_cost",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "default": {
+ "name": "default",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "starting_u_value": {
+ "name": "starting_u_value",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "new_u_value": {
+ "name": "new_u_value",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "sap_points": {
+ "name": "sap_points",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "heat_demand": {
+ "name": "heat_demand",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "kwh_savings": {
+ "name": "kwh_savings",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "co2_equivalent_savings": {
+ "name": "co2_equivalent_savings",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "energy_savings": {
+ "name": "energy_savings",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "energy_cost_savings": {
+ "name": "energy_cost_savings",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "property_valuation_increase": {
+ "name": "property_valuation_increase",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "rental_yield_increase": {
+ "name": "rental_yield_increase",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "total_work_hours": {
+ "name": "total_work_hours",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "labour_days": {
+ "name": "labour_days",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "already_installed": {
+ "name": "already_installed",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false,
+ "default": false
+ }
+ },
+ "indexes": {
+ "recommendation_property_id_idx": {
+ "name": "recommendation_property_id_idx",
+ "columns": [
+ {
+ "expression": "property_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_recommendation_active_defaults": {
+ "name": "idx_recommendation_active_defaults",
+ "columns": [
+ {
+ "expression": "id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "where": "\"recommendation\".\"default\" = true AND \"recommendation\".\"already_installed\" = false",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_recommendation_active_id_property": {
+ "name": "idx_recommendation_active_id_property",
+ "columns": [
+ {
+ "expression": "id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "property_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "where": "\"recommendation\".\"default\" = true AND \"recommendation\".\"already_installed\" = false",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "recommendation_property_id_property_id_fk": {
+ "name": "recommendation_property_id_property_id_fk",
+ "tableFrom": "recommendation",
+ "tableTo": "property",
+ "columnsFrom": [
+ "property_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.recommendation_materials": {
+ "name": "recommendation_materials",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "bigserial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "recommendation_id": {
+ "name": "recommendation_id",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "material_id": {
+ "name": "material_id",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "depth": {
+ "name": "depth",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "quantity": {
+ "name": "quantity",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "quantity_unit": {
+ "name": "quantity_unit",
+ "type": "unit_quantity",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "estimated_cost": {
+ "name": "estimated_cost",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "recommendation_materials_recommendation_id_idx": {
+ "name": "recommendation_materials_recommendation_id_idx",
+ "columns": [
+ {
+ "expression": "recommendation_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "recommendation_materials_recommendation_id_recommendation_id_fk": {
+ "name": "recommendation_materials_recommendation_id_recommendation_id_fk",
+ "tableFrom": "recommendation_materials",
+ "tableTo": "recommendation",
+ "columnsFrom": [
+ "recommendation_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "recommendation_materials_material_id_material_id_fk": {
+ "name": "recommendation_materials_material_id_material_id_fk",
+ "tableFrom": "recommendation_materials",
+ "tableTo": "material",
+ "columnsFrom": [
+ "material_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.scenario": {
+ "name": "scenario",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "bigserial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "budget": {
+ "name": "budget",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "portfolio_id": {
+ "name": "portfolio_id",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "housing_type": {
+ "name": "housing_type",
+ "type": "housing_type",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "goal": {
+ "name": "goal",
+ "type": "goal",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "goal_value": {
+ "name": "goal_value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "ashp_cop": {
+ "name": "ashp_cop",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false,
+ "default": 2.8
+ },
+ "trigger_file_path": {
+ "name": "trigger_file_path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "already_installed_file_path": {
+ "name": "already_installed_file_path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "patches_file_path": {
+ "name": "patches_file_path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "non_invasive_recommendations_file_path": {
+ "name": "non_invasive_recommendations_file_path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "exclusions": {
+ "name": "exclusions",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "multi_plan": {
+ "name": "multi_plan",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "is_default": {
+ "name": "is_default",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "cost": {
+ "name": "cost",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "contingency": {
+ "name": "contingency",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "funding": {
+ "name": "funding",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "total_work_hours": {
+ "name": "total_work_hours",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "energy_savings": {
+ "name": "energy_savings",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "co2_equivalent_savings": {
+ "name": "co2_equivalent_savings",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "energy_cost_savings": {
+ "name": "energy_cost_savings",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "property_valuation_increase": {
+ "name": "property_valuation_increase",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "labour_days": {
+ "name": "labour_days",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "epc_breakdown_pre_retrofit": {
+ "name": "epc_breakdown_pre_retrofit",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "epc_breakdown_post_retrofit": {
+ "name": "epc_breakdown_post_retrofit",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "number_of_properties": {
+ "name": "number_of_properties",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "n_units_to_retrofit": {
+ "name": "n_units_to_retrofit",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "co2_per_unit_pre_retrofit": {
+ "name": "co2_per_unit_pre_retrofit",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "co2_per_unit_post_retrofit": {
+ "name": "co2_per_unit_post_retrofit",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "energy_bill_per_unit_pre_retrofit": {
+ "name": "energy_bill_per_unit_pre_retrofit",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "energy_bill_per_unit_post_retrofit": {
+ "name": "energy_bill_per_unit_post_retrofit",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "energy_consumption_per_unit_pre_retrofit": {
+ "name": "energy_consumption_per_unit_pre_retrofit",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "energy_consumption_per_unit_post_retrofit": {
+ "name": "energy_consumption_per_unit_post_retrofit",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "valuation_improvement_per_unit": {
+ "name": "valuation_improvement_per_unit",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "cost_per_unit": {
+ "name": "cost_per_unit",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "cost_per_co2_saved": {
+ "name": "cost_per_co2_saved",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "cost_per_sap_point": {
+ "name": "cost_per_sap_point",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "valuation_return_on_investment": {
+ "name": "valuation_return_on_investment",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "scenario_portfolio_id_portfolio_id_fk": {
+ "name": "scenario_portfolio_id_portfolio_id_fk",
+ "tableFrom": "scenario",
+ "tableTo": "portfolio",
+ "columnsFrom": [
+ "portfolio_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.property_removal_requests": {
+ "name": "property_removal_requests",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "bigserial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "hubspot_deal_id": {
+ "name": "hubspot_deal_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "portfolio_id": {
+ "name": "portfolio_id",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "reason": {
+ "name": "reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'pending'"
+ },
+ "requested_by": {
+ "name": "requested_by",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "requested_at": {
+ "name": "requested_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "reviewed_by": {
+ "name": "reviewed_by",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "reviewed_at": {
+ "name": "reviewed_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "idx_removal_requests_deal_id": {
+ "name": "idx_removal_requests_deal_id",
+ "columns": [
+ {
+ "expression": "hubspot_deal_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_removal_requests_portfolio_id": {
+ "name": "idx_removal_requests_portfolio_id",
+ "columns": [
+ {
+ "expression": "portfolio_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "property_removal_requests_portfolio_id_portfolio_id_fk": {
+ "name": "property_removal_requests_portfolio_id_portfolio_id_fk",
+ "tableFrom": "property_removal_requests",
+ "tableTo": "portfolio",
+ "columnsFrom": [
+ "portfolio_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "property_removal_requests_requested_by_user_id_fk": {
+ "name": "property_removal_requests_requested_by_user_id_fk",
+ "tableFrom": "property_removal_requests",
+ "tableTo": "user",
+ "columnsFrom": [
+ "requested_by"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "property_removal_requests_reviewed_by_user_id_fk": {
+ "name": "property_removal_requests_reviewed_by_user_id_fk",
+ "tableFrom": "property_removal_requests",
+ "tableTo": "user",
+ "columnsFrom": [
+ "reviewed_by"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.solar": {
+ "name": "solar",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "bigserial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "longitude": {
+ "name": "longitude",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "latitude": {
+ "name": "latitude",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "uprn": {
+ "name": "uprn",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp (6) with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp (6) with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "google_api_response": {
+ "name": "google_api_response",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.solar_scenario": {
+ "name": "solar_scenario",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "bigserial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "solar_id": {
+ "name": "solar_id",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "scenario_type": {
+ "name": "scenario_type",
+ "type": "scenario_type",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "number_panels": {
+ "name": "number_panels",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "array_kwhp": {
+ "name": "array_kwhp",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "lifetime_dc_kwh": {
+ "name": "lifetime_dc_kwh",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "yearly_dc_kwh": {
+ "name": "yearly_dc_kwh",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "lifetime_ac_kwh": {
+ "name": "lifetime_ac_kwh",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "yearly_ac_kwh": {
+ "name": "yearly_ac_kwh",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "cost": {
+ "name": "cost",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "expected_payback_years": {
+ "name": "expected_payback_years",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "panelled_roof_area": {
+ "name": "panelled_roof_area",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "is_default": {
+ "name": "is_default",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "solar_scenario_solar_id_solar_id_fk": {
+ "name": "solar_scenario_solar_id_solar_id_fk",
+ "tableFrom": "solar_scenario",
+ "tableTo": "solar",
+ "columnsFrom": [
+ "solar_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.sub_task": {
+ "name": "sub_task",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "task_id": {
+ "name": "task_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "job_started": {
+ "name": "job_started",
+ "type": "timestamp (6) with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "job_completed": {
+ "name": "job_completed",
+ "type": "timestamp (6) with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'In Progress'"
+ },
+ "inputs": {
+ "name": "inputs",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "outputs": {
+ "name": "outputs",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "cloud_logs_url": {
+ "name": "cloud_logs_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp (6) with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "sub_task_task_id_tasks_id_fk": {
+ "name": "sub_task_task_id_tasks_id_fk",
+ "tableFrom": "sub_task",
+ "tableTo": "tasks",
+ "columnsFrom": [
+ "task_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.tasks": {
+ "name": "tasks",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "task_source": {
+ "name": "task_source",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "job_started": {
+ "name": "job_started",
+ "type": "timestamp (6) with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "job_completed": {
+ "name": "job_completed",
+ "type": "timestamp (6) with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'In Progress'"
+ },
+ "service": {
+ "name": "service",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "source": {
+ "name": "source",
+ "type": "source",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "source_id": {
+ "name": "source_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp (6) with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.team": {
+ "name": "team",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "org_id": {
+ "name": "org_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp (6) with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp (6) with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "team_org_id_organisation_id_fk": {
+ "name": "team_org_id_organisation_id_fk",
+ "tableFrom": "team",
+ "tableTo": "organisation",
+ "columnsFrom": [
+ "org_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.team_members": {
+ "name": "team_members",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "team_id": {
+ "name": "team_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp (6) with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp (6) with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "team_members_user_id_user_id_fk": {
+ "name": "team_members_user_id_user_id_fk",
+ "tableFrom": "team_members",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "team_members_team_id_team_id_fk": {
+ "name": "team_members_team_id_team_id_fk",
+ "tableFrom": "team_members",
+ "tableTo": "team",
+ "columnsFrom": [
+ "team_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.team_portfolio_permissions": {
+ "name": "team_portfolio_permissions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "team_id": {
+ "name": "team_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "portfolio_id": {
+ "name": "portfolio_id",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "role": {
+ "name": "role",
+ "type": "role",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp (6) with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp (6) with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "team_portfolio_permissions_team_id_team_id_fk": {
+ "name": "team_portfolio_permissions_team_id_team_id_fk",
+ "tableFrom": "team_portfolio_permissions",
+ "tableTo": "team",
+ "columnsFrom": [
+ "team_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "team_portfolio_permissions_portfolio_id_portfolio_id_fk": {
+ "name": "team_portfolio_permissions_portfolio_id_portfolio_id_fk",
+ "tableFrom": "team_portfolio_permissions",
+ "tableTo": "portfolio",
+ "columnsFrom": [
+ "portfolio_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.uploaded_files": {
+ "name": "uploaded_files",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "bigserial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "s3_file_bucket": {
+ "name": "s3_file_bucket",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "s3_file_key": {
+ "name": "s3_file_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "s3_upload_timestamp": {
+ "name": "s3_upload_timestamp",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "landlord_property_id": {
+ "name": "landlord_property_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "uprn": {
+ "name": "uprn",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "hubspot_deal_id": {
+ "name": "hubspot_deal_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "hubspot_listing_id": {
+ "name": "hubspot_listing_id",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "file_type": {
+ "name": "file_type",
+ "type": "file_type",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "file_source": {
+ "name": "file_source",
+ "type": "file_source",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "measure_name": {
+ "name": "measure_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "uploaded_by": {
+ "name": "uploaded_by",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "uploaded_files_uploaded_by_user_id_fk": {
+ "name": "uploaded_files_uploaded_by_user_id_fk",
+ "tableFrom": "uploaded_files",
+ "tableTo": "user",
+ "columnsFrom": [
+ "uploaded_by"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.account": {
+ "name": "account",
+ "schema": "",
+ "columns": {
+ "userId": {
+ "name": "userId",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provider": {
+ "name": "provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "providerAccountId": {
+ "name": "providerAccountId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "refresh_token": {
+ "name": "refresh_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "access_token": {
+ "name": "access_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "token_type": {
+ "name": "token_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "scope": {
+ "name": "scope",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "id_token": {
+ "name": "id_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "session_state": {
+ "name": "session_state",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "account_userId_user_id_fk": {
+ "name": "account_userId_user_id_fk",
+ "tableFrom": "account",
+ "tableTo": "user",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "account_provider_providerAccountId_pk": {
+ "name": "account_provider_providerAccountId_pk",
+ "columns": [
+ "provider",
+ "providerAccountId"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.session": {
+ "name": "session",
+ "schema": "",
+ "columns": {
+ "sessionToken": {
+ "name": "sessionToken",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "userId": {
+ "name": "userId",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "expires": {
+ "name": "expires",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "session_userId_user_id_fk": {
+ "name": "session_userId_user_id_fk",
+ "tableFrom": "session",
+ "tableTo": "user",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.user": {
+ "name": "user",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "bigserial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "firstName": {
+ "name": "firstName",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "emailVerified": {
+ "name": "emailVerified",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "oauth_id": {
+ "name": "oauth_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "oauth_provider": {
+ "name": "oauth_provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "image": {
+ "name": "image",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "onboarded": {
+ "name": "onboarded",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "last_login": {
+ "name": "last_login",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp (6) with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp (6) with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "user_email_unique": {
+ "name": "user_email_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "email"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.user_profiles": {
+ "name": "user_profiles",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "bigserial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_type": {
+ "name": "user_type",
+ "type": "user_profiles_user_type",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "property_count": {
+ "name": "property_count",
+ "type": "user_profiles_property_count",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "goals": {
+ "name": "goals",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "referral_source": {
+ "name": "referral_source",
+ "type": "user_profiles_referral_source",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "nrla_membership_id": {
+ "name": "nrla_membership_id",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "accepted_privacy": {
+ "name": "accepted_privacy",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "accepted_privacy_at": {
+ "name": "accepted_privacy_at",
+ "type": "timestamp (6) with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "marketing_opt_in": {
+ "name": "marketing_opt_in",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false,
+ "default": false
+ },
+ "marketing_opt_in_at": {
+ "name": "marketing_opt_in_at",
+ "type": "timestamp (6) with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "first_name": {
+ "name": "first_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_name": {
+ "name": "last_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp (6) with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp (6) with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "user_profiles_user_id_user_id_fk": {
+ "name": "user_profiles_user_id_user_id_fk",
+ "tableFrom": "user_profiles",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.verificationToken": {
+ "name": "verificationToken",
+ "schema": "",
+ "columns": {
+ "identifier": {
+ "name": "identifier",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "expires": {
+ "name": "expires",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "verificationToken_identifier_token_pk": {
+ "name": "verificationToken_identifier_token_pk",
+ "columns": [
+ "identifier",
+ "token"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.whlg": {
+ "name": "whlg",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "bigserial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "postcode": {
+ "name": "postcode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ }
+ },
+ "enums": {
+ "public.aspect_type": {
+ "name": "aspect_type",
+ "schema": "public",
+ "values": [
+ "material",
+ "condition",
+ "type",
+ "area",
+ "configuration",
+ "presence",
+ "risk",
+ "severity",
+ "location",
+ "finish",
+ "insulation",
+ "pointing",
+ "spalling",
+ "lintels",
+ "cladding",
+ "category",
+ "quantity",
+ "adequacy",
+ "rating",
+ "strategy",
+ "extent",
+ "distribution",
+ "structure",
+ "covering",
+ "fire_rating",
+ "external_decoration",
+ "work_required",
+ "age_band",
+ "construction_type",
+ "classification",
+ "system"
+ ]
+ },
+ "public.element_type": {
+ "name": "element_type",
+ "schema": "public",
+ "values": [
+ "property",
+ "property_construction_type",
+ "property_classification",
+ "property_age_band",
+ "storey_count",
+ "floor_level",
+ "floor_level_front_door",
+ "accessible_housing_register",
+ "asbestos",
+ "quality_standard",
+ "ccu",
+ "passenger_lift",
+ "stairlift",
+ "disabled_hoist_tracking",
+ "disabled_facilities",
+ "steps_to_front_door",
+ "roof",
+ "pitched_roof_covering",
+ "flat_roof_covering",
+ "rainwater_goods",
+ "loft_insulation",
+ "porch_canopy",
+ "chimney",
+ "fascia",
+ "soffit",
+ "fascia_soffit_bargeboards",
+ "gutters",
+ "store_roof",
+ "garage_roof",
+ "garage_and_store_roof",
+ "external_wall",
+ "external_noise_insulation",
+ "primary_wall",
+ "secondary_wall",
+ "downpipes",
+ "external_decoration",
+ "cladding",
+ "spandrel_panels",
+ "garage_walls",
+ "party_wall_fire_break",
+ "external_brickwork_pointing",
+ "internal_downpipes_external_area",
+ "external_windows",
+ "communal_windows",
+ "secondary_glazing",
+ "store_windows",
+ "garage_windows",
+ "garage_and_store_windows",
+ "external_door",
+ "front_door",
+ "rear_door",
+ "store_door",
+ "garage_door",
+ "garage_and_store_door",
+ "communal_entrance_door",
+ "main_door",
+ "block_entrance_door",
+ "lintel",
+ "patio_french_door",
+ "door_entry_handset",
+ "paths_and_hardstandings",
+ "parking_areas",
+ "boundary_walls",
+ "front_fencing",
+ "rear_fencing",
+ "side_fencing",
+ "rear_gate",
+ "front_gate",
+ "gates",
+ "retaining_walls",
+ "private_balcony",
+ "balcony_balustrade",
+ "outbuildings",
+ "garage_structure",
+ "paving",
+ "roads",
+ "soil_and_vent",
+ "solar_thermals",
+ "drop_kerb",
+ "outbuilding_overhaul",
+ "external_structural_defects",
+ "access_ramp",
+ "kitchen",
+ "kitchen_space_layout",
+ "tenant_installed_kitchen",
+ "kitchen_extractor_fan",
+ "bathroom",
+ "secondary_bathroom",
+ "secondary_toilet",
+ "bathroom_extractor_fan",
+ "additional_wc_or_whb",
+ "bathroom_remaining_life_source",
+ "kitchen_remaining_life_source",
+ "central_heating",
+ "heating_boiler",
+ "heating_distribution",
+ "secondary_heating",
+ "hot_water_system",
+ "cold_water_storage",
+ "heating_system",
+ "boiler_fuel",
+ "water_heating",
+ "programmable_heating",
+ "community_heating",
+ "gas_available",
+ "heat_recovery_units",
+ "heating_improvements",
+ "electrical_wiring",
+ "consumer_unit",
+ "smoke_detection",
+ "heat_detection",
+ "carbon_monoxide_detection",
+ "fire_door_rating",
+ "fire_risk_assessment",
+ "internal_wiring",
+ "electrics",
+ "communal_heating",
+ "communal_boiler",
+ "communal_electrics",
+ "communal_fire_alarm",
+ "communal_emergency_lighting",
+ "communal_door_entry",
+ "communal_cctv",
+ "communal_bin_store",
+ "communal_bin_store_doors",
+ "communal_bin_store_walls",
+ "communal_bin_store_roof",
+ "communal_refuse_chute",
+ "communal_floor_covering",
+ "communal_kitchen",
+ "communal_bathroom",
+ "communal_toilets",
+ "communal_gates",
+ "communal_lift",
+ "communal_passenger_lift",
+ "communal_balcony_walkway",
+ "communal_entrance",
+ "communal_internal_decorations",
+ "communal_internal_floor",
+ "communal_walkways",
+ "communal_external_doors",
+ "communal_stairs",
+ "communal_aerial",
+ "communal_aov",
+ "communal_internal_doors",
+ "communal_lateral_mains",
+ "communal_lighting",
+ "communal_lighting_conductor",
+ "communal_store_roof",
+ "communal_store_walls",
+ "communal_store_doors",
+ "communal_warden_call_system",
+ "communal_bms",
+ "communal_booster_pump",
+ "communal_dry_riser",
+ "communal_wet_riser",
+ "communal_cold_water_storage",
+ "communal_sprinkler",
+ "communal_plug_sockets",
+ "communal_circulation_space",
+ "ffhh_damp",
+ "ffhh_hold_and_cold_water",
+ "ffhh_drainage_lavatories",
+ "ffhh_neglected",
+ "ffhh_natural_light",
+ "ffhh_ventilation",
+ "ffhh_food_prep_and_washup",
+ "ffhh_unsafe_layout",
+ "ffhh_unstable_building",
+ "hhsrs_damp_and_mould",
+ "hhsrs_excess_cold",
+ "hhsrs_excess_heat",
+ "hhsrs_asbestos_and_mmf",
+ "hhsrs_biocides",
+ "hhsrs_carbon_monoxide",
+ "hhsrs_lead",
+ "hhsrs_radiation",
+ "hhsrs_uncombusted_fuel_gas",
+ "hhsrs_volatile_organic_compounds",
+ "hhsrs_crowding_and_space",
+ "hhsrs_entry_by_intruders",
+ "hhsrs_lighting",
+ "hhsrs_noise",
+ "hhsrs_domestic_hygiene_pests_refuse",
+ "hhsrs_food_safety",
+ "hhsrs_personal_hygiene_sanitation",
+ "hhsrs_water_supply",
+ "hhsrs_falls_associated_with_baths",
+ "hhsrs_falls_on_level_surfaces",
+ "hhsrs_falls_on_stairs",
+ "hhsrs_falls_between_levels",
+ "hhsrs_electrical_hazards",
+ "hhsrs_fire",
+ "hhsrs_flames_hot_surfaces",
+ "hhsrs_collision_and_entrapment",
+ "hhsrs_collision_hazards_low_headroom",
+ "hhsrs_explosions",
+ "hhsrs_ergonomics",
+ "hhsrs_structural_collapse",
+ "hhsrs_amenities"
+ ]
+ },
+ "public.document_type": {
+ "name": "document_type",
+ "schema": "public",
+ "values": [
+ "EPR",
+ "Condition Report",
+ "Evidence Report",
+ "Summary Information",
+ "Floor Plan",
+ "Scenario Draft EPC",
+ "Scenario Site Notes"
+ ]
+ },
+ "public.scheme": {
+ "name": "scheme",
+ "schema": "public",
+ "values": [
+ "eco4",
+ "gbis",
+ "whlg",
+ "none"
+ ]
+ },
+ "public.inspection_archetype_2": {
+ "name": "inspection_archetype_2",
+ "schema": "public",
+ "values": [
+ "detached",
+ "mid-terrace",
+ "enclosed mid-terrace",
+ "end-terrace",
+ "enclosed end-terrace",
+ "semi-detached"
+ ]
+ },
+ "public.inspection_archetype": {
+ "name": "inspection_archetype",
+ "schema": "public",
+ "values": [
+ "Bungalow",
+ "Flat",
+ "Maisonette",
+ "House",
+ "non-domestic"
+ ]
+ },
+ "public.inspection_borescoped": {
+ "name": "inspection_borescoped",
+ "schema": "public",
+ "values": [
+ "yes",
+ "no",
+ "refused"
+ ]
+ },
+ "public.inspections_access_issues": {
+ "name": "inspections_access_issues",
+ "schema": "public",
+ "values": [
+ "see notes",
+ "damp issues",
+ "foliage on walls",
+ "bushes against wall",
+ "trees around/anove property",
+ "high rise block flats/maisonettes",
+ "conservatory",
+ "lean-to",
+ "garage",
+ "extension",
+ "decking",
+ "shed against wall"
+ ]
+ },
+ "public.inspections_cladding": {
+ "name": "inspections_cladding",
+ "schema": "public",
+ "values": [
+ "none",
+ "cladded with “sufficient space to fill the wall”",
+ "cladded with “insufficient space to fill the wall”"
+ ]
+ },
+ "public.inspections_insulation_material": {
+ "name": "inspections_insulation_material",
+ "schema": "public",
+ "values": [
+ "empty 50-90",
+ "empty 100+",
+ "empty 30-40",
+ "empty less than 30",
+ "loose fibre/wool",
+ "eps/celo/king",
+ "fibre batts - with cavity",
+ "fibre batts - no cavity",
+ "loose bead",
+ "glued bead",
+ "formaldehyde",
+ "bubble wrap",
+ "poly chunks"
+ ]
+ },
+ "public.inspections_rendered": {
+ "name": "inspections_rendered",
+ "schema": "public",
+ "values": [
+ "no render",
+ "rendered with “insufficient” space between dpc and render",
+ "rendered with “sufficient” space between dpc and render"
+ ]
+ },
+ "public.inspections_roof_orientation": {
+ "name": "inspections_roof_orientation",
+ "schema": "public",
+ "values": [
+ "north",
+ "east",
+ "south",
+ "west",
+ "north-east",
+ "north-west",
+ "south-east",
+ "south-west",
+ "n/s split",
+ "e/w split",
+ "ne/sw split",
+ "nw/se split",
+ "flat roof",
+ "no roof",
+ "roof too small",
+ "already has solar pv"
+ ]
+ },
+ "public.inspections_tile_hung": {
+ "name": "inspections_tile_hung",
+ "schema": "public",
+ "values": [
+ "yes",
+ "no",
+ "first floor flats are tile hung"
+ ]
+ },
+ "public.inspections_wall_construction": {
+ "name": "inspections_wall_construction",
+ "schema": "public",
+ "values": [
+ "cavity",
+ "solid",
+ "system built",
+ "timber framed",
+ "steel framed",
+ "re-walled cavity",
+ "mansard pre-fab",
+ "mansard ewi",
+ "mansard re-walled"
+ ]
+ },
+ "public.inspections_wall_insulation": {
+ "name": "inspections_wall_insulation",
+ "schema": "public",
+ "values": [
+ "empty cavity",
+ "filled at build",
+ "partial",
+ "retro drilled",
+ "ewi",
+ "iwi",
+ "solid non-cavity",
+ "system built",
+ "timber framed",
+ "steel framed"
+ ]
+ },
+ "public.cost_unit": {
+ "name": "cost_unit",
+ "schema": "public",
+ "values": [
+ "gbp_sq_meter",
+ "gbp_per_unit",
+ "gbp_per_m2",
+ "gbp_per_m"
+ ]
+ },
+ "public.depth_unit": {
+ "name": "depth_unit",
+ "schema": "public",
+ "values": [
+ "mm"
+ ]
+ },
+ "public.type": {
+ "name": "type",
+ "schema": "public",
+ "values": [
+ "suspended_floor_insulation",
+ "solid_floor_insulation",
+ "external_wall_insulation",
+ "internal_wall_insulation",
+ "cavity_wall_insulation",
+ "mechanical_ventilation",
+ "loft_insulation",
+ "exposed_floor_insulation",
+ "flat_roof_insulation",
+ "room_roof_insulation",
+ "cavity_wall_extraction",
+ "iwi_wall_demolition",
+ "iwi_vapour_barrier",
+ "iwi_redecoration",
+ "suspended_floor_demolition",
+ "suspended_floor_redecoration",
+ "suspended_floor_vapour_barrier",
+ "solid_floor_demolition",
+ "solid_floor_preparation",
+ "solid_floor_vapour_barrier",
+ "solid_floor_redecoration",
+ "ewi_wall_demolition",
+ "ewi_wall_preparation",
+ "ewi_wall_redecoration",
+ "low_energy_lighting_installation",
+ "flat_roof_preparation",
+ "flat_roof_vapour_barrier",
+ "flat_roof_waterproofing",
+ "windows_glazing",
+ "secondary_glazing",
+ "double_glazing",
+ "trickle_vent",
+ "door_undercut",
+ "solar_pv",
+ "solar_battery",
+ "scaffolding",
+ "high_heat_retention_storage_heaters",
+ "air_source_heat_pump",
+ "boiler_upgrade",
+ "roomstat_programmer_trvs",
+ "time_temperature_zone_control",
+ "sealing_fireplace"
+ ]
+ },
+ "public.r_value_unit": {
+ "name": "r_value_unit",
+ "schema": "public",
+ "values": [
+ "square_meter_kelvin_per_watt"
+ ]
+ },
+ "public.size_unit": {
+ "name": "size_unit",
+ "schema": "public",
+ "values": [
+ "kWp",
+ "kW",
+ "watt",
+ "storey"
+ ]
+ },
+ "public.thermal_conductivity_unit": {
+ "name": "thermal_conductivity_unit",
+ "schema": "public",
+ "values": [
+ "watt_per_meter_kelvin"
+ ]
+ },
+ "public.goal": {
+ "name": "goal",
+ "schema": "public",
+ "values": [
+ "Valuation Improvement",
+ "Increasing EPC",
+ "Reducing CO2 emissions",
+ "Energy Savings",
+ "None"
+ ]
+ },
+ "public.portfolio_capability": {
+ "name": "portfolio_capability",
+ "schema": "public",
+ "values": [
+ "approver",
+ "contractor"
+ ]
+ },
+ "public.role": {
+ "name": "role",
+ "schema": "public",
+ "values": [
+ "creator",
+ "admin",
+ "read",
+ "write"
+ ]
+ },
+ "public.status": {
+ "name": "status",
+ "schema": "public",
+ "values": [
+ "scoping",
+ "survey",
+ "assessment",
+ "tendering",
+ "project underway",
+ "completion; status: on track",
+ "completion; status: delayed",
+ "completion; status: at risk",
+ "completion; status: completed",
+ "needs review"
+ ]
+ },
+ "public.epc": {
+ "name": "epc",
+ "schema": "public",
+ "values": [
+ "A",
+ "B",
+ "C",
+ "D",
+ "E",
+ "F",
+ "G"
+ ]
+ },
+ "public.creation_status": {
+ "name": "creation_status",
+ "schema": "public",
+ "values": [
+ "LOADING",
+ "READY",
+ "ERROR"
+ ]
+ },
+ "public.housing_type": {
+ "name": "housing_type",
+ "schema": "public",
+ "values": [
+ "Private",
+ "Social"
+ ]
+ },
+ "public.measure_type": {
+ "name": "measure_type",
+ "schema": "public",
+ "values": [
+ "air_source_heat_pump",
+ "boiler_upgrade",
+ "high_heat_retention_storage_heaters",
+ "secondary_heating",
+ "roomstat_programmer_trvs",
+ "time_temperature_zone_control",
+ "cylinder_thermostat",
+ "cavity_wall_insulation",
+ "extension_cavity_wall_insulation",
+ "external_wall_insulation",
+ "internal_wall_insulation",
+ "loft_insulation",
+ "flat_roof_insulation",
+ "room_roof_insulation",
+ "solid_floor_insulation",
+ "suspended_floor_insulation",
+ "double_glazing",
+ "secondary_glazing",
+ "draught_proofing",
+ "mechanical_ventilation",
+ "low_energy_lighting",
+ "solar_pv",
+ "hot_water_tank_insulation",
+ "sealing_open_fireplace"
+ ]
+ },
+ "public.plan_type": {
+ "name": "plan_type",
+ "schema": "public",
+ "values": [
+ "solar_eco4",
+ "solar_hhrsh_eco4",
+ "empty_cavity_eco",
+ "partial_cavity_eco",
+ "extraction_eco"
+ ]
+ },
+ "public.unit_quantity": {
+ "name": "unit_quantity",
+ "schema": "public",
+ "values": [
+ "m2",
+ "part",
+ "kwp"
+ ]
+ },
+ "public.scenario_type": {
+ "name": "scenario_type",
+ "schema": "public",
+ "values": [
+ "unit",
+ "building"
+ ]
+ },
+ "public.source": {
+ "name": "source",
+ "schema": "public",
+ "values": [
+ "portfolio_id"
+ ]
+ },
+ "public.file_source": {
+ "name": "file_source",
+ "schema": "public",
+ "values": [
+ "pas hub",
+ "sharepoint",
+ "hubspot",
+ "ecmk",
+ "contractor"
+ ]
+ },
+ "public.file_type": {
+ "name": "file_type",
+ "schema": "public",
+ "values": [
+ "photo_pack",
+ "site_note",
+ "rd_sap_site_note",
+ "pas_2023_ventilation",
+ "pas_2023_condition",
+ "pas_significance",
+ "par_photo_pack",
+ "pas_2023_property",
+ "pas_2023_occupancy",
+ "ecmk_site_note",
+ "ecmk_rd_sap_site_note",
+ "ecmk_survey_xml",
+ "pre_photo",
+ "mid_photo",
+ "post_photo",
+ "loft_hatch_photo",
+ "dmev_photos",
+ "door_undercut_photos",
+ "trickle_vent_photos",
+ "pre_installation_building_inspection",
+ "point_of_work_risk_assessment",
+ "claim_of_compliance",
+ "mcs_compliance_certificate",
+ "certificate_of_conformity",
+ "minor_works_electrical_certificate",
+ "trustmark_licence_numbers",
+ "operative_competency",
+ "ventilation_assessment_checklist",
+ "anemometer_readings",
+ "commissioning_records",
+ "part_f_ventilation_document",
+ "handover_pack",
+ "insurance_guarantee",
+ "workmanship_warranty",
+ "g98_notification",
+ "installer_qualifications",
+ "installer_feedback",
+ "contractor_other"
+ ]
+ },
+ "public.user_profiles_property_count": {
+ "name": "user_profiles_property_count",
+ "schema": "public",
+ "values": [
+ "1",
+ "2–5",
+ "6–20",
+ "21+",
+ "1–50",
+ "51–100",
+ "101–300",
+ "301–1000",
+ "1000+"
+ ]
+ },
+ "public.user_profiles_referral_source": {
+ "name": "user_profiles_referral_source",
+ "schema": "public",
+ "values": [
+ "search",
+ "social_media",
+ "NRLA",
+ "partner",
+ "word_of_mouth",
+ "other"
+ ]
+ },
+ "public.user_profiles_user_type": {
+ "name": "user_profiles_user_type",
+ "schema": "public",
+ "values": [
+ "private_landlord",
+ "private_tenant",
+ "social_landlord",
+ "social_tenant",
+ "homeowner",
+ "other"
+ ]
+ }
+ },
+ "schemas": {},
+ "sequences": {},
+ "roles": {},
+ "policies": {},
+ "views": {},
+ "_meta": {
+ "columns": {},
+ "schemas": {},
+ "tables": {}
+ }
+}
\ No newline at end of file
diff --git a/src/app/db/migrations/meta/_journal.json b/src/app/db/migrations/meta/_journal.json
index 274de39..ef1efd0 100644
--- a/src/app/db/migrations/meta/_journal.json
+++ b/src/app/db/migrations/meta/_journal.json
@@ -1247,6 +1247,13 @@
"when": 1776451871348,
"tag": "0177_wooden_dexter_bennett",
"breakpoints": true
+ },
+ {
+ "idx": 178,
+ "version": "7",
+ "when": 1776458454019,
+ "tag": "0178_parched_midnight",
+ "breakpoints": true
}
]
}
\ No newline at end of file
diff --git a/src/app/db/schema/approvals.ts b/src/app/db/schema/approvals.ts
new file mode 100644
index 0000000..4d615d5
--- /dev/null
+++ b/src/app/db/schema/approvals.ts
@@ -0,0 +1,65 @@
+import {
+ bigserial,
+ boolean,
+ text,
+ timestamp,
+ pgTable,
+ bigint,
+ index,
+ unique,
+} from "drizzle-orm/pg-core";
+import { user } from "./users";
+import { InferModel } from "drizzle-orm";
+
+// Current approval state per (deal, measure) — upserted on each change.
+// Query WHERE is_approved = true to get the currently approved set.
+export const dealMeasureApprovals = pgTable(
+ "deal_measure_approvals",
+ {
+ id: bigserial("id", { mode: "bigint" }).primaryKey(),
+ hubspotDealId: text("hubspot_deal_id").notNull(),
+ measureName: text("measure_name").notNull(),
+ isApproved: boolean("is_approved").notNull().default(true),
+ approvedBy: bigint("approved_by", { mode: "bigint" })
+ .notNull()
+ .references(() => user.id),
+ approvedAt: timestamp("approved_at", { withTimezone: true })
+ .defaultNow()
+ .notNull(),
+ },
+ (table) => [
+ unique("uq_deal_measure").on(table.hubspotDealId, table.measureName),
+ index("idx_deal_measure_approvals_deal_id").on(table.hubspotDealId),
+ ],
+);
+
+// Append-only audit log — never deleted.
+export const dealMeasureApprovalEvents = pgTable(
+ "deal_measure_approval_events",
+ {
+ id: bigserial("id", { mode: "bigint" }).primaryKey(),
+ hubspotDealId: text("hubspot_deal_id").notNull(),
+ measureName: text("measure_name").notNull(),
+ // 'approved' | 'unapproved'
+ action: text("action").notNull(),
+ actedBy: bigint("acted_by", { mode: "bigint" })
+ .notNull()
+ .references(() => user.id),
+ actedAt: timestamp("acted_at", { withTimezone: true })
+ .defaultNow()
+ .notNull(),
+ },
+ (table) => [
+ index("idx_deal_measure_events_deal_id").on(table.hubspotDealId),
+ index("idx_deal_measure_events_acted_at").on(table.actedAt),
+ ],
+);
+
+export type DealMeasureApproval = InferModel<
+ typeof dealMeasureApprovals,
+ "select"
+>;
+export type DealMeasureApprovalEvent = InferModel<
+ typeof dealMeasureApprovalEvents,
+ "select"
+>;
diff --git a/src/app/db/schema/portfolio.ts b/src/app/db/schema/portfolio.ts
index d424634..8e231bc 100644
--- a/src/app/db/schema/portfolio.ts
+++ b/src/app/db/schema/portfolio.ts
@@ -7,6 +7,7 @@ import {
pgEnum,
integer,
bigint,
+ unique,
} from "drizzle-orm/pg-core";
import { user } from "./users";
import { InferModel } from "drizzle-orm";
@@ -124,7 +125,43 @@ export const portfolioUsers = pgTable("portfolioUsers", {
.notNull(),
});
+export const PortfolioCapability: [string, ...string[]] = [
+ "approver",
+ "contractor",
+];
+export type PortfolioCapabilityType = "approver" | "contractor";
+
+export const portfolioCapabilityEnum = pgEnum(
+ "portfolio_capability",
+ PortfolioCapability as [string, ...string[]],
+);
+
+export const portfolioCapabilities = pgTable(
+ "portfolio_capabilities",
+ {
+ id: bigserial("id", { mode: "bigint" }).primaryKey(),
+ userId: bigint("user_id", { mode: "bigint" })
+ .notNull()
+ .references(() => user.id),
+ portfolioId: bigint("portfolio_id", { mode: "bigint" })
+ .notNull()
+ .references(() => portfolio.id),
+ capability: portfolioCapabilityEnum("capability").notNull(),
+ createdAt: timestamp("created_at", { precision: 6, withTimezone: true })
+ .defaultNow()
+ .notNull(),
+ updatedAt: timestamp("updated_at", { precision: 6, withTimezone: true })
+ .defaultNow()
+ .notNull(),
+ },
+ (table) => [unique().on(table.userId, table.portfolioId, table.capability)],
+);
+
export type Portfolio = InferModel;
export type NewPortfolio = InferModel;
export type PortfolioUsers = InferModel;
export type NewPortfolioUsers = InferModel;
+export type PortfolioCapabilities = InferModel<
+ typeof portfolioCapabilities,
+ "select"
+>;
diff --git a/src/app/db/schema/removal_requests.ts b/src/app/db/schema/removal_requests.ts
new file mode 100644
index 0000000..1ec753b
--- /dev/null
+++ b/src/app/db/schema/removal_requests.ts
@@ -0,0 +1,46 @@
+import {
+ bigserial,
+ text,
+ timestamp,
+ pgTable,
+ bigint,
+ index,
+} from "drizzle-orm/pg-core";
+import { user } from "./users";
+import { portfolio } from "./portfolio";
+import { InferModel } from "drizzle-orm";
+
+// One row per removal request. A property can have multiple requests over time
+// (e.g. request → declined → new request). Query by hubspotDealId to get history.
+export const propertyRemovalRequests = pgTable(
+ "property_removal_requests",
+ {
+ id: bigserial("id", { mode: "bigint" }).primaryKey(),
+ hubspotDealId: text("hubspot_deal_id").notNull(),
+ portfolioId: bigint("portfolio_id", { mode: "bigint" })
+ .notNull()
+ .references(() => portfolio.id),
+ reason: text("reason").notNull(),
+ // 'pending' | 'approved' | 'declined'
+ status: text("status").notNull().default("pending"),
+ requestedBy: bigint("requested_by", { mode: "bigint" })
+ .notNull()
+ .references(() => user.id),
+ requestedAt: timestamp("requested_at", { withTimezone: true })
+ .defaultNow()
+ .notNull(),
+ reviewedBy: bigint("reviewed_by", { mode: "bigint" }).references(
+ () => user.id,
+ ),
+ reviewedAt: timestamp("reviewed_at", { withTimezone: true }),
+ },
+ (table) => [
+ index("idx_removal_requests_deal_id").on(table.hubspotDealId),
+ index("idx_removal_requests_portfolio_id").on(table.portfolioId),
+ ],
+);
+
+export type PropertyRemovalRequest = InferModel<
+ typeof propertyRemovalRequests,
+ "select"
+>;
diff --git a/src/app/db/schema/uploaded_files.ts b/src/app/db/schema/uploaded_files.ts
index 18d99a0..6925310 100644
--- a/src/app/db/schema/uploaded_files.ts
+++ b/src/app/db/schema/uploaded_files.ts
@@ -1,4 +1,5 @@
import { bigint, bigserial, pgEnum, pgTable, text, timestamp } from "drizzle-orm/pg-core";
+import { user } from "./users";
export const fileType = pgEnum("file_type", [
"photo_pack",
@@ -12,14 +13,48 @@ export const fileType = pgEnum("file_type", [
"pas_2023_occupancy",
"ecmk_site_note",
"ecmk_rd_sap_site_note",
- "ecmk_survey_xml"
+ "ecmk_survey_xml",
+ // Contractor install documentation
+ // Photos
+ "pre_photo",
+ "mid_photo",
+ "post_photo",
+ "loft_hatch_photo",
+ "dmev_photos",
+ "door_undercut_photos",
+ "trickle_vent_photos",
+ // Pre-installation
+ "pre_installation_building_inspection",
+ "point_of_work_risk_assessment",
+ // Compliance & lodgement
+ "claim_of_compliance",
+ "mcs_compliance_certificate",
+ "certificate_of_conformity",
+ "minor_works_electrical_certificate",
+ "trustmark_licence_numbers",
+ "operative_competency",
+ // Ventilation
+ "ventilation_assessment_checklist",
+ "anemometer_readings",
+ "commissioning_records",
+ "part_f_ventilation_document",
+ // Handover & warranties
+ "handover_pack",
+ "insurance_guarantee",
+ "workmanship_warranty",
+ "g98_notification",
+ // Qualifications & other
+ "installer_qualifications",
+ "installer_feedback",
+ "contractor_other",
]);
export const fileSource = pgEnum("file_source", [
"pas hub",
"sharepoint",
"hubspot",
- "ecmk"
+ "ecmk",
+ "contractor",
]);
export const uploadedFiles = pgTable(
@@ -36,6 +71,8 @@ export const uploadedFiles = pgTable(
hubsotDealId: text("hubspot_deal_id"),
hubspotListingId: bigint("hubspot_listing_id", { mode: "bigint" }),
fileType: fileType("file_type"),
- source: fileSource("file_source")
+ source: fileSource("file_source"),
+ measureName: text("measure_name"),
+ uploadedBy: bigint("uploaded_by", { mode: "bigint" }).references(() => user.id),
}
);
\ No newline at end of file
diff --git a/src/app/portfolio/[slug]/(portfolio)/settings/CapabilitiesCard.tsx b/src/app/portfolio/[slug]/(portfolio)/settings/CapabilitiesCard.tsx
new file mode 100644
index 0000000..533c2e3
--- /dev/null
+++ b/src/app/portfolio/[slug]/(portfolio)/settings/CapabilitiesCard.tsx
@@ -0,0 +1,230 @@
+"use client";
+
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/app/shadcn_components/ui/table";
+import { Button } from "@/app/shadcn_components/ui/button";
+import { Badge } from "@/app/shadcn_components/ui/badge";
+import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
+
+type Capability = "approver" | "contractor";
+
+type CapabilityEntry = {
+ id: string;
+ userId: string;
+ capability: Capability;
+ name: string | null;
+ email: string;
+};
+
+type CapabilityMap = Record;
+
+async function getCapabilities(portfolioId: string): Promise {
+ const res = await fetch(`/api/portfolio/${portfolioId}/capabilities`);
+ if (!res.ok) throw new Error("Failed to fetch capabilities");
+ return res.json();
+}
+
+async function getCollaborators(
+ portfolioId: string,
+): Promise<{ userId: string; name: string | null; email: string }[]> {
+ const res = await fetch(`/api/portfolio/${portfolioId}/colloborators`);
+ if (!res.ok) throw new Error("Failed to fetch collaborators");
+ const json = await res.json();
+ const users = Array.isArray(json) ? json : json.users ?? [];
+ return users.map((u: any) => ({
+ userId: String(u.userId),
+ name: u.name ?? null,
+ email: u.email ?? "",
+ }));
+}
+
+async function assignCapability(
+ portfolioId: string,
+ userId: string,
+ capability: Capability,
+): Promise {
+ const res = await fetch(`/api/portfolio/${portfolioId}/capabilities`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ userId, capability }),
+ });
+ if (!res.ok) throw new Error("Failed to assign capability");
+}
+
+async function removeCapability(
+ portfolioId: string,
+ userId: string,
+ capability: Capability,
+): Promise {
+ const res = await fetch(`/api/portfolio/${portfolioId}/capabilities`, {
+ method: "DELETE",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ userId, capability }),
+ });
+ if (!res.ok) throw new Error("Failed to remove capability");
+}
+
+export function CapabilitiesCard({ portfolioId }: { portfolioId: string }) {
+ const queryClient = useQueryClient();
+ const queryKey = ["portfolioCapabilities", portfolioId];
+
+ const { data: entries = [], isLoading: loadingCaps } = useQuery({
+ queryKey,
+ queryFn: () => getCapabilities(portfolioId),
+ enabled: !!portfolioId,
+ refetchOnWindowFocus: false,
+ });
+
+ const { data: collaborators = [], isLoading: loadingCollabs } = useQuery({
+ queryKey: ["portfolioUsers", portfolioId],
+ queryFn: () => getCollaborators(portfolioId),
+ enabled: !!portfolioId,
+ refetchOnWindowFocus: false,
+ });
+
+ const isLoading = loadingCaps || loadingCollabs;
+
+ // Build a map: userId -> { capabilities: [] }
+ const capMap: CapabilityMap = {};
+ for (const c of collaborators) {
+ capMap[c.userId] = { name: c.name, email: c.email, capabilities: [] };
+ }
+ for (const e of entries) {
+ if (capMap[e.userId]) {
+ capMap[e.userId].capabilities.push(e.capability);
+ }
+ }
+
+ const toggleMutation = useMutation({
+ mutationFn: ({
+ userId,
+ capability,
+ has,
+ }: {
+ userId: string;
+ capability: Capability;
+ has: boolean;
+ }) =>
+ has
+ ? removeCapability(portfolioId, userId, capability)
+ : assignCapability(portfolioId, userId, capability),
+ onSettled: () => {
+ queryClient.invalidateQueries({ queryKey });
+ },
+ });
+
+ const rows = Object.entries(capMap);
+
+ return (
+
+
+
+
+
+ Project Roles:
+
+ Assign approver or contractor capabilities to users
+
+
+
+
+
+
+
+
+
+ Name
+ Email
+ Approver
+ Contractor
+
+
+
+ {isLoading ? (
+
+
+ Loading…
+
+
+ ) : rows.length === 0 ? (
+
+
+ No collaborators yet. Add users in the section above first.
+
+
+ ) : (
+ rows.map(([userId, { name, email, capabilities }]) => (
+
+ {name || "—"}
+ {email}
+
+
+ toggleMutation.mutate({ userId, capability: "approver", has })
+ }
+ />
+
+
+
+ toggleMutation.mutate({ userId, capability: "contractor", has })
+ }
+ />
+
+
+ ))
+ )}
+
+
+
+
+
+
+
+
+ );
+}
+
+function CapabilityToggle({
+ has,
+ capability,
+ isPending,
+ onToggle,
+}: {
+ has: boolean;
+ capability: Capability;
+ isPending: boolean;
+ onToggle: (has: boolean) => void;
+}) {
+ return (
+ onToggle(has)}
+ className={has ? "bg-brandblue text-white" : ""}
+ >
+ {has ? (
+
+ {capability === "approver" ? "Approver" : "Contractor"} ✓
+
+ ) : (
+
+ Add {capability === "approver" ? "Approver" : "Contractor"}
+
+ )}
+
+ );
+}
diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/ApprovalConfirmDialog.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/ApprovalConfirmDialog.tsx
new file mode 100644
index 0000000..ded3a4a
--- /dev/null
+++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/ApprovalConfirmDialog.tsx
@@ -0,0 +1,140 @@
+"use client";
+
+import { useState } from "react";
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogDescription,
+ DialogFooter,
+} from "@/app/shadcn_components/ui/dialog";
+import { Button } from "@/app/shadcn_components/ui/button";
+import { Input } from "@/app/shadcn_components/ui/input";
+import { CheckCircle2, XCircle } from "lucide-react";
+
+export type PendingDiff = {
+ added: string[];
+ removed: string[];
+};
+
+type Props = {
+ open: boolean;
+ pendingDiffs: Record; // dealId -> diff
+ dealNames: Record; // dealId -> display name
+ onConfirm: () => void;
+ onCancel: () => void;
+ isPending: boolean;
+};
+
+const CONFIRM_WORD = "approve";
+
+export function ApprovalConfirmDialog({
+ open,
+ pendingDiffs,
+ dealNames,
+ onConfirm,
+ onCancel,
+ isPending,
+}: Props) {
+ const [typed, setTyped] = useState("");
+
+ const canConfirm = typed === CONFIRM_WORD && !isPending;
+
+ const totalAdded = Object.values(pendingDiffs).reduce(
+ (sum, d) => sum + d.added.length,
+ 0,
+ );
+ const totalRemoved = Object.values(pendingDiffs).reduce(
+ (sum, d) => sum + d.removed.length,
+ 0,
+ );
+
+ function handleOpenChange(open: boolean) {
+ if (!open) {
+ setTyped("");
+ onCancel();
+ }
+ }
+
+ return (
+
+
+
+ Confirm approval changes
+
+ Review the changes below. This action will be recorded in the audit
+ log and cannot be undone automatically.
+
+
+
+
+ {Object.entries(pendingDiffs).map(([dealId, diff]) => {
+ if (diff.added.length === 0 && diff.removed.length === 0) return null;
+ const name = dealNames[dealId] ?? dealId;
+ return (
+
+
{name}
+
+ {diff.added.map((m) => (
+
+
+ {m}
+ will be approved
+
+ ))}
+ {diff.removed.map((m) => (
+
+
+ {m}
+ will be unapproved
+
+ ))}
+
+
+ );
+ })}
+
+
+
+
+ To confirm{" "}
+
+ {totalAdded > 0 && `${totalAdded} approval${totalAdded > 1 ? "s" : ""}`}
+ {totalAdded > 0 && totalRemoved > 0 && " and "}
+ {totalRemoved > 0 && `${totalRemoved} removal${totalRemoved > 1 ? "s" : ""}`}
+
+ , type{" "}
+
+ {CONFIRM_WORD}
+ {" "}
+ below:
+
+
setTyped(e.target.value)}
+ placeholder={`Type "${CONFIRM_WORD}" to confirm`}
+ className="font-mono"
+ autoFocus
+ />
+
+
+
+
+ Cancel
+
+ {
+ setTyped("");
+ onConfirm();
+ }}
+ disabled={!canConfirm}
+ className="bg-brandblue text-white"
+ >
+ {isPending ? "Saving…" : "Confirm"}
+
+
+
+
+ );
+}
diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/ContractorUploadModal.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/ContractorUploadModal.tsx
new file mode 100644
index 0000000..1fa07b7
--- /dev/null
+++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/ContractorUploadModal.tsx
@@ -0,0 +1,703 @@
+"use client";
+
+import { useEffect, useRef, useState } from "react";
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogDescription,
+ DialogFooter,
+} from "@/app/shadcn_components/ui/dialog";
+import { Button } from "@/app/shadcn_components/ui/button";
+import {
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+ SelectLabel,
+ SelectTrigger,
+ SelectValue,
+} from "@/app/shadcn_components/ui/select";
+import { CheckCircle2, XCircle, Upload, Loader2, Clock, ChevronDown, ChevronRight, Info } from "lucide-react";
+import { uploadFileToS3 } from "@/app/utils/s3";
+import type { ClassifiedDeal } from "./types";
+
+// ── Types ─────────────────────────────────────────────────────────────────
+
+type FileStatus = "queued" | "uploading" | "done" | "error";
+
+type FileEntry = {
+ id: string; // local UUID for React key
+ // One of these will be set:
+ file?: File; // for newly picked files
+ existingS3Key?: string; // for pre-existing unclassified uploads
+ // Display
+ displayName: string;
+ displaySize?: string;
+ // Upload state
+ status: FileStatus;
+ errorMsg?: string;
+ uploadedId?: string; // DB record ID (set after recording or from existing)
+ // Classification
+ docType: string;
+ measureName: string;
+};
+
+type Phase = "loading" | "upload" | "classify";
+
+type Props = {
+ deal: ClassifiedDeal;
+ portfolioId: string;
+ onClose: () => void;
+};
+
+// ── Constants ─────────────────────────────────────────────────────────────
+
+const FILE_TYPE_OPTIONS: { value: string; label: string; group: string; hint?: string }[] = [
+ // Photos
+ { value: "pre_photo", label: "Pre-Install Photos", group: "Photos", hint: "Required for ALL measures. Capture existing condition before any work begins." },
+ { value: "mid_photo", label: "Mid-Install Photos", group: "Photos", hint: "Required for ALL measures. Detailed photos showing all angles and areas during installation. Insufficient pictures will result in non-lodgement." },
+ { value: "post_photo", label: "Post-Install Photos", group: "Photos", hint: "Required for ALL measures. Confirm completed installation." },
+ { value: "loft_hatch_photo", label: "Loft Hatch & Draft Excluder Photos",group: "Photos", hint: "Required for loft insulation. Must show loft hatch insulation, draft excluders, and hook & eye closing. Also include photos of insulation depth with a ruler showing thickness." },
+ { value: "dmev_photos", label: "DMEV Photos (Wetrooms)", group: "Photos", hint: "Clear photos of all Decentralised Mechanical Extract Ventilation units installed in wetrooms." },
+ { value: "door_undercut_photos",label: "Door Undercut Photos", group: "Photos", hint: "Photos of all door undercuts to demonstrate compliant ventilation paths." },
+ { value: "trickle_vent_photos", label: "Trickle Vent Photos", group: "Photos", hint: "Photos of all trickle vents located in windows." },
+ // Pre-installation
+ { value: "pre_installation_building_inspection", label: "PIBI / Tech Survey", group: "Pre-Installation", hint: "Pre-Installation Building Inspection — required per property and per measure." },
+ { value: "point_of_work_risk_assessment", label: "Point of Work Risk Assessment", group: "Pre-Installation" },
+ // Compliance & lodgement
+ { value: "claim_of_compliance", label: "DOCC 2030 (Claim of Compliance)", group: "Compliance & Lodgement", hint: "Required per property and per measure for TrustMark lodgement under PAS 2030." },
+ { value: "mcs_compliance_certificate", label: "MCS Compliance Certificate", group: "Compliance & Lodgement", hint: "Required for Solar PV and Air Source Heat Pump installations." },
+ { value: "certificate_of_conformity", label: "Certificate of Conformity", group: "Compliance & Lodgement" },
+ { value: "minor_works_electrical_certificate", label: "Minor Works Electrical Certificate", group: "Compliance & Lodgement" },
+ { value: "trustmark_licence_numbers", label: "TrustMark Licence Numbers", group: "Compliance & Lodgement", hint: "All installer and subcontractor TrustMark licence numbers. Ensure all are accredited for the correct measures under PAS 2023." },
+ { value: "operative_competency", label: "Operative Competency", group: "Compliance & Lodgement", hint: "PAS 2030 installer accreditation and qualifications of individual workers, suitable for the measure(s) installed. Verify all installers/subcontractors are accredited under PAS 2023." },
+ // Ventilation
+ { value: "ventilation_assessment_checklist", label: "Ventilation Assessment Checklist", group: "Ventilation" },
+ { value: "anemometer_readings", label: "Anemometer Readings", group: "Ventilation", hint: "Required for DMEV/ventilation measures to confirm airflow compliance." },
+ { value: "commissioning_records", label: "Commissioning Records", group: "Ventilation", hint: "Tests, certifications and commissioning records for all systems installed." },
+ { value: "part_f_ventilation_document", label: "Approved Document Part F", group: "Ventilation", hint: "Ventilation compliance document under Approved Document Part F." },
+ // Handover & warranties
+ { value: "handover_pack", label: "Handover Pack", group: "Handover & Warranties" },
+ { value: "workmanship_warranty", label: "Workmanship Warranty", group: "Handover & Warranties", hint: "Required per property and per measure for TrustMark lodgement." },
+ { value: "insurance_guarantee", label: "Insurance Backed Guarantee (IBG)", group: "Handover & Warranties", hint: "Required per property and per measure for TrustMark lodgement." },
+ { value: "g98_notification", label: "G98 / G99 Notification", group: "Handover & Warranties", hint: "Required for Solar PV and other grid-connected installations." },
+ // Qualifications & other
+ { value: "installer_qualifications", label: "Installer Qualifications", group: "Qualifications & Other" },
+ { value: "installer_feedback", label: "Installer Feedback", group: "Qualifications & Other" },
+ { value: "contractor_other", label: "Other", group: "Qualifications & Other" },
+];
+
+const FILE_TYPE_GROUPS = [
+ "Photos",
+ "Pre-Installation",
+ "Compliance & Lodgement",
+ "Ventilation",
+ "Handover & Warranties",
+ "Qualifications & Other",
+];
+
+// ── PAS 2030/2035 requirements summary (for guidance panel) ───────────────
+
+const PAS_REQUIREMENTS = [
+ {
+ heading: "Required for every property & measure",
+ items: [
+ "PIBI / Tech Survey (pre-installation building inspection)",
+ "DOCC 2030 — Claim of Compliance (PAS 2030)",
+ "Insurance Backed Guarantee (IBG)",
+ "Workmanship Warranty",
+ "Pre, mid, and post-install photos (all measures)",
+ ],
+ },
+ {
+ heading: "Additional for Solar PV & ASHP",
+ items: [
+ "MCS Compliance Certificate",
+ "G98 / G99 Notification",
+ ],
+ },
+ {
+ heading: "Loft insulation",
+ items: [
+ "Loft hatch insulation, draft excluders, and hook & eye closing photos",
+ "Photos of insulation depth with a ruler showing thickness",
+ ],
+ },
+ {
+ heading: "Ventilation measures",
+ items: [
+ "Clear DMEV photos in all wetrooms",
+ "Anemometer readings",
+ "Commissioning records",
+ "Approved Document Part F ventilation document",
+ "Door undercut photos",
+ "Trickle vent photos",
+ ],
+ },
+ {
+ heading: "Installer / lodgement pack",
+ items: [
+ "TrustMark licence numbers for all installers & subcontractors (verify PAS 2023 accreditation)",
+ "Operative Competency (PAS 2030 accreditation + individual worker qualifications)",
+ "Minor Works Electrical Certificate (where applicable)",
+ ],
+ },
+];
+
+// ── Helpers ───────────────────────────────────────────────────────────────
+
+function formatSize(bytes: number): string {
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
+}
+
+function contentTypeFor(ext: string): string {
+ const e = ext.toLowerCase();
+ if (e === "pdf") return "application/pdf";
+ if (["jpg", "jpeg"].includes(e)) return "image/jpeg";
+ if (e === "png") return "image/png";
+ return "application/octet-stream";
+}
+
+function parseMeasures(raw: string | null | undefined): string[] {
+ if (!raw) return [];
+ return raw.split(",").map((m) => m.trim()).filter(Boolean);
+}
+
+function s3KeyBasename(key: string): string {
+ return key.split("/").pop() ?? key;
+}
+
+async function getPresignedUrl(path: string, contentType: string): Promise {
+ const res = await fetch("/api/upload/retrofit-energy-assessments", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ path, contentType, expiresInSeconds: 300 }),
+ });
+ if (!res.ok) throw new Error("Failed to get presigned URL");
+ const { url } = await res.json();
+ return url;
+}
+
+async function recordUpload(payload: {
+ s3FileKey: string;
+ s3FileBucket: string;
+ uprn?: string;
+ hubspotDealId?: string;
+ landlordPropertyId?: string;
+}): Promise {
+ const res = await fetch("/api/upload/contractor-install", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(payload),
+ });
+ if (!res.ok) throw new Error("Failed to record upload");
+ const { id } = await res.json();
+ return id;
+}
+
+async function saveClassifications(
+ updates: { id: string; fileType: string; measureName?: string }[],
+): Promise {
+ const res = await fetch("/api/upload/contractor-install", {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ updates }),
+ });
+ if (!res.ok) throw new Error("Failed to save classifications");
+}
+
+// ── PAS guidance panel ────────────────────────────────────────────────────
+
+function PasGuidancePanel() {
+ const [open, setOpen] = useState(false);
+ return (
+
+
setOpen((v) => !v)}
+ className="flex w-full items-center gap-2 px-3 py-2.5 text-left"
+ >
+
+
+ PAS 2030/2035 document requirements
+
+ {open
+ ?
+ :
+ }
+
+ {open && (
+
+ {PAS_REQUIREMENTS.map((section) => (
+
+
+ {section.heading}
+
+
+ {section.items.map((item) => (
+
+
+ {item}
+
+ ))}
+
+
+ ))}
+
+ Insufficient mid-install photos will result in the Retrofit Coordinator sending back for re-submission and non-lodgement.
+
+
+ )}
+
+ );
+}
+
+// ── DocType select ────────────────────────────────────────────────────────
+
+function DocTypeSelect({ value, onChange, showHint = false }: { value: string; onChange: (v: string) => void; showHint?: boolean }) {
+ const selected = FILE_TYPE_OPTIONS.find((o) => o.value === value);
+
+ return (
+
+
onChange(v === "__unset__" ? "" : v)}>
+
+
+
+
+ Select type…
+ {FILE_TYPE_GROUPS.map((group) => {
+ const items = FILE_TYPE_OPTIONS.filter((o) => o.group === group);
+ if (!items.length) return null;
+ return (
+
+
+ {group}
+
+ {items.map((o) => (
+
+ {o.label}
+
+ ))}
+
+ );
+ })}
+
+
+ {showHint && selected?.hint && (
+
{selected.hint}
+ )}
+
+ );
+}
+
+// ── Status icon ────────────────────────────────────────────────────────────
+
+function StatusIcon({ status, isExisting, errorMsg }: { status: FileStatus; isExisting?: boolean; errorMsg?: string }) {
+ if (isExisting) return ;
+ if (status === "queued") return ;
+ if (status === "uploading") return ;
+ if (status === "done") return ;
+ return ;
+}
+
+// ── Main component ─────────────────────────────────────────────────────────
+
+export default function ContractorUploadModal({ deal, portfolioId, onClose }: Props) {
+ const measures = parseMeasures(deal.proposedMeasures);
+ const fileInputRef = useRef(null);
+ const [isDragOver, setIsDragOver] = useState(false);
+ const [queue, setQueue] = useState([]);
+ const [phase, setPhase] = useState("loading");
+ const [isUploading, setIsUploading] = useState(false);
+ const [isSaving, setIsSaving] = useState(false);
+ const [saveError, setSaveError] = useState(null);
+
+ // ── Fetch existing unclassified files on mount ───────────────────────
+
+ useEffect(() => {
+ async function fetchExisting() {
+ const uprnParam = deal.uprn;
+ const propIdParam = deal.landlordPropertyId;
+ if (!uprnParam && !propIdParam) {
+ setPhase("upload");
+ return;
+ }
+
+ try {
+ const param = uprnParam
+ ? `uprn=${encodeURIComponent(uprnParam)}`
+ : `landlordPropertyId=${encodeURIComponent(propIdParam!)}`;
+ const res = await fetch(`/api/live-tracking/property-documents?${param}`);
+ if (!res.ok) throw new Error("fetch failed");
+ const docs: { id: string; s3FileKey: string; docType: string | null; source: string | null }[] = await res.json();
+
+ const unclassified = docs.filter(
+ (d) => d.source === "contractor" && (d.docType === null || d.docType === "unknown"),
+ );
+
+ if (unclassified.length > 0) {
+ const entries: FileEntry[] = unclassified.map((d) => ({
+ id: crypto.randomUUID(),
+ existingS3Key: d.s3FileKey,
+ displayName: s3KeyBasename(d.s3FileKey),
+ status: "done",
+ uploadedId: d.id,
+ docType: "",
+ measureName: measures[0] ?? "",
+ }));
+ setQueue(entries);
+ setPhase("classify");
+ } else {
+ setPhase("upload");
+ }
+ } catch {
+ // If fetch fails, just proceed to upload phase
+ setPhase("upload");
+ }
+ }
+
+ fetchExisting();
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ // ── File selection ───────────────────────────────────────────────────
+
+ function addFiles(files: FileList | File[]) {
+ const newEntries: FileEntry[] = Array.from(files).map((f) => ({
+ id: crypto.randomUUID(),
+ file: f,
+ displayName: f.name,
+ displaySize: formatSize(f.size),
+ status: "queued",
+ docType: "",
+ measureName: measures[0] ?? "",
+ }));
+ setQueue((prev) => [...prev, ...newEntries]);
+ }
+
+ function handleInputChange(e: React.ChangeEvent) {
+ if (e.target.files?.length) addFiles(e.target.files);
+ e.target.value = "";
+ }
+
+ function handleDrop(e: React.DragEvent) {
+ e.preventDefault();
+ setIsDragOver(false);
+ if (e.dataTransfer.files?.length) addFiles(e.dataTransfer.files);
+ }
+
+ function removeFile(id: string) {
+ setQueue((prev) => prev.filter((f) => f.id !== id));
+ }
+
+ // ── Phase 1: Upload new files ────────────────────────────────────────
+
+ async function handleUpload() {
+ const toUpload = queue.filter((f) => f.status === "queued");
+ if (toUpload.length === 0) {
+ // No new files to upload — go straight to classify for existing
+ setPhase("classify");
+ return;
+ }
+ if (isUploading) return;
+ setIsUploading(true);
+
+ setQueue((prev) =>
+ prev.map((f) => f.status === "queued" ? { ...f, status: "uploading" } : f),
+ );
+
+ const uploadResults = await Promise.allSettled(
+ toUpload.map(async (entry) => {
+ const ext = (entry.file!.name.split(".").pop() ?? "bin").toLowerCase();
+ const ct = contentTypeFor(ext);
+ const timestamp = Date.now();
+ const s3Key = `contractor-install/${deal.dealId}/unclassified/${timestamp}_${entry.id.slice(0, 8)}.${ext}`;
+
+ const presignedUrl = await getPresignedUrl(s3Key, ct);
+ await uploadFileToS3({ presignedUrl, file: entry.file!, contentType: ct });
+
+ const urlObj = new URL(presignedUrl);
+ const bucket = urlObj.hostname.split(".")[0];
+
+ const uploadedId = await recordUpload({
+ s3FileKey: s3Key,
+ s3FileBucket: bucket,
+ uprn: deal.uprn ?? undefined,
+ hubspotDealId: deal.dealId,
+ landlordPropertyId: deal.landlordPropertyId ?? undefined,
+ });
+
+ return { id: entry.id, uploadedId };
+ }),
+ );
+
+ const resultMap = new Map(
+ uploadResults.map((r, i) => [
+ toUpload[i].id,
+ r.status === "fulfilled" ? { ok: true, uploadedId: r.value.uploadedId } : { ok: false },
+ ]),
+ );
+
+ setQueue((prev) =>
+ prev.map((f) => {
+ const r = resultMap.get(f.id);
+ if (!r) return f;
+ if (r.ok) return { ...f, status: "done", uploadedId: r.uploadedId };
+ return { ...f, status: "error", errorMsg: "Upload failed" };
+ }),
+ );
+
+ setIsUploading(false);
+ setPhase("classify");
+ }
+
+ // ── Phase 2: Classify ────────────────────────────────────────────────
+
+ function updateEntryField(id: string, field: "docType" | "measureName", value: string) {
+ setQueue((prev) => prev.map((f) => (f.id === id ? { ...f, [field]: value } : f)));
+ }
+
+ const classifiableEntries = queue.filter((f) => f.status === "done" && f.uploadedId);
+ const allClassified = classifiableEntries.length > 0 && classifiableEntries.every((f) => f.docType !== "");
+
+ async function handleSaveClassifications() {
+ setSaveError(null);
+ setIsSaving(true);
+ try {
+ await saveClassifications(
+ classifiableEntries.map((f) => ({
+ id: f.uploadedId!,
+ fileType: f.docType,
+ measureName: (f.measureName && f.measureName !== "__none__") ? f.measureName : undefined,
+ })),
+ );
+ onClose();
+ } catch {
+ setSaveError("Failed to save classifications. Please try again.");
+ } finally {
+ setIsSaving(false);
+ }
+ }
+
+ // ── Computed ─────────────────────────────────────────────────────────
+
+ const newQueuedCount = queue.filter((f) => f.status === "queued").length;
+ const existingCount = queue.filter((f) => f.existingS3Key && f.status === "done").length;
+ const propertyLabel = deal.dealname ?? deal.landlordPropertyId ?? deal.dealId;
+
+ // ── Render ───────────────────────────────────────────────────────────
+
+ return (
+
+
+
+
+ {phase === "loading" ? "Loading…" :
+ phase === "upload" ? "Upload Documents" :
+ "Classify Documents"}
+
+
+ {phase === "loading" && "Checking for pending files…"}
+ {phase === "upload" && (
+ <>
+ Upload install documents for {propertyLabel} .
+ {existingCount > 0 && ` ${existingCount} file${existingCount !== 1 ? "s" : ""} are pending classification.`}
+ >
+ )}
+ {phase === "classify" && (
+ <>
+ {classifiableEntries.length} file{classifiableEntries.length !== 1 ? "s" : ""} ready to classify.
+ Select a document type for each, then save.
+ >
+ )}
+
+
+
+
+
+ {/* ── Loading ── */}
+ {phase === "loading" && (
+
+
+
+ )}
+
+ {/* ── Phase 1: Upload ── */}
+ {phase === "upload" && (
+ <>
+ {/* PAS guidance */}
+
+
+ {/* Existing unclassified banner */}
+ {existingCount > 0 && (
+
+
+
+ {existingCount} previously uploaded file{existingCount !== 1 ? "s" : ""} {existingCount !== 1 ? "are" : "is"} waiting to be classified.
+ Add new files or go straight to classification.
+
+
+ )}
+
+ {/* Drop zone */}
+
fileInputRef.current?.click()}
+ onDragOver={(e) => { e.preventDefault(); setIsDragOver(true); }}
+ onDragLeave={() => setIsDragOver(false)}
+ onDrop={handleDrop}
+ >
+
+
Drop files here or click to browse
+
PDF, JPG, PNG accepted · Multiple files OK
+
+
+
+ {/* New file queue */}
+ {newQueuedCount > 0 && (
+
+ {queue.filter((f) => f.file).map((entry) => (
+
+
+
{entry.displayName}
+ {entry.displaySize &&
{entry.displaySize}
}
+
+
+ {entry.status === "queued" && (
+
removeFile(entry.id)}
+ className="text-gray-300 hover:text-gray-500 text-lg leading-none shrink-0"
+ aria-label="Remove"
+ >
+ ×
+
+ )}
+
+ ))}
+
+ )}
+ >
+ )}
+
+ {/* ── Phase 2: Classify ── */}
+ {phase === "classify" && (
+
+ {/* PAS guidance */}
+
+
+ {/* Column headers */}
+
+ File
+ Document Type *
+ Measure
+
+
+ {classifiableEntries.map((entry) => (
+
+
+
+
+
{entry.displayName}
+ {entry.displaySize &&
{entry.displaySize}
}
+ {entry.existingS3Key &&
Previously uploaded
}
+
+
+
updateEntryField(entry.id, "docType", v)} showHint />
+ {measures.length > 0 ? (
+ updateEntryField(entry.id, "measureName", v === "__none__" ? "" : v)}>
+
+
+
+
+ — None —
+ {measures.map((m) => (
+ {m}
+ ))}
+
+
+ ) : (
+ —
+ )}
+
+ ))}
+
+ {/* Failed uploads (info only) */}
+ {queue.filter((f) => f.status === "error").length > 0 && (
+
+
+ {queue.filter((f) => f.status === "error").length} file(s) failed and are excluded:
+
+ {queue.filter((f) => f.status === "error").map((f) => (
+
{f.displayName}
+ ))}
+
+ )}
+
+ {saveError && (
+
+ {saveError}
+
+ )}
+
+ )}
+
+
+
+ {phase === "loading" && (
+ Cancel
+ )}
+
+ {phase === "upload" && (
+ <>
+ Cancel
+
+ {isUploading ? (
+ <> Uploading…>
+ ) : newQueuedCount > 0 ? (
+ <>Upload {newQueuedCount} file{newQueuedCount !== 1 ? "s" : ""} →>
+ ) : (
+ <>Classify {existingCount} pending file{existingCount !== 1 ? "s" : ""} →>
+ )}
+
+ >
+ )}
+
+ {phase === "classify" && (
+ <>
+
+ Skip for now
+
+
+ {isSaving ? (
+ <> Saving…>
+ ) : (
+ "Save Classifications →"
+ )}
+
+ >
+ )}
+
+
+
+ );
+}
diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/DocumentTable.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/DocumentTable.tsx
index b89ba9b..01baed9 100644
--- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/DocumentTable.tsx
+++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/DocumentTable.tsx
@@ -28,14 +28,18 @@ import {
} from "@/app/shadcn_components/ui/select";
import { Search, ChevronLeft, ChevronRight, Download } from "lucide-react";
import { createDocumentTableColumns } from "./DocumentTableColumns";
-import type { ClassifiedDeal, DocStatusMap } from "./types";
+import ContractorUploadModal from "./ContractorUploadModal";
+import type { ClassifiedDeal, DocStatusMap, PortfolioCapabilityType } from "./types";
-type SurveyStatusFilter = "all" | "none" | "partial" | "complete";
+type RetroAssessmentFilter = "all" | "none" | "partial" | "complete";
+type InstallStatusFilter = "all" | "none" | "hasDocs" | "partial" | "complete";
interface DocumentTableProps {
data: ClassifiedDeal[];
onOpenDrawer: (uprn: string | null, landlordPropertyId: string | null, dealname: string | null) => void;
docStatusMap: DocStatusMap;
+ portfolioId: string;
+ userCapability: PortfolioCapabilityType;
}
function escapeCell(value: unknown): string {
@@ -49,29 +53,46 @@ function escapeCell(value: unknown): string {
: str;
}
-export default function DocumentTable({ data, onOpenDrawer, docStatusMap }: DocumentTableProps) {
+export default function DocumentTable({ data, onOpenDrawer, docStatusMap, portfolioId, userCapability }: DocumentTableProps) {
const [globalFilter, setGlobalFilter] = useState("");
- const [surveyStatusFilter, setSurveyStatusFilter] = useState("all");
+ const [retroAssessmentFilter, setRetroAssessmentFilter] = useState("all");
+ const [installStatusFilter, setInstallStatusFilter] = useState("all");
const [sorting, setSorting] = useState([]);
const [pagination, setPagination] = useState({
pageIndex: 0,
pageSize: 25,
});
+ const [uploadDeal, setUploadDeal] = useState(null);
const filteredData = useMemo(() => {
- if (surveyStatusFilter === "all") return data;
return data.filter((d) => {
const status = d.uprn ? docStatusMap[d.uprn] : undefined;
- if (surveyStatusFilter === "none") return !status || !status.hasDocs;
- if (surveyStatusFilter === "partial") return !!status?.hasDocs && !status.isComplete;
- if (surveyStatusFilter === "complete") return !!status?.isComplete;
+
+ if (retroAssessmentFilter !== "all") {
+ if (retroAssessmentFilter === "none" && !(!status || !status.hasSurveyDocs)) return false;
+ if (retroAssessmentFilter === "partial" && !(status?.hasSurveyDocs && !status.isSurveyComplete)) return false;
+ if (retroAssessmentFilter === "complete" && !status?.isSurveyComplete) return false;
+ }
+
+ if (installStatusFilter !== "all") {
+ const s = status?.installStatus ?? "none";
+ if (installStatusFilter === "none" && s !== "none") return false;
+ if (installStatusFilter === "hasDocs" && s !== "hasDocs") return false;
+ if (installStatusFilter === "partial" && s !== "partial") return false;
+ if (installStatusFilter === "complete" && s !== "all") return false;
+ }
+
return true;
});
- }, [data, surveyStatusFilter, docStatusMap]);
+ }, [data, retroAssessmentFilter, installStatusFilter, docStatusMap]);
const columns = useMemo(
- () => createDocumentTableColumns(onOpenDrawer, docStatusMap),
- [onOpenDrawer, docStatusMap],
+ () => createDocumentTableColumns(
+ onOpenDrawer,
+ docStatusMap,
+ userCapability.includes("contractor") ? setUploadDeal : undefined,
+ ),
+ [onOpenDrawer, docStatusMap, userCapability],
);
const table = useReactTable({
@@ -90,19 +111,27 @@ export default function DocumentTable({ data, onOpenDrawer, docStatusMap }: Docu
const downloadCsv = () => {
const rows = table.getFilteredRowModel().rows;
- const header = "Address,Landlord ID,Survey Status";
+ const header = "Address,Landlord ID,Retrofit Assessment Status,Install Docs Status";
const body = rows
.map((row) => {
const status = row.original.uprn ? docStatusMap[row.original.uprn] : undefined;
- const surveyStatus = status?.isComplete
+ const retroStatus = status?.isSurveyComplete
? "Complete"
- : status?.hasDocs
+ : status?.hasSurveyDocs
? "Partial"
: "No Docs";
+ const installStatusMap: Record = {
+ all: "All Measures",
+ partial: "Some Measures",
+ hasDocs: "Has Docs",
+ none: "No Docs",
+ };
+ const installStatus = installStatusMap[status?.installStatus ?? "none"];
return [
escapeCell(row.original.dealname),
escapeCell(row.original.landlordPropertyId),
- surveyStatus,
+ retroStatus,
+ installStatus,
].join(",");
})
.join("\n");
@@ -119,11 +148,19 @@ export default function DocumentTable({ data, onOpenDrawer, docStatusMap }: Docu
const currentPage = table.getState().pagination.pageIndex + 1;
const totalFiltered = table.getFilteredRowModel().rows.length;
- const surveyStatusLabel: Record = {
- all: "All statuses",
- none: "No Survey Docs",
- partial: "Partial Survey Docs",
- complete: "Complete Survey Docs",
+ const retroAssessmentLabel: Record = {
+ all: "All retrofit statuses",
+ none: "No Retrofit Docs",
+ partial: "Partial Retrofit Docs",
+ complete: "Complete Retrofit Docs",
+ };
+
+ const installStatusLabel: Record = {
+ all: "All install statuses",
+ none: "No Install Docs",
+ hasDocs: "Has Install Docs",
+ partial: "Some Measures",
+ complete: "All Measures",
};
return (
@@ -144,22 +181,42 @@ export default function DocumentTable({ data, onOpenDrawer, docStatusMap }: Docu
/>
- {/* Survey status filter */}
+ {/* Retrofit assessment filter */}
{
- setSurveyStatusFilter(v as SurveyStatusFilter);
+ setRetroAssessmentFilter(v as RetroAssessmentFilter);
setPagination((p) => ({ ...p, pageIndex: 0 }));
}}
>
-
- {surveyStatusLabel[surveyStatusFilter]}
+
+ {retroAssessmentLabel[retroAssessmentFilter]}
- All statuses
- No Survey Docs
- Partial Survey Docs
- Complete Survey Docs
+ All retrofit statuses
+ No Retrofit Docs
+ Partial Retrofit Docs
+ Complete Retrofit Docs
+
+
+
+ {/* Install docs filter */}
+ {
+ setInstallStatusFilter(v as InstallStatusFilter);
+ setPagination((p) => ({ ...p, pageIndex: 0 }));
+ }}
+ >
+
+ {installStatusLabel[installStatusFilter]}
+
+
+ All install statuses
+ No Install Docs
+ Has Install Docs
+ Some Measures
+ All Measures
@@ -184,7 +241,7 @@ export default function DocumentTable({ data, onOpenDrawer, docStatusMap }: Docu
{" "}
of{" "}
{totalFiltered} {" "}
- {surveyStatusFilter !== "all" ? `(${surveyStatusLabel[surveyStatusFilter].toLowerCase()}) ` : ""}
+ {(retroAssessmentFilter !== "all" || installStatusFilter !== "all") ? "(filtered) " : ""}
propert{totalFiltered === 1 ? "y" : "ies"}
@@ -239,6 +296,15 @@ export default function DocumentTable({ data, onOpenDrawer, docStatusMap }: Docu
+ {/* Contractor upload modal */}
+ {uploadDeal && (
+ setUploadDeal(null)}
+ />
+ )}
+
{/* Pagination */}
{pageCount > 1 && (
diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/DocumentTableColumns.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/DocumentTableColumns.tsx
index f88514d..d630130 100644
--- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/DocumentTableColumns.tsx
+++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/DocumentTableColumns.tsx
@@ -1,7 +1,7 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
-import { ArrowUpDown, CheckCircle2, AlertCircle, FileX } from "lucide-react";
+import { ArrowUpDown, CheckCircle2, AlertCircle, FileX, Upload, Package } from "lucide-react";
import type { ClassifiedDeal, DocStatusMap, DocStatus } from "./types";
function SortableHeader({
@@ -22,8 +22,8 @@ function SortableHeader({
);
}
-function SurveyStatusBadge({ status }: { status: DocStatus | undefined }) {
- if (status?.isComplete) {
+function RetroAssessmentBadge({ status }: { status: DocStatus | undefined }) {
+ if (status?.isSurveyComplete) {
return (
@@ -31,7 +31,7 @@ function SurveyStatusBadge({ status }: { status: DocStatus | undefined }) {
);
}
- if (status?.hasDocs) {
+ if (status?.hasSurveyDocs) {
return (
@@ -47,9 +47,44 @@ function SurveyStatusBadge({ status }: { status: DocStatus | undefined }) {
);
}
+function InstallDocsBadge({ status }: { status: DocStatus | undefined }) {
+ const installStatus = status?.installStatus ?? "none";
+ if (installStatus === "all") {
+ return (
+
+
+ All Measures
+
+ );
+ }
+ if (installStatus === "partial") {
+ return (
+
+
+ Some Measures
+
+ );
+ }
+ if (installStatus === "hasDocs") {
+ return (
+
+
+ Has Docs
+
+ );
+ }
+ return (
+
+
+ No Docs
+
+ );
+}
+
export function createDocumentTableColumns(
onOpenDrawer: (uprn: string | null, landlordPropertyId: string | null, dealname: string | null) => void,
docStatusMap: DocStatusMap = {},
+ onUpload?: (deal: ClassifiedDeal) => void,
): ColumnDef[] {
return [
// ── Address ──────────────────────────────────────────────────────────
@@ -80,19 +115,38 @@ export function createDocumentTableColumns(
enableHiding: false,
},
- // ── Survey Status ─────────────────────────────────────────────────────
+ // ── Retrofit Assessment Docs Status ───────────────────────────────────
{
- id: "surveyStatus",
+ id: "retroAssessmentStatus",
accessorFn: (row) => {
const status = row.uprn ? docStatusMap[row.uprn] : undefined;
- if (status?.isComplete) return 2;
- if (status?.hasDocs) return 1;
+ if (status?.isSurveyComplete) return 2;
+ if (status?.hasSurveyDocs) return 1;
return 0;
},
- header: ({ column }) => ,
+ header: ({ column }) => ,
cell: ({ row }) => {
const status = row.original.uprn ? docStatusMap[row.original.uprn] : undefined;
- return ;
+ return ;
+ },
+ enableHiding: false,
+ },
+
+ // ── Install Docs Status ───────────────────────────────────────────────
+ {
+ id: "installDocs",
+ accessorFn: (row) => {
+ const status = row.uprn ? docStatusMap[row.uprn] : undefined;
+ const s = status?.installStatus ?? "none";
+ if (s === "all") return 3;
+ if (s === "partial") return 2;
+ if (s === "hasDocs") return 1;
+ return 0;
+ },
+ header: ({ column }) => ,
+ cell: ({ row }) => {
+ const status = row.original.uprn ? docStatusMap[row.original.uprn] : undefined;
+ return ;
},
enableHiding: false,
},
@@ -110,11 +164,11 @@ export function createDocumentTableColumns(
let icon: React.ReactNode;
let className: string;
- if (status?.isComplete) {
+ if (status?.isSurveyComplete) {
icon = ;
className =
"inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs font-medium border border-emerald-200 text-emerald-700 bg-emerald-50 hover:bg-emerald-100 hover:border-emerald-300 transition-all duration-150 whitespace-nowrap";
- } else if (status?.hasDocs) {
+ } else if (status?.hasSurveyDocs) {
icon = ;
className =
"inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs font-medium border border-amber-200 text-amber-700 bg-amber-50 hover:bg-amber-100 hover:border-amber-300 transition-all duration-150 whitespace-nowrap";
@@ -143,5 +197,24 @@ export function createDocumentTableColumns(
enableSorting: false,
enableHiding: false,
},
+
+ // ── Upload button (contractor only) ──────────────────────────────────
+ ...(onUpload ? [{
+ id: "upload",
+ header: () => (
+ Upload
+ ),
+ cell: ({ row }: { row: { original: ClassifiedDeal } }) => (
+ onUpload(row.original)}
+ className="inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs font-medium border border-brandblue/20 text-brandblue bg-brandlightblue/20 hover:bg-brandlightblue/40 hover:border-brandblue/40 transition-all duration-150 whitespace-nowrap"
+ >
+
+ Upload Docs
+
+ ),
+ enableSorting: false,
+ enableHiding: false,
+ } as ColumnDef] : []),
];
}
diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/LiveTracker.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/LiveTracker.tsx
index 502bd71..f8e92ab 100644
--- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/LiveTracker.tsx
+++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/LiveTracker.tsx
@@ -9,10 +9,11 @@ import {
TabsTrigger,
} from "@/app/shadcn_components/ui/tabs";
import { Card, CardContent } from "@/app/shadcn_components/ui/card";
-import { BarChart2, Table2, FolderOpen } from "lucide-react";
+import { BarChart2, Table2, FolderOpen, Wrench } from "lucide-react";
import DrillDownTable from "./DrillDownTable";
import PropertyTable from "./PropertyTable";
import DocumentTable from "./DocumentTable";
+import MeasuresTable from "./MeasuresTable";
import type { HubspotDeal } from "./types";
import PropertyDrawer from "./PropertyDrawer";
import PropertyDetailDrawer from "./PropertyDetailDrawer";
@@ -30,9 +31,14 @@ export default function LiveTracker({
totalDeals,
majorConditionDeals,
docStatusMap,
+ userCapability,
+ approvalsByDeal,
+ portfolioId,
+ userRole,
+ userEmail,
}: LiveTrackerProps) {
// ── Tab state ────────────────────────────────────────────────────────
- const [activeTab, setActiveTab] = useState<"analytics" | "properties" | "documents">(
+ const [activeTab, setActiveTab] = useState<"analytics" | "properties" | "documents" | "measures">(
"analytics",
);
@@ -94,7 +100,7 @@ export default function LiveTracker({
setActiveTab(v as "analytics" | "properties" | "documents")}
+ onValueChange={(v) => setActiveTab(v as "analytics" | "properties" | "documents" | "measures")}
>
{/* Tab bar */}
@@ -119,6 +125,13 @@ export default function LiveTracker({
Document Management
+
+
+ Measures
+
{/* Analytics tab */}
@@ -207,6 +220,40 @@ export default function LiveTracker({
/>
+
+ {/* Measures tab */}
+
+
+ {projects.length > 1 && (
+
+ Project:
+ setCurrentProjectCode(e.target.value)}
+ className="px-3 py-1.5 border border-brandblue/20 rounded-lg bg-white text-sm text-gray-800 font-medium focus:ring-2 focus:ring-brandblue focus:border-brandblue focus:outline-none transition-all appearance-none pr-8"
+ >
+ {projectCodes.map((code) =>
+ code === "__ALL__" ? (
+
+ ★ All Projects
+
+ ) : (
+
+ {code}
+
+ ),
+ )}
+
+
+ )}
+
+
+
{/* ── Drill-down table modal ─────────────────────────────────────── */}
@@ -312,6 +359,10 @@ export default function LiveTracker({
setDetailDeal(null)}
+ portfolioId={portfolioId}
+ userRole={userRole}
+ userCapability={userCapability}
+ userEmail={userEmail}
/>
);
diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/MeasuresTable.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/MeasuresTable.tsx
new file mode 100644
index 0000000..f7e3414
--- /dev/null
+++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/MeasuresTable.tsx
@@ -0,0 +1,469 @@
+"use client";
+
+import React, { useMemo, useState } from "react";
+import { useMutation, useQuery } from "@tanstack/react-query";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/app/shadcn_components/ui/table";
+import { Input } from "@/app/shadcn_components/ui/input";
+import { Button } from "@/app/shadcn_components/ui/button";
+import { Badge } from "@/app/shadcn_components/ui/badge";
+import { Checkbox } from "@/app/shadcn_components/ui/checkbox";
+import { Search, Save, ChevronDown, ChevronRight } from "lucide-react";
+import { STAGE_COLORS } from "./types";
+import type { ClassifiedDeal, PortfolioCapabilityType, ApprovalsByDeal } from "./types";
+import { ApprovalConfirmDialog, type PendingDiff } from "./ApprovalConfirmDialog";
+
+type AuditEvent = {
+ id: string;
+ hubspotDealId: string;
+ measureName: string;
+ action: string; // 'approved' | 'unapproved'
+ actedByEmail: string;
+ actedByName: string | null;
+ actedAt: string; // ISO string
+};
+
+type Props = {
+ data: ClassifiedDeal[];
+ userCapability: PortfolioCapabilityType;
+ approvalsByDeal: ApprovalsByDeal;
+ portfolioId: string;
+};
+
+function parseMeasures(raw: string | null | undefined): string[] {
+ if (!raw) return [];
+ return raw.split(",").map((m) => m.trim()).filter(Boolean);
+}
+
+function ApprovalStatus({
+ proposed,
+ approved,
+}: {
+ proposed: string[];
+ approved: string[];
+}) {
+ if (proposed.length === 0) return null;
+ const approvedSet = new Set(approved);
+ const approvedCount = proposed.filter((m) => approvedSet.has(m)).length;
+
+ if (approvedCount === 0) {
+ return (
+
+ Pending
+
+ );
+ }
+ if (approvedCount === proposed.length) {
+ return (
+
+ Fully Approved
+
+ );
+ }
+ return (
+
+ {approvedCount}/{proposed.length} Approved
+
+ );
+}
+
+function formatDate(iso: string) {
+ return new Date(iso).toLocaleString("en-GB", {
+ day: "numeric",
+ month: "short",
+ year: "numeric",
+ hour: "2-digit",
+ minute: "2-digit",
+ });
+}
+
+function ActivityLog({
+ dealId,
+ portfolioId,
+}: {
+ dealId: string;
+ portfolioId: string;
+}) {
+ const { data, isLoading } = useQuery<{ events: AuditEvent[] }>({
+ queryKey: ["approvalEvents", portfolioId, dealId],
+ queryFn: async () => {
+ const res = await fetch(
+ `/api/portfolio/${portfolioId}/approvals?dealIds=${dealId}&include=events`,
+ );
+ if (!res.ok) throw new Error("Failed to fetch events");
+ return res.json();
+ },
+ staleTime: 30_000,
+ });
+
+ if (isLoading) {
+ return (
+ Loading activity…
+ );
+ }
+
+ const events = data?.events ?? [];
+
+ if (events.length === 0) {
+ return (
+ No activity yet.
+ );
+ }
+
+ return (
+
+ {events.map((e) => (
+
+
+ {e.action === "approved" ? "Approved" : "Unapproved"}
+
+ {e.measureName}
+ ·
+
+ {e.actedByName ?? e.actedByEmail}
+
+ ·
+ {formatDate(e.actedAt)}
+
+ ))}
+
+ );
+}
+
+async function postApprovalChanges(
+ portfolioId: string,
+ changes: { hubspotDealId: string; measureName: string; approved: boolean }[],
+) {
+ const res = await fetch(`/api/portfolio/${portfolioId}/approvals`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ changes }),
+ });
+ if (!res.ok) throw new Error("Failed to save approvals");
+}
+
+export default function MeasuresTable({
+ data,
+ userCapability,
+ approvalsByDeal,
+ portfolioId,
+}: Props) {
+ const [search, setSearch] = useState("");
+ // pendingChanges: dealId -> desired Set (the full intended approved set)
+ const [pendingChanges, setPendingChanges] = useState<
+ Record>
+ >({});
+ const [savedApprovals, setSavedApprovals] =
+ useState(approvalsByDeal);
+ const [showConfirm, setShowConfirm] = useState(false);
+ const [expandedRows, setExpandedRows] = useState>(new Set());
+
+ // Filter to only properties with proposed measures
+ const dealsWithMeasures = useMemo(
+ () => data.filter((d) => d.proposedMeasures),
+ [data],
+ );
+
+ const filtered = useMemo(() => {
+ const q = search.toLowerCase();
+ if (!q) return dealsWithMeasures;
+ return dealsWithMeasures.filter(
+ (d) =>
+ d.dealname?.toLowerCase().includes(q) ||
+ d.landlordPropertyId?.toLowerCase().includes(q) ||
+ d.proposedMeasures?.toLowerCase().includes(q),
+ );
+ }, [dealsWithMeasures, search]);
+
+ const hasPendingChanges = Object.keys(pendingChanges).length > 0;
+
+ // Compute diffs: for each deal in pendingChanges, what's added vs removed vs saved
+ const pendingDiffs = useMemo>(() => {
+ const diffs: Record = {};
+ for (const [dealId, pending] of Object.entries(pendingChanges)) {
+ const saved = new Set(savedApprovals[dealId] ?? []);
+ const added = [...pending].filter((m) => !saved.has(m));
+ const removed = [...saved].filter((m) => !pending.has(m));
+ if (added.length > 0 || removed.length > 0) {
+ diffs[dealId] = { added, removed };
+ }
+ }
+ return diffs;
+ }, [pendingChanges, savedApprovals]);
+
+ const dealNames = useMemo>(() => {
+ const map: Record = {};
+ for (const d of dealsWithMeasures) {
+ map[d.dealId] = d.dealname ?? d.landlordPropertyId ?? d.dealId;
+ }
+ return map;
+ }, [dealsWithMeasures]);
+
+ const saveMutation = useMutation({
+ mutationFn: () => {
+ // Build flat list of explicit changes from diffs
+ const changes: { hubspotDealId: string; measureName: string; approved: boolean }[] = [];
+ for (const [dealId, diff] of Object.entries(pendingDiffs)) {
+ for (const m of diff.added) changes.push({ hubspotDealId: dealId, measureName: m, approved: true });
+ for (const m of diff.removed) changes.push({ hubspotDealId: dealId, measureName: m, approved: false });
+ }
+ return postApprovalChanges(portfolioId, changes);
+ },
+ onSuccess: () => {
+ setSavedApprovals((prev) => {
+ const next = { ...prev };
+ for (const [dealId, pending] of Object.entries(pendingChanges)) {
+ next[dealId] = Array.from(pending);
+ }
+ return next;
+ });
+ setPendingChanges({});
+ setShowConfirm(false);
+ },
+ });
+
+ function toggleMeasure(dealId: string, measure: string) {
+ setPendingChanges((prev) => {
+ const base =
+ prev[dealId] !== undefined
+ ? new Set(prev[dealId])
+ : new Set(savedApprovals[dealId] ?? []);
+
+ if (base.has(measure)) {
+ base.delete(measure);
+ } else {
+ base.add(measure);
+ }
+
+ // If pending equals saved, remove from tracking
+ const saved = new Set(savedApprovals[dealId] ?? []);
+ const equal = base.size === saved.size && [...base].every((m) => saved.has(m));
+
+ const next = { ...prev };
+ if (equal) {
+ delete next[dealId];
+ } else {
+ next[dealId] = base;
+ }
+ return next;
+ });
+ }
+
+ function toggleRowExpand(dealId: string) {
+ setExpandedRows((prev) => {
+ const next = new Set(prev);
+ if (next.has(dealId)) next.delete(dealId);
+ else next.add(dealId);
+ return next;
+ });
+ }
+
+ if (dealsWithMeasures.length === 0) {
+ return (
+
+
+ No properties with proposed measures found in this project.
+
+
+ );
+ }
+
+ return (
+
+ {/* Toolbar */}
+
+
+
+ setSearch(e.target.value)}
+ className="pl-9 h-9 text-sm"
+ />
+
+
+
+ {filtered.length} of {dealsWithMeasures.length} properties
+
+ {userCapability.includes("approver") && hasPendingChanges && (
+ setShowConfirm(true)}
+ className="bg-brandblue text-white gap-1.5"
+ >
+
+ Review changes ({Object.keys(pendingDiffs).length})
+
+ )}
+
+
+
+ {/* Table */}
+
+
+
+
+
+
+ Address
+
+
+ Stage
+
+
+ Proposed Measures
+
+
+ Status
+
+
+
+
+ {filtered.map((deal) => {
+ const proposed = parseMeasures(deal.proposedMeasures);
+ const approvedForDeal =
+ pendingChanges[deal.dealId] !== undefined
+ ? Array.from(pendingChanges[deal.dealId])
+ : (savedApprovals[deal.dealId] ?? []);
+ const approvedSet = new Set(approvedForDeal);
+ const stageColor = STAGE_COLORS[deal.displayStage];
+ const hasPending = pendingChanges[deal.dealId] !== undefined;
+ const isExpanded = expandedRows.has(deal.dealId);
+
+ return (
+
+
+ {/* Expand toggle */}
+
+ toggleRowExpand(deal.dealId)}
+ className="text-gray-400 hover:text-brandblue transition-colors"
+ aria-label={isExpanded ? "Collapse activity" : "Expand activity"}
+ >
+ {isExpanded ? (
+
+ ) : (
+
+ )}
+
+
+
+ {/* Address */}
+
+
+ {deal.dealname ?? "—"}
+
+ {deal.landlordPropertyId && (
+
+ {deal.landlordPropertyId}
+
+ )}
+
+
+ {/* Stage */}
+
+
+
+ {deal.displayStage}
+
+
+
+ {/* Proposed measures */}
+
+
+ {proposed.map((measure) => {
+ const isApproved = approvedSet.has(measure);
+ if (userCapability.includes("approver")) {
+ return (
+
+ toggleMeasure(deal.dealId, measure)}
+ className="h-3 w-3"
+ />
+ {measure}
+
+ );
+ }
+ return (
+
+ {measure}
+
+ );
+ })}
+
+
+
+ {/* Status */}
+
+
+
+
+
+
+ {/* Expandable activity log row */}
+ {isExpanded && (
+
+
+
+
+
+ )}
+
+ );
+ })}
+
+
+
+
+ {/* Confirmation dialog */}
+
saveMutation.mutate()}
+ onCancel={() => setShowConfirm(false)}
+ isPending={saveMutation.isPending}
+ />
+
+
+ );
+}
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 cbe9c9d..3dbb42c 100644
--- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyDetailDrawer.tsx
+++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyDetailDrawer.tsx
@@ -1,17 +1,284 @@
"use client";
-import { motion, AnimatePresence } from "framer-motion";
-import { X, CheckCircle2, Circle, AlertTriangle } from "lucide-react";
+import { useState } from "react";
+import { useQuery, useQueryClient } from "@tanstack/react-query";
+import { X, CheckCircle2, Circle, AlertTriangle, ChevronRight, ChevronDown, Trash2 } from "lucide-react";
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerTitle,
- DrawerDescription,
} from "@/app/shadcn_components/ui/drawer";
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogFooter,
+} from "@/app/shadcn_components/ui/dialog";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@/app/shadcn_components/ui/tooltip";
import { STAGE_COLORS } from "./types";
-import type { ClassifiedDeal } from "./types";
+import type { ClassifiedDeal, PortfolioCapabilityType, RemovalRequest } from "./types";
+
+// -----------------------------------------------------------------------
+// Removal request section
+// -----------------------------------------------------------------------
+const WRITE_ROLES = ["creator", "admin", "write"];
+
+function RemovalRequestSection({
+ dealId,
+ portfolioId,
+ userRole,
+ userCapability,
+}: {
+ dealId: string;
+ portfolioId: string;
+ userRole: string;
+ userCapability: PortfolioCapabilityType;
+}) {
+ const queryClient = useQueryClient();
+ const [dialogOpen, setDialogOpen] = useState(false);
+ const [reason, setReason] = useState("");
+ const [submitting, setSubmitting] = useState(false);
+ const [reviewing, setReviewing] = useState(false);
+ const [error, setError] = useState(null);
+
+ const canRequest = WRITE_ROLES.includes(userRole);
+ const isApprover = userCapability.includes("approver");
+
+ const { data, isLoading } = useQuery<{ requests: RemovalRequest[] }>({
+ queryKey: ["removalRequests", portfolioId, dealId],
+ queryFn: async () => {
+ const res = await fetch(
+ `/api/portfolio/${portfolioId}/removal-requests?dealId=${dealId}`,
+ );
+ if (!res.ok) throw new Error("Failed to fetch removal requests");
+ return res.json();
+ },
+ staleTime: 30_000,
+ });
+
+ const pendingRequest = data?.requests?.find((r) => r.status === "pending") ?? null;
+ const latestResolvedRequest = data?.requests?.find((r) => r.status !== "pending") ?? null;
+
+ async function handleSubmit() {
+ if (!reason.trim()) 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() }),
+ });
+ if (!res.ok) {
+ const json = await res.json().catch(() => ({}));
+ setError(json.error ?? "Failed to submit request");
+ return;
+ }
+ setDialogOpen(false);
+ setReason("");
+ queryClient.invalidateQueries({ queryKey: ["removalRequests", portfolioId, dealId] });
+ } finally {
+ setSubmitting(false);
+ }
+ }
+
+ async function handleReview(requestId: string, action: "approved" | "declined") {
+ setReviewing(true);
+ setError(null);
+ try {
+ const res = await fetch(`/api/portfolio/${portfolioId}/removal-requests`, {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ requestId: Number(requestId), action }),
+ });
+ if (!res.ok) {
+ const json = await res.json().catch(() => ({}));
+ setError(json.error ?? "Failed to review request");
+ return;
+ }
+ queryClient.invalidateQueries({ queryKey: ["removalRequests", portfolioId, dealId] });
+ } finally {
+ setReviewing(false);
+ }
+ }
+
+ if (isLoading) {
+ return Loading…
;
+ }
+
+ return (
+
+ {error && (
+
{error}
+ )}
+
+ {/* Pending request — visible to everyone */}
+ {pendingRequest && (
+
+
+
+ Pending Removal Request
+
+
+
{pendingRequest.reason}
+
+ Requested by {pendingRequest.requestedByEmail}
+ {" · "}
+ {formatDateTime(pendingRequest.requestedAt)}
+
+ {/* Approver actions */}
+ {isApprover && (
+
+ handleReview(pendingRequest.id, "approved")}
+ 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
+
+ handleReview(pendingRequest.id, "declined")}
+ disabled={reviewing}
+ className="flex-1 text-xs font-medium px-3 py-1.5 rounded-lg border border-gray-200 text-gray-600 hover:bg-gray-100 disabled:opacity-50 transition-colors"
+ >
+ Decline
+
+
+ )}
+
+ )}
+
+ {/* Most recent resolved request */}
+ {!pendingRequest && latestResolvedRequest && (
+
+
+
+ {latestResolvedRequest.status === "approved" ? "Removal Approved" : "Removal Declined"}
+
+
+
{latestResolvedRequest.reason}
+
+ Requested by {latestResolvedRequest.requestedByEmail}
+ {" · "}
+ {formatDateTime(latestResolvedRequest.requestedAt)}
+
+ {latestResolvedRequest.reviewedByEmail && (
+
+ {latestResolvedRequest.status === "approved" ? "Approved" : "Declined"} by{" "}
+ {latestResolvedRequest.reviewedByEmail}
+ {latestResolvedRequest.reviewedAt && ` · ${formatDateTime(latestResolvedRequest.reviewedAt)}`}
+
+ )}
+
+ )}
+
+ {/* Request button — only shown when no pending request exists */}
+ {!pendingRequest && (
+
+
+
+
+ { 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"
+ }`}
+ >
+
+ Request Removal from Project
+
+
+
+ {!canRequest && (
+
+ Not available with read-only permissions
+
+ )}
+
+
+ )}
+
+ {/* Reason dialog */}
+
{ if (!v) { setDialogOpen(false); setReason(""); setError(null); } }}>
+
+
+
+ Request Removal from Project
+
+
+
+
+ Please provide a reason why this property should be removed from the project. This will be recorded for audit purposes.
+
+
+
+ { setDialogOpen(false); setReason(""); setError(null); }}
+ 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
+
+
+ {submitting ? "Submitting…" : "Submit Request"}
+
+
+
+
+
+ );
+}
+
+// -----------------------------------------------------------------------
+// Approval log placeholder (expand into a real implementation as needed)
+// -----------------------------------------------------------------------
+function ApprovalLogSection({ dealId, portfolioId }: { dealId: string; portfolioId: string }) {
+ void dealId; void portfolioId;
+ return No approvals recorded.
;
+}
+
+function formatDateTime(d: string | Date | null | undefined): string {
+ if (!d) return "";
+ try {
+ return new Date(d).toLocaleString("en-GB", {
+ day: "numeric",
+ month: "short",
+ year: "numeric",
+ hour: "2-digit",
+ minute: "2-digit",
+ });
+ } catch { return ""; }
+}
// -----------------------------------------------------------------------
// Milestone definitions — ordered pipeline steps with their date fields
@@ -142,10 +409,21 @@ function MilestoneTimeline({ deal }: { deal: ClassifiedDeal }) {
interface PropertyDetailDrawerProps {
deal: ClassifiedDeal | null;
onClose: () => void;
+ portfolioId: string;
+ userRole: string;
+ userCapability: PortfolioCapabilityType;
+ userEmail: string;
}
-export default function PropertyDetailDrawer({ deal, onClose }: PropertyDetailDrawerProps) {
+export default function PropertyDetailDrawer({
+ deal,
+ portfolioId,
+ onClose,
+ userRole,
+ userCapability,
+}: PropertyDetailDrawerProps) {
const open = !!deal;
+ const [isLogOpen, setIsLogOpen] = useState(false);
return (
!v && onClose()} direction="right">
@@ -255,6 +533,41 @@ export default function PropertyDetailDrawer({ deal, onClose }: PropertyDetailDr
Project Timeline
+
+ {/* Removal request */}
+
+
+ Project Removal
+
+
+
+
+ {/* Approval log — collapsible */}
+
+
setIsLogOpen((v) => !v)}
+ className="flex items-center gap-2 w-full text-left group"
+ >
+ {isLogOpen ? (
+
+ ) : (
+
+ )}
+
+ Approval Log
+
+
+ {isLogOpen && (
+
+ )}
+
{/* Footer */}
diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyDrawer.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyDrawer.tsx
index 6dc83ef..63ef131 100644
--- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyDrawer.tsx
+++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyDrawer.tsx
@@ -11,6 +11,7 @@ import {
FolderOpen,
X,
ExternalLink,
+ HardHat,
} from "lucide-react";
import {
Drawer,
@@ -21,10 +22,11 @@ import {
DrawerDescription,
} from "@/app/shadcn_components/ui/drawer";
import type { PropertyDocument } from "./types";
-import { EXPECTED_SURVEY_DOC_TYPES } from "./types";
+import { EXPECTED_RETROFIT_ASSESSMENT_DOC_TYPES, SURVEY_ALL_DOC_TYPES } from "./types";
-// Human-readable labels for the main DB fileType enum values
+// Human-readable labels for all DB fileType enum values
const DOC_TYPE_LABELS: Record = {
+ // Survey / retrofit assessment docs
photo_pack: "Photo Pack",
site_note: "Site Note",
rd_sap_site_note: "RdSAP Site Note",
@@ -34,13 +36,43 @@ const DOC_TYPE_LABELS: Record = {
par_photo_pack: "PAR Photo Pack",
pas_2023_property: "PAS 2023 Property Report",
pas_2023_occupancy: "PAS 2023 Occupancy Report",
+ ecmk_site_note: "ECMK Site Note",
+ ecmk_rd_sap_site_note: "ECMK RdSAP Site Note",
+ ecmk_survey_xml: "ECMK Survey XML",
+ // Install docs — photos
+ pre_photo: "Pre-Install Photos",
+ mid_photo: "Mid-Install Photos",
+ post_photo: "Post-Install Photos",
+ loft_hatch_photo: "Loft Hatch & Draft Excluder Photos",
+ dmev_photos: "DMEV Photos (Wetrooms)",
+ door_undercut_photos: "Door Undercut Photos",
+ trickle_vent_photos: "Trickle Vent Photos",
+ // Install docs — pre-installation
+ pre_installation_building_inspection: "PIBI / Tech Survey",
+ point_of_work_risk_assessment: "Point of Work Risk Assessment",
+ // Install docs — compliance & lodgement
+ claim_of_compliance: "DOCC 2030 (Claim of Compliance)",
+ mcs_compliance_certificate: "MCS Compliance Certificate",
+ certificate_of_conformity: "Certificate of Conformity",
+ minor_works_electrical_certificate: "Minor Works Electrical Certificate",
+ trustmark_licence_numbers: "TrustMark Licence Numbers",
+ operative_competency: "Operative Competency",
+ // Install docs — ventilation
+ ventilation_assessment_checklist: "Ventilation Assessment Checklist",
+ anemometer_readings: "Anemometer Readings",
+ commissioning_records: "Commissioning Records",
+ part_f_ventilation_document: "Approved Document Part F",
+ // Install docs — handover & warranties
+ handover_pack: "Handover Pack",
+ insurance_guarantee: "Insurance Backed Guarantee (IBG)",
+ workmanship_warranty: "Workmanship Warranty",
+ g98_notification: "G98 / G99 Notification",
+ // Install docs — qualifications & other
+ installer_qualifications: "Installer Qualifications",
+ installer_feedback: "Installer Feedback",
+ contractor_other: "Other",
};
-// All survey docs go under this group for now (extensible later)
-function getDocCategory(_docType: string): string {
- return "Survey Documents";
-}
-
function formatDate(iso: string): string {
try {
return new Date(iso).toLocaleDateString("en-GB", {
@@ -56,7 +88,7 @@ function formatDate(iso: string): string {
// -----------------------------------------------------------------------
// Individual document row
// -----------------------------------------------------------------------
-function DocumentRow({ doc }: { doc: PropertyDocument }) {
+function DocumentRow({ doc, showMeasure }: { doc: PropertyDocument; showMeasure?: boolean }) {
const label = DOC_TYPE_LABELS[doc.docType] ?? doc.docType;
const { mutate: download, isPending: signing } = useMutation({
@@ -90,7 +122,10 @@ function DocumentRow({ doc }: { doc: PropertyDocument }) {
{label}
- {formatDate(doc.s3UploadTimestamp)}
+ {showMeasure && doc.measureName
+ ? <>{doc.measureName} · {formatDate(doc.s3UploadTimestamp)}>
+ : formatDate(doc.s3UploadTimestamp)
+ }
@@ -161,20 +196,16 @@ export default function PropertyDrawer({
}
const documents = open ? (fetchedDocuments as PropertyDocument[]) : lastDocumentsRef.current;
- // Group docs by category for display
- const grouped = documents.reduce<
- Record
- >((acc, doc) => {
- const category = getDocCategory(doc.docType);
- (acc[category] ??= []).push(doc);
- return acc;
- }, {});
+ // Split documents into the two sections
+ const retrofitDocs = documents.filter((d) => SURVEY_ALL_DOC_TYPES.has(d.docType));
+ const installDocs = documents.filter((d) => !SURVEY_ALL_DOC_TYPES.has(d.docType));
const hasDocuments = documents.length > 0;
- const presentTypes = new Set(documents.map((d) => d.docType));
- const missingTypes = EXPECTED_SURVEY_DOC_TYPES.filter(
- (t) => !presentTypes.has(t),
+ // Missing mandatory retrofit assessment docs (ecmk types are optional — not shown as missing)
+ const presentRetrofitTypes = new Set(retrofitDocs.map((d) => d.docType));
+ const missingRetrofitTypes = EXPECTED_RETROFIT_ASSESSMENT_DOC_TYPES.filter(
+ (t) => !presentRetrofitTypes.has(t),
);
return (
@@ -220,7 +251,7 @@ export default function PropertyDrawer({
{/* Body */}
-
+
{/* Loading state */}
{isFetching && (
@@ -248,7 +279,7 @@ export default function PropertyDrawer({
)}
- {/* Empty state — shows all missing doc types */}
+ {/* Empty state */}
{!isFetching && !isError && !hasDocuments && (
@@ -259,15 +290,14 @@ export default function PropertyDrawer({
No documents available
- All {EXPECTED_SURVEY_DOC_TYPES.length} survey documents are
- outstanding.
+ All {EXPECTED_RETROFIT_ASSESSMENT_DOC_TYPES.length} retrofit assessment documents are outstanding.
- Missing Documents ({missingTypes.length})
+ Missing Documents ({missingRetrofitTypes.length})
- {missingTypes.map((t) => (
+ {missingRetrofitTypes.map((t) => (
)}
- {/* Document groups */}
- {!isFetching &&
- !isError &&
- hasDocuments &&
- Object.entries(grouped).map(([category, docs]) => (
+ {!isFetching && !isError && hasDocuments && (
+ <>
+ {/* ── Retrofit Assessment Documents ── */}
- {category}
+ Retrofit Assessment Documents
-
- {docs.map((doc) => (
-
- ))}
-
-
- ))}
-
-
- {/* Missing documents section — shown when some but not all docs are present */}
- {!isFetching &&
- !isError &&
- hasDocuments &&
- missingTypes.length > 0 && (
-
-
- Missing Documents ({missingTypes.length})
-
-
- {missingTypes.map((t) => (
-
-
-
- {DOC_TYPE_LABELS[t] ?? t}
-
+ {retrofitDocs.length > 0 ? (
+
+ {retrofitDocs.map((doc) => (
+
+ ))}
- ))}
-
-
+ ) : (
+
None uploaded yet.
+ )}
+
+ {/* Missing mandatory retrofit assessment docs */}
+ {missingRetrofitTypes.length > 0 && (
+
+
+ Missing ({missingRetrofitTypes.length})
+
+ {missingRetrofitTypes.map((t) => (
+
+
+
+ {DOC_TYPE_LABELS[t] ?? t}
+
+
+ ))}
+
+ )}
+
+
+ {/* ── Install Documents ── */}
+
+
+
+ Install Documents
+
+ {installDocs.length > 0 ? (
+
+ {installDocs.map((doc) => (
+
+ ))}
+
+ ) : (
+ No install documents uploaded yet.
+ )}
+
+ >
)}
+
{/* Footer */}
diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyTable.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyTable.tsx
index ac29be0..db9c867 100644
--- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyTable.tsx
+++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyTable.tsx
@@ -126,9 +126,9 @@ export default function PropertyTable({ data, onOpenDrawer, onOpenDetail, showDo
if (docFilter !== "all") {
result = result.filter((d) => {
const status = d.uprn ? docStatusMap[d.uprn] : undefined;
- if (docFilter === "none") return !status || !status.hasDocs;
- if (docFilter === "has_docs") return !!status?.hasDocs;
- if (docFilter === "incomplete") return !!status?.hasDocs && !status.isComplete;
+ if (docFilter === "none") return !status || !status.hasSurveyDocs;
+ if (docFilter === "has_docs") return !!status?.hasSurveyDocs;
+ if (docFilter === "incomplete") return !!status?.hasSurveyDocs && !status.isSurveyComplete;
return true;
});
}
diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyTableColumns.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyTableColumns.tsx
index 9562418..03804ee 100644
--- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyTableColumns.tsx
+++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyTableColumns.tsx
@@ -285,8 +285,8 @@ export function createPropertyTableColumns(
cell: ({ row }) => {
const uprn = row.original.uprn ?? "";
const status = uprn ? docStatusMap[uprn] : undefined;
- const isComplete = status?.isComplete;
- const hasDocs = status?.hasDocs;
+ const isComplete = status?.isSurveyComplete;
+ const hasDocs = status?.hasSurveyDocs;
let icon: React.ReactNode;
let className: string;
diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/page.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/page.tsx
index f7002fc..488b4c2 100644
--- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/page.tsx
+++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/page.tsx
@@ -1,7 +1,7 @@
import { getServerSession } from "next-auth";
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { redirect } from "next/navigation";
-import { eq, inArray } from "drizzle-orm";
+import { eq, inArray, and } from "drizzle-orm";
import LiveTracker from "./LiveTracker";
import { computeLiveTrackerData } from "./transforms";
import { db } from "@/app/db/db";
@@ -9,8 +9,11 @@ import { hubspotDealData } from "@/app/db/schema/crm/hubspot_deal_table";
import { uploadedFiles } from "@/app/db/schema/uploaded_files";
import { portfolioOrganisation } from "@/app/db/schema/portfolio_organisation";
import { organisation } from "@/app/db/schema/organisation";
-import type { HubspotDeal, DocStatusMap, DocStatus } from "./types";
-import { EXPECTED_SURVEY_DOC_TYPES } from "./types";
+import { portfolioCapabilities, portfolioUsers } from "@/app/db/schema/portfolio";
+import { dealMeasureApprovals } from "@/app/db/schema/approvals";
+import { user as userTable } from "@/app/db/schema/users";
+import type { HubspotDeal, DocStatusMap, DocStatus, PortfolioCapabilityType, ApprovalsByDeal } from "./types";
+import { EXPECTED_RETROFIT_ASSESSMENT_DOC_TYPES, SURVEY_ALL_DOC_TYPES } from "./types";
import type { InferSelectModel } from "drizzle-orm";
import { Card, CardContent } from "@/app/shadcn_components/ui/card";
import { Building2 } from "lucide-react";
@@ -120,6 +123,78 @@ export default async function LiveReportingPage(props: {
const deals = rawDeals.map(mapDbRowToHubspotDeal);
const trackerData = computeLiveTrackerData(deals);
+ // Fetch current user's portfolio capabilities (approver / contractor — can have both)
+ let userCapability: PortfolioCapabilityType = [];
+ const userEmail = user?.user?.email;
+ if (userEmail) {
+ const userRow = await db
+ .select({ id: userTable.id })
+ .from(userTable)
+ .where(eq(userTable.email, userEmail))
+ .limit(1);
+
+ if (userRow[0]) {
+ const capRows = await db
+ .select({ capability: portfolioCapabilities.capability })
+ .from(portfolioCapabilities)
+ .where(
+ and(
+ eq(portfolioCapabilities.portfolioId, BigInt(portfolioId)),
+ eq(portfolioCapabilities.userId, userRow[0].id),
+ ),
+ );
+ userCapability = capRows
+ .map((r) => r.capability)
+ .filter((c): c is "approver" | "contractor" => c === "approver" || c === "contractor");
+ }
+ }
+
+ // Fetch current user's portfolio role (creator / admin / write / read)
+ let userRole = "read";
+ if (userEmail) {
+ const userRow = await db
+ .select({ id: userTable.id })
+ .from(userTable)
+ .where(eq(userTable.email, userEmail))
+ .limit(1);
+
+ if (userRow[0]) {
+ const roleRow = await db
+ .select({ role: portfolioUsers.role })
+ .from(portfolioUsers)
+ .where(
+ and(
+ eq(portfolioUsers.portfolioId, BigInt(portfolioId)),
+ eq(portfolioUsers.userId, userRow[0].id),
+ ),
+ )
+ .limit(1);
+ userRole = roleRow[0]?.role ?? "read";
+ }
+ }
+
+ // Fetch currently approved measures for all deals in scope
+ const approvalsByDeal: ApprovalsByDeal = {};
+ const dealIds = deals.map((d) => d.dealId).filter(Boolean);
+ if (dealIds.length > 0) {
+ const approvalRows = await db
+ .select({
+ hubspotDealId: dealMeasureApprovals.hubspotDealId,
+ measureName: dealMeasureApprovals.measureName,
+ })
+ .from(dealMeasureApprovals)
+ .where(
+ and(
+ inArray(dealMeasureApprovals.hubspotDealId, dealIds),
+ eq(dealMeasureApprovals.isApproved, true),
+ ),
+ );
+
+ for (const row of approvalRows) {
+ (approvalsByDeal[row.hubspotDealId] ??= []).push(row.measureName);
+ }
+ }
+
// Fetch survey document status for all properties
const uprnList = deals
.map((d) => d.uprn)
@@ -133,23 +208,58 @@ export default async function LiveReportingPage(props: {
if (uprnList.length > 0) {
const docRows = await db
- .select()
+ .select({
+ uprn: uploadedFiles.uprn,
+ fileType: uploadedFiles.fileType,
+ measureName: uploadedFiles.measureName,
+ })
.from(uploadedFiles)
.where(inArray(uploadedFiles.uprn, uprnList));
- const grouped: Record> = {};
+ // Group docs by UPRN
+ const docsByUprn = new Map>();
for (const row of docRows) {
if (row.uprn === null || row.fileType === null) continue;
const key = String(row.uprn);
- (grouped[key] ??= new Set()).add(row.fileType);
+ if (!docsByUprn.has(key)) docsByUprn.set(key, []);
+ docsByUprn.get(key)!.push({ fileType: row.fileType, measureName: row.measureName });
}
- for (const [uprn, types] of Object.entries(grouped)) {
- const presentTypes = Array.from(types);
+ // Build measures lookup from deals (uprn → proposed measure names)
+ const measuresByUprn = new Map();
+ for (const deal of deals) {
+ if (deal.uprn) {
+ const key = String(deal.uprn);
+ const measures = (deal.proposedMeasures ?? "")
+ .split(",").map((m: string) => m.trim()).filter(Boolean);
+ measuresByUprn.set(key, measures);
+ }
+ }
+
+ for (const [uprn, docs] of docsByUprn) {
+ const surveyDocs = docs.filter((d) => SURVEY_ALL_DOC_TYPES.has(d.fileType));
+ const installDocs = docs.filter((d) => !SURVEY_ALL_DOC_TYPES.has(d.fileType));
+ const surveyTypeSet = new Set(surveyDocs.map((d) => d.fileType));
+
+ const measures = measuresByUprn.get(uprn) ?? [];
+ let installStatus: DocStatus["installStatus"] = "none";
+ if (installDocs.length > 0) {
+ if (measures.length === 0) {
+ installStatus = "hasDocs";
+ } else {
+ const measuresWithDocs = new Set(
+ installDocs.map((d) => d.measureName).filter(Boolean),
+ );
+ installStatus = measures.every((m) => measuresWithDocs.has(m)) ? "all" : "partial";
+ }
+ }
+
const status: DocStatus = {
- presentTypes,
- hasDocs: presentTypes.length > 0,
- isComplete: EXPECTED_SURVEY_DOC_TYPES.every((t) => types.has(t)),
+ presentSurveyTypes: Array.from(surveyTypeSet),
+ hasSurveyDocs: surveyDocs.length > 0,
+ isSurveyComplete: EXPECTED_RETROFIT_ASSESSMENT_DOC_TYPES.every((t) => surveyTypeSet.has(t)),
+ hasInstallDocs: installDocs.length > 0,
+ installStatus,
};
docStatusMap[uprn] = status;
}
@@ -158,7 +268,15 @@ export default async function LiveReportingPage(props: {
return (
{pageHeader}
-
+
);
}
diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/transforms.ts b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/transforms.ts
index fe1da87..b829c91 100644
--- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/transforms.ts
+++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/transforms.ts
@@ -375,7 +375,7 @@ export function computeOutcomeSlices(deals: ClassifiedDeal[]): OutcomeSlice[] {
// -----------------------------------------------------------------------
export function computeLiveTrackerData(
rawDeals: HubspotDeal[]
-): Omit {
+): Omit {
// Classify all deals (add displayStage field)
const classified = classifyDeals(rawDeals);
diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/types.ts b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/types.ts
index 40fa764..430be38 100644
--- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/types.ts
+++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/types.ts
@@ -161,6 +161,28 @@ export type ProjectData = {
allDeals: ClassifiedDeal[]; // for table drill-downs within project
};
+// -----------------------------------------------------------------------
+// Portfolio capability for the current viewing user
+// -----------------------------------------------------------------------
+export type PortfolioCapabilityType = ("approver" | "contractor")[];
+
+// Approved measure names per HubSpot deal ID
+export type ApprovalsByDeal = Record;
+
+// -----------------------------------------------------------------------
+// Removal request record returned by the API
+// -----------------------------------------------------------------------
+export type RemovalRequest = {
+ id: string;
+ hubspotDealId: string;
+ status: "pending" | "approved" | "declined";
+ reason: string;
+ requestedByEmail: string;
+ requestedAt: string;
+ reviewedByEmail: string | null;
+ reviewedAt: string | null;
+};
+
// -----------------------------------------------------------------------
// Top-level props for LiveTracker (client root)
// -----------------------------------------------------------------------
@@ -169,6 +191,11 @@ export type LiveTrackerProps = {
totalDeals: number;
majorConditionDeals: ClassifiedDeal[]; // for Awaab's Law card
docStatusMap: DocStatusMap;
+ userCapability: PortfolioCapabilityType;
+ approvalsByDeal: ApprovalsByDeal;
+ portfolioId: string;
+ userRole: string;
+ userEmail: string;
};
// -----------------------------------------------------------------------
@@ -194,10 +221,11 @@ export type PropertyDocument = {
s3UploadTimestamp: string; // ISO string
uprn: string | null;
landlordPropertyId: string | null;
+ measureName: string | null; // set for install docs
};
-// All survey document types expected for a complete survey
-export const EXPECTED_SURVEY_DOC_TYPES = [
+// Mandatory retrofit assessment doc types (used for completeness check — ecmk types are optional)
+export const EXPECTED_RETROFIT_ASSESSMENT_DOC_TYPES = [
"photo_pack",
"site_note",
"rd_sap_site_note",
@@ -209,10 +237,26 @@ export const EXPECTED_SURVEY_DOC_TYPES = [
"pas_2023_occupancy",
] as const;
+// All survey-adjacent types (including optional ecmk docs) — used for display categorisation
+export const SURVEY_ALL_DOC_TYPES = new Set([
+ ...EXPECTED_RETROFIT_ASSESSMENT_DOC_TYPES,
+ "ecmk_site_note",
+ "ecmk_rd_sap_site_note",
+ "ecmk_survey_xml",
+]);
+
export type DocStatus = {
- presentTypes: string[];
- hasDocs: boolean;
- isComplete: boolean; // all EXPECTED_SURVEY_DOC_TYPES present
+ // Retrofit assessment docs
+ presentSurveyTypes: string[];
+ hasSurveyDocs: boolean;
+ isSurveyComplete: boolean; // all 9 EXPECTED_RETROFIT_ASSESSMENT_DOC_TYPES present (ecmk not counted)
+ // Install docs
+ hasInstallDocs: boolean;
+ installStatus: "none" | "partial" | "hasDocs" | "all";
+ // "all" = install docs exist for every proposed measure
+ // "partial" = some (but not all) proposed measures have docs
+ // "hasDocs" = has install docs but no measures defined on the deal
+ // "none" = no install docs at all
};
export type DocStatusMap = Record; // keyed by UPRN string