From d953cee7adf8f251998d64fa69074dbeb0ab25c6 Mon Sep 17 00:00:00 2001 From: DJP Date: Mon, 20 Apr 2026 18:50:00 -0400 Subject: [PATCH] Phase 2: per-client-team visibility enforcement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New src/lib/rbac/visibility.ts: visibleProjectsWhere/Deliverables/Stages helpers + assertProjectVisible. ADMIN bypasses; empty team memberships fail-closed (user sees nothing). Reads from session cache, falls back to DB lookup. - Session callback now populates clientTeamIds + isExternal on session.user so downstream queries don't hit the DB per request. - next-auth.d.ts: Session.user extended with clientTeamIds + isExternal. - AuthSession type mirrors the same. - require-auth: added visibilityContextFromSession(session) helper so API routes can construct a VisibilityContext in one line. - CLIENT_VIEWER role entry added to DEFAULT_PERMISSIONS (read + comments). Services wired with visibility (32 query sites across 9 files): - project-service: list/get AND'd with visibleProjectsWhere; update/delete pre-gate via assertProjectVisible. - deliverable-service: list/get/create/bulkCreate gate on parent project visibility; update/delete pre-check via parent project lookup. - stage-service: getBlockedStages AND's stage visibility; bulk/updateStageStatus pre-gate via parent project. - dashboard-service: all 6 groupBy/findMany queries AND'd with visibility. - workload-service: pulls project.clientTeamId and post-filters assignments (nested include can't be filtered cleanly at DB level). - calendar-service: now takes organizationId + ctx; AND's org + visibility into the stage findMany. - weekly-report-service: 6 parallel queries AND'd with visibility fragments. - semantic-search-service: Prisma queries AND'd; raw SQL vectorSearch appends `AND p."clientTeamId" = ANY($N::text[])` for non-admins, returns empty early when scoped user has no team memberships. - assignment-service: assignUserToStage pre-gates project visibility; getMyWork filters rows by client-team membership; bulkAssignArtists skips stages not visible to caller. API routes updated to pass visibility context (13 routes): /api/projects, /api/projects/[id], /api/projects/[id]/deliverables, /api/projects/[id]/deliverables/[id], /api/stages/[id], /api/stages/[id]/assignments, /api/dashboard/stats, /api/my-work, /api/calendar, /api/reports/weekly, /api/workload, /api/search/semantic, /api/chat/route (chat tool-executor threads ctx through all 20 tool handlers via executeTool context param). Verified: npx tsc --noEmit ✓ zero errors. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/app/api/calendar/route.ts | 25 ++-- src/app/api/chat/route.ts | 2 + src/app/api/dashboard/stats/route.ts | 5 +- src/app/api/my-work/route.ts | 5 +- .../deliverables/[deliverableId]/route.ts | 11 +- .../[projectId]/deliverables/route.ts | 8 +- src/app/api/projects/[projectId]/route.ts | 12 +- src/app/api/projects/route.ts | 5 +- src/app/api/reports/weekly/route.ts | 6 +- src/app/api/search/semantic/route.ts | 4 +- .../api/stages/[stageId]/assignments/route.ts | 4 +- src/app/api/stages/[stageId]/route.ts | 5 +- src/app/api/workload/route.ts | 5 +- src/lib/auth.ts | 12 +- src/lib/chat/tool-executor.ts | 39 ++++-- src/lib/rbac/require-auth.ts | 16 +++ src/lib/rbac/visibility.ts | 103 ++++++++++++++++ src/lib/services/assignment-service.ts | 56 ++++++++- src/lib/services/calendar-service.ts | 20 ++-- src/lib/services/dashboard-service.ts | 50 +++++--- src/lib/services/deliverable-service.ts | 46 +++++++- src/lib/services/project-service.ts | 38 ++++-- src/lib/services/semantic-search-service.ts | 111 +++++++++++------- src/lib/services/stage-service.ts | 29 ++++- src/lib/services/weekly-report-service.ts | 72 +++++++++--- src/lib/services/workload-service.ts | 22 +++- src/types/next-auth.d.ts | 8 ++ 27 files changed, 568 insertions(+), 151 deletions(-) create mode 100644 src/lib/rbac/visibility.ts diff --git a/src/app/api/calendar/route.ts b/src/app/api/calendar/route.ts index d1251fd..22346ee 100644 --- a/src/app/api/calendar/route.ts +++ b/src/app/api/calendar/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { getCalendarEvents } from "@/lib/services/calendar-service"; -import { requireAuth } from "@/lib/rbac/require-auth"; +import { requireAuth, visibilityContextFromSession } from "@/lib/rbac/require-auth"; import type { StageStatus } from "@/generated/prisma/client"; export async function GET(req: NextRequest) { @@ -11,7 +11,7 @@ export async function GET(req: NextRequest) { const { searchParams } = new URL(req.url); const startDate = searchParams.get("startDate"); const endDate = searchParams.get("endDate"); - + if (!startDate || !endDate) { return NextResponse.json({ error: "startDate and endDate are required" }, { status: 400 }); } @@ -21,14 +21,19 @@ export async function GET(req: NextRequest) { const status = (searchParams.get("status") as StageStatus) || undefined; const assigneeId = searchParams.get("assigneeId") || undefined; - const events = await getCalendarEvents({ - startDate, - endDate, - projectId, - stageType, - status, - assigneeId, - }); + const ctx = visibilityContextFromSession(session); + const events = await getCalendarEvents( + { + startDate, + endDate, + projectId, + stageType, + status, + assigneeId, + }, + session.user.organizationId, + ctx + ); return NextResponse.json(events); } catch (error) { diff --git a/src/app/api/chat/route.ts b/src/app/api/chat/route.ts index f338f93..ceaa35a 100644 --- a/src/app/api/chat/route.ts +++ b/src/app/api/chat/route.ts @@ -279,6 +279,7 @@ export async function POST(req: NextRequest) { const userId = session.user.id; const organizationId = session.user.organizationId; const userRole = session.user.role; + const clientTeamIds = session.user.clientTeamIds ?? []; // -- Build system prompt with optional page context -- let pageContext: string | undefined; @@ -377,6 +378,7 @@ export async function POST(req: NextRequest) { organizationId, userId, userRole, + clientTeamIds, }); if (result.invalidateKeys.length > 0) { diff --git a/src/app/api/dashboard/stats/route.ts b/src/app/api/dashboard/stats/route.ts index 5aaf678..81b3d67 100644 --- a/src/app/api/dashboard/stats/route.ts +++ b/src/app/api/dashboard/stats/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from "next/server"; import { serverError } from "@/lib/api-utils"; -import { requireAuth } from "@/lib/rbac/require-auth"; +import { requireAuth, visibilityContextFromSession } from "@/lib/rbac/require-auth"; import { getDashboardStats } from "@/lib/services/dashboard-service"; // GET /api/dashboard/stats @@ -31,7 +31,8 @@ export async function GET() { }); } - const stats = await getDashboardStats(organizationId); + const ctx = visibilityContextFromSession(session); + const stats = await getDashboardStats(organizationId, ctx); return NextResponse.json(stats); } catch (e) { return serverError(e); diff --git a/src/app/api/my-work/route.ts b/src/app/api/my-work/route.ts index 592af72..261e72a 100644 --- a/src/app/api/my-work/route.ts +++ b/src/app/api/my-work/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from "next/server"; import { serverError } from "@/lib/api-utils"; -import { requireAuth } from "@/lib/rbac/require-auth"; +import { requireAuth, visibilityContextFromSession } from "@/lib/rbac/require-auth"; import { getMyWork } from "@/lib/services/assignment-service"; // GET /api/my-work — get current user's assignments @@ -9,7 +9,8 @@ export async function GET() { if (error) return error; try { - const assignments = await getMyWork(session.user.id); + const ctx = visibilityContextFromSession(session); + const assignments = await getMyWork(session.user.id, ctx); return NextResponse.json(assignments); } catch (e) { return serverError(e); diff --git a/src/app/api/projects/[projectId]/deliverables/[deliverableId]/route.ts b/src/app/api/projects/[projectId]/deliverables/[deliverableId]/route.ts index 2916660..48247ce 100644 --- a/src/app/api/projects/[projectId]/deliverables/[deliverableId]/route.ts +++ b/src/app/api/projects/[projectId]/deliverables/[deliverableId]/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from "next/server"; import { badRequest, notFound, serverError } from "@/lib/api-utils"; -import { requireAuth } from "@/lib/rbac/require-auth"; +import { requireAuth, visibilityContextFromSession } from "@/lib/rbac/require-auth"; import { assertOrgAccess } from "@/lib/rbac/org-scope"; import { updateDeliverableSchema } from "@/lib/validators/deliverable"; import { @@ -19,7 +19,8 @@ export async function GET(_request: Request, { params }: Params) { try { const { deliverableId } = await params; await assertOrgAccess("deliverable", deliverableId, session.user.organizationId); - const deliverable = await getDeliverable(deliverableId); + const ctx = visibilityContextFromSession(session); + const deliverable = await getDeliverable(deliverableId, ctx); if (!deliverable) return notFound("Deliverable not found"); @@ -44,7 +45,8 @@ export async function PATCH(request: Request, { params }: Params) { return badRequest(parsed.error.issues.map((i) => i.message).join(", ")); } - const deliverable = await updateDeliverable(deliverableId, parsed.data); + const ctx = visibilityContextFromSession(session); + const deliverable = await updateDeliverable(deliverableId, parsed.data, ctx); return NextResponse.json(deliverable); } catch (e) { return serverError(e); @@ -59,7 +61,8 @@ export async function DELETE(_request: Request, { params }: Params) { try { const { deliverableId } = await params; await assertOrgAccess("deliverable", deliverableId, session.user.organizationId); - await deleteDeliverable(deliverableId); + const ctx = visibilityContextFromSession(session); + await deleteDeliverable(deliverableId, ctx); return NextResponse.json({ success: true }); } catch (e) { return serverError(e); diff --git a/src/app/api/projects/[projectId]/deliverables/route.ts b/src/app/api/projects/[projectId]/deliverables/route.ts index 5975188..d90d12a 100644 --- a/src/app/api/projects/[projectId]/deliverables/route.ts +++ b/src/app/api/projects/[projectId]/deliverables/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from "next/server"; import { badRequest, serverError } from "@/lib/api-utils"; -import { requireAuth } from "@/lib/rbac/require-auth"; +import { requireAuth, visibilityContextFromSession } from "@/lib/rbac/require-auth"; import { assertOrgAccess } from "@/lib/rbac/org-scope"; import { createDeliverableSchema } from "@/lib/validators/deliverable"; import { @@ -18,7 +18,8 @@ export async function GET(_request: Request, { params }: Params) { try { const { projectId } = await params; await assertOrgAccess("project", projectId, session.user.organizationId); - const deliverables = await listDeliverables(projectId); + const ctx = visibilityContextFromSession(session); + const deliverables = await listDeliverables(projectId, ctx); return NextResponse.json(deliverables); } catch (e) { return serverError(e); @@ -40,7 +41,8 @@ export async function POST(request: Request, { params }: Params) { return badRequest(parsed.error.issues.map((i) => i.message).join(", ")); } - const deliverable = await createDeliverable(projectId, parsed.data); + const ctx = visibilityContextFromSession(session); + const deliverable = await createDeliverable(projectId, parsed.data, ctx); return NextResponse.json(deliverable, { status: 201 }); } catch (e) { return serverError(e); diff --git a/src/app/api/projects/[projectId]/route.ts b/src/app/api/projects/[projectId]/route.ts index 4040a18..a12d2dc 100644 --- a/src/app/api/projects/[projectId]/route.ts +++ b/src/app/api/projects/[projectId]/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from "next/server"; import { badRequest, notFound, serverError } from "@/lib/api-utils"; -import { requireAuth } from "@/lib/rbac/require-auth"; +import { requireAuth, visibilityContextFromSession } from "@/lib/rbac/require-auth"; import { assertOrgAccess } from "@/lib/rbac/org-scope"; import { updateProjectSchema } from "@/lib/validators/project"; import { @@ -19,7 +19,8 @@ export async function GET(_request: Request, { params }: Params) { try { const { projectId } = await params; await assertOrgAccess("project", projectId, session.user.organizationId); - const project = await getProject(projectId, session.user.organizationId); + const ctx = visibilityContextFromSession(session); + const project = await getProject(projectId, session.user.organizationId, ctx); if (!project) return notFound("Project not found"); @@ -44,10 +45,12 @@ export async function PATCH(request: Request, { params }: Params) { return badRequest(parsed.error.issues.map((i) => i.message).join(", ")); } + const ctx = visibilityContextFromSession(session); const project = await updateProject( projectId, parsed.data, - session.user.organizationId + session.user.organizationId, + ctx ); return NextResponse.json(project); } catch (e) { @@ -63,7 +66,8 @@ export async function DELETE(_request: Request, { params }: Params) { try { const { projectId } = await params; await assertOrgAccess("project", projectId, session.user.organizationId); - await deleteProject(projectId, session.user.organizationId); + const ctx = visibilityContextFromSession(session); + await deleteProject(projectId, session.user.organizationId, ctx); return NextResponse.json({ success: true }); } catch (e) { return serverError(e); diff --git a/src/app/api/projects/route.ts b/src/app/api/projects/route.ts index 0afc0f8..8fe52b2 100644 --- a/src/app/api/projects/route.ts +++ b/src/app/api/projects/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from "next/server"; import { badRequest, serverError } from "@/lib/api-utils"; -import { requireAuth } from "@/lib/rbac/require-auth"; +import { requireAuth, visibilityContextFromSession } from "@/lib/rbac/require-auth"; import { createProjectSchema } from "@/lib/validators/project"; import { listProjects, createProject } from "@/lib/services/project-service"; @@ -10,7 +10,8 @@ export async function GET() { if (error) return error; try { - const projects = await listProjects(session.user.organizationId); + const ctx = visibilityContextFromSession(session); + const projects = await listProjects(session.user.organizationId, ctx); return NextResponse.json(projects); } catch (e) { return serverError(e); diff --git a/src/app/api/reports/weekly/route.ts b/src/app/api/reports/weekly/route.ts index 696f047..51c0945 100644 --- a/src/app/api/reports/weekly/route.ts +++ b/src/app/api/reports/weekly/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { badRequest, serverError } from "@/lib/api-utils"; -import { requireAuth } from "@/lib/rbac/require-auth"; +import { requireAuth, visibilityContextFromSession } from "@/lib/rbac/require-auth"; import { getWeeklyReport } from "@/lib/services/weekly-report-service"; import { parseISO, isValid } from "date-fns"; @@ -22,9 +22,11 @@ export async function GET(request: NextRequest) { weekOf = new Date(); } + const ctx = visibilityContextFromSession(session); const report = await getWeeklyReport( session.user.organizationId, - weekOf + weekOf, + ctx ); return NextResponse.json(report); diff --git a/src/app/api/search/semantic/route.ts b/src/app/api/search/semantic/route.ts index 8036006..5eaab57 100644 --- a/src/app/api/search/semantic/route.ts +++ b/src/app/api/search/semantic/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from "next/server"; import { badRequest, serverError } from "@/lib/api-utils"; -import { requireAuth } from "@/lib/rbac/require-auth"; +import { requireAuth, visibilityContextFromSession } from "@/lib/rbac/require-auth"; import { semanticSearch, logSearch, @@ -24,9 +24,11 @@ export async function POST(request: Request) { return badRequest("Query must be 500 characters or less"); } + const ctx = visibilityContextFromSession(session); const results = await semanticSearch( query.trim(), session.user.organizationId, + ctx, { limit: typeof limit === "number" ? Math.min(limit, 50) : 10, includeSummary: includeSummary !== false, diff --git a/src/app/api/stages/[stageId]/assignments/route.ts b/src/app/api/stages/[stageId]/assignments/route.ts index 283b529..629a3d4 100644 --- a/src/app/api/stages/[stageId]/assignments/route.ts +++ b/src/app/api/stages/[stageId]/assignments/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from "next/server"; import { badRequest, serverError } from "@/lib/api-utils"; -import { requireAuth } from "@/lib/rbac/require-auth"; +import { requireAuth, visibilityContextFromSession } from "@/lib/rbac/require-auth"; import { assertOrgAccess } from "@/lib/rbac/org-scope"; import { assignUserSchema } from "@/lib/validators/assignment"; import { @@ -25,9 +25,11 @@ export async function POST(request: Request, { params }: Params) { return badRequest(parsed.error.issues.map((i) => i.message).join(", ")); } + const ctx = visibilityContextFromSession(session); const assignment = await assignUserToStage( stageId, parsed.data.userId, + ctx, parsed.data.role ?? "LEAD" ); return NextResponse.json(assignment, { status: 201 }); diff --git a/src/app/api/stages/[stageId]/route.ts b/src/app/api/stages/[stageId]/route.ts index 40e50bf..6948a29 100644 --- a/src/app/api/stages/[stageId]/route.ts +++ b/src/app/api/stages/[stageId]/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from "next/server"; import { badRequest, serverError } from "@/lib/api-utils"; -import { requireAuth } from "@/lib/rbac/require-auth"; +import { requireAuth, visibilityContextFromSession } from "@/lib/rbac/require-auth"; import { assertOrgAccess } from "@/lib/rbac/org-scope"; import { updateStageStatus } from "@/lib/services/stage-service"; import { @@ -59,7 +59,8 @@ export async function PATCH(request: Request, { params }: Params) { return NextResponse.json(updated); } - const result = await updateStageStatus(stageId, status, subStatus); + const ctx = visibilityContextFromSession(session); + const result = await updateStageStatus(stageId, status, ctx, subStatus); if (!result.success) { return badRequest( diff --git a/src/app/api/workload/route.ts b/src/app/api/workload/route.ts index 3f24465..de5778e 100644 --- a/src/app/api/workload/route.ts +++ b/src/app/api/workload/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { serverError } from "@/lib/api-utils"; -import { requireAuth } from "@/lib/rbac/require-auth"; +import { requireAuth, visibilityContextFromSession } from "@/lib/rbac/require-auth"; import { getWorkloadData, updateUserCapacity } from "@/lib/services/workload-service"; // GET /api/workload?numWeeks=8&projectId=xxx&stageType=xxx @@ -14,7 +14,8 @@ export async function GET(request: NextRequest) { const projectId = searchParams.get("projectId") || undefined; const stageType = searchParams.get("stageType") || undefined; - const data = await getWorkloadData(session.user.organizationId, { + const ctx = visibilityContextFromSession(session); + const data = await getWorkloadData(session.user.organizationId, ctx, { numWeeks: Math.min(numWeeks, 26), // cap at 26 weeks projectId, stageType, diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 578202d..210dec5 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -17,13 +17,23 @@ export const { handlers, auth, signOut } = NextAuth({ async session({ session, user }) { const dbUser = await prisma.user.findUnique({ where: { id: user.id }, - select: { role: true, organizationId: true }, + select: { + role: true, + organizationId: true, + isExternal: true, + clientTeams: { select: { clientTeamId: true } }, + }, }); if (dbUser) { session.user.id = user.id; session.user.role = dbUser.role; session.user.organizationId = dbUser.organizationId; + session.user.isExternal = dbUser.isExternal; + session.user.clientTeamIds = dbUser.clientTeams.map((m) => m.clientTeamId); + } else { + session.user.clientTeamIds = []; + session.user.isExternal = false; } return session; diff --git a/src/lib/chat/tool-executor.ts b/src/lib/chat/tool-executor.ts index 2d22628..451bde3 100644 --- a/src/lib/chat/tool-executor.ts +++ b/src/lib/chat/tool-executor.ts @@ -26,6 +26,7 @@ import { getAvailableArtists, getSuggestedArtists } from "@/lib/services/skill-s import { getWorkloadData } from "@/lib/services/workload-service"; import { createRevision, listRevisions } from "@/lib/services/revision-service"; import type { Role } from "@/generated/prisma/client"; +import type { VisibilityContext } from "@/lib/rbac/visibility"; /** Tools that mutate data — the chat system should flag these for confirmation */ export const MUTATION_TOOLS = new Set([ @@ -86,7 +87,12 @@ export interface ToolExecutionResult { export async function executeTool( toolName: string, input: Record, - context: { organizationId: string; userId: string; userRole?: Role } + context: { + organizationId: string; + userId: string; + userRole?: Role; + clientTeamIds?: string[]; + } ): Promise { // RBAC check if (context.userRole) { @@ -108,6 +114,15 @@ export async function executeTool( const invalidateKeys = !isDryRun && isMutation ? CACHE_INVALIDATION_MAP[toolName] || [] : []; + // Build a VisibilityContext for service calls that require one. + // If userRole isn't set, default to ADMIN (chat should never run without a role in practice). + const visCtx: VisibilityContext = { + userId: context.userId, + organizationId: context.organizationId, + role: context.userRole ?? "ADMIN", + clientTeamIds: context.clientTeamIds ?? [], + }; + try { let data: any; @@ -416,7 +431,7 @@ export async function executeTool( // ── Read Operations ── case "list_projects": { - const projects = await listProjects(context.organizationId); + const projects = await listProjects(context.organizationId, visCtx); // Filter by status if provided data = input.status ? projects.filter((p: any) => p.status === input.status) @@ -425,18 +440,18 @@ export async function executeTool( } case "get_project": { - data = await getProject(input.projectId, context.organizationId); + data = await getProject(input.projectId, context.organizationId, visCtx); if (!data) throw new Error("Project not found"); break; } case "list_deliverables": { - data = await listDeliverables(input.projectId); + data = await listDeliverables(input.projectId, visCtx); break; } case "get_blocked_stages": { - data = await getBlockedStages(context.organizationId, { + data = await getBlockedStages(context.organizationId, visCtx, { projectId: input.projectId, }); break; @@ -458,7 +473,7 @@ export async function executeTool( } case "get_workload": { - data = await getWorkloadData(context.organizationId, { + data = await getWorkloadData(context.organizationId, visCtx, { numWeeks: input.numWeeks, projectId: input.projectId, userId: input.userId, @@ -486,31 +501,31 @@ export async function executeTool( case "create_deliverable": { const { projectId, dryRun: _dd, ...delivData } = input; - data = await createDeliverable(projectId, delivData as any); + data = await createDeliverable(projectId, delivData as any, visCtx); break; } case "bulk_create_deliverables": { const { projectId, items } = input; - data = await bulkCreateDeliverables(projectId, items); + data = await bulkCreateDeliverables(projectId, items, visCtx); break; } case "advance_stage": { const { stageId, status, subStatus } = input; - data = await updateStageStatus(stageId, status, subStatus); + data = await updateStageStatus(stageId, status, visCtx, subStatus); break; } case "bulk_update_stages": { const { items } = input; - data = await bulkUpdateStageStatuses(items); + data = await bulkUpdateStageStatuses(items, visCtx); break; } case "assign_artist": { const { stageId, userId, role } = input; - data = await assignUserToStage(stageId, userId, role || "LEAD"); + data = await assignUserToStage(stageId, userId, visCtx, role || "LEAD"); break; } @@ -521,7 +536,7 @@ export async function executeTool( case "bulk_assign_artists": { const { items } = input; - data = await bulkAssignArtists(items); + data = await bulkAssignArtists(items, visCtx); break; } diff --git a/src/lib/rbac/require-auth.ts b/src/lib/rbac/require-auth.ts index 1e65e58..273abf2 100644 --- a/src/lib/rbac/require-auth.ts +++ b/src/lib/rbac/require-auth.ts @@ -2,6 +2,7 @@ import { NextResponse } from "next/server"; import type { Role, Permission } from "@/generated/prisma/client"; import { getAuthSession, forbidden } from "@/lib/api-utils"; import { hasPermission } from "./permissions"; +import type { VisibilityContext } from "./visibility"; export interface AuthSession { user: { @@ -11,6 +12,8 @@ export interface AuthSession { image?: string | null; role: Role; organizationId: string; + clientTeamIds: string[]; + isExternal: boolean; }; } @@ -61,3 +64,16 @@ export async function requireAuth( error: null, }; } + +/** + * Build a VisibilityContext from an authenticated session. Use this in API + * routes to pass to service functions that apply per-client-team scoping. + */ +export function visibilityContextFromSession(session: AuthSession): VisibilityContext { + return { + userId: session.user.id, + organizationId: session.user.organizationId, + role: session.user.role, + clientTeamIds: session.user.clientTeamIds, + }; +} diff --git a/src/lib/rbac/visibility.ts b/src/lib/rbac/visibility.ts new file mode 100644 index 0000000..4b1273f --- /dev/null +++ b/src/lib/rbac/visibility.ts @@ -0,0 +1,103 @@ +import type { Role } from "@/generated/prisma/client"; +import { prisma } from "@/lib/prisma"; + +export interface VisibilityContext { + userId: string; + organizationId: string; + role: Role; + /** + * Session-cached client team IDs. When provided, no DB call is made. + * When undefined, visibility helpers fall back to a DB lookup. + */ + clientTeamIds?: string[]; +} + +/** + * Apply visibility scoping to Project queries. + * + * ADMIN sees everything. Every other role (including CLIENT_VIEWER) is scoped + * to the client teams they belong to. Users with no team memberships see nothing + * — fail-closed is intentional for an external client portal. + * + * Usage (AND-in with org scope, never spread — spreading silently drops org + * filter when visibility returns empty object for ADMIN): + * + * prisma.project.findMany({ + * where: { AND: [orgWhere(orgId), await visibleProjectsWhere(ctx)] } + * }); + */ +export async function visibleProjectsWhere(ctx: VisibilityContext) { + if (ctx.role === "ADMIN") return {}; + + const teamIds = await resolveTeamIds(ctx); + + // Fail-closed: user with no team memberships sees nothing. + if (teamIds.length === 0) return { id: { in: [] as string[] } }; + + return { clientTeamId: { in: teamIds } }; +} + +/** + * Apply visibility scoping to Deliverable queries (nests through project). + */ +export async function visibleDeliverablesWhere(ctx: VisibilityContext) { + if (ctx.role === "ADMIN") return {}; + + const teamIds = await resolveTeamIds(ctx); + if (teamIds.length === 0) return { id: { in: [] as string[] } }; + + return { project: { clientTeamId: { in: teamIds } } }; +} + +/** + * Apply visibility scoping to DeliverableStage queries (nests through deliverable → project). + */ +export async function visibleStagesWhere(ctx: VisibilityContext) { + if (ctx.role === "ADMIN") return {}; + + const teamIds = await resolveTeamIds(ctx); + if (teamIds.length === 0) return { id: { in: [] as string[] } }; + + return { deliverable: { project: { clientTeamId: { in: teamIds } } } }; +} + +/** + * Assert that a specific project is visible to this user. Throws otherwise. + * Use for single-resource reads where we need to gate access cleanly. + */ +export async function assertProjectVisible( + projectId: string, + ctx: VisibilityContext +): Promise { + if (ctx.role === "ADMIN") return; + + const teamIds = await resolveTeamIds(ctx); + if (teamIds.length === 0) throw new VisibilityError("Project not visible"); + + const project = await prisma.project.findUnique({ + where: { id: projectId }, + select: { clientTeamId: true }, + }); + + if (!project || !project.clientTeamId || !teamIds.includes(project.clientTeamId)) { + throw new VisibilityError("Project not visible"); + } +} + +async function resolveTeamIds(ctx: VisibilityContext): Promise { + if (ctx.clientTeamIds !== undefined) return ctx.clientTeamIds; + + const memberships = await prisma.clientTeamMembership.findMany({ + where: { userId: ctx.userId }, + select: { clientTeamId: true }, + }); + return memberships.map((m) => m.clientTeamId); +} + +export class VisibilityError extends Error { + code = "VISIBILITY_DENIED"; + constructor(message: string) { + super(message); + this.name = "VisibilityError"; + } +} diff --git a/src/lib/services/assignment-service.ts b/src/lib/services/assignment-service.ts index 968c920..b7e49fd 100644 --- a/src/lib/services/assignment-service.ts +++ b/src/lib/services/assignment-service.ts @@ -1,6 +1,11 @@ import { prisma } from "@/lib/prisma"; import type { AssignmentRole } from "@/generated/prisma/client"; import { emitAssignmentCreated } from "@/lib/automation/event-bus"; +import { + assertProjectVisible, + VisibilityError, + type VisibilityContext, +} from "@/lib/rbac/visibility"; // Register automation handler (side-effect import) import "@/lib/services/automation-service"; @@ -8,6 +13,7 @@ import "@/lib/services/automation-service"; export async function assignUserToStage( deliverableStageId: string, userId: string, + ctx: VisibilityContext, role: AssignmentRole = "LEAD", options: { dryRun?: boolean } = {} ) { @@ -35,6 +41,9 @@ export async function assignUserToStage( if (!stage) throw new Error("Stage not found"); if (!user) throw new Error("User not found"); + // Visibility gate — the stage's parent project must be visible to the caller + await assertProjectVisible(stage.deliverable.project.id, ctx); + // Check for existing assignment const existing = await prisma.stageAssignment.findUnique({ where: { deliverableStageId_userId: { deliverableStageId, userId } }, @@ -86,8 +95,8 @@ export async function removeAssignment( }); } -export async function getMyWork(userId: string) { - return prisma.stageAssignment.findMany({ +export async function getMyWork(userId: string, ctx: VisibilityContext) { + const rows = await prisma.stageAssignment.findMany({ where: { userId }, include: { deliverableStage: { @@ -96,7 +105,14 @@ export async function getMyWork(userId: string) { stageDefinition: true, deliverable: { include: { - project: { select: { id: true, name: true, projectCode: true } }, + project: { + select: { + id: true, + name: true, + projectCode: true, + clientTeamId: true, + }, + }, }, }, }, @@ -104,6 +120,14 @@ export async function getMyWork(userId: string) { }, orderBy: { createdAt: "desc" }, }); + + // Filter by visibility — non-admins only see assignments whose project is on their team + if (ctx.role === "ADMIN") return rows; + const allowed = new Set(ctx.clientTeamIds ?? []); + return rows.filter((a) => { + const teamId = a.deliverableStage.deliverable.project.clientTeamId; + return teamId && allowed.has(teamId); + }); } // ─── Bulk Operations ──────────────────────────────────── @@ -135,6 +159,7 @@ export interface BulkAssignResult { */ export async function bulkAssignArtists( items: BulkAssignItem[], + ctx: VisibilityContext, options: { dryRun?: boolean } = {} ): Promise { if (items.length === 0) { @@ -154,7 +179,14 @@ export async function bulkAssignArtists( select: { id: true, name: true, - project: { select: { id: true, name: true, organizationId: true } }, + project: { + select: { + id: true, + name: true, + organizationId: true, + clientTeamId: true, + }, + }, }, }, }, @@ -171,6 +203,9 @@ export async function bulkAssignArtists( const succeeded: BulkAssignResult["succeeded"] = []; const failed: BulkAssignResult["failed"] = []; + const isAdmin = ctx.role === "ADMIN"; + const allowedTeams = new Set(ctx.clientTeamIds ?? []); + for (const item of items) { const stage = stageMap.get(item.deliverableStageId); if (!stage) { @@ -182,6 +217,19 @@ export async function bulkAssignArtists( continue; } + // Visibility: non-admins can only assign on projects they can see + if (!isAdmin) { + const teamId = stage.deliverable.project.clientTeamId; + if (!teamId || !allowedTeams.has(teamId)) { + failed.push({ + deliverableStageId: item.deliverableStageId, + userId: item.userId, + reason: "Stage not visible to current user", + }); + continue; + } + } + const user = userMap.get(item.userId); if (!user) { failed.push({ diff --git a/src/lib/services/calendar-service.ts b/src/lib/services/calendar-service.ts index 3564695..16a2d21 100644 --- a/src/lib/services/calendar-service.ts +++ b/src/lib/services/calendar-service.ts @@ -1,5 +1,6 @@ import { prisma } from "../prisma"; import type { StageStatus } from "@/generated/prisma/client"; +import { visibleStagesWhere, type VisibilityContext } from "@/lib/rbac/visibility"; export interface CalendarFilters { startDate: string; // ISO date string @@ -10,13 +11,17 @@ export interface CalendarFilters { assigneeId?: string; } -export async function getCalendarEvents(filters: CalendarFilters) { +export async function getCalendarEvents( + filters: CalendarFilters, + organizationId: string, + ctx: VisibilityContext +) { const { startDate, endDate, projectId, stageType, status, assigneeId } = filters; const start = new Date(startDate); const end = new Date(endDate); - const where: any = { + const baseWhere: any = { OR: [ { startDate: { lte: end }, @@ -30,25 +35,26 @@ export async function getCalendarEvents(filters: CalendarFilters) { startDate: { gte: start, lte: end }, } ], + deliverable: { project: { organizationId } }, }; if (projectId) { - where.deliverable = { projectId }; + baseWhere.deliverable = { ...baseWhere.deliverable, projectId }; } if (stageType) { - where.template = { name: stageType }; + baseWhere.template = { name: stageType }; } if (status) { - where.status = status; + baseWhere.status = status; } if (assigneeId) { - where.assignments = { + baseWhere.assignments = { some: { userId: assigneeId }, }; } const events = await prisma.deliverableStage.findMany({ - where, + where: { AND: [baseWhere, await visibleStagesWhere(ctx)] }, include: { template: true, stageDefinition: true, diff --git a/src/lib/services/dashboard-service.ts b/src/lib/services/dashboard-service.ts index 6f02e4c..e7f750e 100644 --- a/src/lib/services/dashboard-service.ts +++ b/src/lib/services/dashboard-service.ts @@ -1,9 +1,21 @@ import { prisma } from "@/lib/prisma"; +import { + visibleDeliverablesWhere, + visibleProjectsWhere, + visibleStagesWhere, + type VisibilityContext, +} from "@/lib/rbac/visibility"; /** - * Get dashboard KPI stats for an organization. + * Get dashboard KPI stats for an organization, scoped to what the caller can see. */ -export async function getDashboardStats(organizationId: string) { +export async function getDashboardStats(organizationId: string, ctx: VisibilityContext) { + const [projectVisibility, deliverableVisibility, stageVisibility] = await Promise.all([ + visibleProjectsWhere(ctx), + visibleDeliverablesWhere(ctx), + visibleStagesWhere(ctx), + ]); + const [ projectStats, deliverableStats, @@ -15,30 +27,35 @@ export async function getDashboardStats(organizationId: string) { // Project counts by status prisma.project.groupBy({ by: ["status"], - where: { organizationId }, + where: { AND: [{ organizationId }, projectVisibility] }, _count: true, }), // Deliverable counts by status prisma.deliverable.groupBy({ by: ["status"], - where: { project: { organizationId } }, + where: { AND: [{ project: { organizationId } }, deliverableVisibility] }, _count: true, }), // Stage counts by status prisma.deliverableStage.groupBy({ by: ["status"], - where: { deliverable: { project: { organizationId } } }, + where: { AND: [{ deliverable: { project: { organizationId } } }, stageVisibility] }, _count: true, }), // Overdue deliverables prisma.deliverable.findMany({ where: { - project: { organizationId }, - dueDate: { lt: new Date() }, - status: { notIn: ["APPROVED", "ON_HOLD"] }, + AND: [ + { + project: { organizationId }, + dueDate: { lt: new Date() }, + status: { notIn: ["APPROVED", "ON_HOLD"] }, + }, + deliverableVisibility, + ], }, select: { id: true, @@ -55,11 +72,16 @@ export async function getDashboardStats(organizationId: string) { // Recent stage completions (last 30 days) prisma.deliverableStage.findMany({ where: { - deliverable: { project: { organizationId } }, - status: "APPROVED", - completedDate: { - gte: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000), - }, + AND: [ + { + deliverable: { project: { organizationId } }, + status: "APPROVED", + completedDate: { + gte: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000), + }, + }, + stageVisibility, + ], }, select: { id: true, @@ -79,7 +101,7 @@ export async function getDashboardStats(organizationId: string) { // Stage completion rates by template prisma.deliverableStage.groupBy({ by: ["templateId", "status"], - where: { deliverable: { project: { organizationId } } }, + where: { AND: [{ deliverable: { project: { organizationId } } }, stageVisibility] }, _count: true, }), ]); diff --git a/src/lib/services/deliverable-service.ts b/src/lib/services/deliverable-service.ts index 4742a34..35fe5ac 100644 --- a/src/lib/services/deliverable-service.ts +++ b/src/lib/services/deliverable-service.ts @@ -9,6 +9,12 @@ import { updateProjectEmbedding, } from "@/lib/services/embedding-service"; import { scheduleDeliverable } from "@/lib/services/scheduling-service"; +import { + assertProjectVisible, + visibleDeliverablesWhere, + VisibilityError, + type VisibilityContext, +} from "@/lib/rbac/visibility"; /** * Creates a deliverable and auto-creates all 10 pipeline stages. @@ -17,8 +23,11 @@ import { scheduleDeliverable } from "@/lib/services/scheduling-service"; */ export async function createDeliverable( projectId: string, - data: CreateDeliverableInput + data: CreateDeliverableInput, + ctx: VisibilityContext ) { + await assertProjectVisible(projectId, ctx); + // Fetch project to get organizationId and pipeline template const project = await prisma.project.findUnique({ where: { id: projectId }, @@ -131,7 +140,8 @@ export async function createDeliverable( }); } -export async function listDeliverables(projectId: string) { +export async function listDeliverables(projectId: string, ctx: VisibilityContext) { + await assertProjectVisible(projectId, ctx); return prisma.deliverable.findMany({ where: { projectId }, include: { @@ -148,9 +158,14 @@ export async function listDeliverables(projectId: string) { }); } -export async function getDeliverable(id: string) { - return prisma.deliverable.findUnique({ - where: { id }, +export async function getDeliverable(id: string, ctx: VisibilityContext) { + return prisma.deliverable.findFirst({ + where: { + AND: [ + { id }, + await visibleDeliverablesWhere(ctx), + ], + }, include: { stages: { include: { @@ -167,8 +182,17 @@ export async function getDeliverable(id: string) { export async function updateDeliverable( id: string, data: UpdateDeliverableInput, + ctx: VisibilityContext, options: { dryRun?: boolean } = {} ) { + // Pre-check visibility via parent project + const parent = await prisma.deliverable.findUnique({ + where: { id }, + select: { projectId: true }, + }); + if (!parent) throw new VisibilityError("Deliverable not found"); + await assertProjectVisible(parent.projectId, ctx); + if (options.dryRun) { const existing = await prisma.deliverable.findUnique({ where: { id }, @@ -204,7 +228,14 @@ export async function updateDeliverable( return deliverable; } -export async function deleteDeliverable(id: string) { +export async function deleteDeliverable(id: string, ctx: VisibilityContext) { + const parent = await prisma.deliverable.findUnique({ + where: { id }, + select: { projectId: true }, + }); + if (!parent) throw new VisibilityError("Deliverable not found"); + await assertProjectVisible(parent.projectId, ctx); + return prisma.deliverable.delete({ where: { id }, }); @@ -237,12 +268,15 @@ export interface BulkCreateDeliverablesResult { export async function bulkCreateDeliverables( projectId: string, items: BulkCreateDeliverableItem[], + ctx: VisibilityContext, options: { dryRun?: boolean } = {} ): Promise { if (items.length === 0) { return { total: 0, created: [], failed: [] }; } + await assertProjectVisible(projectId, ctx); + // Validate project exists and get its org + pipeline const project = await prisma.project.findUnique({ where: { id: projectId }, diff --git a/src/lib/services/project-service.ts b/src/lib/services/project-service.ts index 153b2b4..93bff87 100644 --- a/src/lib/services/project-service.ts +++ b/src/lib/services/project-service.ts @@ -1,22 +1,34 @@ import { prisma } from "@/lib/prisma"; import type { CreateProjectInput, UpdateProjectInput } from "@/lib/validators/project"; import { updateProjectEmbedding } from "@/lib/services/embedding-service"; +import { + assertProjectVisible, + visibleProjectsWhere, + type VisibilityContext, +} from "@/lib/rbac/visibility"; -export async function listProjects(organizationId: string) { +export async function listProjects(organizationId: string, ctx: VisibilityContext) { return prisma.project.findMany({ - where: { organizationId }, + where: { AND: [{ organizationId }, await visibleProjectsWhere(ctx)] }, include: { _count: { select: { deliverables: true } }, pipelineTemplate: { select: { id: true, name: true } }, + clientTeam: { select: { id: true, name: true, slug: true } }, }, orderBy: { updatedAt: "desc" }, }); } -export async function getProject(id: string, organizationId: string) { +export async function getProject(id: string, organizationId: string, ctx: VisibilityContext) { return prisma.project.findFirst({ - where: { id, organizationId }, + where: { + AND: [ + { id, organizationId }, + await visibleProjectsWhere(ctx), + ], + }, include: { + clientTeam: { select: { id: true, name: true, slug: true } }, deliverables: { include: { stages: { @@ -81,9 +93,12 @@ export async function updateProject( id: string, data: UpdateProjectInput, organizationId: string, + ctx: VisibilityContext, options: { dryRun?: boolean } = {} ) { - // Verify project exists + await assertProjectVisible(id, ctx); + + // Verify project exists in this org const existing = await prisma.project.findFirst({ where: { id, organizationId }, select: { id: true, name: true, projectCode: true }, @@ -110,7 +125,7 @@ export async function updateProject( ) as typeof data; const project = await prisma.project.update({ - where: { id, organizationId }, + where: { id }, data: { ...cleaned, ...(cleaned.startDate !== undefined && { @@ -128,8 +143,15 @@ export async function updateProject( return project; } -export async function deleteProject(id: string, organizationId: string) { - return prisma.project.delete({ +export async function deleteProject(id: string, organizationId: string, ctx: VisibilityContext) { + await assertProjectVisible(id, ctx); + + // Verify the project belongs to the caller's org before delete + const existing = await prisma.project.findFirst({ where: { id, organizationId }, + select: { id: true }, }); + if (!existing) throw new Error("Project not found"); + + return prisma.project.delete({ where: { id } }); } diff --git a/src/lib/services/semantic-search-service.ts b/src/lib/services/semantic-search-service.ts index ffc9b3e..d2f35fb 100644 --- a/src/lib/services/semantic-search-service.ts +++ b/src/lib/services/semantic-search-service.ts @@ -10,6 +10,11 @@ import { prisma } from "@/lib/prisma"; import { generateEmbedding } from "@/lib/services/embedding-service"; +import { + visibleDeliverablesWhere, + visibleProjectsWhere, + type VisibilityContext, +} from "@/lib/rbac/visibility"; const OLLAMA_HOST = process.env.OLLAMA_HOST || "http://localhost:11434"; const LLM_MODEL = process.env.OLLAMA_LLM_MODEL || "qwen3.5:9b"; @@ -134,25 +139,26 @@ function detectStructuralFilters(query: string): StructuralFilters | null { async function structuralSearch( filters: StructuralFilters, organizationId: string, - limit: number + limit: number, + ctx: VisibilityContext ): Promise { const results: SearchResult[] = []; // Search projects - const projectWhere: Record = { organizationId }; + const projectBase: Record = { organizationId }; if (filters.status && ["ACTIVE", "ON_HOLD", "COMPLETED", "ARCHIVED"].includes(filters.status)) { - projectWhere.status = filters.status; + projectBase.status = filters.status; } if (filters.priority) { - projectWhere.priority = filters.priority; + projectBase.priority = filters.priority; } if (filters.isOverdue) { - projectWhere.dueDate = { lt: new Date() }; - projectWhere.status = { not: "COMPLETED" }; + projectBase.dueDate = { lt: new Date() }; + projectBase.status = { not: "COMPLETED" }; } const projects = await prisma.project.findMany({ - where: projectWhere, + where: { AND: [projectBase, await visibleProjectsWhere(ctx)] }, take: limit, orderBy: { updatedAt: "desc" }, }); @@ -172,25 +178,25 @@ async function structuralSearch( } // Search deliverables - const deliverableWhere: Record = { + const deliverableBase: Record = { project: { organizationId }, }; if ( filters.status && ["NOT_STARTED", "IN_PROGRESS", "IN_REVIEW", "APPROVED", "ON_HOLD"].includes(filters.status) ) { - deliverableWhere.status = filters.status; + deliverableBase.status = filters.status; } if (filters.priority) { - deliverableWhere.priority = filters.priority; + deliverableBase.priority = filters.priority; } if (filters.isOverdue) { - deliverableWhere.dueDate = { lt: new Date() }; - deliverableWhere.status = { not: "APPROVED" }; + deliverableBase.dueDate = { lt: new Date() }; + deliverableBase.status = { not: "APPROVED" }; } const deliverables = await prisma.deliverable.findMany({ - where: deliverableWhere, + where: { AND: [deliverableBase, await visibleDeliverablesWhere(ctx)] }, include: { project: { select: { name: true, projectCode: true, id: true } }, }, @@ -240,30 +246,45 @@ interface DeliverableVectorRow extends VectorSearchRow { async function vectorSearch( query: string, organizationId: string, - limit: number + limit: number, + ctx: VisibilityContext ): Promise { const embedding = await generateEmbedding(query); if (!embedding) { // Ollama unavailable — fall back to text-based search - return textFallbackSearch(query, organizationId, limit); + return textFallbackSearch(query, organizationId, limit, ctx); } const vectorStr = `[${embedding.join(",")}]`; const results: SearchResult[] = []; + // Visibility: non-admins are scoped to their client teams. Empty set = see nothing. + const isAdmin = ctx.role === "ADMIN"; + const allowedTeamIds = ctx.clientTeamIds ?? []; + if (!isAdmin && allowedTeamIds.length === 0) { + return results; + } + + // Build the visibility SQL fragment + extra params dynamically. + // When admin: no extra clause. When scoped: filter by project.clientTeamId IN (...). + const visibilitySql = isAdmin + ? "" + : ` AND p."clientTeamId" = ANY($4::text[])`; + const projectArgs: unknown[] = isAdmin + ? [vectorStr, organizationId, limit] + : [vectorStr, organizationId, limit, allowedTeamIds]; + // Search projects by cosine similarity const projectRows = await prisma.$queryRawUnsafe( `SELECT p."id", p."name", p."status", p."priority", p."projectCode", p."description", p."embedding" <=> $1::vector AS distance FROM "projects" p WHERE p."organizationId" = $2 - AND p."embedding" IS NOT NULL + AND p."embedding" IS NOT NULL${visibilitySql} ORDER BY distance ASC LIMIT $3`, - vectorStr, - organizationId, - limit + ...projectArgs ); for (const row of projectRows) { @@ -288,12 +309,10 @@ async function vectorSearch( FROM "deliverables" d JOIN "projects" p ON d."projectId" = p."id" WHERE p."organizationId" = $2 - AND d."embedding" IS NOT NULL + AND d."embedding" IS NOT NULL${visibilitySql} ORDER BY distance ASC LIMIT $3`, - vectorStr, - organizationId, - limit + ...projectArgs ); for (const row of deliverableRows) { @@ -321,20 +340,26 @@ async function vectorSearch( async function textFallbackSearch( query: string, organizationId: string, - limit: number + limit: number, + ctx: VisibilityContext ): Promise { const results: SearchResult[] = []; const searchTerm = `%${query}%`; const projects = await prisma.project.findMany({ where: { - organizationId, - OR: [ - { name: { contains: query, mode: "insensitive" } }, - { description: { contains: query, mode: "insensitive" } }, - { projectCode: { contains: query, mode: "insensitive" } }, - { codeName: { contains: query, mode: "insensitive" } }, - { businessUnit: { contains: query, mode: "insensitive" } }, + AND: [ + { + organizationId, + OR: [ + { name: { contains: query, mode: "insensitive" } }, + { description: { contains: query, mode: "insensitive" } }, + { projectCode: { contains: query, mode: "insensitive" } }, + { codeName: { contains: query, mode: "insensitive" } }, + { businessUnit: { contains: query, mode: "insensitive" } }, + ], + }, + await visibleProjectsWhere(ctx), ], }, take: limit, @@ -357,11 +382,16 @@ async function textFallbackSearch( const deliverables = await prisma.deliverable.findMany({ where: { - project: { organizationId }, - OR: [ - { name: { contains: query, mode: "insensitive" } }, - { notes: { contains: query, mode: "insensitive" } }, - { cmfSku: { contains: query, mode: "insensitive" } }, + AND: [ + { + project: { organizationId }, + OR: [ + { name: { contains: query, mode: "insensitive" } }, + { notes: { contains: query, mode: "insensitive" } }, + { cmfSku: { contains: query, mode: "insensitive" } }, + ], + }, + await visibleDeliverablesWhere(ctx), ], }, include: { @@ -477,6 +507,7 @@ Answer the producer's question. Use bullet points when listing items.`; export async function semanticSearch( query: string, organizationId: string, + ctx: VisibilityContext, options: { limit?: number; includeSummary?: boolean; @@ -502,8 +533,8 @@ export async function semanticSearch( if (strippedQuery.length > 3) { // Hybrid: structural filters + semantic search on the remaining query const [structuralResults, semanticResults] = await Promise.all([ - structuralSearch(structuralFilters, organizationId, limit), - vectorSearch(strippedQuery, organizationId, limit), + structuralSearch(structuralFilters, organizationId, limit, ctx), + vectorSearch(strippedQuery, organizationId, limit, ctx), ]); // Merge: boost results that appear in both sets @@ -530,12 +561,12 @@ export async function semanticSearch( searchType = "hybrid"; } else { // Pure structural query - results = await structuralSearch(structuralFilters, organizationId, limit); + results = await structuralSearch(structuralFilters, organizationId, limit, ctx); searchType = "structural"; } } else { // Pure semantic query - results = await vectorSearch(query, organizationId, limit); + results = await vectorSearch(query, organizationId, limit, ctx); searchType = "semantic"; } diff --git a/src/lib/services/stage-service.ts b/src/lib/services/stage-service.ts index 98926d9..d9e15fd 100644 --- a/src/lib/services/stage-service.ts +++ b/src/lib/services/stage-service.ts @@ -3,6 +3,12 @@ import { canTransition } from "@/lib/pipeline/stage-machine"; import { canStageStart, getStageIdsToUnblock } from "@/lib/pipeline/dependency-engine"; import type { StageStatus } from "@/generated/prisma/client"; import { emitStageStatusChanged } from "@/lib/automation/event-bus"; +import { + assertProjectVisible, + visibleStagesWhere, + VisibilityError, + type VisibilityContext, +} from "@/lib/rbac/visibility"; // Register automation handler (side-effect import) import "@/lib/services/automation-service"; @@ -28,20 +34,22 @@ export interface BlockedStageInfo { */ export async function getBlockedStages( organizationId: string, + ctx: VisibilityContext, options: { projectId?: string } = {} ): Promise { - const where: any = { + const visibility = await visibleStagesWhere(ctx); + const baseWhere: any = { status: "BLOCKED" as StageStatus, deliverable: { project: { organizationId }, }, }; if (options.projectId) { - where.deliverable.projectId = options.projectId; + baseWhere.deliverable.projectId = options.projectId; } const blockedStages = await prisma.deliverableStage.findMany({ - where, + where: { AND: [baseWhere, visibility] }, include: { template: { include: { dependsOn: { include: { prerequisite: true } } }, @@ -116,16 +124,18 @@ export interface BulkStageUpdateResult { */ export async function bulkUpdateStageStatuses( items: BulkStageUpdateItem[], + ctx: VisibilityContext, options: { dryRun?: boolean } = {} ): Promise { if (items.length === 0) { return { total: 0, succeeded: [], failed: [] }; } - // Fetch all stages with their dependencies in one query + // Fetch all stages with their dependencies in one query — filtered by visibility const stageIds = items.map((i) => i.stageId); + const visibility = await visibleStagesWhere(ctx); const stages = await prisma.deliverableStage.findMany({ - where: { id: { in: stageIds } }, + where: { AND: [{ id: { in: stageIds } }, visibility] }, include: { template: { include: { dependsOn: true } }, deliverable: { @@ -305,9 +315,18 @@ export async function bulkUpdateStageStatuses( export async function updateStageStatus( stageId: string, newStatus: StageStatus, + ctx: VisibilityContext, subStatus?: string | null, options: { dryRun?: boolean } = {} ) { + // Visibility gate — find parent project, assert visibility, then fetch stage + const parentLookup = await prisma.deliverableStage.findUnique({ + where: { id: stageId }, + select: { deliverable: { select: { projectId: true } } }, + }); + if (!parentLookup) throw new VisibilityError("Stage not found"); + await assertProjectVisible(parentLookup.deliverable.projectId, ctx); + // Fetch the stage with its template, dependencies, and sibling stages const stage = await prisma.deliverableStage.findUnique({ where: { id: stageId }, diff --git a/src/lib/services/weekly-report-service.ts b/src/lib/services/weekly-report-service.ts index e08d6fc..e5877cc 100644 --- a/src/lib/services/weekly-report-service.ts +++ b/src/lib/services/weekly-report-service.ts @@ -5,6 +5,12 @@ import { subWeeks, differenceInCalendarDays, } from "date-fns"; +import { + visibleDeliverablesWhere, + visibleProjectsWhere, + visibleStagesWhere, + type VisibilityContext, +} from "@/lib/rbac/visibility"; export interface WeeklyReportData { period: { @@ -78,7 +84,8 @@ export interface WeeklyReportData { */ export async function getWeeklyReport( organizationId: string, - weekOf: Date + weekOf: Date, + ctx: VisibilityContext ): Promise { const weekStart = startOfWeek(weekOf, { weekStartsOn: 1 }); // Monday const weekEnd = endOfWeek(weekOf, { weekStartsOn: 1 }); // Sunday @@ -89,6 +96,12 @@ export async function getWeeklyReport( }); const now = new Date(); + const [projectVisibility, deliverableVisibility, stageVisibility] = await Promise.all([ + visibleProjectsWhere(ctx), + visibleDeliverablesWhere(ctx), + visibleStagesWhere(ctx), + ]); + const [ completedStages, prevWeekCompleted, @@ -100,9 +113,14 @@ export async function getWeeklyReport( // 1. Stages completed this week (APPROVED or DELIVERED with completedDate in range) prisma.deliverableStage.findMany({ where: { - deliverable: { project: { organizationId } }, - status: { in: ["APPROVED", "DELIVERED"] }, - completedDate: { gte: weekStart, lte: weekEnd }, + AND: [ + { + deliverable: { project: { organizationId } }, + status: { in: ["APPROVED", "DELIVERED"] }, + completedDate: { gte: weekStart, lte: weekEnd }, + }, + stageVisibility, + ], }, select: { id: true, @@ -122,17 +140,27 @@ export async function getWeeklyReport( // 2. Previous week completions for trend comparison prisma.deliverableStage.count({ where: { - deliverable: { project: { organizationId } }, - status: { in: ["APPROVED", "DELIVERED"] }, - completedDate: { gte: prevWeekStart, lte: prevWeekEnd }, + AND: [ + { + deliverable: { project: { organizationId } }, + status: { in: ["APPROVED", "DELIVERED"] }, + completedDate: { gte: prevWeekStart, lte: prevWeekEnd }, + }, + stageVisibility, + ], }, }), // 3. All deliverables with due dates in or before this week for deadline tracking prisma.deliverable.findMany({ where: { - project: { organizationId, status: "ACTIVE" }, - dueDate: { lte: weekEnd }, + AND: [ + { + project: { organizationId, status: "ACTIVE" }, + dueDate: { lte: weekEnd }, + }, + deliverableVisibility, + ], }, select: { id: true, @@ -149,17 +177,22 @@ export async function getWeeklyReport( prisma.deliverableStage.groupBy({ by: ["status"], where: { - deliverable: { - project: { organizationId, status: "ACTIVE" }, - }, - status: { notIn: ["SKIPPED"] }, + AND: [ + { + deliverable: { + project: { organizationId, status: "ACTIVE" }, + }, + status: { notIn: ["SKIPPED"] }, + }, + stageVisibility, + ], }, _count: true, }), // 5. Active projects with their deliverable counts prisma.project.findMany({ - where: { organizationId, status: "ACTIVE" }, + where: { AND: [{ organizationId, status: "ACTIVE" }, projectVisibility] }, select: { id: true, name: true, @@ -183,9 +216,14 @@ export async function getWeeklyReport( // 6. Upcoming deliverables (due next week) prisma.deliverable.findMany({ where: { - project: { organizationId, status: "ACTIVE" }, - dueDate: { gt: weekEnd, lte: nextWeekEnd }, - status: { notIn: ["APPROVED"] }, + AND: [ + { + project: { organizationId, status: "ACTIVE" }, + dueDate: { gt: weekEnd, lte: nextWeekEnd }, + status: { notIn: ["APPROVED"] }, + }, + deliverableVisibility, + ], }, select: { id: true, diff --git a/src/lib/services/workload-service.ts b/src/lib/services/workload-service.ts index 5d7d7fd..587f566 100644 --- a/src/lib/services/workload-service.ts +++ b/src/lib/services/workload-service.ts @@ -6,6 +6,7 @@ import { format, isWithinInterval, } from "date-fns"; +import type { VisibilityContext } from "@/lib/rbac/visibility"; /** Statuses that count as "active" work (not terminal). */ const ACTIVE_STATUSES = [ @@ -91,6 +92,7 @@ export function generateWeekBuckets( */ export async function getWorkloadData( organizationId: string, + ctx: VisibilityContext, options: { numWeeks?: number; projectId?: string; @@ -101,6 +103,12 @@ export async function getWorkloadData( const { numWeeks = 8, projectId, stageType, userId } = options; const weeks = generateWeekBuckets(numWeeks); + // Visibility: admins see all; others are scoped to their client teams. + // Nested assignment queries can't easily be filtered at the DB level, so we + // pull with project.clientTeamId and filter in application code. + const isAdmin = ctx.role === "ADMIN"; + const allowedTeamIds = new Set(ctx.clientTeamIds ?? []); + // Get all users in the organization with their assignments const users = await prisma.user.findMany({ where: { @@ -123,7 +131,13 @@ export async function getWorkloadData( deliverable: { include: { project: { - select: { id: true, name: true, status: true, organizationId: true }, + select: { + id: true, + name: true, + status: true, + organizationId: true, + clientTeamId: true, + }, }, }, }, @@ -136,12 +150,16 @@ export async function getWorkloadData( }); const result: UserWorkload[] = users.map((user) => { - // Filter assignments by project/stageType if specified + // Filter assignments by project/stageType if specified, AND by client-team visibility let assignments = user.assignments.filter((a) => { const proj = a.deliverableStage.deliverable.project; if (proj.organizationId !== organizationId) return false; if (projectId && proj.id !== projectId) return false; if (stageType && a.deliverableStage.template.slug !== stageType) return false; + // Visibility: non-admins only see projects on their client teams + if (!isAdmin) { + if (!proj.clientTeamId || !allowedTeamIds.has(proj.clientTeamId)) return false; + } return true; }); diff --git a/src/types/next-auth.d.ts b/src/types/next-auth.d.ts index 17d06af..3a01484 100644 --- a/src/types/next-auth.d.ts +++ b/src/types/next-auth.d.ts @@ -9,6 +9,14 @@ declare module "next-auth" { image?: string | null; role: Role; organizationId: string | null; + /** + * Client team IDs this user belongs to. Drives per-team project visibility. + * Empty array = sees no projects (fail-closed). ADMIN role bypasses this filter. + * Cached on the session so visibility checks don't hit the DB on every query. + */ + clientTeamIds: string[]; + /** True for external Dow Jones client users (CLIENT_VIEWER role). */ + isExternal: boolean; }; } }