import Link from "next/link"; import { notFound } from "next/navigation"; import { and, desc, eq, inArray, isNull } from "drizzle-orm"; import { auth } from "@/auth"; import { db } from "@/lib/db/client"; import { groups, memories, projects, projectShares, users, } from "@/lib/db/schema"; import { getProjectAccess, getUserGroupNames, readableProjectIds, } from "@/lib/access"; import { addProjectShareAction, removeProjectShareAction, updateProjectShareAction, } from "@/lib/share-actions"; import { Container, PageHeader } from "@/app/_components/ui/container"; import { Card, CardBody, CardHeader } from "@/app/_components/ui/card"; import { Badge } from "@/app/_components/ui/badge"; import { Button } from "@/app/_components/ui/button"; import { Input, Label } from "@/app/_components/ui/input"; import { EmptyState } from "@/app/_components/ui/empty-state"; export const dynamic = "force-dynamic"; /** * Project detail page. * * Three personas converge here: * - Owner viewing their own project: full memory list + share- * management UI. * - Member of a group with rw access: same memory list, can edit * memories, but cannot edit shares. * - Member with ro access: memory list rendered read-only-ish; no * "New memory" button. * * Authorization is centralised in `lib/access.ts` so this page only * has to ask "what's my access level" once and branch on the answer. */ export default async function ProjectDetailPage({ params, }: { params: Promise<{ key: string }>; }) { const session = await auth(); const userId = session!.user.id; const { key: rawKey } = await params; const key = decodeURIComponent(rawKey); const groupNames = await getUserGroupNames(userId); // Resolve the project. Prefer an owned project; otherwise look for a // shared project with this key the user can read. Mirrors the // MCP-side project.identify priority. const ownedRow = await db .select() .from(projects) .where(and(eq(projects.userId, userId), eq(projects.key, key))) .limit(1); let project = ownedRow[0]; if (!project) { if (groupNames.length === 0) notFound(); const readableIds = await readableProjectIds(userId, groupNames); if (readableIds.length === 0) notFound(); const sharedRow = await db .select() .from(projects) .where(and(eq(projects.key, key), inArray(projects.id, readableIds))) .limit(1); if (!sharedRow[0]) notFound(); project = sharedRow[0]; } const access = await getProjectAccess(userId, groupNames, project.id); if (access === null) notFound(); const isOwner = access === "owner"; const canWrite = access === "owner" || access === "rw"; // Owner display name for the page header. When the viewer IS the // owner we just say "Owned by you"; otherwise look up the owner. let ownerDisplayName: string | null = null; if (!isOwner) { const ownerRow = await db .select({ name: users.name, email: users.email }) .from(users) .where(eq(users.id, project.userId)) .limit(1); ownerDisplayName = ownerRow[0]?.name ?? ownerRow[0]?.email ?? "another user"; } // All shares on this project, regardless of viewer's group memberships // — the owner needs to see everything; non-owners see the same list // for situational awareness. const shareRows = await db .select({ groupId: groups.id, groupName: groups.name, access: projectShares.access, grantedAt: projectShares.grantedAt, }) .from(projectShares) .innerJoin(groups, eq(groups.id, projectShares.groupId)) .where(eq(projectShares.projectId, project.id)) .orderBy(groups.name); // Memories: visible to owner + members alike — anyone with read // access on the project sees every memory under it. The query is // unchanged from the pre-sharing version; project_id is the gate. const mem = await db .select({ id: memories.id, scope: memories.scope, content: memories.content, tags: memories.tags, createdAt: memories.createdAt, }) .from(memories) .where(and(eq(memories.projectId, project.id), isNull(memories.deletedAt))) .orderBy(desc(memories.createdAt)) .limit(100); // Groups the viewer is a member of — drives the share-add datalist // for owners (only show groups they could plausibly invite). Returns // an empty list when the user has no group memberships so the // datalist is simply absent rather than emitting a broken IN (). const myGroups = groupNames.length > 0 ? await db .select({ id: groups.id, name: groups.name, displayName: groups.displayName, }) .from(groups) .where(inArray(groups.name, groupNames)) .limit(50) : []; return ( {project.key} · {mem.length} memor{mem.length === 1 ? "y" : "ies"} · {isOwner ? ( Owned by you ) : ( Owned by {ownerDisplayName} )} {shareRows.length > 0 ? ( <> · Shared with {shareRows.length} group {shareRows.length === 1 ? "" : "s"} ) : null} {!isOwner ? ( <> · {access === "rw" ? "read + write" : "read only"} ) : null} } actions={ <> {canWrite ? ( ) : null} } /> {shareRows.length > 0 || isOwner ? ( Sharing {shareRows.length === 0 ? "No groups have access" : `${shareRows.length} group${shareRows.length === 1 ? "" : "s"}`} {shareRows.length === 0 && !isOwner ? (

Only the owner has access.

) : null} {shareRows.length > 0 ? (
    {shareRows.map((s) => (
  • {s.groupName} {s.access} since {new Date(s.grantedAt).toLocaleDateString()} {isOwner ? (
    ) : null}
  • ))}
) : null} {isOwner ? (
{myGroups.length > 0 ? ( {myGroups.map((g) => ( ))} ) : null}
) : null}
) : null} {mem.length === 0 ? ( ) : null } /> ) : (
    {mem.map((m) => (
  • {m.scope} {shareRows.length > 0 ? ( s.groupName).join(", ")}`} > Shared ) : null} {new Date(m.createdAt).toLocaleString()}

    {m.content}

    {m.tags.length ? (
    {m.tags.map((t) => ( {t} ))}
    ) : null}
  • ))}
)}
); }