diff --git a/apps/web/app/(authed)/projects/page.tsx b/apps/web/app/(authed)/projects/page.tsx index 2a6f1c1..7ebd91b 100644 --- a/apps/web/app/(authed)/projects/page.tsx +++ b/apps/web/app/(authed)/projects/page.tsx @@ -1,8 +1,9 @@ import Link from "next/link"; -import { and, desc, eq, isNull, sql } from "drizzle-orm"; +import { and, desc, eq, inArray, isNull, sql } from "drizzle-orm"; import { auth } from "@/auth"; import { db } from "@/lib/db/client"; import { memories, projects } from "@/lib/db/schema"; +import { getAccessibleProjects, getUserGroupNames } from "@/lib/access"; import { Container, PageHeader } from "@/app/_components/ui/container"; import { Card } from "@/app/_components/ui/card"; import { Badge } from "@/app/_components/ui/badge"; @@ -13,24 +14,37 @@ export const dynamic = "force-dynamic"; export default async function ProjectsPage() { const session = await auth(); const userId = session!.user.id; + const groupNames = await getUserGroupNames(userId); - const rows = await db - .select({ - id: projects.id, - key: projects.key, - displayName: projects.displayName, - createdAt: projects.createdAt, - memoryCount: sql`count(${memories.id})::int`, - lastActivity: sql`max(${memories.createdAt})`, - }) - .from(projects) - .leftJoin( - memories, - and(eq(memories.projectId, projects.id), isNull(memories.deletedAt)), - ) - .where(eq(projects.userId, userId)) - .groupBy(projects.id) - .orderBy(desc(sql`max(${memories.createdAt})`)); + // The project list is owned ∪ shared: projects the user owns PLUS + // projects shared with one of their groups (any access). Visibility was + // previously owner-only (`eq(projects.userId, userId)`), which hid + // projects another user shared in via project_shares even though + // project.identify already reported them as {shared, access}. + const accessible = await getAccessibleProjects(userId, groupNames); + const accessById = new Map(accessible.map((p) => [p.projectId, p.access])); + const accessibleIds = accessible.map((p) => p.projectId); + + const rows = + accessibleIds.length === 0 + ? [] + : await db + .select({ + id: projects.id, + key: projects.key, + displayName: projects.displayName, + createdAt: projects.createdAt, + memoryCount: sql`count(${memories.id})::int`, + lastActivity: sql`max(${memories.createdAt})`, + }) + .from(projects) + .leftJoin( + memories, + and(eq(memories.projectId, projects.id), isNull(memories.deletedAt)), + ) + .where(inArray(projects.id, accessibleIds)) + .groupBy(projects.id) + .orderBy(desc(sql`max(${memories.createdAt})`)); return ( @@ -57,6 +71,9 @@ export default async function ProjectsPage() {
{p.key} {p.memoryCount} + {accessById.get(p.id) !== "owner" ? ( + shared · {accessById.get(p.id)} + ) : null}
{p.displayName && p.displayName !== p.key ? (
{p.displayName}
diff --git a/apps/web/lib/mcp/tools.ts b/apps/web/lib/mcp/tools.ts index 0ec4e00..6e4e8ac 100644 --- a/apps/web/lib/mcp/tools.ts +++ b/apps/web/lib/mcp/tools.ts @@ -1,4 +1,4 @@ -import { and, desc, eq, inArray, isNull, or, sql } from "drizzle-orm"; +import { and, arrayContains, desc, eq, inArray, isNull, or } from "drizzle-orm"; import { db } from "@/lib/db/client"; import { memories, @@ -465,7 +465,13 @@ const memoryList: ToolDef = { } if (parsed.data.tags && parsed.data.tags.length > 0) { - where.push(sql`${memories.tags} @> ${parsed.data.tags}::text[]`); + // Require ALL listed tags (array containment). Use Drizzle's + // arrayContains so the JS array binds as a single text[] param + // (via the column's toDriver) rather than being expanded into + // positional params — a raw `${tags}::text[]` template expands to + // `($1)::text[]` / `($1,$2)::text[]`, which Postgres rejects as a + // malformed array literal / record cast. + where.push(arrayContains(memories.tags, parsed.data.tags)); } const rows = await db diff --git a/apps/web/lib/snippets.ts b/apps/web/lib/snippets.ts index 668aab8..4fba1a7 100644 --- a/apps/web/lib/snippets.ts +++ b/apps/web/lib/snippets.ts @@ -1,4 +1,4 @@ -import { and, desc, eq, inArray, isNull, or, sql } from "drizzle-orm"; +import { and, arrayContains, desc, eq, inArray, isNull, or } from "drizzle-orm"; import { db } from "@/lib/db/client"; import { snippets, projects } from "@/lib/db/schema"; import type { Snippet } from "@/lib/db/schema"; @@ -349,7 +349,13 @@ export async function listSnippets( } if (tags && tags.length > 0) { - where.push(sql`${snippets.tags} @> ${tags}::text[]`); + // Require ALL listed tags (array containment). Use Drizzle's + // arrayContains so the JS array binds as a single text[] param + // (via the column's toDriver) rather than being expanded into + // positional params — a raw `${tags}::text[]` template expands to + // `($1)::text[]` / `($1,$2)::text[]`, which Postgres rejects as a + // malformed array literal / record cast. + where.push(arrayContains(snippets.tags, tags)); } const rows = await db