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:
parent
0381363d4e
commit
4708e00a04
|
|
@ -1,8 +1,10 @@
|
|||
import { redirect } from "next/navigation";
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { SideNavProvider } from "@/components/nav/side-nav-provider";
|
||||
import { SideNav } from "@/components/nav/side-nav";
|
||||
import { ProjectsProvider } from "@/components/projects/projects-context";
|
||||
|
||||
export default async function AppLayout({ children }: { children: React.ReactNode }) {
|
||||
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.
|
||||
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 (
|
||||
<SideNavProvider>
|
||||
<div className="flex h-screen overflow-hidden">
|
||||
<SideNav userEmail={session.user.email ?? ""} role={session.user.role} />
|
||||
<main className="flex-1 overflow-auto">{children}</main>
|
||||
</div>
|
||||
<ProjectsProvider initialProjects={projects}>
|
||||
<div className="flex h-screen overflow-hidden">
|
||||
<SideNav userEmail={session.user.email ?? ""} role={session.user.role} />
|
||||
<main className="flex-1 overflow-auto">{children}</main>
|
||||
</div>
|
||||
</ProjectsProvider>
|
||||
</SideNavProvider>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,49 +1,14 @@
|
|||
import { redirect } from "next/navigation";
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getBoard } from "@/lib/board";
|
||||
import { KanbanBoard } from "@/components/board/kanban-board";
|
||||
import type { CategoryDTO } from "@/types/board";
|
||||
|
||||
export default async function HomePage() {
|
||||
const session = await auth();
|
||||
if (!session?.user) redirect("/login");
|
||||
|
||||
const categories = await prisma.category.findMany({
|
||||
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,
|
||||
})),
|
||||
})),
|
||||
}));
|
||||
const board = await getBoard({ userId: session.user.id, projectId: null });
|
||||
|
||||
return <KanbanBoard initialCategories={board} />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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} />;
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
import { ProjectsListView } from "@/components/projects/projects-list-view";
|
||||
|
||||
export default function ProjectsPage() {
|
||||
return <ProjectsListView />;
|
||||
}
|
||||
|
|
@ -72,9 +72,15 @@ const BoardContext = createContext<BoardContextValue | null>(null);
|
|||
|
||||
export function BoardProvider({
|
||||
initialCategories,
|
||||
projectId,
|
||||
children,
|
||||
}: {
|
||||
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;
|
||||
}) {
|
||||
const [categories, setCategories] = useState(initialCategories);
|
||||
|
|
@ -96,14 +102,17 @@ export function BoardProvider({
|
|||
[updateCategory]
|
||||
);
|
||||
|
||||
const addCategory = useCallback(async (name: string) => {
|
||||
try {
|
||||
const category = await createCategory(name);
|
||||
setCategories((prev) => [...prev, category]);
|
||||
} catch {
|
||||
toast.error("Couldn't create category. Try again.");
|
||||
}
|
||||
}, []);
|
||||
const addCategory = useCallback(
|
||||
async (name: string) => {
|
||||
try {
|
||||
const category = await createCategory(name, projectId);
|
||||
setCategories((prev) => [...prev, category]);
|
||||
} catch {
|
||||
toast.error("Couldn't create category. Try again.");
|
||||
}
|
||||
},
|
||||
[projectId]
|
||||
);
|
||||
|
||||
const removeCategory = useCallback(async (categoryId: string) => {
|
||||
let prevState: CategoryDTO[] = [];
|
||||
|
|
@ -123,20 +132,23 @@ export function BoardProvider({
|
|||
}
|
||||
}, []);
|
||||
|
||||
const reorderLanes = useCallback(async (orderedIds: string[]) => {
|
||||
let prevState: CategoryDTO[] = [];
|
||||
setCategories((prev) => {
|
||||
prevState = prev;
|
||||
const byId = new Map(prev.map((c) => [c.id, c]));
|
||||
return orderedIds.map((id, order) => ({ ...byId.get(id)!, order }));
|
||||
});
|
||||
try {
|
||||
await reorderCategories(orderedIds);
|
||||
} catch {
|
||||
setCategories(prevState);
|
||||
toast.error("Couldn't reorder categories. Try again.");
|
||||
}
|
||||
}, []);
|
||||
const reorderLanes = useCallback(
|
||||
async (orderedIds: string[]) => {
|
||||
let prevState: CategoryDTO[] = [];
|
||||
setCategories((prev) => {
|
||||
prevState = prev;
|
||||
const byId = new Map(prev.map((c) => [c.id, c]));
|
||||
return orderedIds.map((id, order) => ({ ...byId.get(id)!, order }));
|
||||
});
|
||||
try {
|
||||
await reorderCategories(orderedIds, projectId);
|
||||
} catch {
|
||||
setCategories(prevState);
|
||||
toast.error("Couldn't reorder categories. Try again.");
|
||||
}
|
||||
},
|
||||
[projectId]
|
||||
);
|
||||
|
||||
const addGroup = useCallback(async (categoryId: string, title: string, color: string) => {
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ import { GroupCardOverlay } from "@/components/board/group-card-overlay";
|
|||
import { EmptyState } from "@/components/board/empty-state";
|
||||
import type { CategoryDTO, GroupDTO } from "@/types/board";
|
||||
|
||||
function Board() {
|
||||
function Board({ title }: { title: string }) {
|
||||
const { categories, reorderLanes, reorderGroups, moveGroup } = useBoard();
|
||||
const [draggedGroup, setDraggedGroup] = useState<GroupDTO | null>(null);
|
||||
|
||||
|
|
@ -115,7 +115,7 @@ function Board() {
|
|||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<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 ? (
|
||||
<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 (
|
||||
<BoardProvider initialCategories={initialCategories}>
|
||||
<Board />
|
||||
<BoardProvider initialCategories={initialCategories} projectId={projectId}>
|
||||
<Board title={title} />
|
||||
</BoardProvider>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@ import { Separator } from "@/components/ui/separator";
|
|||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { useSideNav } from "@/components/nav/side-nav-provider";
|
||||
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 { ThemeToggle } from "@/components/theme/theme-toggle";
|
||||
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 }) {
|
||||
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 (
|
||||
<aside
|
||||
|
|
@ -31,8 +32,12 @@ export function SideNav({ userEmail, role }: { userEmail: string; role: Role })
|
|||
|
||||
<Separator />
|
||||
|
||||
<nav className="flex flex-1 flex-col gap-1 p-2">
|
||||
{items.map((item) => (
|
||||
<nav className="flex flex-1 flex-col gap-1 overflow-y-auto p-2">
|
||||
{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} />
|
||||
))}
|
||||
</nav>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}` : "/";
|
||||
}
|
||||
|
|
@ -4,20 +4,38 @@ import { revalidatePath } from "next/cache";
|
|||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { requireUserId } from "@/lib/auth-helpers";
|
||||
import { boardPath, categoryAccessFilter, projectAccessFilter } from "@/lib/access";
|
||||
import { CategoryNameSchema } from "@/lib/validation/category";
|
||||
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 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({
|
||||
data: { name: parsedName, order: count, userId },
|
||||
const count = await prisma.category.count({
|
||||
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: [] };
|
||||
}
|
||||
|
||||
|
|
@ -25,13 +43,15 @@ export async function renameCategory(categoryId: string, name: string): Promise<
|
|||
const userId = await requireUserId();
|
||||
const parsedName = CategoryNameSchema.parse(name);
|
||||
|
||||
const { count } = await prisma.category.updateMany({
|
||||
where: { id: categoryId, userId },
|
||||
data: { name: parsedName },
|
||||
const category = await prisma.category.findFirst({
|
||||
where: { id: categoryId, ...categoryAccessFilter(userId) },
|
||||
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 };
|
||||
|
|
@ -48,8 +68,8 @@ export async function deleteCategory(categoryId: string): Promise<DeleteCategory
|
|||
const userId = await requireUserId();
|
||||
|
||||
const category = await prisma.category.findFirst({
|
||||
where: { id: categoryId, userId },
|
||||
select: { _count: { select: { groups: true } } },
|
||||
where: { id: categoryId, ...categoryAccessFilter(userId) },
|
||||
select: { projectId: true, _count: { select: { groups: true } } },
|
||||
});
|
||||
if (!category) return { error: "Category not found." };
|
||||
if (category._count.groups > 0) {
|
||||
|
|
@ -58,24 +78,27 @@ export async function deleteCategory(categoryId: string): Promise<DeleteCategory
|
|||
|
||||
await prisma.category.delete({ where: { id: categoryId } });
|
||||
|
||||
revalidatePath("/");
|
||||
revalidatePath(boardPath(category.projectId));
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists a new lane order for all of the current user's categories.
|
||||
* `orderedIds` must contain every category id the user owns.
|
||||
* Persists a new lane order for every category in one board -- the
|
||||
* 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 owned = await prisma.category.findMany({
|
||||
where: { userId },
|
||||
where: projectId
|
||||
? { projectId, project: projectAccessFilter(userId) }
|
||||
: { userId, projectId: null },
|
||||
select: { id: true },
|
||||
});
|
||||
const ownedIds = new Set(owned.map((c) => c.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(
|
||||
|
|
@ -84,5 +107,5 @@ export async function reorderCategories(orderedIds: string[]): Promise<void> {
|
|||
)
|
||||
);
|
||||
|
||||
revalidatePath("/");
|
||||
revalidatePath(boardPath(projectId));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { revalidatePath } from "next/cache";
|
|||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { requireUserId } from "@/lib/auth-helpers";
|
||||
import { boardPath, categoryAccessFilter } from "@/lib/access";
|
||||
import { GroupColorSchema, GroupTitleSchema } from "@/lib/validation/group";
|
||||
import type { GroupDTO } from "@/types/board";
|
||||
|
||||
|
|
@ -17,8 +18,8 @@ export async function createGroup(
|
|||
const parsedColor = GroupColorSchema.parse(color);
|
||||
|
||||
const category = await prisma.category.findFirst({
|
||||
where: { id: categoryId, userId },
|
||||
select: { id: true },
|
||||
where: { id: categoryId, ...categoryAccessFilter(userId) },
|
||||
select: { id: true, projectId: true },
|
||||
});
|
||||
if (!category) throw new Error("Category not found");
|
||||
|
||||
|
|
@ -28,7 +29,7 @@ export async function createGroup(
|
|||
data: { title: parsedTitle, color: parsedColor, order: count, categoryId },
|
||||
});
|
||||
|
||||
revalidatePath("/");
|
||||
revalidatePath(boardPath(category.projectId));
|
||||
return {
|
||||
id: group.id,
|
||||
title: group.title,
|
||||
|
|
@ -50,40 +51,47 @@ export async function updateGroup(
|
|||
if (data.title !== undefined) update.title = GroupTitleSchema.parse(data.title);
|
||||
if (data.color !== undefined) update.color = GroupColorSchema.parse(data.color);
|
||||
|
||||
const { count } = await prisma.group.updateMany({
|
||||
where: { id: groupId, category: { userId } },
|
||||
data: update,
|
||||
const group = await prisma.group.findFirst({
|
||||
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.update({ where: { id: groupId }, data: update });
|
||||
|
||||
revalidatePath(boardPath(group.category.projectId));
|
||||
}
|
||||
|
||||
export async function deleteGroup(groupId: string): Promise<void> {
|
||||
const userId = await requireUserId();
|
||||
|
||||
const { count } = await prisma.group.deleteMany({
|
||||
where: { id: groupId, category: { userId } },
|
||||
const group = await prisma.group.findFirst({
|
||||
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
|
||||
* 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> {
|
||||
const userId = await requireUserId();
|
||||
|
||||
const { count } = await prisma.group.updateMany({
|
||||
where: { id: groupId, category: { userId } },
|
||||
data: { archivedAt: new Date() },
|
||||
const group = await prisma.group.findFirst({
|
||||
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.update({ where: { id: groupId }, data: { archivedAt: new Date() } });
|
||||
|
||||
revalidatePath(boardPath(group.category.projectId));
|
||||
}
|
||||
|
||||
/** Persists a new card order within a single lane. */
|
||||
|
|
@ -94,7 +102,7 @@ export async function reorderGroupsInCategory(
|
|||
const userId = await requireUserId();
|
||||
|
||||
const category = await prisma.category.findFirst({
|
||||
where: { id: categoryId, userId },
|
||||
where: { id: categoryId, ...categoryAccessFilter(userId) },
|
||||
include: { groups: { select: { id: true } } },
|
||||
});
|
||||
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
|
||||
* 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(
|
||||
groupId: string,
|
||||
|
|
@ -130,13 +140,19 @@ export async function moveGroupToCategory(
|
|||
|
||||
const [group, targetCategory] = await Promise.all([
|
||||
prisma.group.findFirst({
|
||||
where: { id: groupId, category: { userId } },
|
||||
select: { id: true, categoryId: true },
|
||||
where: { id: groupId, category: categoryAccessFilter(userId) },
|
||||
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 (!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);
|
||||
if (targetIndex === -1) {
|
||||
|
|
@ -157,5 +173,5 @@ export async function moveGroupToCategory(
|
|||
),
|
||||
]);
|
||||
|
||||
revalidatePath("/");
|
||||
revalidatePath(boardPath(targetCategory.projectId));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,17 +4,20 @@ import { revalidatePath } from "next/cache";
|
|||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { requireUserId } from "@/lib/auth-helpers";
|
||||
import { boardPath, categoryAccessFilter } from "@/lib/access";
|
||||
import { GroupNoteContentSchema } from "@/lib/validation/group";
|
||||
|
||||
export async function updateGroupNote(groupId: string, noteContent: string): Promise<void> {
|
||||
const userId = await requireUserId();
|
||||
const parsed = GroupNoteContentSchema.parse(noteContent);
|
||||
|
||||
const { count } = await prisma.group.updateMany({
|
||||
where: { id: groupId, category: { userId } },
|
||||
data: { noteContent: parsed },
|
||||
const group = await prisma.group.findFirst({
|
||||
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.update({ where: { id: groupId }, data: { noteContent: parsed } });
|
||||
|
||||
revalidatePath(boardPath(group.category.projectId));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {};
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import { revalidatePath } from "next/cache";
|
|||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { requireUserId } from "@/lib/auth-helpers";
|
||||
import { boardPath, categoryAccessFilter } from "@/lib/access";
|
||||
import { TodoDetailsSchema, TodoTitleSchema } from "@/lib/validation/todo";
|
||||
import type { TodoDTO } from "@/types/board";
|
||||
|
||||
|
|
@ -17,8 +18,8 @@ export async function createTodo(
|
|||
const parsedDetails = details ? TodoDetailsSchema.parse(details) : undefined;
|
||||
|
||||
const group = await prisma.group.findFirst({
|
||||
where: { id: groupId, category: { userId } },
|
||||
select: { id: true },
|
||||
where: { id: groupId, category: categoryAccessFilter(userId) },
|
||||
select: { id: true, category: { select: { projectId: true } } },
|
||||
});
|
||||
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 },
|
||||
});
|
||||
|
||||
revalidatePath("/");
|
||||
revalidatePath(boardPath(group.category.projectId));
|
||||
return {
|
||||
id: todo.id,
|
||||
title: todo.title,
|
||||
|
|
@ -51,36 +52,43 @@ export async function updateTodo(
|
|||
update.details = data.details ? TodoDetailsSchema.parse(data.details) : null;
|
||||
}
|
||||
|
||||
const { count } = await prisma.todo.updateMany({
|
||||
where: { id: todoId, group: { category: { userId } } },
|
||||
data: update,
|
||||
const todo = await prisma.todo.findFirst({
|
||||
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.update({ where: { id: todoId }, data: update });
|
||||
|
||||
revalidatePath(boardPath(todo.group.category.projectId));
|
||||
}
|
||||
|
||||
export async function toggleTodo(todoId: string, completed: boolean): Promise<void> {
|
||||
const userId = await requireUserId();
|
||||
|
||||
const { count } = await prisma.todo.updateMany({
|
||||
where: { id: todoId, group: { category: { userId } } },
|
||||
data: { completed },
|
||||
const todo = await prisma.todo.findFirst({
|
||||
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.update({ where: { id: todoId }, data: { completed } });
|
||||
|
||||
revalidatePath(boardPath(todo.group.category.projectId));
|
||||
}
|
||||
|
||||
export async function deleteTodo(todoId: string): Promise<void> {
|
||||
const userId = await requireUserId();
|
||||
|
||||
const { count } = await prisma.todo.deleteMany({
|
||||
where: { id: todoId, group: { category: { userId } } },
|
||||
const todo = await prisma.todo.findFirst({
|
||||
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. */
|
||||
|
|
@ -88,8 +96,11 @@ export async function reorderTodos(groupId: string, orderedTodoIds: string[]): P
|
|||
const userId = await requireUserId();
|
||||
|
||||
const group = await prisma.group.findFirst({
|
||||
where: { id: groupId, category: { userId } },
|
||||
include: { todos: { select: { id: true } } },
|
||||
where: { id: groupId, category: categoryAccessFilter(userId) },
|
||||
include: {
|
||||
todos: { select: { id: true } },
|
||||
category: { select: { projectId: true } },
|
||||
},
|
||||
});
|
||||
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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})),
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
import { z } from "zod";
|
||||
|
||||
export const ProjectTitleSchema = z.string().trim().min(1, "Title is required").max(60);
|
||||
|
|
@ -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)
|
||||
);
|
||||
|
|
@ -44,6 +44,30 @@ model User {
|
|||
updatedAt DateTime @updatedAt
|
||||
|
||||
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
|
||||
|
|
@ -56,18 +80,26 @@ model SiteSettings {
|
|||
}
|
||||
|
||||
model Category {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
order Int
|
||||
userId String
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
order Int
|
||||
// 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())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
groups Group[]
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
project Project? @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
groups Group[]
|
||||
|
||||
@@index([userId, order])
|
||||
@@index([projectId, order])
|
||||
}
|
||||
|
||||
model Group {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,4 @@
|
|||
export interface ProjectDTO {
|
||||
id: string;
|
||||
title: string;
|
||||
}
|
||||
Loading…
Reference in New Issue