feat(board): introduce Projects as isolated boards

- Add Project model with owner-based access and cascade delete
- Restructure Category to belong to either Home (userId) or a Project (projectId), enforced by DB constraints and access filters
- Scope all board operations (create, update, delete, reorder) to the correct board context using `categoryAccessFilter` and `projectAccessFilter`
- Replace hardcoded `revalidatePath("/")` with dynamic `boardPath()` for proper cache invalidation
- Add `ProjectsProvider` and `ProjectsNavSection` for project sidebar navigation
- Make KanbanBoard accept `projectId` and `title` props to render Home or Project boards
- Extract `getBoard()` utility to centralize board data fetching
- Refactor group/todo/notes actions to derive board context from category membership rather than trusting caller input
This commit is contained in:
Brian Fertig 2026-08-12 12:57:41 -06:00
parent 0381363d4e
commit 4708e00a04
24 changed files with 939 additions and 146 deletions

View File

@ -1,8 +1,10 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { auth } from "@/auth"; import { auth } from "@/auth";
import { prisma } from "@/lib/db";
import { SideNavProvider } from "@/components/nav/side-nav-provider"; import { SideNavProvider } from "@/components/nav/side-nav-provider";
import { SideNav } from "@/components/nav/side-nav"; import { SideNav } from "@/components/nav/side-nav";
import { ProjectsProvider } from "@/components/projects/projects-context";
export default async function AppLayout({ children }: { children: React.ReactNode }) { export default async function AppLayout({ children }: { children: React.ReactNode }) {
const session = await auth(); const session = await auth();
@ -10,12 +12,22 @@ export default async function AppLayout({ children }: { children: React.ReactNod
// but every protected data boundary should check for itself too. // but every protected data boundary should check for itself too.
if (!session?.user) redirect("/login"); if (!session?.user) redirect("/login");
// Just the list for the sidebar/`/projects` page -- each project's own
// board (categories/groups/todos) is fetched separately by its own page.
const projects = await prisma.project.findMany({
where: { ownerId: session.user.id },
orderBy: { createdAt: "asc" },
select: { id: true, title: true },
});
return ( return (
<SideNavProvider> <SideNavProvider>
<ProjectsProvider initialProjects={projects}>
<div className="flex h-screen overflow-hidden"> <div className="flex h-screen overflow-hidden">
<SideNav userEmail={session.user.email ?? ""} role={session.user.role} /> <SideNav userEmail={session.user.email ?? ""} role={session.user.role} />
<main className="flex-1 overflow-auto">{children}</main> <main className="flex-1 overflow-auto">{children}</main>
</div> </div>
</ProjectsProvider>
</SideNavProvider> </SideNavProvider>
); );
} }

View File

@ -1,49 +1,14 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { auth } from "@/auth"; import { auth } from "@/auth";
import { prisma } from "@/lib/db"; import { getBoard } from "@/lib/board";
import { KanbanBoard } from "@/components/board/kanban-board"; import { KanbanBoard } from "@/components/board/kanban-board";
import type { CategoryDTO } from "@/types/board";
export default async function HomePage() { export default async function HomePage() {
const session = await auth(); const session = await auth();
if (!session?.user) redirect("/login"); if (!session?.user) redirect("/login");
const categories = await prisma.category.findMany({ const board = await getBoard({ userId: session.user.id, projectId: null });
where: { userId: session.user.id },
orderBy: { order: "asc" },
include: {
groups: {
// Archived groups are kept in the database (not deleted) but
// excluded from the Home board.
where: { archivedAt: null },
orderBy: { order: "asc" },
include: { todos: { orderBy: { order: "asc" } } },
},
},
});
const board: CategoryDTO[] = categories.map((category) => ({
id: category.id,
name: category.name,
order: category.order,
groups: category.groups.map((group) => ({
id: group.id,
title: group.title,
color: group.color,
order: group.order,
noteContent: group.noteContent,
categoryId: group.categoryId,
todos: group.todos.map((todo) => ({
id: todo.id,
title: todo.title,
details: todo.details,
completed: todo.completed,
order: todo.order,
groupId: todo.groupId,
})),
})),
}));
return <KanbanBoard initialCategories={board} />; return <KanbanBoard initialCategories={board} />;
} }

View File

@ -0,0 +1,29 @@
import { notFound, redirect } from "next/navigation";
import { auth } from "@/auth";
import { prisma } from "@/lib/db";
import { projectAccessFilter } from "@/lib/access";
import { getBoard } from "@/lib/board";
import { KanbanBoard } from "@/components/board/kanban-board";
export default async function ProjectPage({
params,
}: {
params: Promise<{ projectId: string }>;
}) {
const session = await auth();
if (!session?.user) redirect("/login");
const { projectId } = await params;
const project = await prisma.project.findFirst({
where: { id: projectId, ...projectAccessFilter(session.user.id) },
select: { id: true, title: true },
});
// Same response whether the project doesn't exist or just isn't this
// user's -- no need to distinguish "not found" from "not yours".
if (!project) notFound();
const board = await getBoard({ projectId: project.id });
return <KanbanBoard initialCategories={board} projectId={project.id} title={project.title} />;
}

View File

@ -0,0 +1,5 @@
import { ProjectsListView } from "@/components/projects/projects-list-view";
export default function ProjectsPage() {
return <ProjectsListView />;
}

View File

@ -72,9 +72,15 @@ const BoardContext = createContext<BoardContextValue | null>(null);
export function BoardProvider({ export function BoardProvider({
initialCategories, initialCategories,
projectId,
children, children,
}: { }: {
initialCategories: CategoryDTO[]; initialCategories: CategoryDTO[];
// Undefined means Home. The Project-scoped board reuses every bit of
// this provider -- the only calls that need to know which board they're
// on are the two that operate over "all of this board's categories"
// rather than a specific existing category/group/todo id.
projectId?: string;
children: React.ReactNode; children: React.ReactNode;
}) { }) {
const [categories, setCategories] = useState(initialCategories); const [categories, setCategories] = useState(initialCategories);
@ -96,14 +102,17 @@ export function BoardProvider({
[updateCategory] [updateCategory]
); );
const addCategory = useCallback(async (name: string) => { const addCategory = useCallback(
async (name: string) => {
try { try {
const category = await createCategory(name); const category = await createCategory(name, projectId);
setCategories((prev) => [...prev, category]); setCategories((prev) => [...prev, category]);
} catch { } catch {
toast.error("Couldn't create category. Try again."); toast.error("Couldn't create category. Try again.");
} }
}, []); },
[projectId]
);
const removeCategory = useCallback(async (categoryId: string) => { const removeCategory = useCallback(async (categoryId: string) => {
let prevState: CategoryDTO[] = []; let prevState: CategoryDTO[] = [];
@ -123,7 +132,8 @@ export function BoardProvider({
} }
}, []); }, []);
const reorderLanes = useCallback(async (orderedIds: string[]) => { const reorderLanes = useCallback(
async (orderedIds: string[]) => {
let prevState: CategoryDTO[] = []; let prevState: CategoryDTO[] = [];
setCategories((prev) => { setCategories((prev) => {
prevState = prev; prevState = prev;
@ -131,12 +141,14 @@ export function BoardProvider({
return orderedIds.map((id, order) => ({ ...byId.get(id)!, order })); return orderedIds.map((id, order) => ({ ...byId.get(id)!, order }));
}); });
try { try {
await reorderCategories(orderedIds); await reorderCategories(orderedIds, projectId);
} catch { } catch {
setCategories(prevState); setCategories(prevState);
toast.error("Couldn't reorder categories. Try again."); toast.error("Couldn't reorder categories. Try again.");
} }
}, []); },
[projectId]
);
const addGroup = useCallback(async (categoryId: string, title: string, color: string) => { const addGroup = useCallback(async (categoryId: string, title: string, color: string) => {
try { try {

View File

@ -21,7 +21,7 @@ import { GroupCardOverlay } from "@/components/board/group-card-overlay";
import { EmptyState } from "@/components/board/empty-state"; import { EmptyState } from "@/components/board/empty-state";
import type { CategoryDTO, GroupDTO } from "@/types/board"; import type { CategoryDTO, GroupDTO } from "@/types/board";
function Board() { function Board({ title }: { title: string }) {
const { categories, reorderLanes, reorderGroups, moveGroup } = useBoard(); const { categories, reorderLanes, reorderGroups, moveGroup } = useBoard();
const [draggedGroup, setDraggedGroup] = useState<GroupDTO | null>(null); const [draggedGroup, setDraggedGroup] = useState<GroupDTO | null>(null);
@ -115,7 +115,7 @@ function Board() {
onDragEnd={handleDragEnd} onDragEnd={handleDragEnd}
> >
<div className="flex h-full flex-col gap-4 p-4"> <div className="flex h-full flex-col gap-4 p-4">
<h1 className="px-1 text-2xl font-bold">Home</h1> <h1 className="truncate px-1 text-2xl font-bold">{title}</h1>
{categories.length === 0 ? ( {categories.length === 0 ? (
<div className="flex flex-1 items-center gap-4 overflow-x-auto"> <div className="flex flex-1 items-center gap-4 overflow-x-auto">
@ -139,10 +139,21 @@ function Board() {
); );
} }
export function KanbanBoard({ initialCategories }: { initialCategories: CategoryDTO[] }) { export function KanbanBoard({
initialCategories,
projectId,
title = "Home",
}: {
initialCategories: CategoryDTO[];
// Undefined renders the caller's Home board; set it to scope the whole
// board -- creating/reordering categories, and everything nested under
// them -- to that Project instead.
projectId?: string;
title?: string;
}) {
return ( return (
<BoardProvider initialCategories={initialCategories}> <BoardProvider initialCategories={initialCategories} projectId={projectId}>
<Board /> <Board title={title} />
</BoardProvider> </BoardProvider>
); );
} }

View File

@ -0,0 +1,112 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { ChevronDown, ChevronRight, FolderKanban } from "lucide-react";
import { cn } from "@/lib/utils";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useProjects } from "@/components/projects/projects-context";
const STORAGE_KEY = "organize:projects-expanded";
/**
* The "Projects" nav entry: a link to /projects (the list + create page)
* plus, underneath it, a live list of the user's projects that expands
* and contracts independently of the link itself -- clicking the label
* navigates, clicking the chevron just toggles the list.
*/
export function ProjectsNavSection({ collapsed }: { collapsed: boolean }) {
const pathname = usePathname();
const { projects } = useProjects();
// Defaults to expanded on both server and first client render (same
// reasoning as SideNavProvider's `collapsed`) to avoid a hydration
// mismatch; the persisted value applies right after mount.
const [expanded, setExpanded] = useState(true);
useEffect(() => {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored !== null) setExpanded(stored === "true");
}, []);
function toggleExpanded() {
setExpanded((prev) => {
const next = !prev;
localStorage.setItem(STORAGE_KEY, String(next));
return next;
});
}
const isListActive = pathname === "/projects";
const link = (
<Link
href="/projects"
className={cn(
"flex flex-1 items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors",
"hover:bg-accent hover:text-accent-foreground",
collapsed && "justify-center px-0",
isListActive && "bg-accent text-accent-foreground"
)}
>
<FolderKanban className="size-5 shrink-0" />
{!collapsed && <span className="truncate">Projects</span>}
</Link>
);
// No room for an expandable sub-list in the icon rail -- just a link,
// matching how every other collapsed nav item behaves.
if (collapsed) {
return (
<Tooltip>
<TooltipTrigger render={link} />
<TooltipContent side="right">Projects</TooltipContent>
</Tooltip>
);
}
return (
<div>
<div className="flex items-center gap-0.5">
{link}
<button
type="button"
onClick={toggleExpanded}
aria-label={expanded ? "Collapse projects list" : "Expand projects list"}
aria-expanded={expanded}
className="flex size-7 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-accent-foreground"
>
{expanded ? (
<ChevronDown className="size-4" />
) : (
<ChevronRight className="size-4" />
)}
</button>
</div>
{expanded && projects.length > 0 && (
<div className="mt-0.5 flex flex-col gap-0.5 border-l border-sidebar-border pl-3">
{projects.map((project) => {
const isActive = pathname === `/projects/${project.id}`;
return (
<Link
key={project.id}
href={`/projects/${project.id}`}
className={cn(
"truncate rounded-lg px-3 py-1.5 text-sm transition-colors",
"hover:bg-accent hover:text-accent-foreground",
isActive
? "bg-accent font-medium text-accent-foreground"
: "text-muted-foreground"
)}
>
{project.title}
</Link>
);
})}
</div>
)}
</div>
);
}

View File

@ -8,6 +8,7 @@ import { Separator } from "@/components/ui/separator";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useSideNav } from "@/components/nav/side-nav-provider"; import { useSideNav } from "@/components/nav/side-nav-provider";
import { SideNavItem } from "@/components/nav/side-nav-item"; import { SideNavItem } from "@/components/nav/side-nav-item";
import { ProjectsNavSection } from "@/components/nav/projects-nav-section";
import { NAV_ITEMS, ADMIN_NAV_ITEM } from "@/components/nav/nav-items"; import { NAV_ITEMS, ADMIN_NAV_ITEM } from "@/components/nav/nav-items";
import { ThemeToggle } from "@/components/theme/theme-toggle"; import { ThemeToggle } from "@/components/theme/theme-toggle";
import { logout } from "@/lib/actions/auth"; import { logout } from "@/lib/actions/auth";
@ -15,7 +16,7 @@ import { Role } from "@/lib/generated/prisma/enums";
export function SideNav({ userEmail, role }: { userEmail: string; role: Role }) { export function SideNav({ userEmail, role }: { userEmail: string; role: Role }) {
const { collapsed, toggle } = useSideNav(); const { collapsed, toggle } = useSideNav();
const items = role === Role.ADMIN ? [...NAV_ITEMS, ADMIN_NAV_ITEM] : NAV_ITEMS; const adminItem = role === Role.ADMIN ? [ADMIN_NAV_ITEM] : [];
return ( return (
<aside <aside
@ -31,8 +32,12 @@ export function SideNav({ userEmail, role }: { userEmail: string; role: Role })
<Separator /> <Separator />
<nav className="flex flex-1 flex-col gap-1 p-2"> <nav className="flex flex-1 flex-col gap-1 overflow-y-auto p-2">
{items.map((item) => ( {NAV_ITEMS.map((item) => (
<SideNavItem key={item.href} item={item} collapsed={collapsed} />
))}
<ProjectsNavSection collapsed={collapsed} />
{adminItem.map((item) => (
<SideNavItem key={item.href} item={item} collapsed={collapsed} /> <SideNavItem key={item.href} item={item} collapsed={collapsed} />
))} ))}
</nav> </nav>

View File

@ -0,0 +1,82 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Plus } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { useProjects } from "@/components/projects/projects-context";
const TITLE_MAX = 60;
/** The "+ New project" tile on the /projects list -- creates, then jumps
* straight into the new project's (empty) board. */
export function CreateProjectDialog() {
const { addProject } = useProjects();
const router = useRouter();
const [open, setOpen] = useState(false);
const [title, setTitle] = useState("");
const [pending, setPending] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
const trimmed = title.trim();
if (!trimmed) return;
setPending(true);
const project = await addProject(trimmed);
setPending(false);
if (!project) return;
setTitle("");
setOpen(false);
router.push(`/projects/${project.id}`);
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger
render={
<button
className="flex h-32 flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed border-border text-muted-foreground transition-colors hover:border-primary hover:text-primary"
aria-label="New project"
>
<Plus className="size-6" />
<span className="text-sm font-medium">New Project</span>
</button>
}
/>
<DialogContent>
<DialogHeader>
<DialogTitle>New project</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="project-title">Title</Label>
<Input
id="project-title"
value={title}
maxLength={TITLE_MAX}
autoFocus
onChange={(e) => setTitle(e.target.value)}
placeholder="e.g. Kitchen Remodel"
/>
</div>
<DialogFooter>
<Button type="submit" disabled={pending || !title.trim()}>
{pending ? "Creating…" : "Create project"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,85 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { FolderKanban, MoreVertical, Pencil, Trash2 } from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { ConfirmDeleteDialog } from "@/components/confirm-delete-dialog";
import { RenameProjectDialog } from "@/components/projects/rename-project-dialog";
import { useProjects } from "@/components/projects/projects-context";
import type { ProjectDTO } from "@/types/project";
export function ProjectCard({ project }: { project: ProjectDTO }) {
const { removeProject } = useProjects();
const [renameOpen, setRenameOpen] = useState(false);
const [confirmOpen, setConfirmOpen] = useState(false);
return (
<>
{/* The whole tile opens the project; the dropdown sits on top of it
as a sibling (not nested in the link) with pointer-events
re-enabled, so its clicks don't also navigate. `relative` on that
wrapper matters, not just decorative: an absolutely-positioned
element paints after (on top of) non-positioned in-flow content
regardless of DOM order, so without it the Link would still win
every hit-test even under a pointer-events-none ancestor. */}
<div className="group relative flex h-32 flex-col justify-between rounded-xl border p-4 shadow-sm transition-shadow hover:-translate-y-0.5 hover:shadow-md">
<Link
href={`/projects/${project.id}`}
className="absolute inset-0 rounded-xl"
aria-label={`Open ${project.title}`}
/>
<div className="pointer-events-none flex items-start justify-between gap-1">
<FolderKanban className="size-5 shrink-0 text-primary" />
<div className="relative pointer-events-auto">
<DropdownMenu>
<DropdownMenuTrigger
render={
<button
className="flex size-7 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-accent-foreground"
aria-label={`More options for ${project.title}`}
>
<MoreVertical className="size-4" />
</button>
}
/>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setRenameOpen(true)}>
<Pencil className="size-4" />
Rename
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" onClick={() => setConfirmOpen(true)}>
<Trash2 className="size-4" />
Delete project
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<h3 className="pointer-events-none min-w-0 truncate text-sm font-semibold">
{project.title}
</h3>
</div>
<RenameProjectDialog project={project} open={renameOpen} onOpenChange={setRenameOpen} />
<ConfirmDeleteDialog
open={confirmOpen}
onOpenChange={setConfirmOpen}
title="Delete project?"
description={`Delete "${project.title}" and everything in it -- all of its categories, groups, and to-dos? This can't be undone.`}
onConfirm={async () => {
await removeProject(project.id);
}}
/>
</>
);
}

View File

@ -0,0 +1,89 @@
"use client";
import { createContext, useContext, useState, useCallback } from "react";
import { toast } from "sonner";
import type { ProjectDTO } from "@/types/project";
import {
createProject,
renameProject as renameProjectAction,
deleteProject as deleteProjectAction,
} from "@/lib/actions/projects";
interface ProjectsContextValue {
projects: ProjectDTO[];
addProject: (title: string) => Promise<ProjectDTO | undefined>;
renameProject: (projectId: string, title: string) => Promise<void>;
removeProject: (projectId: string) => Promise<boolean>;
}
const ProjectsContext = createContext<ProjectsContextValue | null>(null);
/**
* Holds the signed-in user's project list, fetched once in the (app)
* layout and kept live from there -- the sidebar's expandable "Projects"
* section and the /projects page both read from this same instance, so
* creating/renaming/deleting a project in one place shows up in the other
* immediately, with no extra round-trip.
*/
export function ProjectsProvider({
initialProjects,
children,
}: {
initialProjects: ProjectDTO[];
children: React.ReactNode;
}) {
const [projects, setProjects] = useState(initialProjects);
const addProject = useCallback(async (title: string) => {
try {
const project = await createProject(title);
setProjects((prev) => [...prev, project]);
return project;
} catch {
toast.error("Couldn't create project. Try again.");
return undefined;
}
}, []);
const renameProject = useCallback(async (projectId: string, title: string) => {
let prevState: ProjectDTO[] = [];
setProjects((prev) => {
prevState = prev;
return prev.map((p) => (p.id === projectId ? { ...p, title } : p));
});
try {
await renameProjectAction(projectId, title);
} catch {
setProjects(prevState);
toast.error("Couldn't rename project. Try again.");
}
}, []);
const removeProject = useCallback(async (projectId: string) => {
let prevState: ProjectDTO[] = [];
setProjects((prev) => {
prevState = prev;
return prev.filter((p) => p.id !== projectId);
});
const result = await deleteProjectAction(projectId);
if (result?.error) {
setProjects(prevState);
toast.error(result.error);
return false;
}
return true;
}, []);
return (
<ProjectsContext.Provider value={{ projects, addProject, renameProject, removeProject }}>
{children}
</ProjectsContext.Provider>
);
}
export function useProjects() {
const ctx = useContext(ProjectsContext);
if (!ctx) throw new Error("useProjects must be used within a ProjectsProvider");
return ctx;
}

View File

@ -0,0 +1,42 @@
"use client";
import { FolderKanban } from "lucide-react";
import { useProjects } from "@/components/projects/projects-context";
import { ProjectCard } from "@/components/projects/project-card";
import { CreateProjectDialog } from "@/components/projects/create-project-dialog";
export function ProjectsListView() {
const { projects } = useProjects();
return (
<div className="flex h-full flex-col gap-4 p-4">
<div>
<h1 className="text-2xl font-bold">Projects</h1>
<p className="text-sm text-muted-foreground">
Each project is its own isolated board -- separate categories, groups, and to-dos from
Home and from every other project.
</p>
</div>
{projects.length === 0 && (
<div className="flex flex-col items-center gap-3 p-8 text-center">
<div className="flex size-16 items-center justify-center rounded-full bg-primary/10 text-primary">
<FolderKanban className="size-8" />
</div>
<h2 className="text-lg font-semibold">No projects yet</h2>
<p className="max-w-sm text-sm text-muted-foreground">
Create one to get its own board, separate from Home.
</p>
</div>
)}
<div className="grid grid-cols-[repeat(auto-fill,minmax(220px,1fr))] gap-4">
{projects.map((project) => (
<ProjectCard key={project.id} project={project} />
))}
<CreateProjectDialog />
</div>
</div>
);
}

View File

@ -0,0 +1,75 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { useProjects } from "@/components/projects/projects-context";
import type { ProjectDTO } from "@/types/project";
const TITLE_MAX = 60;
export function RenameProjectDialog({
project,
open,
onOpenChange,
}: {
project: ProjectDTO;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const { renameProject } = useProjects();
const [title, setTitle] = useState(project.title);
const [pending, setPending] = useState(false);
function handleOpenChange(next: boolean) {
if (next) setTitle(project.title);
onOpenChange(next);
}
async function handleSave() {
const trimmed = title.trim();
if (!trimmed) return;
setPending(true);
await renameProject(project.id, trimmed);
setPending(false);
onOpenChange(false);
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Rename project</DialogTitle>
</DialogHeader>
<div className="space-y-2">
<Label htmlFor={`rename-project-${project.id}`}>Title</Label>
<Input
id={`rename-project-${project.id}`}
value={title}
maxLength={TITLE_MAX}
autoFocus
onChange={(e) => setTitle(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSave()}
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={pending}>
Cancel
</Button>
<Button onClick={handleSave} disabled={pending || !title.trim()}>
{pending ? "Saving…" : "Save"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

29
lib/access.ts Normal file
View File

@ -0,0 +1,29 @@
import "server-only";
/**
* Central place for "does this user have access to this Category" -- every
* action in lib/actions/{categories,groups,todos,notes}.ts filters its
* Prisma queries through this instead of writing `userId` directly, so
* there's exactly one place to update when Projects become shared spaces
* (add a `project: { members: { some: { userId } } }` branch below) rather
* than an action-by-action audit.
*
* A category belongs to exactly one of:
* - a User directly (a Home category, `projectId` null), or
* - a Project (`projectId` set) -- accessible today only to its owner.
*/
export function categoryAccessFilter(userId: string) {
return {
OR: [{ userId, projectId: null }, { project: projectAccessFilter(userId) }],
};
}
/** Same idea as categoryAccessFilter, but starting from a Project row itself. */
export function projectAccessFilter(userId: string) {
return { ownerId: userId };
}
/** The board route a given scope's mutations should revalidate. */
export function boardPath(projectId: string | null | undefined): string {
return projectId ? `/projects/${projectId}` : "/";
}

View File

@ -4,20 +4,38 @@ import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { requireUserId } from "@/lib/auth-helpers"; import { requireUserId } from "@/lib/auth-helpers";
import { boardPath, categoryAccessFilter, projectAccessFilter } from "@/lib/access";
import { CategoryNameSchema } from "@/lib/validation/category"; import { CategoryNameSchema } from "@/lib/validation/category";
import type { CategoryDTO } from "@/types/board"; import type { CategoryDTO } from "@/types/board";
export async function createCategory(name: string): Promise<CategoryDTO> { /**
* Creates a Category in the caller's Home board, or inside a Project the
* caller owns when `projectId` is given -- verified against
* projectAccessFilter() rather than trusted from the argument.
*/
export async function createCategory(name: string, projectId?: string): Promise<CategoryDTO> {
const userId = await requireUserId(); const userId = await requireUserId();
const parsedName = CategoryNameSchema.parse(name); const parsedName = CategoryNameSchema.parse(name);
const count = await prisma.category.count({ where: { userId } }); if (projectId) {
const project = await prisma.project.findFirst({
where: { id: projectId, ...projectAccessFilter(userId) },
select: { id: true },
});
if (!project) throw new Error("Project not found");
}
const category = await prisma.category.create({ const count = await prisma.category.count({
data: { name: parsedName, order: count, userId }, where: projectId ? { projectId } : { userId, projectId: null },
}); });
revalidatePath("/"); const category = await prisma.category.create({
data: projectId
? { name: parsedName, order: count, projectId }
: { name: parsedName, order: count, userId },
});
revalidatePath(boardPath(projectId));
return { id: category.id, name: category.name, order: category.order, groups: [] }; return { id: category.id, name: category.name, order: category.order, groups: [] };
} }
@ -25,13 +43,15 @@ export async function renameCategory(categoryId: string, name: string): Promise<
const userId = await requireUserId(); const userId = await requireUserId();
const parsedName = CategoryNameSchema.parse(name); const parsedName = CategoryNameSchema.parse(name);
const { count } = await prisma.category.updateMany({ const category = await prisma.category.findFirst({
where: { id: categoryId, userId }, where: { id: categoryId, ...categoryAccessFilter(userId) },
data: { name: parsedName }, select: { id: true, projectId: true },
}); });
if (count === 0) throw new Error("Category not found"); if (!category) throw new Error("Category not found");
revalidatePath("/"); await prisma.category.update({ where: { id: categoryId }, data: { name: parsedName } });
revalidatePath(boardPath(category.projectId));
} }
export type DeleteCategoryResult = { error?: string }; export type DeleteCategoryResult = { error?: string };
@ -48,8 +68,8 @@ export async function deleteCategory(categoryId: string): Promise<DeleteCategory
const userId = await requireUserId(); const userId = await requireUserId();
const category = await prisma.category.findFirst({ const category = await prisma.category.findFirst({
where: { id: categoryId, userId }, where: { id: categoryId, ...categoryAccessFilter(userId) },
select: { _count: { select: { groups: true } } }, select: { projectId: true, _count: { select: { groups: true } } },
}); });
if (!category) return { error: "Category not found." }; if (!category) return { error: "Category not found." };
if (category._count.groups > 0) { if (category._count.groups > 0) {
@ -58,24 +78,27 @@ export async function deleteCategory(categoryId: string): Promise<DeleteCategory
await prisma.category.delete({ where: { id: categoryId } }); await prisma.category.delete({ where: { id: categoryId } });
revalidatePath("/"); revalidatePath(boardPath(category.projectId));
return {}; return {};
} }
/** /**
* Persists a new lane order for all of the current user's categories. * Persists a new lane order for every category in one board -- the
* `orderedIds` must contain every category id the user owns. * caller's Home board, or a Project they own when `projectId` is given.
* `orderedIds` must contain every category id in that scope.
*/ */
export async function reorderCategories(orderedIds: string[]): Promise<void> { export async function reorderCategories(orderedIds: string[], projectId?: string): Promise<void> {
const userId = await requireUserId(); const userId = await requireUserId();
const owned = await prisma.category.findMany({ const owned = await prisma.category.findMany({
where: { userId }, where: projectId
? { projectId, project: projectAccessFilter(userId) }
: { userId, projectId: null },
select: { id: true }, select: { id: true },
}); });
const ownedIds = new Set(owned.map((c) => c.id)); const ownedIds = new Set(owned.map((c) => c.id));
if (orderedIds.length !== ownedIds.size || !orderedIds.every((id) => ownedIds.has(id))) { if (orderedIds.length !== ownedIds.size || !orderedIds.every((id) => ownedIds.has(id))) {
throw new Error("Category list does not match the current user's categories"); throw new Error("Category list does not match this board's categories");
} }
await prisma.$transaction( await prisma.$transaction(
@ -84,5 +107,5 @@ export async function reorderCategories(orderedIds: string[]): Promise<void> {
) )
); );
revalidatePath("/"); revalidatePath(boardPath(projectId));
} }

View File

@ -4,6 +4,7 @@ import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { requireUserId } from "@/lib/auth-helpers"; import { requireUserId } from "@/lib/auth-helpers";
import { boardPath, categoryAccessFilter } from "@/lib/access";
import { GroupColorSchema, GroupTitleSchema } from "@/lib/validation/group"; import { GroupColorSchema, GroupTitleSchema } from "@/lib/validation/group";
import type { GroupDTO } from "@/types/board"; import type { GroupDTO } from "@/types/board";
@ -17,8 +18,8 @@ export async function createGroup(
const parsedColor = GroupColorSchema.parse(color); const parsedColor = GroupColorSchema.parse(color);
const category = await prisma.category.findFirst({ const category = await prisma.category.findFirst({
where: { id: categoryId, userId }, where: { id: categoryId, ...categoryAccessFilter(userId) },
select: { id: true }, select: { id: true, projectId: true },
}); });
if (!category) throw new Error("Category not found"); if (!category) throw new Error("Category not found");
@ -28,7 +29,7 @@ export async function createGroup(
data: { title: parsedTitle, color: parsedColor, order: count, categoryId }, data: { title: parsedTitle, color: parsedColor, order: count, categoryId },
}); });
revalidatePath("/"); revalidatePath(boardPath(category.projectId));
return { return {
id: group.id, id: group.id,
title: group.title, title: group.title,
@ -50,40 +51,47 @@ export async function updateGroup(
if (data.title !== undefined) update.title = GroupTitleSchema.parse(data.title); if (data.title !== undefined) update.title = GroupTitleSchema.parse(data.title);
if (data.color !== undefined) update.color = GroupColorSchema.parse(data.color); if (data.color !== undefined) update.color = GroupColorSchema.parse(data.color);
const { count } = await prisma.group.updateMany({ const group = await prisma.group.findFirst({
where: { id: groupId, category: { userId } }, where: { id: groupId, category: categoryAccessFilter(userId) },
data: update, select: { category: { select: { projectId: true } } },
}); });
if (count === 0) throw new Error("Group not found"); if (!group) throw new Error("Group not found");
revalidatePath("/"); await prisma.group.update({ where: { id: groupId }, data: update });
revalidatePath(boardPath(group.category.projectId));
} }
export async function deleteGroup(groupId: string): Promise<void> { export async function deleteGroup(groupId: string): Promise<void> {
const userId = await requireUserId(); const userId = await requireUserId();
const { count } = await prisma.group.deleteMany({ const group = await prisma.group.findFirst({
where: { id: groupId, category: { userId } }, where: { id: groupId, category: categoryAccessFilter(userId) },
select: { category: { select: { projectId: true } } },
}); });
if (count === 0) throw new Error("Group not found"); if (!group) throw new Error("Group not found");
revalidatePath("/"); await prisma.group.delete({ where: { id: groupId } });
revalidatePath(boardPath(group.category.projectId));
} }
/** /**
* Archives a group instead of deleting it -- the row (and its to-dos) is * Archives a group instead of deleting it -- the row (and its to-dos) is
* kept, just excluded from the Home board query going forward. * kept, just excluded from the board query going forward.
*/ */
export async function archiveGroup(groupId: string): Promise<void> { export async function archiveGroup(groupId: string): Promise<void> {
const userId = await requireUserId(); const userId = await requireUserId();
const { count } = await prisma.group.updateMany({ const group = await prisma.group.findFirst({
where: { id: groupId, category: { userId } }, where: { id: groupId, category: categoryAccessFilter(userId) },
data: { archivedAt: new Date() }, select: { category: { select: { projectId: true } } },
}); });
if (count === 0) throw new Error("Group not found"); if (!group) throw new Error("Group not found");
revalidatePath("/"); await prisma.group.update({ where: { id: groupId }, data: { archivedAt: new Date() } });
revalidatePath(boardPath(group.category.projectId));
} }
/** Persists a new card order within a single lane. */ /** Persists a new card order within a single lane. */
@ -94,7 +102,7 @@ export async function reorderGroupsInCategory(
const userId = await requireUserId(); const userId = await requireUserId();
const category = await prisma.category.findFirst({ const category = await prisma.category.findFirst({
where: { id: categoryId, userId }, where: { id: categoryId, ...categoryAccessFilter(userId) },
include: { groups: { select: { id: true } } }, include: { groups: { select: { id: true } } },
}); });
if (!category) throw new Error("Category not found"); if (!category) throw new Error("Category not found");
@ -113,12 +121,14 @@ export async function reorderGroupsInCategory(
) )
); );
revalidatePath("/"); revalidatePath(boardPath(category.projectId));
} }
/** /**
* Moves a card to a different lane and persists the resulting order for * Moves a card to a different lane and persists the resulting order for
* both the source and destination lanes. * both the source and destination lanes. Both lanes must be in the same
* board (Home, or the same Project) -- boards are isolated spaces, so a
* group can't be dragged from one into another.
*/ */
export async function moveGroupToCategory( export async function moveGroupToCategory(
groupId: string, groupId: string,
@ -130,13 +140,19 @@ export async function moveGroupToCategory(
const [group, targetCategory] = await Promise.all([ const [group, targetCategory] = await Promise.all([
prisma.group.findFirst({ prisma.group.findFirst({
where: { id: groupId, category: { userId } }, where: { id: groupId, category: categoryAccessFilter(userId) },
select: { id: true, categoryId: true }, select: { id: true, categoryId: true, category: { select: { projectId: true } } },
}),
prisma.category.findFirst({
where: { id: targetCategoryId, ...categoryAccessFilter(userId) },
select: { id: true, projectId: true },
}), }),
prisma.category.findFirst({ where: { id: targetCategoryId, userId }, select: { id: true } }),
]); ]);
if (!group) throw new Error("Group not found"); if (!group) throw new Error("Group not found");
if (!targetCategory) throw new Error("Target category not found"); if (!targetCategory) throw new Error("Target category not found");
if (group.category.projectId !== targetCategory.projectId) {
throw new Error("Can't move a group into a different board");
}
const targetIndex = orderedGroupIdsInTargetLane.indexOf(groupId); const targetIndex = orderedGroupIdsInTargetLane.indexOf(groupId);
if (targetIndex === -1) { if (targetIndex === -1) {
@ -157,5 +173,5 @@ export async function moveGroupToCategory(
), ),
]); ]);
revalidatePath("/"); revalidatePath(boardPath(targetCategory.projectId));
} }

View File

@ -4,17 +4,20 @@ import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { requireUserId } from "@/lib/auth-helpers"; import { requireUserId } from "@/lib/auth-helpers";
import { boardPath, categoryAccessFilter } from "@/lib/access";
import { GroupNoteContentSchema } from "@/lib/validation/group"; import { GroupNoteContentSchema } from "@/lib/validation/group";
export async function updateGroupNote(groupId: string, noteContent: string): Promise<void> { export async function updateGroupNote(groupId: string, noteContent: string): Promise<void> {
const userId = await requireUserId(); const userId = await requireUserId();
const parsed = GroupNoteContentSchema.parse(noteContent); const parsed = GroupNoteContentSchema.parse(noteContent);
const { count } = await prisma.group.updateMany({ const group = await prisma.group.findFirst({
where: { id: groupId, category: { userId } }, where: { id: groupId, category: categoryAccessFilter(userId) },
data: { noteContent: parsed }, select: { category: { select: { projectId: true } } },
}); });
if (count === 0) throw new Error("Group not found"); if (!group) throw new Error("Group not found");
revalidatePath("/"); await prisma.group.update({ where: { id: groupId }, data: { noteContent: parsed } });
revalidatePath(boardPath(group.category.projectId));
} }

56
lib/actions/projects.ts Normal file
View File

@ -0,0 +1,56 @@
"use server";
import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/db";
import { requireUserId } from "@/lib/auth-helpers";
import { projectAccessFilter } from "@/lib/access";
import { ProjectTitleSchema } from "@/lib/validation/project";
import type { ProjectDTO } from "@/types/project";
export async function createProject(title: string): Promise<ProjectDTO> {
const userId = await requireUserId();
const parsedTitle = ProjectTitleSchema.parse(title);
const project = await prisma.project.create({
data: { title: parsedTitle, ownerId: userId },
});
revalidatePath("/projects");
return { id: project.id, title: project.title };
}
export async function renameProject(projectId: string, title: string): Promise<void> {
const userId = await requireUserId();
const parsedTitle = ProjectTitleSchema.parse(title);
const { count } = await prisma.project.updateMany({
where: { id: projectId, ...projectAccessFilter(userId) },
data: { title: parsedTitle },
});
if (count === 0) throw new Error("Project not found");
revalidatePath("/projects");
revalidatePath(`/projects/${projectId}`);
}
export type DeleteProjectResult = { error?: string };
/**
* Deletes a project and everything in it -- its categories, groups, and
* to-dos all cascade away with it (unlike deleting a single Category,
* which refuses if it still has groups). The confirmation dialog on the
* client carries the "this can't be undone" warning; this action doesn't
* re-check emptiness.
*/
export async function deleteProject(projectId: string): Promise<DeleteProjectResult> {
const userId = await requireUserId();
const { count } = await prisma.project.deleteMany({
where: { id: projectId, ...projectAccessFilter(userId) },
});
if (count === 0) return { error: "Project not found." };
revalidatePath("/projects");
return {};
}

View File

@ -4,6 +4,7 @@ import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { requireUserId } from "@/lib/auth-helpers"; import { requireUserId } from "@/lib/auth-helpers";
import { boardPath, categoryAccessFilter } from "@/lib/access";
import { TodoDetailsSchema, TodoTitleSchema } from "@/lib/validation/todo"; import { TodoDetailsSchema, TodoTitleSchema } from "@/lib/validation/todo";
import type { TodoDTO } from "@/types/board"; import type { TodoDTO } from "@/types/board";
@ -17,8 +18,8 @@ export async function createTodo(
const parsedDetails = details ? TodoDetailsSchema.parse(details) : undefined; const parsedDetails = details ? TodoDetailsSchema.parse(details) : undefined;
const group = await prisma.group.findFirst({ const group = await prisma.group.findFirst({
where: { id: groupId, category: { userId } }, where: { id: groupId, category: categoryAccessFilter(userId) },
select: { id: true }, select: { id: true, category: { select: { projectId: true } } },
}); });
if (!group) throw new Error("Group not found"); if (!group) throw new Error("Group not found");
@ -28,7 +29,7 @@ export async function createTodo(
data: { title: parsedTitle, details: parsedDetails ?? null, order: count, groupId }, data: { title: parsedTitle, details: parsedDetails ?? null, order: count, groupId },
}); });
revalidatePath("/"); revalidatePath(boardPath(group.category.projectId));
return { return {
id: todo.id, id: todo.id,
title: todo.title, title: todo.title,
@ -51,36 +52,43 @@ export async function updateTodo(
update.details = data.details ? TodoDetailsSchema.parse(data.details) : null; update.details = data.details ? TodoDetailsSchema.parse(data.details) : null;
} }
const { count } = await prisma.todo.updateMany({ const todo = await prisma.todo.findFirst({
where: { id: todoId, group: { category: { userId } } }, where: { id: todoId, group: { category: categoryAccessFilter(userId) } },
data: update, select: { group: { select: { category: { select: { projectId: true } } } } },
}); });
if (count === 0) throw new Error("To-do not found"); if (!todo) throw new Error("To-do not found");
revalidatePath("/"); await prisma.todo.update({ where: { id: todoId }, data: update });
revalidatePath(boardPath(todo.group.category.projectId));
} }
export async function toggleTodo(todoId: string, completed: boolean): Promise<void> { export async function toggleTodo(todoId: string, completed: boolean): Promise<void> {
const userId = await requireUserId(); const userId = await requireUserId();
const { count } = await prisma.todo.updateMany({ const todo = await prisma.todo.findFirst({
where: { id: todoId, group: { category: { userId } } }, where: { id: todoId, group: { category: categoryAccessFilter(userId) } },
data: { completed }, select: { group: { select: { category: { select: { projectId: true } } } } },
}); });
if (count === 0) throw new Error("To-do not found"); if (!todo) throw new Error("To-do not found");
revalidatePath("/"); await prisma.todo.update({ where: { id: todoId }, data: { completed } });
revalidatePath(boardPath(todo.group.category.projectId));
} }
export async function deleteTodo(todoId: string): Promise<void> { export async function deleteTodo(todoId: string): Promise<void> {
const userId = await requireUserId(); const userId = await requireUserId();
const { count } = await prisma.todo.deleteMany({ const todo = await prisma.todo.findFirst({
where: { id: todoId, group: { category: { userId } } }, where: { id: todoId, group: { category: categoryAccessFilter(userId) } },
select: { group: { select: { category: { select: { projectId: true } } } } },
}); });
if (count === 0) throw new Error("To-do not found"); if (!todo) throw new Error("To-do not found");
revalidatePath("/"); await prisma.todo.delete({ where: { id: todoId } });
revalidatePath(boardPath(todo.group.category.projectId));
} }
/** Persists a new to-do order within a single group. */ /** Persists a new to-do order within a single group. */
@ -88,8 +96,11 @@ export async function reorderTodos(groupId: string, orderedTodoIds: string[]): P
const userId = await requireUserId(); const userId = await requireUserId();
const group = await prisma.group.findFirst({ const group = await prisma.group.findFirst({
where: { id: groupId, category: { userId } }, where: { id: groupId, category: categoryAccessFilter(userId) },
include: { todos: { select: { id: true } } }, include: {
todos: { select: { id: true } },
category: { select: { projectId: true } },
},
}); });
if (!group) throw new Error("Group not found"); if (!group) throw new Error("Group not found");
@ -107,5 +118,5 @@ export async function reorderTodos(groupId: string, orderedTodoIds: string[]): P
) )
); );
revalidatePath("/"); revalidatePath(boardPath(group.category.projectId));
} }

49
lib/board.ts Normal file
View File

@ -0,0 +1,49 @@
import "server-only";
import { prisma } from "@/lib/db";
import type { CategoryDTO } from "@/types/board";
/**
* Fetches one board's worth of categories/groups/todos, shaped for the
* client -- shared by the Home board and every Project board, which are
* identical in every way except which categories they scope to.
*/
export async function getBoard(
where: { userId: string; projectId: null } | { projectId: string }
): Promise<CategoryDTO[]> {
const categories = await prisma.category.findMany({
where,
orderBy: { order: "asc" },
include: {
// Archived groups are kept in the database (not deleted) but
// excluded from the board query.
groups: {
where: { archivedAt: null },
orderBy: { order: "asc" },
include: { todos: { orderBy: { order: "asc" } } },
},
},
});
return categories.map((category) => ({
id: category.id,
name: category.name,
order: category.order,
groups: category.groups.map((group) => ({
id: group.id,
title: group.title,
color: group.color,
order: group.order,
noteContent: group.noteContent,
categoryId: group.categoryId,
todos: group.todos.map((todo) => ({
id: todo.id,
title: todo.title,
details: todo.details,
completed: todo.completed,
order: todo.order,
groupId: todo.groupId,
})),
})),
}));
}

View File

@ -0,0 +1,3 @@
import { z } from "zod";
export const ProjectTitleSchema = z.string().trim().min(1, "Title is required").max(60);

View File

@ -0,0 +1,43 @@
-- DropForeignKey
-- Category.userId is becoming optional -- a category now belongs to
-- exactly one of a User (Home) or a Project, never both.
ALTER TABLE "Category" DROP CONSTRAINT "Category_userId_fkey";
-- AlterTable
ALTER TABLE "Category" ALTER COLUMN "userId" DROP NOT NULL;
ALTER TABLE "Category" ADD COLUMN "projectId" TEXT;
-- CreateTable
CREATE TABLE "Project" (
"id" TEXT NOT NULL,
"title" TEXT NOT NULL,
"ownerId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Project_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "Project_ownerId_createdAt_idx" ON "Project"("ownerId", "createdAt");
-- CreateIndex
CREATE INDEX "Category_projectId_order_idx" ON "Category"("projectId", "order");
-- AddForeignKey
ALTER TABLE "Category" ADD CONSTRAINT "Category_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Category" ADD CONSTRAINT "Category_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Project" ADD CONSTRAINT "Project_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- Every existing Category row already has userId set and projectId null,
-- so this holds immediately with no backfill -- enforce the "exactly one
-- of userId/projectId" invariant at the database level too, not just in
-- application code (lib/access.ts).
ALTER TABLE "Category" ADD CONSTRAINT "Category_owner_xor_project" CHECK (
("userId" IS NOT NULL AND "projectId" IS NULL) OR
("userId" IS NULL AND "projectId" IS NOT NULL)
);

View File

@ -44,6 +44,30 @@ model User {
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
categories Category[] categories Category[]
projects Project[] @relation("ProjectOwner")
}
/**
* An isolated board: its own Categories/Groups/To-Dos, invisible from Home
* and from every other Project. Sole-owner for now -- `ownerId` is both
* "who created it" and (for now) the only person with access. When
* Projects become shared spaces, add a `ProjectMember` join table
* (projectId, userId, role) and extend the access checks in
* lib/access.ts to consult it too; `ownerId` stays as the implicit
* full-access member and doesn't need to change.
*/
model Project {
id String @id @default(cuid())
title String
ownerId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
owner User @relation("ProjectOwner", fields: [ownerId], references: [id], onDelete: Cascade)
categories Category[]
@@index([ownerId, createdAt])
} }
// Single-row table of site-wide settings. Always has exactly one row, at // Single-row table of site-wide settings. Always has exactly one row, at
@ -59,15 +83,23 @@ model Category {
id String @id @default(cuid()) id String @id @default(cuid())
name String name String
order Int order Int
userId String // Exactly one of these is set: userId for a Home category (personal,
// outside any project), projectId for a category inside a Project.
// Enforced by a DB check constraint (see migration) as well as by every
// access path going through lib/access.ts rather than querying userId
// or projectId directly.
userId String?
projectId String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
project Project? @relation(fields: [projectId], references: [id], onDelete: Cascade)
groups Group[] groups Group[]
@@index([userId, order]) @@index([userId, order])
@@index([projectId, order])
} }
model Group { model Group {

4
types/project.ts Normal file
View File

@ -0,0 +1,4 @@
export interface ProjectDTO {
id: string;
title: string;
}