diff --git a/src/app/api/me/folders/[folderId]/route.ts b/src/app/api/me/folders/[folderId]/route.ts new file mode 100644 index 00000000..d00a3bfd --- /dev/null +++ b/src/app/api/me/folders/[folderId]/route.ts @@ -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 }); + } +} diff --git a/src/app/api/me/folders/reorder/route.ts b/src/app/api/me/folders/reorder/route.ts new file mode 100644 index 00000000..3addbd8e --- /dev/null +++ b/src/app/api/me/folders/reorder/route.ts @@ -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 }); + } +} diff --git a/src/app/api/me/folders/route.ts b/src/app/api/me/folders/route.ts new file mode 100644 index 00000000..1479f90b --- /dev/null +++ b/src/app/api/me/folders/route.ts @@ -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 }); + } +} diff --git a/src/app/api/me/portfolio-home/route.ts b/src/app/api/me/portfolio-home/route.ts new file mode 100644 index 00000000..4fb62a7c --- /dev/null +++ b/src/app/api/me/portfolio-home/route.ts @@ -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 }); + } +} diff --git a/src/app/api/me/portfolios/[portfolioId]/config/route.ts b/src/app/api/me/portfolios/[portfolioId]/config/route.ts new file mode 100644 index 00000000..7ec6adde --- /dev/null +++ b/src/app/api/me/portfolios/[portfolioId]/config/route.ts @@ -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 }); + } +} diff --git a/src/app/lib/portfolioUserConfig.test.ts b/src/app/lib/portfolioUserConfig.test.ts new file mode 100644 index 00000000..9ed087b5 --- /dev/null +++ b/src/app/lib/portfolioUserConfig.test.ts @@ -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, + }); + }); +}); diff --git a/src/app/lib/portfolioUserConfig.ts b/src/app/lib/portfolioUserConfig.ts new file mode 100644 index 00000000..37203e64 --- /dev/null +++ b/src/app/lib/portfolioUserConfig.ts @@ -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
({ + 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()), + }; +}