rolling back migration

This commit is contained in:
Khalim Conn-Kowlessar 2026-04-17 19:38:26 +00:00
parent ffe79a27e4
commit 9774a52207
3 changed files with 344 additions and 0 deletions

View file

@ -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 });
}
}

View file

@ -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
}
]
}

View file

@ -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"
>;