feat(home): per-user portfolio config planning layer + /api/me routes

TDD'd pure planning core (planFolderReorder with payload validation,
groupPortfoliosForHome with starred float + recency, planFolderDeletion
encoding unfile-then-delete, buildStarChange owning starred_at-as-flag),
applied by thin route handlers: folder create/rename/delete/reorder,
per-portfolio config upsert, and the grouped home read.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-07-07 15:12:07 +00:00
parent 31b7c28ad5
commit 633c127620
7 changed files with 608 additions and 0 deletions

View file

@ -0,0 +1,116 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { and, eq, inArray } from "drizzle-orm";
import { getServerSession } from "next-auth";
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { db } from "@/app/db/db";
import {
userPortfolioConfig,
userPortfolioFolders,
} from "@/app/db/schema/user_portfolio_config";
import { planFolderDeletion } from "@/app/lib/portfolioUserConfig";
const RenameSchema = z.object({
name: z.string().trim().min(1).max(120),
});
const FolderIdSchema = z.string().regex(/^\d+$/);
export async function PATCH(
req: NextRequest,
props: { params: Promise<{ folderId: string }> },
) {
try {
const session = await getServerSession(AuthOptions);
if (!session?.user?.dbId) {
return NextResponse.json({ error: "Unauthenticated" }, { status: 401 });
}
const userId = session.user.dbId;
const { folderId } = await props.params;
if (!FolderIdSchema.safeParse(folderId).success) {
return NextResponse.json({ error: "Folder not found" }, { status: 404 });
}
const parsed = RenameSchema.safeParse(await req.json());
if (!parsed.success) {
return NextResponse.json({ error: "Invalid folder name" }, { status: 400 });
}
const renamed = await db
.update(userPortfolioFolders)
.set({ name: parsed.data.name, updatedAt: new Date() })
.where(
and(
eq(userPortfolioFolders.id, BigInt(folderId)),
eq(userPortfolioFolders.userId, userId),
),
)
.returning({ id: userPortfolioFolders.id });
if (renamed.length === 0) {
return NextResponse.json({ error: "Folder not found" }, { status: 404 });
}
return NextResponse.json({ ok: true });
} catch (err) {
console.error("Error renaming folder:", err);
return NextResponse.json({ error: "Server error" }, { status: 500 });
}
}
// Folder deletion never deletes portfolios — it unfiles them. The same-owner
// FK is NO ACTION, so unfile + delete must happen in one transaction,
// unfile first (see planFolderDeletion).
export async function DELETE(
_req: NextRequest,
props: { params: Promise<{ folderId: string }> },
) {
try {
const session = await getServerSession(AuthOptions);
if (!session?.user?.dbId) {
return NextResponse.json({ error: "Unauthenticated" }, { status: 401 });
}
const userId = session.user.dbId;
const { folderId } = await props.params;
if (!FolderIdSchema.safeParse(folderId).success) {
return NextResponse.json({ error: "Folder not found" }, { status: 404 });
}
const configs = await db
.select({
id: userPortfolioConfig.id,
folderId: userPortfolioConfig.folderId,
})
.from(userPortfolioConfig)
.where(eq(userPortfolioConfig.userId, userId));
const plan = planFolderDeletion({ folderId: BigInt(folderId), configs });
const deleted = await db.transaction(async (tx) => {
if (plan.unfileConfigIds.length > 0) {
await tx
.update(userPortfolioConfig)
.set({ folderId: null, updatedAt: new Date() })
.where(inArray(userPortfolioConfig.id, plan.unfileConfigIds));
}
return tx
.delete(userPortfolioFolders)
.where(
and(
eq(userPortfolioFolders.id, plan.deleteFolderId),
eq(userPortfolioFolders.userId, userId),
),
)
.returning({ id: userPortfolioFolders.id });
});
if (deleted.length === 0) {
return NextResponse.json({ error: "Folder not found" }, { status: 404 });
}
return NextResponse.json({ ok: true });
} catch (err) {
console.error("Error deleting folder:", err);
return NextResponse.json({ error: "Server error" }, { status: 500 });
}
}

View file

@ -0,0 +1,62 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { and, eq } from "drizzle-orm";
import { getServerSession } from "next-auth";
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { db } from "@/app/db/db";
import { userPortfolioFolders } from "@/app/db/schema/user_portfolio_config";
import { planFolderReorder } from "@/app/lib/portfolioUserConfig";
const ReorderSchema = z.object({
orderedIds: z.array(z.string().regex(/^\d+$/)),
});
// Drag-and-drop reorder: the client sends the FULL ordered folder id list in
// one request; positions are renumbered in a single transaction so the write
// is idempotent and can't drift.
export async function PATCH(req: NextRequest) {
try {
const session = await getServerSession(AuthOptions);
if (!session?.user?.dbId) {
return NextResponse.json({ error: "Unauthenticated" }, { status: 401 });
}
const userId = session.user.dbId;
const parsed = ReorderSchema.safeParse(await req.json());
if (!parsed.success) {
return NextResponse.json({ error: "Invalid folder order" }, { status: 400 });
}
const current = await db
.select({ id: userPortfolioFolders.id })
.from(userPortfolioFolders)
.where(eq(userPortfolioFolders.userId, userId));
const plan = planFolderReorder({
currentIds: current.map((f) => f.id),
orderedIds: parsed.data.orderedIds.map(BigInt),
});
if ("error" in plan) {
return NextResponse.json({ error: plan.error }, { status: 400 });
}
await db.transaction(async (tx) => {
for (const { id, position } of plan.updates) {
await tx
.update(userPortfolioFolders)
.set({ position, updatedAt: new Date() })
.where(
and(
eq(userPortfolioFolders.id, id),
eq(userPortfolioFolders.userId, userId),
),
);
}
});
return NextResponse.json({ ok: true });
} catch (err) {
console.error("Error reordering folders:", err);
return NextResponse.json({ error: "Server error" }, { status: 500 });
}
}

View file

@ -0,0 +1,53 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { eq, sql } from "drizzle-orm";
import { getServerSession } from "next-auth";
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { db } from "@/app/db/db";
import { userPortfolioFolders } from "@/app/db/schema/user_portfolio_config";
const CreateFolderSchema = z.object({
name: z.string().trim().min(1).max(120),
});
export async function POST(req: NextRequest) {
try {
const session = await getServerSession(AuthOptions);
if (!session?.user?.dbId) {
return NextResponse.json({ error: "Unauthenticated" }, { status: 401 });
}
const userId = session.user.dbId;
const parsed = CreateFolderSchema.safeParse(await req.json());
if (!parsed.success) {
return NextResponse.json({ error: "Invalid folder name" }, { status: 400 });
}
// New folders go last: position = current folder count.
const [created] = await db
.insert(userPortfolioFolders)
.values({
userId,
name: parsed.data.name,
position: sql`(select count(*) from ${userPortfolioFolders} where ${eq(
userPortfolioFolders.userId,
userId,
)})`,
})
.returning();
return NextResponse.json(
{
folder: {
id: created.id.toString(),
name: created.name,
position: created.position,
},
},
{ status: 201 },
);
} catch (err) {
console.error("Error creating folder:", err);
return NextResponse.json({ error: "Server error" }, { status: 500 });
}
}

View file

@ -0,0 +1,65 @@
import { NextResponse } from "next/server";
import { eq } from "drizzle-orm";
import { getServerSession } from "next-auth";
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { db } from "@/app/db/db";
import { getPortfolios } from "@/app/home/utils";
import {
userPortfolioConfig,
userPortfolioFolders,
} from "@/app/db/schema/user_portfolio_config";
import { groupPortfoliosForHome } from "@/app/lib/portfolioUserConfig";
// Everything the home page renders: the caller's portfolios grouped by their
// personal folders, with starred float and a most-recently-starred list.
export async function GET() {
try {
const session = await getServerSession(AuthOptions);
if (!session?.user?.dbId) {
return NextResponse.json({ error: "Unauthenticated" }, { status: 401 });
}
const userId = session.user.dbId;
const [portfolios, configs, folders] = await Promise.all([
getPortfolios(userId),
db
.select({
portfolioId: userPortfolioConfig.portfolioId,
folderId: userPortfolioConfig.folderId,
starredAt: userPortfolioConfig.starredAt,
})
.from(userPortfolioConfig)
.where(eq(userPortfolioConfig.userId, userId)),
db
.select({
id: userPortfolioFolders.id,
name: userPortfolioFolders.name,
position: userPortfolioFolders.position,
})
.from(userPortfolioFolders)
.where(eq(userPortfolioFolders.userId, userId)),
]);
const starredIds = new Set(
configs.filter((c) => c.starredAt !== null).map((c) => c.portfolioId),
);
const grouped = groupPortfoliosForHome({ portfolios, configs, folders });
const body = {
folders: grouped.folders,
unfiled: grouped.unfiled,
starred: grouped.starred,
starredIds: [...starredIds],
};
// bigint isn't JSON-serialisable; ids cross the wire as strings.
return new NextResponse(
JSON.stringify(body, (_key, value) =>
typeof value === "bigint" ? value.toString() : value,
),
{ status: 200, headers: { "content-type": "application/json" } },
);
} catch (err) {
console.error("Error loading portfolio home:", err);
return NextResponse.json({ error: "Server error" }, { status: 500 });
}
}

View file

@ -0,0 +1,89 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { and, eq } from "drizzle-orm";
import { getServerSession } from "next-auth";
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { db } from "@/app/db/db";
import { portfolioUsers } from "@/app/db/schema/portfolio";
import { userPortfolioConfig } from "@/app/db/schema/user_portfolio_config";
import { buildStarChange } from "@/app/lib/portfolioUserConfig";
const ConfigSchema = z
.object({
starred: z.boolean().optional(),
folderId: z.string().regex(/^\d+$/).nullable().optional(),
})
.refine((v) => v.starred !== undefined || v.folderId !== undefined, {
message: "Nothing to update",
});
// Upserts the caller's personal config for one portfolio (star / move to
// folder). Rows are created lazily: no row means all defaults.
export async function PATCH(
req: NextRequest,
props: { params: Promise<{ portfolioId: string }> },
) {
try {
const session = await getServerSession(AuthOptions);
if (!session?.user?.dbId) {
return NextResponse.json({ error: "Unauthenticated" }, { status: 401 });
}
const userId = session.user.dbId;
const { portfolioId } = await props.params;
if (!/^\d+$/.test(portfolioId)) {
return NextResponse.json({ error: "Portfolio not found" }, { status: 404 });
}
const parsed = ConfigSchema.safeParse(await req.json());
if (!parsed.success) {
return NextResponse.json({ error: "Invalid config" }, { status: 400 });
}
// Config only exists for portfolios the user can actually see.
const membership = await db
.select({ id: portfolioUsers.id })
.from(portfolioUsers)
.where(
and(
eq(portfolioUsers.userId, userId),
eq(portfolioUsers.portfolioId, BigInt(portfolioId)),
),
);
if (membership.length === 0) {
return NextResponse.json({ error: "Portfolio not found" }, { status: 404 });
}
const change: Partial<{
starredAt: Date | null;
folderId: bigint | null;
}> = {};
if (parsed.data.starred !== undefined) {
change.starredAt = buildStarChange({
starred: parsed.data.starred,
now: new Date(),
}).starredAt;
}
if (parsed.data.folderId !== undefined) {
change.folderId =
parsed.data.folderId === null ? null : BigInt(parsed.data.folderId);
}
await db
.insert(userPortfolioConfig)
.values({
userId,
portfolioId: BigInt(portfolioId),
...change,
})
.onConflictDoUpdate({
target: [userPortfolioConfig.userId, userPortfolioConfig.portfolioId],
set: { ...change, updatedAt: new Date() },
});
return NextResponse.json({ ok: true });
} catch (err) {
console.error("Error updating portfolio config:", err);
return NextResponse.json({ error: "Server error" }, { status: 500 });
}
}

View file

@ -0,0 +1,114 @@
import { describe, expect, it } from "vitest";
import {
buildStarChange,
groupPortfoliosForHome,
planFolderDeletion,
planFolderReorder,
} from "./portfolioUserConfig";
describe("planFolderReorder", () => {
it("renumbers every folder to its index in the requested order", () => {
const plan = planFolderReorder({
currentIds: [1n, 2n, 3n],
orderedIds: [3n, 1n, 2n],
});
expect(plan).toEqual({
updates: [
{ id: 3n, position: 0 },
{ id: 1n, position: 1 },
{ id: 2n, position: 2 },
],
});
});
it("rejects an order naming a folder the user does not have", () => {
const plan = planFolderReorder({
currentIds: [1n, 2n],
orderedIds: [2n, 99n],
});
expect(plan).toEqual({
error: "Folder order does not match your folders",
});
});
it("rejects an order that lists the same folder twice", () => {
const plan = planFolderReorder({
currentIds: [1n, 2n],
orderedIds: [1n, 1n, 2n],
});
expect(plan).toEqual({
error: "Folder order does not match your folders",
});
});
});
describe("groupPortfoliosForHome", () => {
it("groups portfolios into folders ordered by position, with the rest unfiled", () => {
const grouped = groupPortfoliosForHome({
portfolios: [{ id: 1n }, { id: 2n }, { id: 3n }],
configs: [
{ portfolioId: 1n, folderId: 10n, starredAt: null },
{ portfolioId: 2n, folderId: 11n, starredAt: null },
],
folders: [
{ id: 10n, name: "SHDF Wave 3", position: 1 },
{ id: 11n, name: "Demos", position: 0 },
],
});
expect(grouped.folders).toEqual([
{ id: 11n, name: "Demos", portfolios: [{ id: 2n }] },
{ id: 10n, name: "SHDF Wave 3", portfolios: [{ id: 1n }] },
]);
expect(grouped.unfiled).toEqual([{ id: 3n }]);
});
it("floats starred portfolios to the top of their group and lists them most-recently-starred first", () => {
const grouped = groupPortfoliosForHome({
portfolios: [{ id: 1n }, { id: 2n }, { id: 3n }, { id: 4n }],
configs: [
{ portfolioId: 1n, folderId: 10n, starredAt: null },
{ portfolioId: 2n, folderId: 10n, starredAt: new Date("2026-07-01") },
{ portfolioId: 4n, folderId: null, starredAt: new Date("2026-07-05") },
],
folders: [{ id: 10n, name: "SHDF Wave 3", position: 0 }],
});
expect(grouped.folders[0].portfolios).toEqual([{ id: 2n }, { id: 1n }]);
expect(grouped.unfiled).toEqual([{ id: 4n }, { id: 3n }]);
expect(grouped.starred).toEqual([{ id: 4n }, { id: 2n }]);
});
});
describe("planFolderDeletion", () => {
it("unfiles exactly the configs in the folder, then deletes the folder", () => {
const plan = planFolderDeletion({
folderId: 10n,
configs: [
{ id: 1n, folderId: 10n },
{ id: 2n, folderId: 11n },
{ id: 3n, folderId: 10n },
{ id: 4n, folderId: null },
],
});
expect(plan).toEqual({
unfileConfigIds: [1n, 3n],
deleteFolderId: 10n,
});
});
});
describe("buildStarChange", () => {
it("starring stamps the moment, unstarring clears it", () => {
const now = new Date("2026-07-07T12:00:00Z");
expect(buildStarChange({ starred: true, now })).toEqual({ starredAt: now });
expect(buildStarChange({ starred: false, now })).toEqual({
starredAt: null,
});
});
});

View file

@ -0,0 +1,109 @@
// Planning functions for the per-user portfolio workspace (starred portfolios
// and personal folders — user_portfolio_config / user_portfolio_folders).
// Pure data-in/plan-out: route handlers and server components apply the plans
// with drizzle. See docs/design/home-redesign/ for the interaction design.
export type FolderReorderPlan =
| { updates: Array<{ id: bigint; position: number }> }
| { error: string };
// The drag-drop reorder PATCH sends the full ordered id list; the plan
// renumbers every folder to its index so positions stay dense (0..n-1).
export function planFolderReorder({
currentIds,
orderedIds,
}: {
currentIds: bigint[];
orderedIds: bigint[];
}): FolderReorderPlan {
const current = new Set(currentIds);
const requested = new Set(orderedIds);
const sameSet =
orderedIds.length === currentIds.length &&
current.size === requested.size &&
[...requested].every((id) => current.has(id));
if (!sameSet) {
return { error: "Folder order does not match your folders" };
}
return {
updates: orderedIds.map((id, index) => ({ id, position: index })),
};
}
// starred_at doubles as the flag (non-null = starred) and the "recently
// starred" sort key; this is the one place that convention is written down.
export function buildStarChange({
starred,
now,
}: {
starred: boolean;
now: Date;
}): { starredAt: Date | null } {
return { starredAt: starred ? now : null };
}
// The same-owner FK on user_portfolio_config is NO ACTION, so a folder can
// only be deleted after its configs are unfiled — both steps must run in one
// transaction, unfile first. This plan makes that ordering explicit.
export function planFolderDeletion({
folderId,
configs,
}: {
folderId: bigint;
configs: Array<{ id: bigint; folderId: bigint | null }>;
}): { unfileConfigIds: bigint[]; deleteFolderId: bigint } {
return {
unfileConfigIds: configs
.filter((c) => c.folderId === folderId)
.map((c) => c.id),
deleteFolderId: folderId,
};
}
type ConfigRow = {
portfolioId: bigint;
folderId: bigint | null;
starredAt: Date | null;
};
type FolderRow = { id: bigint; name: string; position: number };
// Assembles the home page's render model from the three per-user reads.
// Generic over the portfolio payload so it doesn't care which columns the
// page selects.
export function groupPortfoliosForHome<P extends { id: bigint }>({
portfolios,
configs,
folders,
}: {
portfolios: P[];
configs: ConfigRow[];
folders: FolderRow[];
}): {
folders: Array<{ id: bigint; name: string; portfolios: P[] }>;
unfiled: P[];
starred: P[];
} {
const configByPortfolio = new Map(configs.map((c) => [c.portfolioId, c]));
const folderOf = (p: P) => configByPortfolio.get(p.id)?.folderId ?? null;
const starredAt = (p: P) => configByPortfolio.get(p.id)?.starredAt ?? null;
// Starred float above unstarred; original relative order is otherwise kept.
const floatStarred = (group: P[]) =>
[...group].sort(
(a, b) => Number(starredAt(b) !== null) - Number(starredAt(a) !== null),
);
const orderedFolders = [...folders].sort((a, b) => a.position - b.position);
return {
folders: orderedFolders.map((f) => ({
id: f.id,
name: f.name,
portfolios: floatStarred(portfolios.filter((p) => folderOf(p) === f.id)),
})),
unfiled: floatStarred(portfolios.filter((p) => folderOf(p) === null)),
starred: portfolios
.filter((p) => starredAt(p) !== null)
.sort((a, b) => starredAt(b)!.getTime() - starredAt(a)!.getTime()),
};
}