Merge: Phase 4c+d+e project sharing + co-edit + awareness (Agent B)
# Conflicts: # apps/web/lib/db/schema.ts # apps/web/lib/mcp/context.ts
This commit is contained in:
@@ -1,8 +1,9 @@
|
||||
import Link from "next/link";
|
||||
import { and, desc, eq, isNull, sql, count } from "drizzle-orm";
|
||||
import { and, desc, eq, inArray, isNull, or, sql, count } from "drizzle-orm";
|
||||
import { auth } from "@/auth";
|
||||
import { db } from "@/lib/db/client";
|
||||
import { memories, projects } from "@/lib/db/schema";
|
||||
import { memories, projects, projectShares } from "@/lib/db/schema";
|
||||
import { getUserGroupNames, readableProjectIds } from "@/lib/access";
|
||||
import { Container, PageHeader } from "@/app/_components/ui/container";
|
||||
import { Card, CardBody, CardHeader } from "@/app/_components/ui/card";
|
||||
import { Badge } from "@/app/_components/ui/badge";
|
||||
@@ -14,14 +15,27 @@ export const dynamic = "force-dynamic";
|
||||
export default async function DashboardPage() {
|
||||
const session = await auth();
|
||||
const userId = session!.user.id;
|
||||
const groupNames = await getUserGroupNames(userId);
|
||||
|
||||
// Visibility widening — recent + counts include memories under
|
||||
// projects shared with the user's groups.
|
||||
const accessibleIds = await readableProjectIds(userId, groupNames);
|
||||
const visibility =
|
||||
accessibleIds.length > 0
|
||||
? or(eq(memories.userId, userId), inArray(memories.projectId, accessibleIds))
|
||||
: eq(memories.userId, userId);
|
||||
|
||||
// Dashboard's "Projects" card stays owned-only — the list of projects
|
||||
// you actively own. Shared projects show up via the memory list and
|
||||
// the per-project page; surfacing them here would make the panel
|
||||
// confusing about who owns what.
|
||||
const [counts, recent, topProjects] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
total: count(memories.id),
|
||||
})
|
||||
.from(memories)
|
||||
.where(and(eq(memories.userId, userId), isNull(memories.deletedAt))),
|
||||
.where(and(visibility!, isNull(memories.deletedAt))),
|
||||
db
|
||||
.select({
|
||||
id: memories.id,
|
||||
@@ -29,11 +43,12 @@ export default async function DashboardPage() {
|
||||
scope: memories.scope,
|
||||
tags: memories.tags,
|
||||
createdAt: memories.createdAt,
|
||||
projectId: memories.projectId,
|
||||
projectKey: projects.key,
|
||||
})
|
||||
.from(memories)
|
||||
.leftJoin(projects, eq(memories.projectId, projects.id))
|
||||
.where(and(eq(memories.userId, userId), isNull(memories.deletedAt)))
|
||||
.where(and(visibility!, isNull(memories.deletedAt)))
|
||||
.orderBy(desc(memories.createdAt))
|
||||
.limit(5),
|
||||
db
|
||||
@@ -54,6 +69,22 @@ export default async function DashboardPage() {
|
||||
.limit(4),
|
||||
]);
|
||||
|
||||
// Annotate "Shared" chips on the recent panel.
|
||||
const projectIds = recent
|
||||
.map((r) => r.projectId)
|
||||
.filter((p): p is string => p !== null);
|
||||
const sharedProjects =
|
||||
projectIds.length > 0
|
||||
? new Set(
|
||||
(
|
||||
await db
|
||||
.selectDistinct({ projectId: projectShares.projectId })
|
||||
.from(projectShares)
|
||||
.where(inArray(projectShares.projectId, projectIds))
|
||||
).map((r) => r.projectId),
|
||||
)
|
||||
: new Set<string>();
|
||||
|
||||
const memoryTotal = counts[0]?.total ?? 0;
|
||||
|
||||
return (
|
||||
@@ -94,6 +125,11 @@ export default async function DashboardPage() {
|
||||
<Badge tone={m.scope === "user" ? "accent" : "neutral"}>
|
||||
{m.scope}
|
||||
</Badge>
|
||||
{m.projectId && sharedProjects.has(m.projectId) ? (
|
||||
<Badge tone="accent" title="Shared with one or more groups">
|
||||
Shared
|
||||
</Badge>
|
||||
) : null}
|
||||
{m.projectKey ? <span>· {m.projectKey}</span> : null}
|
||||
<span className="ml-auto">
|
||||
{new Date(m.createdAt).toLocaleDateString()}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { and, desc, eq, isNull } from "drizzle-orm";
|
||||
import { and, desc, eq, inArray, isNull, or } from "drizzle-orm";
|
||||
import { auth } from "@/auth";
|
||||
import { db } from "@/lib/db/client";
|
||||
import { memories, projects } from "@/lib/db/schema";
|
||||
import { memories, projects, projectShares, groups, users } from "@/lib/db/schema";
|
||||
import { updateMemoryAction, deleteMemoryAction } from "@/lib/memory-actions";
|
||||
import { getProjectAccess, getUserGroupNames, readableProjectIds } from "@/lib/access";
|
||||
import { Container, PageHeader } from "@/app/_components/ui/container";
|
||||
import { Card, CardBody, CardHeader } from "@/app/_components/ui/card";
|
||||
import { Input, Textarea, Label } from "@/app/_components/ui/input";
|
||||
@@ -22,31 +23,81 @@ export default async function MemoryDetailPage({
|
||||
}) {
|
||||
const session = await auth();
|
||||
const userId = session!.user.id;
|
||||
const groupNames = await getUserGroupNames(userId);
|
||||
const { id } = await params;
|
||||
const { edit } = await searchParams;
|
||||
|
||||
// Widen the visibility predicate: a user can see a memory they own,
|
||||
// or any memory whose project is shared with them. Project_id filter
|
||||
// uses the precomputed accessible-id list for parity with the search
|
||||
// / list paths.
|
||||
const accessibleProjectIds = await readableProjectIds(userId, groupNames);
|
||||
const visibility =
|
||||
accessibleProjectIds.length > 0
|
||||
? or(
|
||||
eq(memories.userId, userId),
|
||||
inArray(memories.projectId, accessibleProjectIds),
|
||||
)
|
||||
: eq(memories.userId, userId);
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
id: memories.id,
|
||||
scope: memories.scope,
|
||||
content: memories.content,
|
||||
tags: memories.tags,
|
||||
version: memories.version,
|
||||
lastEditedBy: memories.lastEditedBy,
|
||||
createdAt: memories.createdAt,
|
||||
updatedAt: memories.updatedAt,
|
||||
projectKey: projects.key,
|
||||
projectId: memories.projectId,
|
||||
ownerUserId: memories.userId,
|
||||
})
|
||||
.from(memories)
|
||||
.leftJoin(projects, eq(memories.projectId, projects.id))
|
||||
.where(
|
||||
and(eq(memories.id, id), eq(memories.userId, userId), isNull(memories.deletedAt)),
|
||||
)
|
||||
.where(and(eq(memories.id, id), isNull(memories.deletedAt), visibility!))
|
||||
.limit(1);
|
||||
|
||||
const m = rows[0];
|
||||
if (!m) notFound();
|
||||
|
||||
const isEditing = edit === "1";
|
||||
// Determine the viewer's write permission. user-scope memories =
|
||||
// owner-only; project-scope = canWriteProject. Used to gate the
|
||||
// Edit / Delete affordances.
|
||||
let canWrite: boolean;
|
||||
if (m.scope === "user") {
|
||||
canWrite = m.ownerUserId === userId;
|
||||
} else if (m.projectId) {
|
||||
const access = await getProjectAccess(userId, groupNames, m.projectId);
|
||||
canWrite = access === "owner" || access === "rw";
|
||||
} else {
|
||||
canWrite = false;
|
||||
}
|
||||
|
||||
const isEditing = edit === "1" && canWrite;
|
||||
|
||||
// Shares on this project drive the "Shared" chip plus an editor-name
|
||||
// lookup (we want to display who last edited, even if they're another
|
||||
// member of the same group).
|
||||
const shareRows = m.projectId
|
||||
? await db
|
||||
.select({ groupName: groups.name })
|
||||
.from(projectShares)
|
||||
.innerJoin(groups, eq(groups.id, projectShares.groupId))
|
||||
.where(eq(projectShares.projectId, m.projectId))
|
||||
: [];
|
||||
|
||||
const editorRow = m.lastEditedBy
|
||||
? await db
|
||||
.select({ name: users.name, email: users.email })
|
||||
.from(users)
|
||||
.where(eq(users.id, m.lastEditedBy))
|
||||
.limit(1)
|
||||
: [];
|
||||
const editorLabel = editorRow[0]
|
||||
? editorRow[0].name ?? editorRow[0].email ?? "unknown"
|
||||
: null;
|
||||
|
||||
const projectList = isEditing
|
||||
? await db
|
||||
@@ -67,7 +118,7 @@ export default async function MemoryDetailPage({
|
||||
<Link href="/memories" className="no-underline">
|
||||
<Button type="button" variant="secondary">Back</Button>
|
||||
</Link>
|
||||
{!isEditing ? (
|
||||
{!isEditing && canWrite ? (
|
||||
<Link href={`/memories/${m.id}?edit=1`} className="no-underline">
|
||||
<Button>Edit</Button>
|
||||
</Link>
|
||||
@@ -77,19 +128,33 @@ export default async function MemoryDetailPage({
|
||||
/>
|
||||
|
||||
<Card className="mb-4">
|
||||
<CardHeader className="flex items-center gap-2 text-xs text-fg-muted">
|
||||
<CardHeader className="flex items-center gap-2 text-xs text-fg-muted flex-wrap">
|
||||
<Badge tone={m.scope === "user" ? "accent" : "neutral"}>{m.scope}</Badge>
|
||||
{m.projectKey ? <span className="font-mono">{m.projectKey}</span> : null}
|
||||
{shareRows.length > 0 ? (
|
||||
<Badge
|
||||
tone="accent"
|
||||
title={`Shared with ${shareRows.map((s) => s.groupName).join(", ")}`}
|
||||
>
|
||||
Shared
|
||||
</Badge>
|
||||
) : null}
|
||||
<span>· Created {new Date(m.createdAt).toLocaleString()}</span>
|
||||
{m.updatedAt.getTime() !== m.createdAt.getTime() ? (
|
||||
<span>· Updated {new Date(m.updatedAt).toLocaleString()}</span>
|
||||
) : null}
|
||||
{editorLabel && m.lastEditedBy !== m.ownerUserId ? (
|
||||
<span className="text-fg-subtle">
|
||||
· Last edited by {editorLabel}
|
||||
</span>
|
||||
) : null}
|
||||
</CardHeader>
|
||||
|
||||
{isEditing ? (
|
||||
<CardBody>
|
||||
<form action={updateMemoryAction} className="space-y-4">
|
||||
<input type="hidden" name="id" value={m.id} />
|
||||
<input type="hidden" name="version" value={m.version} />
|
||||
<div>
|
||||
<Label htmlFor="scope">Scope</Label>
|
||||
<select
|
||||
@@ -168,7 +233,7 @@ export default async function MemoryDetailPage({
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{!isEditing ? (
|
||||
{!isEditing && canWrite ? (
|
||||
<form action={deleteMemoryAction} className="flex justify-end">
|
||||
<input type="hidden" name="id" value={m.id} />
|
||||
<Button type="submit" variant="danger" size="sm">
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import Link from "next/link";
|
||||
import { and, desc, eq, isNull, inArray, sql } from "drizzle-orm";
|
||||
import { and, desc, eq, isNull, inArray, or, sql } from "drizzle-orm";
|
||||
import { auth } from "@/auth";
|
||||
import { db } from "@/lib/db/client";
|
||||
import { memories, projects } from "@/lib/db/schema";
|
||||
import { memories, projects, projectShares } from "@/lib/db/schema";
|
||||
import { searchMemories } from "@/lib/memories";
|
||||
import { getUserGroupNames, readableProjectIds } from "@/lib/access";
|
||||
import { Container, PageHeader } from "@/app/_components/ui/container";
|
||||
import { Card, CardBody } from "@/app/_components/ui/card";
|
||||
import { Badge } from "@/app/_components/ui/badge";
|
||||
@@ -18,15 +19,16 @@ type Scope = "project" | "user";
|
||||
interface MemoryRow {
|
||||
id: string;
|
||||
scope: "project" | "user";
|
||||
projectId: string | null;
|
||||
projectKey: string | null;
|
||||
content: string;
|
||||
tags: string[];
|
||||
createdAt: Date;
|
||||
rank?: { rrfScore: number; vectorRank: number | null; ftsRank: number | null; tagRank: number | null };
|
||||
shared?: boolean;
|
||||
}
|
||||
|
||||
async function fetchMemoriesByIds(
|
||||
userId: string,
|
||||
ids: string[],
|
||||
): Promise<Map<string, MemoryRow>> {
|
||||
if (ids.length === 0) return new Map();
|
||||
@@ -37,22 +39,39 @@ async function fetchMemoriesByIds(
|
||||
content: memories.content,
|
||||
tags: memories.tags,
|
||||
createdAt: memories.createdAt,
|
||||
projectId: memories.projectId,
|
||||
projectKey: projects.key,
|
||||
})
|
||||
.from(memories)
|
||||
.leftJoin(projects, eq(memories.projectId, projects.id))
|
||||
.where(and(eq(memories.userId, userId), inArray(memories.id, ids), isNull(memories.deletedAt)));
|
||||
.where(and(inArray(memories.id, ids), isNull(memories.deletedAt)));
|
||||
return new Map(rows.map((r) => [r.id, r as MemoryRow]));
|
||||
}
|
||||
|
||||
async function listRecent(userId: string, scope?: Scope, project?: string): Promise<MemoryRow[]> {
|
||||
const filters = [eq(memories.userId, userId), isNull(memories.deletedAt)];
|
||||
async function listRecent(
|
||||
userId: string,
|
||||
groupNames: string[],
|
||||
scope?: Scope,
|
||||
project?: string,
|
||||
): Promise<MemoryRow[]> {
|
||||
// Visibility: own rows OR rows in an accessible project.
|
||||
const accessibleIds = await readableProjectIds(userId, groupNames);
|
||||
const visibility =
|
||||
accessibleIds.length > 0
|
||||
? or(eq(memories.userId, userId), inArray(memories.projectId, accessibleIds))
|
||||
: eq(memories.userId, userId);
|
||||
const filters = [visibility!, isNull(memories.deletedAt)];
|
||||
if (scope) filters.push(eq(memories.scope, scope));
|
||||
if (project) {
|
||||
// Project filter — match the project key against any project the
|
||||
// user can read (owned or shared). When the key matches none of
|
||||
// those, return empty.
|
||||
filters.push(
|
||||
sql`${memories.projectId} = (
|
||||
sql`${memories.projectId} IN (
|
||||
SELECT id FROM ${projects}
|
||||
WHERE ${projects.userId} = ${userId} AND ${projects.key} = ${project}
|
||||
WHERE ${projects.key} = ${project}
|
||||
AND (${projects.userId} = ${userId}
|
||||
OR ${projects.id} = ANY(${accessibleIds}::uuid[]))
|
||||
)`,
|
||||
);
|
||||
}
|
||||
@@ -63,6 +82,7 @@ async function listRecent(userId: string, scope?: Scope, project?: string): Prom
|
||||
content: memories.content,
|
||||
tags: memories.tags,
|
||||
createdAt: memories.createdAt,
|
||||
projectId: memories.projectId,
|
||||
projectKey: projects.key,
|
||||
})
|
||||
.from(memories)
|
||||
@@ -73,6 +93,20 @@ async function listRecent(userId: string, scope?: Scope, project?: string): Prom
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lookup which projects in `projectIds` have any share rows. Used so
|
||||
* we can show a "Shared" chip per memory card. One query covers every
|
||||
* row on the page; per-row inspection would be N+1 here.
|
||||
*/
|
||||
async function sharedProjectSet(projectIds: string[]): Promise<Set<string>> {
|
||||
if (projectIds.length === 0) return new Set();
|
||||
const rows = await db
|
||||
.selectDistinct({ projectId: projectShares.projectId })
|
||||
.from(projectShares)
|
||||
.where(inArray(projectShares.projectId, projectIds));
|
||||
return new Set(rows.map((r) => r.projectId));
|
||||
}
|
||||
|
||||
export default async function MemoriesPage({
|
||||
searchParams,
|
||||
}: {
|
||||
@@ -80,6 +114,7 @@ export default async function MemoriesPage({
|
||||
}) {
|
||||
const session = await auth();
|
||||
const userId = session!.user.id;
|
||||
const groupNames = await getUserGroupNames(userId);
|
||||
const params = await searchParams;
|
||||
const q = params.q?.trim() || undefined;
|
||||
const scope = params.scope === "user" || params.scope === "project" ? params.scope : undefined;
|
||||
@@ -89,18 +124,35 @@ export default async function MemoriesPage({
|
||||
let debug: { vec: number; fts: number; tag: number } | null = null;
|
||||
|
||||
if (q) {
|
||||
const result = await searchMemories(userId, q, { scope, projectKey: project }, 30);
|
||||
const result = await searchMemories(
|
||||
userId,
|
||||
q,
|
||||
{ scope, projectKey: project, groupNames },
|
||||
30,
|
||||
);
|
||||
const ids = result.hits.map((h) => h.id);
|
||||
const byId = await fetchMemoriesByIds(userId, ids);
|
||||
const byId = await fetchMemoriesByIds(ids);
|
||||
rows = result.hits.flatMap((h) => {
|
||||
const r = byId.get(h.id);
|
||||
return r ? [{ ...r, rank: h.rank }] : [];
|
||||
});
|
||||
debug = result.debug;
|
||||
} else {
|
||||
rows = await listRecent(userId, scope, project);
|
||||
rows = await listRecent(userId, groupNames, scope, project);
|
||||
}
|
||||
|
||||
// Annotate which rows belong to projects that have any active share.
|
||||
// Done in a single query so the listing stays O(1) DB calls regardless
|
||||
// of page size.
|
||||
const projectIds = rows
|
||||
.map((r) => r.projectId)
|
||||
.filter((p): p is string => p !== null);
|
||||
const sharedProjects = await sharedProjectSet(projectIds);
|
||||
rows = rows.map((r) => ({
|
||||
...r,
|
||||
shared: r.projectId ? sharedProjects.has(r.projectId) : false,
|
||||
}));
|
||||
|
||||
return (
|
||||
<Container className="pt-6">
|
||||
<PageHeader
|
||||
@@ -168,6 +220,11 @@ export default async function MemoriesPage({
|
||||
<Badge tone={m.scope === "user" ? "accent" : "neutral"}>
|
||||
{m.scope}
|
||||
</Badge>
|
||||
{m.shared ? (
|
||||
<Badge tone="accent" title="Shared with one or more groups">
|
||||
Shared
|
||||
</Badge>
|
||||
) : null}
|
||||
{m.projectKey ? (
|
||||
<span className="font-mono">· {m.projectKey}</span>
|
||||
) : null}
|
||||
|
||||
@@ -1,17 +1,48 @@
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { and, desc, eq, isNull } from "drizzle-orm";
|
||||
import { and, desc, eq, inArray, isNull } from "drizzle-orm";
|
||||
import { auth } from "@/auth";
|
||||
import { db } from "@/lib/db/client";
|
||||
import { memories, projects } from "@/lib/db/schema";
|
||||
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 } from "@/app/_components/ui/card";
|
||||
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,
|
||||
}: {
|
||||
@@ -21,15 +52,66 @@ export default async function ProjectDetailPage({
|
||||
const userId = session!.user.id;
|
||||
const { key: rawKey } = await params;
|
||||
const key = decodeURIComponent(rawKey);
|
||||
const groupNames = await getUserGroupNames(userId);
|
||||
|
||||
const projectRow = await db
|
||||
// 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);
|
||||
const project = projectRow[0];
|
||||
if (!project) notFound();
|
||||
|
||||
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,
|
||||
@@ -39,53 +121,211 @@ export default async function ProjectDetailPage({
|
||||
createdAt: memories.createdAt,
|
||||
})
|
||||
.from(memories)
|
||||
.where(
|
||||
and(
|
||||
eq(memories.userId, userId),
|
||||
eq(memories.projectId, project.id),
|
||||
isNull(memories.deletedAt),
|
||||
),
|
||||
)
|
||||
.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 (
|
||||
<Container className="pt-6">
|
||||
<PageHeader
|
||||
title={project.displayName ?? project.key}
|
||||
description={
|
||||
<>
|
||||
<span className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-mono">{project.key}</span>
|
||||
{" · "}
|
||||
{mem.length} memor{mem.length === 1 ? "y" : "ies"}
|
||||
</>
|
||||
<span>·</span>
|
||||
<span>
|
||||
{mem.length} memor{mem.length === 1 ? "y" : "ies"}
|
||||
</span>
|
||||
<span>·</span>
|
||||
{isOwner ? (
|
||||
<Badge tone="success">Owned by you</Badge>
|
||||
) : (
|
||||
<span className="text-fg-subtle">Owned by {ownerDisplayName}</span>
|
||||
)}
|
||||
{shareRows.length > 0 ? (
|
||||
<>
|
||||
<span>·</span>
|
||||
<Badge tone="accent">
|
||||
Shared with {shareRows.length} group
|
||||
{shareRows.length === 1 ? "" : "s"}
|
||||
</Badge>
|
||||
</>
|
||||
) : null}
|
||||
{!isOwner ? (
|
||||
<>
|
||||
<span>·</span>
|
||||
<Badge tone={access === "rw" ? "success" : "neutral"}>
|
||||
{access === "rw" ? "read + write" : "read only"}
|
||||
</Badge>
|
||||
</>
|
||||
) : null}
|
||||
</span>
|
||||
}
|
||||
actions={
|
||||
<>
|
||||
<Link href="/projects" className="no-underline">
|
||||
<Button type="button" variant="secondary">All projects</Button>
|
||||
</Link>
|
||||
<Link
|
||||
href={`/memories/new?project=${encodeURIComponent(project.key)}`}
|
||||
className="no-underline"
|
||||
>
|
||||
<Button>New in this project</Button>
|
||||
</Link>
|
||||
{canWrite ? (
|
||||
<Link
|
||||
href={`/memories/new?project=${encodeURIComponent(project.key)}`}
|
||||
className="no-underline"
|
||||
>
|
||||
<Button>New in this project</Button>
|
||||
</Link>
|
||||
) : null}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{shareRows.length > 0 || isOwner ? (
|
||||
<Card className="mb-6">
|
||||
<CardHeader className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">Sharing</span>
|
||||
<span className="text-xs text-fg-subtle">
|
||||
{shareRows.length === 0
|
||||
? "No groups have access"
|
||||
: `${shareRows.length} group${shareRows.length === 1 ? "" : "s"}`}
|
||||
</span>
|
||||
</CardHeader>
|
||||
<CardBody className="space-y-3">
|
||||
{shareRows.length === 0 && !isOwner ? (
|
||||
<p className="text-sm text-fg-subtle">Only the owner has access.</p>
|
||||
) : null}
|
||||
|
||||
{shareRows.length > 0 ? (
|
||||
<ul className="divide-y divide-border">
|
||||
{shareRows.map((s) => (
|
||||
<li
|
||||
key={s.groupId}
|
||||
className="flex items-center gap-3 py-2 text-sm"
|
||||
>
|
||||
<Badge tone="accent">{s.groupName}</Badge>
|
||||
<Badge tone={s.access === "rw" ? "success" : "neutral"}>
|
||||
{s.access}
|
||||
</Badge>
|
||||
<span className="text-xs text-fg-subtle">
|
||||
since {new Date(s.grantedAt).toLocaleDateString()}
|
||||
</span>
|
||||
{isOwner ? (
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<form action={updateProjectShareAction}>
|
||||
<input type="hidden" name="projectKey" value={project.key} />
|
||||
<input type="hidden" name="groupId" value={s.groupId} />
|
||||
<input
|
||||
type="hidden"
|
||||
name="access"
|
||||
value={s.access === "rw" ? "ro" : "rw"}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
title={
|
||||
s.access === "rw"
|
||||
? "Downgrade to read-only"
|
||||
: "Promote to read-write"
|
||||
}
|
||||
>
|
||||
{s.access === "rw" ? "→ ro" : "→ rw"}
|
||||
</Button>
|
||||
</form>
|
||||
<form action={removeProjectShareAction}>
|
||||
<input type="hidden" name="projectKey" value={project.key} />
|
||||
<input type="hidden" name="groupId" value={s.groupId} />
|
||||
<Button type="submit" variant="danger" size="sm">
|
||||
Remove
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
|
||||
{isOwner ? (
|
||||
<form
|
||||
action={addProjectShareAction}
|
||||
className="flex flex-wrap items-end gap-2 pt-2 border-t border-border"
|
||||
>
|
||||
<input type="hidden" name="projectKey" value={project.key} />
|
||||
<div className="flex-1 min-w-[200px]">
|
||||
<Label htmlFor="groupName" hint="must be a group you're a member of">
|
||||
Group name
|
||||
</Label>
|
||||
<Input
|
||||
id="groupName"
|
||||
name="groupName"
|
||||
list="my-group-list"
|
||||
placeholder="engineering"
|
||||
required
|
||||
className="mt-1"
|
||||
/>
|
||||
{myGroups.length > 0 ? (
|
||||
<datalist id="my-group-list">
|
||||
{myGroups.map((g) => (
|
||||
<option key={g.id} value={g.name}>
|
||||
{g.displayName ?? g.name}
|
||||
</option>
|
||||
))}
|
||||
</datalist>
|
||||
) : null}
|
||||
</div>
|
||||
<div>
|
||||
<Label>Access</Label>
|
||||
<div className="mt-1 flex items-center gap-3 h-9">
|
||||
<label className="text-sm flex items-center gap-1">
|
||||
<input type="radio" name="access" value="ro" defaultChecked />
|
||||
ro
|
||||
</label>
|
||||
<label className="text-sm flex items-center gap-1">
|
||||
<input type="radio" name="access" value="rw" />
|
||||
rw
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<Button type="submit">Add share</Button>
|
||||
</form>
|
||||
) : null}
|
||||
</CardBody>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{mem.length === 0 ? (
|
||||
<EmptyState
|
||||
title="No memories in this project yet"
|
||||
description="Use the MCP from a Claude Code session, or create one here."
|
||||
description={
|
||||
canWrite
|
||||
? "Use the MCP from a Claude Code session, or create one here."
|
||||
: "Members with write access can add memories from the MCP or the Web UI."
|
||||
}
|
||||
action={
|
||||
<Link
|
||||
href={`/memories/new?project=${encodeURIComponent(project.key)}`}
|
||||
className="no-underline"
|
||||
>
|
||||
<Button>Create the first one</Button>
|
||||
</Link>
|
||||
canWrite ? (
|
||||
<Link
|
||||
href={`/memories/new?project=${encodeURIComponent(project.key)}`}
|
||||
className="no-underline"
|
||||
>
|
||||
<Button>Create the first one</Button>
|
||||
</Link>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
@@ -99,6 +339,14 @@ export default async function ProjectDetailPage({
|
||||
<Badge tone={m.scope === "user" ? "accent" : "neutral"}>
|
||||
{m.scope}
|
||||
</Badge>
|
||||
{shareRows.length > 0 ? (
|
||||
<Badge
|
||||
tone="accent"
|
||||
title={`Shared with ${shareRows.map((s) => s.groupName).join(", ")}`}
|
||||
>
|
||||
Shared
|
||||
</Badge>
|
||||
) : null}
|
||||
<span>{new Date(m.createdAt).toLocaleString()}</span>
|
||||
</div>
|
||||
<p className="text-sm text-fg line-clamp-3">{m.content}</p>
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { and, eq, isNull } from "drizzle-orm";
|
||||
import { and, eq, inArray, isNull, or } from "drizzle-orm";
|
||||
import { auth } from "@/auth";
|
||||
import { db } from "@/lib/db/client";
|
||||
import { snippets, projects } from "@/lib/db/schema";
|
||||
import { snippets, projects, users } from "@/lib/db/schema";
|
||||
import { updateSnippetAction, deleteSnippetAction } from "@/lib/snippet-actions";
|
||||
import { getSnippet, type SnippetWithProjectKey } from "@/lib/snippets";
|
||||
import { getProjectAccess, getUserGroupNames, readableProjectIds } from "@/lib/access";
|
||||
import { Container, PageHeader } from "@/app/_components/ui/container";
|
||||
import { Card, CardBody, CardHeader } from "@/app/_components/ui/card";
|
||||
import { Input, Textarea, Label } from "@/app/_components/ui/input";
|
||||
@@ -23,8 +24,25 @@ interface SiblingHit {
|
||||
* When a snippet name exists in more than one scope (e.g. a user-scope
|
||||
* default plus one or more project-scope variants), we need to either
|
||||
* disambiguate by query string or, if no hint is given, show a picker.
|
||||
*
|
||||
* Visibility widening: with sharing, the user may also see project-
|
||||
* scope snippets under shared projects. Match rows that the viewer can
|
||||
* read (own user-scope rows, or project-scope rows in an accessible
|
||||
* project).
|
||||
*/
|
||||
async function findAllMatches(userId: string, name: string): Promise<SiblingHit[]> {
|
||||
async function findAllMatches(
|
||||
userId: string,
|
||||
groupNames: string[],
|
||||
name: string,
|
||||
): Promise<SiblingHit[]> {
|
||||
const accessibleProjectIds = await readableProjectIds(userId, groupNames);
|
||||
const visibility =
|
||||
accessibleProjectIds.length > 0
|
||||
? or(
|
||||
and(eq(snippets.userId, userId), isNull(snippets.projectId)),
|
||||
inArray(snippets.projectId, accessibleProjectIds),
|
||||
)
|
||||
: and(eq(snippets.userId, userId), isNull(snippets.projectId));
|
||||
const rows = await db
|
||||
.select({
|
||||
scope: snippets.scope,
|
||||
@@ -32,7 +50,7 @@ async function findAllMatches(userId: string, name: string): Promise<SiblingHit[
|
||||
})
|
||||
.from(snippets)
|
||||
.leftJoin(projects, eq(snippets.projectId, projects.id))
|
||||
.where(and(eq(snippets.userId, userId), eq(snippets.name, name), isNull(snippets.deletedAt)));
|
||||
.where(and(eq(snippets.name, name), isNull(snippets.deletedAt), visibility!));
|
||||
return rows as SiblingHit[];
|
||||
}
|
||||
|
||||
@@ -45,15 +63,16 @@ export default async function SnippetDetailPage({
|
||||
}) {
|
||||
const session = await auth();
|
||||
const userId = session!.user.id;
|
||||
const groupNames = await getUserGroupNames(userId);
|
||||
const { name: rawName } = await params;
|
||||
const name = decodeURIComponent(rawName);
|
||||
const sp = await searchParams;
|
||||
const scope: "project" | "user" | undefined =
|
||||
sp.scope === "user" || sp.scope === "project" ? sp.scope : undefined;
|
||||
const project = sp.project?.trim() || undefined;
|
||||
const isEditing = sp.edit === "1";
|
||||
const wantsEdit = sp.edit === "1";
|
||||
|
||||
const siblings = await findAllMatches(userId, name);
|
||||
const siblings = await findAllMatches(userId, groupNames, name);
|
||||
if (siblings.length === 0) notFound();
|
||||
|
||||
// If multiple matches and the user hasn't disambiguated, show a picker.
|
||||
@@ -103,10 +122,36 @@ export default async function SnippetDetailPage({
|
||||
name,
|
||||
scope,
|
||||
projectKey: project,
|
||||
groupNames,
|
||||
});
|
||||
|
||||
if (!snippet) notFound();
|
||||
|
||||
// Authorize: user-scope rows belong solely to their owner; project-
|
||||
// scope rows require rw on the project (or ownership) to edit.
|
||||
let canWrite: boolean;
|
||||
if (snippet.scope === "user") {
|
||||
canWrite = snippet.userId === userId;
|
||||
} else if (snippet.projectId) {
|
||||
const access = await getProjectAccess(userId, groupNames, snippet.projectId);
|
||||
canWrite = access === "owner" || access === "rw";
|
||||
} else {
|
||||
canWrite = false;
|
||||
}
|
||||
const isEditing = wantsEdit && canWrite;
|
||||
|
||||
// Editor name for "Last edited by ..." footer.
|
||||
const editorRow = snippet.lastEditedBy
|
||||
? await db
|
||||
.select({ name: users.name, email: users.email })
|
||||
.from(users)
|
||||
.where(eq(users.id, snippet.lastEditedBy))
|
||||
.limit(1)
|
||||
: [];
|
||||
const editorLabel = editorRow[0]
|
||||
? editorRow[0].name ?? editorRow[0].email ?? "unknown"
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Container className="pt-6 max-w-3xl">
|
||||
<PageHeader
|
||||
@@ -124,7 +169,7 @@ export default async function SnippetDetailPage({
|
||||
Back
|
||||
</Button>
|
||||
</Link>
|
||||
{!isEditing ? (
|
||||
{!isEditing && canWrite ? (
|
||||
<Link
|
||||
href={`/snippets/${encodeURIComponent(snippet.name)}?${new URLSearchParams({
|
||||
scope: snippet.scope,
|
||||
@@ -150,6 +195,9 @@ export default async function SnippetDetailPage({
|
||||
{snippet.updatedAt.getTime() !== snippet.createdAt.getTime() ? (
|
||||
<span>· Updated {new Date(snippet.updatedAt).toLocaleString()}</span>
|
||||
) : null}
|
||||
{editorLabel && snippet.lastEditedBy !== snippet.userId ? (
|
||||
<span className="text-fg-subtle">· Last edited by {editorLabel}</span>
|
||||
) : null}
|
||||
</CardHeader>
|
||||
|
||||
{isEditing ? (
|
||||
@@ -157,6 +205,7 @@ export default async function SnippetDetailPage({
|
||||
<form action={updateSnippetAction} className="space-y-4">
|
||||
<input type="hidden" name="name" value={snippet.name} />
|
||||
<input type="hidden" name="scope" value={snippet.scope} />
|
||||
<input type="hidden" name="version" value={snippet.version} />
|
||||
{snippet.scope === "project" && snippet.projectKey ? (
|
||||
<input type="hidden" name="project" value={snippet.projectKey} />
|
||||
) : null}
|
||||
@@ -234,7 +283,7 @@ export default async function SnippetDetailPage({
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{!isEditing ? (
|
||||
{!isEditing && canWrite ? (
|
||||
<form action={deleteSnippetAction} className="flex justify-end">
|
||||
<input type="hidden" name="name" value={snippet.name} />
|
||||
<input type="hidden" name="scope" value={snippet.scope} />
|
||||
|
||||
Reference in New Issue
Block a user