Phase 2: per-client-team visibility enforcement

- 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) <noreply@anthropic.com>
This commit is contained in:
DJP 2026-04-20 18:50:00 -04:00
parent cadffa4bd6
commit d953cee7ad
27 changed files with 568 additions and 151 deletions

View file

@ -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) {

View file

@ -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) {

View file

@ -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);

View file

@ -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);

View file

@ -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);

View file

@ -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);

View file

@ -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);

View file

@ -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);

View file

@ -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);

View file

@ -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,

View file

@ -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 });

View file

@ -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(

View file

@ -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,

View file

@ -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;

View file

@ -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<string, any>,
context: { organizationId: string; userId: string; userRole?: Role }
context: {
organizationId: string;
userId: string;
userRole?: Role;
clientTeamIds?: string[];
}
): Promise<ToolExecutionResult> {
// 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;
}

View file

@ -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,
};
}

103
src/lib/rbac/visibility.ts Normal file
View file

@ -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<void> {
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<string[]> {
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";
}
}

View file

@ -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<BulkAssignResult> {
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({

View file

@ -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,

View file

@ -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,
}),
]);

View file

@ -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<BulkCreateDeliverablesResult> {
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 },

View file

@ -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 } });
}

View file

@ -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<SearchResult[]> {
const results: SearchResult[] = [];
// Search projects
const projectWhere: Record<string, unknown> = { organizationId };
const projectBase: Record<string, unknown> = { 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<string, unknown> = {
const deliverableBase: Record<string, unknown> = {
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<SearchResult[]> {
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<ProjectVectorRow[]>(
`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<SearchResult[]> {
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";
}

View file

@ -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<BlockedStageInfo[]> {
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<BulkStageUpdateResult> {
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 },

View file

@ -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<WeeklyReportData> {
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,

View file

@ -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;
});

View file

@ -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;
};
}
}