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:
parent
cadffa4bd6
commit
d953cee7ad
27 changed files with 568 additions and 151 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
103
src/lib/rbac/visibility.ts
Normal 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";
|
||||
}
|
||||
}
|
||||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}),
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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 } });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
|
||||
|
|
|
|||
8
src/types/next-auth.d.ts
vendored
8
src/types/next-auth.d.ts
vendored
|
|
@ -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;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue