feat: timeline UI overhaul with project/resource panel redesign, quick filters, and API improvements
Redesigned timeline project and resource panels with expanded detail views, added quick filter toolbar, improved drag handling, and enhanced vacation/entitlement router logic. Includes e2e test updates and minor API fixes. Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
@@ -13,13 +13,18 @@ const createCaller = createCallerFactory(entitlementRouter);
|
||||
|
||||
// ── Caller factories ─────────────────────────────────────────────────────────
|
||||
|
||||
/** Injects a default resource ownership mock so the ownership check in getBalance passes */
|
||||
function createProtectedCaller(db: Record<string, unknown>) {
|
||||
const withResourceOwnership = {
|
||||
resource: { findUnique: vi.fn().mockResolvedValue({ userId: "user_1" }) },
|
||||
...db,
|
||||
};
|
||||
return createCaller({
|
||||
session: {
|
||||
user: { email: "user@example.com", name: "User", image: null },
|
||||
expires: "2099-01-01T00:00:00.000Z",
|
||||
},
|
||||
db: db as never,
|
||||
db: withResourceOwnership as never,
|
||||
dbUser: {
|
||||
id: "user_1",
|
||||
systemRole: SystemRole.USER,
|
||||
|
||||
@@ -12,7 +12,7 @@ function createProtectedCaller(db: Record<string, unknown>) {
|
||||
expires: "2026-03-13T00:00:00.000Z",
|
||||
},
|
||||
db: db as never,
|
||||
dbUser: null,
|
||||
dbUser: { id: "user_1", systemRole: "ADMIN", permissionOverrides: null },
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -203,6 +203,9 @@ describe("vacation router", () => {
|
||||
user: {
|
||||
findUnique: vi.fn().mockResolvedValue({ id: "user_1", systemRole: "USER" }),
|
||||
},
|
||||
resource: {
|
||||
findUnique: vi.fn().mockResolvedValue({ userId: "user_1" }),
|
||||
},
|
||||
vacation: {
|
||||
findFirst: vi.fn().mockResolvedValue(null),
|
||||
create: vi.fn().mockResolvedValue(createdVacation),
|
||||
@@ -270,6 +273,9 @@ describe("vacation router", () => {
|
||||
user: {
|
||||
findUnique: vi.fn().mockResolvedValue({ id: "user_1", systemRole: "USER" }),
|
||||
},
|
||||
resource: {
|
||||
findUnique: vi.fn().mockResolvedValue({ userId: "user_1" }),
|
||||
},
|
||||
vacation: {
|
||||
findFirst: vi.fn().mockResolvedValue({ id: "existing_vac" }),
|
||||
},
|
||||
@@ -314,6 +320,9 @@ describe("vacation router", () => {
|
||||
user: {
|
||||
findUnique: vi.fn().mockResolvedValue({ id: "user_1", systemRole: "USER" }),
|
||||
},
|
||||
resource: {
|
||||
findUnique: vi.fn().mockResolvedValue({ userId: "user_1" }),
|
||||
},
|
||||
vacation: {
|
||||
findFirst: vi.fn().mockResolvedValue(null),
|
||||
create: vi.fn().mockResolvedValue(createdVacation),
|
||||
@@ -469,6 +478,9 @@ describe("vacation router", () => {
|
||||
};
|
||||
|
||||
const db = {
|
||||
user: {
|
||||
findUnique: vi.fn().mockResolvedValue({ id: "user_1", systemRole: "USER" }),
|
||||
},
|
||||
vacation: {
|
||||
findUnique: vi.fn().mockResolvedValue(sampleVacation),
|
||||
update: vi.fn().mockResolvedValue(updatedVacation),
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod";
|
||||
import { findUniqueOrThrow } from "../db/helpers.js";
|
||||
import { anonymizeResource, getAnonymizationDirectory } from "../lib/anonymization.js";
|
||||
import { emitAllocationCreated, emitAllocationDeleted, emitAllocationUpdated } from "../sse/event-bus.js";
|
||||
import { createTRPCRouter, managerProcedure, protectedProcedure, requirePermission } from "../trpc.js";
|
||||
import { PROJECT_BRIEF_SELECT, RESOURCE_BRIEF_SELECT, ROLE_BRIEF_SELECT } from "../db/selects.js";
|
||||
@@ -102,7 +103,7 @@ function toAssignmentUpdateInput(input: AllocationEntryUpdateInput) {
|
||||
}
|
||||
|
||||
async function loadAllocationReadModel(
|
||||
db: Pick<import("@planarchy/db").PrismaClient, "demandRequirement" | "assignment">,
|
||||
db: Pick<import("@planarchy/db").PrismaClient, "demandRequirement" | "assignment" | "systemSettings" | "resource">,
|
||||
input: AllocationListFilters,
|
||||
) {
|
||||
const [demandRequirements, assignments] = await Promise.all([
|
||||
@@ -127,7 +128,22 @@ async function loadAllocationReadModel(
|
||||
}),
|
||||
]);
|
||||
|
||||
return buildSplitAllocationReadModel({ demandRequirements, assignments });
|
||||
const readModel = buildSplitAllocationReadModel({ demandRequirements, assignments });
|
||||
|
||||
const directory = await getAnonymizationDirectory(db as import("@planarchy/db").PrismaClient);
|
||||
if (!directory) return readModel;
|
||||
|
||||
function anonymizeAllocation<T extends { resource?: { id: string; eid?: string | null; displayName?: string | null; email?: string | null } | null }>(alloc: T): T {
|
||||
if (!alloc.resource) return alloc;
|
||||
return { ...alloc, resource: anonymizeResource(alloc.resource, directory) };
|
||||
}
|
||||
|
||||
return {
|
||||
...readModel,
|
||||
allocations: readModel.allocations.map(anonymizeAllocation),
|
||||
demands: readModel.demands.map(anonymizeAllocation),
|
||||
assignments: readModel.assignments.map(anonymizeAllocation),
|
||||
};
|
||||
}
|
||||
|
||||
async function findAllocationEntryOrNull(
|
||||
@@ -236,7 +252,7 @@ export const allocationRouter = createTRPCRouter({
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
return ctx.db.demandRequirement.findMany({
|
||||
const demands = await ctx.db.demandRequirement.findMany({
|
||||
where: {
|
||||
...(input.projectId ? { projectId: input.projectId } : {}),
|
||||
...(input.status ? { status: input.status } : {}),
|
||||
@@ -245,6 +261,14 @@ export const allocationRouter = createTRPCRouter({
|
||||
include: DEMAND_INCLUDE,
|
||||
orderBy: { startDate: "asc" },
|
||||
});
|
||||
const dir = await getAnonymizationDirectory(ctx.db);
|
||||
if (!dir) return demands;
|
||||
return demands.map((d) => ({
|
||||
...d,
|
||||
assignments: d.assignments.map((a) =>
|
||||
a.resource ? { ...a, resource: anonymizeResource(a.resource, dir) } : a,
|
||||
),
|
||||
}));
|
||||
}),
|
||||
|
||||
listAssignments: protectedProcedure
|
||||
@@ -257,7 +281,7 @@ export const allocationRouter = createTRPCRouter({
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
return ctx.db.assignment.findMany({
|
||||
const assignments = await ctx.db.assignment.findMany({
|
||||
where: {
|
||||
...(input.projectId ? { projectId: input.projectId } : {}),
|
||||
...(input.resourceId ? { resourceId: input.resourceId } : {}),
|
||||
@@ -267,6 +291,11 @@ export const allocationRouter = createTRPCRouter({
|
||||
include: ASSIGNMENT_INCLUDE,
|
||||
orderBy: { startDate: "asc" },
|
||||
});
|
||||
const dir = await getAnonymizationDirectory(ctx.db);
|
||||
if (!dir) return assignments;
|
||||
return assignments.map((a) =>
|
||||
a.resource ? { ...a, resource: anonymizeResource(a.resource, dir) } : a,
|
||||
);
|
||||
}),
|
||||
|
||||
createDemandRequirement: managerProcedure
|
||||
|
||||
@@ -108,6 +108,23 @@ export const entitlementRouter = createTRPCRouter({
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
// Ownership check: USER can only query their own balance
|
||||
if (ctx.dbUser) {
|
||||
const allowedRoles = ["ADMIN", "MANAGER", "CONTROLLER"];
|
||||
if (!allowedRoles.includes(ctx.dbUser.systemRole)) {
|
||||
const resource = await ctx.db.resource.findUnique({
|
||||
where: { id: input.resourceId },
|
||||
select: { userId: true },
|
||||
});
|
||||
if (!resource || resource.userId !== ctx.dbUser.id) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "You can only view your own vacation balance",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const settings = await ctx.db.systemSettings.findUnique({ where: { id: "singleton" } });
|
||||
const defaultDays = settings?.vacationDefaultDays ?? 28;
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ export const PROJECT_PLANNING_ALLOCATION_INCLUDE = {
|
||||
name: true,
|
||||
shortCode: true,
|
||||
orderType: true,
|
||||
clientId: true,
|
||||
budgetCents: true,
|
||||
winProbability: true,
|
||||
status: true,
|
||||
|
||||
@@ -15,7 +15,7 @@ import { findUniqueOrThrow } from "../db/helpers.js";
|
||||
import { adminProcedure, createTRPCRouter, protectedProcedure } from "../trpc.js";
|
||||
|
||||
export const userRouter = createTRPCRouter({
|
||||
list: protectedProcedure.query(async ({ ctx }) => {
|
||||
list: adminProcedure.query(async ({ ctx }) => {
|
||||
return ctx.db.user.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
|
||||
@@ -161,6 +161,21 @@ export const vacationRouter = createTRPCRouter({
|
||||
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
}
|
||||
|
||||
// Ownership check: USER role can only create vacations for their own resource
|
||||
const isManager = userRecord.systemRole === "ADMIN" || userRecord.systemRole === "MANAGER";
|
||||
if (!isManager) {
|
||||
const resource = await ctx.db.resource.findUnique({
|
||||
where: { id: input.resourceId },
|
||||
select: { userId: true },
|
||||
});
|
||||
if (!resource || resource.userId !== userRecord.id) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "You can only create vacation requests for your own resource",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Check for overlapping APPROVED or PENDING vacations
|
||||
const overlapping = await ctx.db.vacation.findFirst({
|
||||
where: {
|
||||
@@ -177,7 +192,6 @@ export const vacationRouter = createTRPCRouter({
|
||||
});
|
||||
}
|
||||
|
||||
const isManager = userRecord.systemRole === "ADMIN" || userRecord.systemRole === "MANAGER";
|
||||
const status = isManager ? VacationStatus.APPROVED : VacationStatus.PENDING;
|
||||
|
||||
const vacation = await ctx.db.vacation.create({
|
||||
@@ -354,6 +368,30 @@ export const vacationRouter = createTRPCRouter({
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Already cancelled" });
|
||||
}
|
||||
|
||||
// Ownership check: USER can only cancel their own vacations
|
||||
const userRecord = await ctx.db.user.findUnique({
|
||||
where: { email: ctx.session.user?.email ?? "" },
|
||||
select: { id: true, systemRole: true },
|
||||
});
|
||||
if (!userRecord) {
|
||||
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
}
|
||||
const isManagerOrAdmin = userRecord.systemRole === "ADMIN" || userRecord.systemRole === "MANAGER";
|
||||
if (!isManagerOrAdmin) {
|
||||
if (existing.requestedById !== userRecord.id) {
|
||||
const resource = await ctx.db.resource.findUnique({
|
||||
where: { id: existing.resourceId },
|
||||
select: { userId: true },
|
||||
});
|
||||
if (!resource || resource.userId !== userRecord.id) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "You can only cancel your own vacation requests",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const updated = await ctx.db.vacation.update({
|
||||
where: { id: input.id },
|
||||
data: { status: VacationStatus.CANCELLED },
|
||||
|
||||
@@ -54,17 +54,22 @@ export const createCallerFactory = t.createCallerFactory;
|
||||
export const publicProcedure = t.procedure;
|
||||
|
||||
/**
|
||||
* Protected procedure — requires any authenticated session.
|
||||
* Protected procedure — requires authenticated session AND a valid DB user record.
|
||||
* This prevents stale sessions from accessing data after the DB user is deleted.
|
||||
*/
|
||||
export const protectedProcedure = t.procedure.use(({ ctx, next }) => {
|
||||
if (!ctx.session?.user) {
|
||||
throw new TRPCError({ code: "UNAUTHORIZED", message: "Authentication required" });
|
||||
}
|
||||
if (!ctx.dbUser) {
|
||||
throw new TRPCError({ code: "UNAUTHORIZED", message: "User account not found" });
|
||||
}
|
||||
return next({
|
||||
ctx: {
|
||||
...ctx,
|
||||
session: ctx.session,
|
||||
user: ctx.session.user,
|
||||
dbUser: ctx.dbUser,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user