mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-07-19 17:02:59 +00:00
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:
parent
31b7c28ad5
commit
633c127620
7 changed files with 608 additions and 0 deletions
116
src/app/api/me/folders/[folderId]/route.ts
Normal file
116
src/app/api/me/folders/[folderId]/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
62
src/app/api/me/folders/reorder/route.ts
Normal file
62
src/app/api/me/folders/reorder/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
53
src/app/api/me/folders/route.ts
Normal file
53
src/app/api/me/folders/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
65
src/app/api/me/portfolio-home/route.ts
Normal file
65
src/app/api/me/portfolio-home/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
89
src/app/api/me/portfolios/[portfolioId]/config/route.ts
Normal file
89
src/app/api/me/portfolios/[portfolioId]/config/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
114
src/app/lib/portfolioUserConfig.test.ts
Normal file
114
src/app/lib/portfolioUserConfig.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
109
src/app/lib/portfolioUserConfig.ts
Normal file
109
src/app/lib/portfolioUserConfig.ts
Normal 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()),
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue