From 9774a52207b39465cbc1b963abf33939257109f0 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Fri, 17 Apr 2026 19:38:26 +0000 Subject: [PATCH] rolling back migration --- .../[portfolioId]/removal-requests/route.ts | 291 ++++++++++++++++++ src/app/db/migrations/meta/_journal.json | 7 + src/app/db/schema/removal_requests.ts | 46 +++ 3 files changed, 344 insertions(+) create mode 100644 src/app/api/portfolio/[portfolioId]/removal-requests/route.ts create mode 100644 src/app/db/schema/removal_requests.ts 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 00000000..5b7aa0a2 --- /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/db/migrations/meta/_journal.json b/src/app/db/migrations/meta/_journal.json index e0fb9824..11ba445b 100644 --- a/src/app/db/migrations/meta/_journal.json +++ b/src/app/db/migrations/meta/_journal.json @@ -1219,6 +1219,13 @@ "when": 1776378570612, "tag": "0173_neat_bastion", "breakpoints": true + }, + { + "idx": 174, + "version": "7", + "when": 1776453858204, + "tag": "0174_condemned_lady_bullseye", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/app/db/schema/removal_requests.ts b/src/app/db/schema/removal_requests.ts new file mode 100644 index 00000000..1ec753bc --- /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" +>;