refactor: complete v2 refactoring plan (Phases 1-5)

Phase 1 — Quick Wins: centralize formatMoney/formatCents, extract
findUniqueOrThrow helper (19 routers), shared Prisma select constants,
useInvalidatePlanningViews hook, status badge consolidation, composite
DB indexes.

Phase 2 — Timeline Split: extract TimelineContext, TimelineResourcePanel,
TimelineProjectPanel; split 28-dep useMemo into 3 focused memos.
TimelineView.tsx reduced from 1,903 to 538 lines.

Phase 3 — Query Performance: server-side filtering for getEntriesView,
remove availability from timeline resource select, SSE event debouncing
(50ms batch window).

Phase 4 — Estimate Workspace: extract 7 tab components and 3 editor
components. EstimateWorkspaceClient 1,298→306 lines,
EstimateWorkspaceDraftEditor 1,205→581 lines.

Phase 5 — Package Cleanup: split commit-dispo-import-batch (1,112→573
lines), extract shared pagination helper with 11 tests.

All tests pass: 209 API, 254 engine, 67 application.

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
2026-03-14 23:03:42 +01:00
parent 4dabb9d4ce
commit ad0855902b
65 changed files with 7108 additions and 4740 deletions
@@ -0,0 +1,153 @@
import { SSE_EVENT_TYPES } from "@planarchy/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
cancelPendingEvents,
eventBus,
flushPendingEvents,
type SseEvent,
} from "../sse/event-bus.js";
// Mock Redis so the module loads without a real connection.
// publish() throws so the event bus falls back to local-only delivery,
// which is the path that exercises the debounce buffer.
vi.mock("ioredis", () => {
const RedisMock = vi.fn().mockImplementation(() => ({
on: vi.fn(),
subscribe: vi.fn().mockResolvedValue(undefined),
publish: vi.fn().mockImplementation(() => {
throw new Error("Redis unavailable (test)");
}),
}));
return { Redis: RedisMock };
});
describe("event-bus debounce", () => {
let received: SseEvent[];
let unsubscribe: () => void;
beforeEach(() => {
vi.useFakeTimers();
received = [];
unsubscribe = eventBus.subscribe((event) => {
received.push(event);
});
});
afterEach(() => {
unsubscribe();
cancelPendingEvents();
vi.useRealTimers();
});
it("delivers a single event after the debounce window", () => {
eventBus.emit(SSE_EVENT_TYPES.ALLOCATION_CREATED, { id: "a1" });
// Not yet delivered — still in the debounce window
expect(received).toHaveLength(0);
vi.advanceTimersByTime(50);
// Now delivered
expect(received).toHaveLength(1);
expect(received[0]!.type).toBe(SSE_EVENT_TYPES.ALLOCATION_CREATED);
expect(received[0]!.payload).toEqual({ id: "a1" });
});
it("aggregates multiple events of the same type into a single _batch event", () => {
eventBus.emit(SSE_EVENT_TYPES.ALLOCATION_UPDATED, { id: "a1" });
eventBus.emit(SSE_EVENT_TYPES.ALLOCATION_UPDATED, { id: "a2" });
eventBus.emit(SSE_EVENT_TYPES.ALLOCATION_UPDATED, { id: "a3" });
expect(received).toHaveLength(0);
vi.advanceTimersByTime(50);
expect(received).toHaveLength(1);
expect(received[0]!.type).toBe(SSE_EVENT_TYPES.ALLOCATION_UPDATED);
expect(received[0]!.payload).toEqual({
_batch: [{ id: "a1" }, { id: "a2" }, { id: "a3" }],
});
});
it("keeps different event types separate", () => {
eventBus.emit(SSE_EVENT_TYPES.ALLOCATION_CREATED, { id: "a1" });
eventBus.emit(SSE_EVENT_TYPES.ROLE_UPDATED, { id: "r1" });
vi.advanceTimersByTime(50);
expect(received).toHaveLength(2);
const types = received.map((e) => e.type);
expect(types).toContain(SSE_EVENT_TYPES.ALLOCATION_CREATED);
expect(types).toContain(SSE_EVENT_TYPES.ROLE_UPDATED);
// Both should be single payloads (not batched)
for (const event of received) {
expect(event.payload).not.toHaveProperty("_batch");
}
});
it("resets the debounce timer when new events arrive within the window", () => {
eventBus.emit(SSE_EVENT_TYPES.ALLOCATION_UPDATED, { id: "a1" });
// Advance 30ms (still within window)
vi.advanceTimersByTime(30);
expect(received).toHaveLength(0);
// Emit another — this resets the timer
eventBus.emit(SSE_EVENT_TYPES.ALLOCATION_UPDATED, { id: "a2" });
// Advance another 30ms (60ms total from first, but only 30ms from second)
vi.advanceTimersByTime(30);
expect(received).toHaveLength(0);
// Advance remaining 20ms (now 50ms from second event)
vi.advanceTimersByTime(20);
expect(received).toHaveLength(1);
expect(received[0]!.payload).toEqual({
_batch: [{ id: "a1" }, { id: "a2" }],
});
});
it("flushPendingEvents delivers immediately", () => {
eventBus.emit(SSE_EVENT_TYPES.ALLOCATION_CREATED, { id: "a1" });
eventBus.emit(SSE_EVENT_TYPES.ALLOCATION_CREATED, { id: "a2" });
expect(received).toHaveLength(0);
flushPendingEvents();
expect(received).toHaveLength(1);
expect(received[0]!.payload).toEqual({
_batch: [{ id: "a1" }, { id: "a2" }],
});
// Timer should not fire again
vi.advanceTimersByTime(100);
expect(received).toHaveLength(1);
});
it("cancelPendingEvents discards events without delivering", () => {
eventBus.emit(SSE_EVENT_TYPES.ALLOCATION_CREATED, { id: "a1" });
eventBus.emit(SSE_EVENT_TYPES.ALLOCATION_CREATED, { id: "a2" });
cancelPendingEvents();
vi.advanceTimersByTime(100);
expect(received).toHaveLength(0);
});
it("preserves the timestamp of the first event in a batch", () => {
const before = new Date().toISOString();
eventBus.emit(SSE_EVENT_TYPES.ALLOCATION_UPDATED, { id: "a1" });
// Advance a bit, emit another
vi.advanceTimersByTime(10);
eventBus.emit(SSE_EVENT_TYPES.ALLOCATION_UPDATED, { id: "a2" });
vi.advanceTimersByTime(50);
expect(received).toHaveLength(1);
// The timestamp should be from the first event (not later)
expect(received[0]!.timestamp).toBe(before);
});
});
@@ -0,0 +1,169 @@
import { describe, expect, it } from "vitest";
import { paginate, paginateCursor } from "../db/pagination.js";
// ---------------------------------------------------------------------------
// Test data
// ---------------------------------------------------------------------------
function makeItems(n: number) {
return Array.from({ length: n }, (_, i) => ({
id: `id_${String(i + 1).padStart(3, "0")}`,
name: `Item ${i + 1}`,
}));
}
const ALL_ITEMS = makeItems(55);
function mockFindMany(items: typeof ALL_ITEMS) {
return async ({ skip, take }: { skip: number; take: number }) =>
items.slice(skip, skip + take);
}
function mockCount(items: typeof ALL_ITEMS) {
return async () => items.length;
}
// ---------------------------------------------------------------------------
// paginate()
// ---------------------------------------------------------------------------
describe("paginate", () => {
it("returns first page with correct metadata", async () => {
const result = await paginate(
mockFindMany(ALL_ITEMS),
mockCount(ALL_ITEMS),
{ page: 1, limit: 20 },
);
expect(result.items).toHaveLength(20);
expect(result.total).toBe(55);
expect(result.page).toBe(1);
expect(result.limit).toBe(20);
expect(result.nextCursor).toBe("id_020");
});
it("returns last page without nextCursor", async () => {
const result = await paginate(
mockFindMany(ALL_ITEMS),
mockCount(ALL_ITEMS),
{ page: 3, limit: 20 },
);
expect(result.items).toHaveLength(15);
expect(result.total).toBe(55);
expect(result.nextCursor).toBeNull();
});
it("skips zero when cursor is provided", async () => {
let capturedSkip = -1;
const result = await paginate(
async ({ skip, take }) => {
capturedSkip = skip;
return ALL_ITEMS.slice(skip, skip + take);
},
mockCount(ALL_ITEMS),
{ page: 3, limit: 10, cursor: "irrelevant" },
);
expect(capturedSkip).toBe(0);
// When cursor is given skip=0, so we get from the start of the pre-filtered data
expect(result.items).toHaveLength(10);
});
it("uses defaults when page and limit omitted", async () => {
const result = await paginate(
mockFindMany(ALL_ITEMS),
mockCount(ALL_ITEMS),
{},
);
expect(result.page).toBe(1);
expect(result.limit).toBe(50);
expect(result.items).toHaveLength(50);
expect(result.nextCursor).toBe("id_050");
});
it("handles empty result set", async () => {
const result = await paginate(
mockFindMany([]),
mockCount([]),
{ page: 1, limit: 20 },
);
expect(result.items).toHaveLength(0);
expect(result.total).toBe(0);
expect(result.nextCursor).toBeNull();
});
it("returns no nextCursor when items exactly equal limit", async () => {
const items = makeItems(20);
const result = await paginate(
mockFindMany(items),
mockCount(items),
{ page: 1, limit: 20 },
);
expect(result.items).toHaveLength(20);
expect(result.nextCursor).toBeNull();
});
it("supports custom getId", async () => {
const items = [{ uuid: "abc" }, { uuid: "def" }, { uuid: "ghi" }];
const result = await paginate(
async ({ skip, take }) => items.slice(skip, skip + take),
async () => items.length,
{ page: 1, limit: 2 },
(item) => item.uuid,
);
expect(result.nextCursor).toBe("def");
expect(result.items).toHaveLength(2);
});
});
// ---------------------------------------------------------------------------
// paginateCursor()
// ---------------------------------------------------------------------------
describe("paginateCursor", () => {
it("returns items and nextCursor when more exist", async () => {
const result = await paginateCursor(
async ({ take }) => ALL_ITEMS.slice(0, take),
{ limit: 10 },
);
expect(result.items).toHaveLength(10);
expect(result.nextCursor).toBe("id_010");
});
it("returns null cursor when no more items", async () => {
const items = makeItems(5);
const result = await paginateCursor(
async ({ take }) => items.slice(0, take),
{ limit: 10 },
);
expect(result.items).toHaveLength(5);
expect(result.nextCursor).toBeNull();
});
it("handles empty result", async () => {
const result = await paginateCursor(
async () => [],
{ limit: 10 },
);
expect(result.items).toHaveLength(0);
expect(result.nextCursor).toBeNull();
});
it("uses default limit", async () => {
const result = await paginateCursor(
async ({ take }) => ALL_ITEMS.slice(0, take),
{},
);
expect(result.items).toHaveLength(50);
expect(result.nextCursor).toBe("id_050");
});
});
+12
View File
@@ -0,0 +1,12 @@
import { TRPCError } from "@trpc/server";
export async function findUniqueOrThrow<T>(
query: Promise<T | null>,
entityName: string,
): Promise<T> {
const result = await query;
if (!result) {
throw new TRPCError({ code: "NOT_FOUND", message: `${entityName} not found` });
}
return result;
}
+100
View File
@@ -0,0 +1,100 @@
import { z } from "zod";
// ---------------------------------------------------------------------------
// Input schema — merge into any tRPC procedure input with `.merge()` or spread
// ---------------------------------------------------------------------------
export const PaginationInputSchema = z.object({
page: z.number().int().min(1).default(1),
limit: z.number().int().min(1).max(500).default(50),
cursor: z.string().optional(),
});
export type PaginationInput = z.infer<typeof PaginationInputSchema>;
// ---------------------------------------------------------------------------
// Result envelope
// ---------------------------------------------------------------------------
export interface PaginatedResult<T> {
items: T[];
total: number;
page: number;
limit: number;
nextCursor: string | null;
}
// ---------------------------------------------------------------------------
// Core paginate helper
// ---------------------------------------------------------------------------
/**
* Run a Prisma-style `findMany` + `count` with the project's standard
* offset+cursor hybrid pagination and the `take: limit + 1` sentinel trick.
*
* @param findMany Prisma `findMany` (or compatible) that receives `{ skip, take }`.
* The caller is responsible for applying `where`/`orderBy`/`include`
* and for injecting the cursor condition into `where` when
* `cursorWhere` is provided.
* @param count Prisma `count` for the *base* where clause (without cursor).
* @param input The validated pagination input fields.
* @param getId Optional — extract the cursor id from the last item.
* Defaults to `item.id` for items that have an `id: string`.
*/
export async function paginate<T>(
findMany: (args: { skip: number; take: number }) => Promise<T[]>,
count: () => Promise<number>,
input: PaginationInput,
getId: (item: T) => string = (item) => (item as unknown as { id: string }).id,
): Promise<PaginatedResult<T>> {
const page = input.page ?? 1;
const limit = input.limit ?? 50;
const skip = input.cursor ? 0 : (page - 1) * limit;
const [rawItems, total] = await Promise.all([
findMany({ skip, take: limit + 1 }),
count(),
]);
const hasMore = rawItems.length > limit;
const items = hasMore ? rawItems.slice(0, limit) : rawItems;
const nextCursor = hasMore && items.length > 0 ? getId(items[items.length - 1]!) : null;
return { items, total, page, limit, nextCursor };
}
// ---------------------------------------------------------------------------
// Cursor-only variant (no total count, lighter)
// ---------------------------------------------------------------------------
export interface CursorResult<T> {
items: T[];
nextCursor: string | null;
}
export const CursorInputSchema = z.object({
limit: z.number().int().min(1).max(500).default(50),
cursor: z.string().optional(),
});
export type CursorInput = z.infer<typeof CursorInputSchema>;
/**
* Cursor-only pagination without a total count query.
* Useful for large tables or infinite-scroll UIs that don't need a total.
*/
export async function paginateCursor<T>(
findMany: (args: { take: number }) => Promise<T[]>,
input: CursorInput,
getId: (item: T) => string = (item) => (item as unknown as { id: string }).id,
): Promise<CursorResult<T>> {
const limit = input.limit ?? 50;
const rawItems = await findMany({ take: limit + 1 });
const hasMore = rawItems.length > limit;
const items = hasMore ? rawItems.slice(0, limit) : rawItems;
const nextCursor = hasMore && items.length > 0 ? getId(items[items.length - 1]!) : null;
return { items, nextCursor };
}
+3
View File
@@ -0,0 +1,3 @@
export const ROLE_BRIEF_SELECT = { id: true, name: true, color: true } as const;
export const PROJECT_BRIEF_SELECT = { id: true, name: true, shortCode: true, status: true, endDate: true } as const;
export const RESOURCE_BRIEF_SELECT = { id: true, displayName: true, eid: true, lcrCents: true } as const;
+2 -1
View File
@@ -1,3 +1,4 @@
export { appRouter, type AppRouter } from "./router/index.js";
export { createTRPCContext, createTRPCRouter, createCallerFactory, publicProcedure, protectedProcedure, managerProcedure, controllerProcedure, adminProcedure, requirePermission } from "./trpc.js";
export { eventBus, emitAllocationCreated, emitAllocationUpdated, emitAllocationDeleted, emitProjectShifted, emitBudgetWarning } from "./sse/event-bus.js";
export { eventBus, emitAllocationCreated, emitAllocationUpdated, emitAllocationDeleted, emitProjectShifted, emitBudgetWarning, flushPendingEvents, cancelPendingEvents } from "./sse/event-bus.js";
export { anonymizeResource, anonymizeResources, anonymizeUser, getAnonymizationConfig, getAnonymizationDirectory } from "./lib/anonymization.js";
+24 -24
View File
@@ -26,25 +26,27 @@ import {
} from "@planarchy/shared";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { findUniqueOrThrow } from "../db/helpers.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";
const DEMAND_INCLUDE = {
project: { select: { id: true, name: true, shortCode: true, status: true, endDate: true } },
roleEntity: { select: { id: true, name: true, color: true } },
project: { select: PROJECT_BRIEF_SELECT },
roleEntity: { select: ROLE_BRIEF_SELECT },
assignments: {
include: {
resource: { select: { id: true, displayName: true, eid: true, lcrCents: true } },
project: { select: { id: true, name: true, shortCode: true, status: true, endDate: true } },
roleEntity: { select: { id: true, name: true, color: true } },
resource: { select: RESOURCE_BRIEF_SELECT },
project: { select: PROJECT_BRIEF_SELECT },
roleEntity: { select: ROLE_BRIEF_SELECT },
},
},
} as const;
const ASSIGNMENT_INCLUDE = {
resource: { select: { id: true, displayName: true, eid: true, lcrCents: true } },
project: { select: { id: true, name: true, shortCode: true, status: true, endDate: true } },
roleEntity: { select: { id: true, name: true, color: true } },
resource: { select: RESOURCE_BRIEF_SELECT },
project: { select: PROJECT_BRIEF_SELECT },
roleEntity: { select: ROLE_BRIEF_SELECT },
demandRequirement: {
select: {
id: true,
@@ -358,14 +360,13 @@ export const allocationRouter = createTRPCRouter({
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
const existing = await ctx.db.demandRequirement.findUnique({
where: { id: input.id },
include: DEMAND_INCLUDE,
});
if (!existing) {
throw new TRPCError({ code: "NOT_FOUND", message: "Demand requirement not found" });
}
const existing = await findUniqueOrThrow(
ctx.db.demandRequirement.findUnique({
where: { id: input.id },
include: DEMAND_INCLUDE,
}),
"Demand requirement",
);
await ctx.db.$transaction(async (tx) => {
await deleteDemandRequirement(
@@ -473,14 +474,13 @@ export const allocationRouter = createTRPCRouter({
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
const existing = await ctx.db.assignment.findUnique({
where: { id: input.id },
include: ASSIGNMENT_INCLUDE,
});
if (!existing) {
throw new TRPCError({ code: "NOT_FOUND", message: "Assignment not found" });
}
const existing = await findUniqueOrThrow(
ctx.db.assignment.findUnique({
where: { id: input.id },
include: ASSIGNMENT_INCLUDE,
}),
"Assignment",
);
await ctx.db.$transaction(async (tx) => {
await deleteAssignment(
@@ -1,6 +1,7 @@
import { validateCustomFields } from "@planarchy/engine";
import { BlueprintTarget, type BlueprintFieldDefinition } from "@planarchy/shared";
import { TRPCError } from "@trpc/server";
import { findUniqueOrThrow } from "../db/helpers.js";
interface BlueprintLookup {
blueprint: {
@@ -26,14 +27,13 @@ export async function assertBlueprintDynamicFields({
}: AssertBlueprintDynamicFieldsInput): Promise<void> {
if (!blueprintId) return;
const blueprint = await db.blueprint.findUnique({
where: { id: blueprintId },
select: { fieldDefs: true, target: true },
});
if (!blueprint) {
throw new TRPCError({ code: "NOT_FOUND", message: "Blueprint not found" });
}
const blueprint = await findUniqueOrThrow(
db.blueprint.findUnique({
where: { id: blueprintId },
select: { fieldDefs: true, target: true },
}),
"Blueprint",
);
if (blueprint.target !== target) {
throw new TRPCError({
+13 -12
View File
@@ -1,6 +1,7 @@
import { BlueprintTarget, CreateBlueprintSchema, UpdateBlueprintSchema, type BlueprintFieldDefinition } from "@planarchy/shared";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { findUniqueOrThrow } from "../db/helpers.js";
import { adminProcedure, createTRPCRouter, protectedProcedure } from "../trpc.js";
export const blueprintRouter = createTRPCRouter({
@@ -24,10 +25,10 @@ export const blueprintRouter = createTRPCRouter({
getById: protectedProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
const blueprint = await ctx.db.blueprint.findUnique({ where: { id: input.id } });
if (!blueprint) {
throw new TRPCError({ code: "NOT_FOUND", message: "Blueprint not found" });
}
const blueprint = await findUniqueOrThrow(
ctx.db.blueprint.findUnique({ where: { id: input.id } }),
"Blueprint",
);
return blueprint;
}),
@@ -49,10 +50,10 @@ export const blueprintRouter = createTRPCRouter({
update: adminProcedure
.input(z.object({ id: z.string(), data: UpdateBlueprintSchema }))
.mutation(async ({ ctx, input }) => {
const existing = await ctx.db.blueprint.findUnique({ where: { id: input.id } });
if (!existing) {
throw new TRPCError({ code: "NOT_FOUND", message: "Blueprint not found" });
}
await findUniqueOrThrow(
ctx.db.blueprint.findUnique({ where: { id: input.id } }),
"Blueprint",
);
return ctx.db.blueprint.update({
where: { id: input.id },
@@ -70,10 +71,10 @@ export const blueprintRouter = createTRPCRouter({
updateRolePresets: adminProcedure
.input(z.object({ id: z.string(), rolePresets: z.array(z.unknown()) }))
.mutation(async ({ ctx, input }) => {
const existing = await ctx.db.blueprint.findUnique({ where: { id: input.id } });
if (!existing) {
throw new TRPCError({ code: "NOT_FOUND", message: "Blueprint not found" });
}
await findUniqueOrThrow(
ctx.db.blueprint.findUnique({ where: { id: input.id } }),
"Blueprint",
);
return ctx.db.blueprint.update({
where: { id: input.id },
data: { rolePresets: input.rolePresets as unknown as import("@planarchy/db").Prisma.InputJsonValue },
+20 -13
View File
@@ -1,6 +1,7 @@
import { CreateClientSchema, UpdateClientSchema } from "@planarchy/shared";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { findUniqueOrThrow } from "../db/helpers.js";
import { createTRPCRouter, managerProcedure, protectedProcedure } from "../trpc.js";
import type { ClientTree } from "@planarchy/shared";
@@ -64,15 +65,17 @@ export const clientRouter = createTRPCRouter({
getById: protectedProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
const client = await ctx.db.client.findUnique({
where: { id: input.id },
include: {
parent: true,
children: { orderBy: { sortOrder: "asc" } },
_count: { select: { projects: true, children: true } },
},
});
if (!client) throw new TRPCError({ code: "NOT_FOUND", message: "Client not found" });
const client = await findUniqueOrThrow(
ctx.db.client.findUnique({
where: { id: input.id },
include: {
parent: true,
children: { orderBy: { sortOrder: "asc" } },
_count: { select: { projects: true, children: true } },
},
}),
"Client",
);
return client;
}),
@@ -80,8 +83,10 @@ export const clientRouter = createTRPCRouter({
.input(CreateClientSchema)
.mutation(async ({ ctx, input }) => {
if (input.parentId) {
const parent = await ctx.db.client.findUnique({ where: { id: input.parentId } });
if (!parent) throw new TRPCError({ code: "NOT_FOUND", message: "Parent client not found" });
await findUniqueOrThrow(
ctx.db.client.findUnique({ where: { id: input.parentId } }),
"Parent client",
);
}
if (input.code) {
@@ -104,8 +109,10 @@ export const clientRouter = createTRPCRouter({
update: managerProcedure
.input(z.object({ id: z.string(), data: UpdateClientSchema }))
.mutation(async ({ ctx, input }) => {
const existing = await ctx.db.client.findUnique({ where: { id: input.id } });
if (!existing) throw new TRPCError({ code: "NOT_FOUND", message: "Client not found" });
const existing = await findUniqueOrThrow(
ctx.db.client.findUnique({ where: { id: input.id } }),
"Client",
);
if (input.data.code && input.data.code !== existing.code) {
const conflict = await ctx.db.client.findUnique({ where: { code: input.data.code } });
+26 -17
View File
@@ -7,6 +7,7 @@ import {
import { Prisma } from "@planarchy/db";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { findUniqueOrThrow } from "../db/helpers.js";
import { adminProcedure, createTRPCRouter, protectedProcedure } from "../trpc.js";
/** Convert nullable JSON to Prisma-compatible value (null → Prisma.JsonNull). */
@@ -31,14 +32,16 @@ export const countryRouter = createTRPCRouter({
getById: protectedProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
const country = await ctx.db.country.findUnique({
where: { id: input.id },
include: {
metroCities: { orderBy: { name: "asc" } },
_count: { select: { resources: true } },
},
});
if (!country) throw new TRPCError({ code: "NOT_FOUND", message: "Country not found" });
const country = await findUniqueOrThrow(
ctx.db.country.findUnique({
where: { id: input.id },
include: {
metroCities: { orderBy: { name: "asc" } },
_count: { select: { resources: true } },
},
}),
"Country",
);
return country;
}),
@@ -63,8 +66,10 @@ export const countryRouter = createTRPCRouter({
update: adminProcedure
.input(z.object({ id: z.string(), data: UpdateCountrySchema }))
.mutation(async ({ ctx, input }) => {
const existing = await ctx.db.country.findUnique({ where: { id: input.id } });
if (!existing) throw new TRPCError({ code: "NOT_FOUND", message: "Country not found" });
const existing = await findUniqueOrThrow(
ctx.db.country.findUnique({ where: { id: input.id } }),
"Country",
);
if (input.data.code && input.data.code !== existing.code) {
const conflict = await ctx.db.country.findUnique({ where: { code: input.data.code } });
@@ -91,8 +96,10 @@ export const countryRouter = createTRPCRouter({
createCity: adminProcedure
.input(CreateMetroCitySchema)
.mutation(async ({ ctx, input }) => {
const country = await ctx.db.country.findUnique({ where: { id: input.countryId } });
if (!country) throw new TRPCError({ code: "NOT_FOUND", message: "Country not found" });
await findUniqueOrThrow(
ctx.db.country.findUnique({ where: { id: input.countryId } }),
"Country",
);
return ctx.db.metroCity.create({
data: { name: input.name, countryId: input.countryId },
@@ -111,11 +118,13 @@ export const countryRouter = createTRPCRouter({
deleteCity: adminProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
const city = await ctx.db.metroCity.findUnique({
where: { id: input.id },
include: { _count: { select: { resources: true } } },
});
if (!city) throw new TRPCError({ code: "NOT_FOUND", message: "Metro city not found" });
const city = await findUniqueOrThrow(
ctx.db.metroCity.findUnique({
where: { id: input.id },
include: { _count: { select: { resources: true } } },
}),
"Metro city",
);
if (city._count.resources > 0) {
throw new TRPCError({
code: "PRECONDITION_FAILED",
+57 -44
View File
@@ -11,6 +11,7 @@ import {
} from "@planarchy/shared";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { findUniqueOrThrow } from "../db/helpers.js";
import { createTRPCRouter, controllerProcedure, managerProcedure } from "../trpc.js";
const ruleInclude = {
@@ -28,11 +29,13 @@ export const effortRuleRouter = createTRPCRouter({
getById: controllerProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
const ruleSet = await ctx.db.effortRuleSet.findUnique({
where: { id: input.id },
include: ruleInclude,
});
if (!ruleSet) throw new TRPCError({ code: "NOT_FOUND", message: "Effort rule set not found" });
const ruleSet = await findUniqueOrThrow(
ctx.db.effortRuleSet.findUnique({
where: { id: input.id },
include: ruleInclude,
}),
"Effort rule set",
);
return ruleSet;
}),
@@ -71,8 +74,10 @@ export const effortRuleRouter = createTRPCRouter({
update: managerProcedure
.input(UpdateEffortRuleSetSchema)
.mutation(async ({ ctx, input }) => {
const existing = await ctx.db.effortRuleSet.findUnique({ where: { id: input.id } });
if (!existing) throw new TRPCError({ code: "NOT_FOUND", message: "Effort rule set not found" });
await findUniqueOrThrow(
ctx.db.effortRuleSet.findUnique({ where: { id: input.id } }),
"Effort rule set",
);
// If setting as default, unset others
if (input.isDefault) {
@@ -113,8 +118,10 @@ export const effortRuleRouter = createTRPCRouter({
delete: managerProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
const existing = await ctx.db.effortRuleSet.findUnique({ where: { id: input.id } });
if (!existing) throw new TRPCError({ code: "NOT_FOUND", message: "Effort rule set not found" });
await findUniqueOrThrow(
ctx.db.effortRuleSet.findUnique({ where: { id: input.id } }),
"Effort rule set",
);
await ctx.db.effortRuleSet.delete({ where: { id: input.id } });
return { id: input.id };
}),
@@ -127,25 +134,28 @@ export const effortRuleRouter = createTRPCRouter({
}))
.query(async ({ ctx, input }) => {
const [estimate, ruleSet] = await Promise.all([
ctx.db.estimate.findUnique({
where: { id: input.estimateId },
include: {
versions: {
orderBy: { versionNumber: "desc" },
take: 1,
include: { scopeItems: { orderBy: { sortOrder: "asc" } } },
findUniqueOrThrow(
ctx.db.estimate.findUnique({
where: { id: input.estimateId },
include: {
versions: {
orderBy: { versionNumber: "desc" },
take: 1,
include: { scopeItems: { orderBy: { sortOrder: "asc" } } },
},
},
},
}),
ctx.db.effortRuleSet.findUnique({
where: { id: input.ruleSetId },
include: ruleInclude,
}),
}),
"Estimate",
),
findUniqueOrThrow(
ctx.db.effortRuleSet.findUnique({
where: { id: input.ruleSetId },
include: ruleInclude,
}),
"Effort rule set",
),
]);
if (!estimate) throw new TRPCError({ code: "NOT_FOUND", message: "Estimate not found" });
if (!ruleSet) throw new TRPCError({ code: "NOT_FOUND", message: "Effort rule set not found" });
const version = estimate.versions[0];
if (!version) throw new TRPCError({ code: "NOT_FOUND", message: "Estimate has no versions" });
@@ -178,32 +188,35 @@ export const effortRuleRouter = createTRPCRouter({
}),
/** Apply effort rules to generate demand lines on the working version */
apply: managerProcedure
applyRules: managerProcedure
.input(ApplyEffortRulesSchema)
.mutation(async ({ ctx, input }) => {
const [estimate, ruleSet] = await Promise.all([
ctx.db.estimate.findUnique({
where: { id: input.estimateId },
include: {
versions: {
orderBy: { versionNumber: "desc" },
take: 1,
include: {
scopeItems: { orderBy: { sortOrder: "asc" } },
demandLines: true,
findUniqueOrThrow(
ctx.db.estimate.findUnique({
where: { id: input.estimateId },
include: {
versions: {
orderBy: { versionNumber: "desc" },
take: 1,
include: {
scopeItems: { orderBy: { sortOrder: "asc" } },
demandLines: true,
},
},
},
},
}),
ctx.db.effortRuleSet.findUnique({
where: { id: input.ruleSetId },
include: ruleInclude,
}),
}),
"Estimate",
),
findUniqueOrThrow(
ctx.db.effortRuleSet.findUnique({
where: { id: input.ruleSetId },
include: ruleInclude,
}),
"Effort rule set",
),
]);
if (!estimate) throw new TRPCError({ code: "NOT_FOUND", message: "Estimate not found" });
if (!ruleSet) throw new TRPCError({ code: "NOT_FOUND", message: "Effort rule set not found" });
const version = estimate.versions[0];
if (!version) throw new TRPCError({ code: "NOT_FOUND", message: "Estimate has no versions" });
if (version.status !== "WORKING") {
+120 -37
View File
@@ -22,6 +22,7 @@ import {
import {
ApproveEstimateVersionSchema,
CloneEstimateSchema,
CommercialTermsSchema,
CreateEstimateExportSchema,
CreateEstimatePlanningHandoffSchema,
CreateEstimateSchema,
@@ -30,10 +31,12 @@ import {
GenerateWeeklyPhasingSchema,
PermissionKey,
SubmitEstimateVersionSchema,
UpdateCommercialTermsSchema,
UpdateEstimateDraftSchema,
} from "@planarchy/shared";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { findUniqueOrThrow } from "../db/helpers.js";
import {
controllerProcedure,
createTRPCRouter,
@@ -151,15 +154,14 @@ export const estimateRouter = createTRPCRouter({
getById: controllerProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
const estimate = await getEstimateById(
ctx.db as unknown as Parameters<typeof getEstimateById>[0],
input.id,
const estimate = await findUniqueOrThrow(
getEstimateById(
ctx.db as unknown as Parameters<typeof getEstimateById>[0],
input.id,
),
"Estimate",
);
if (!estimate) {
throw new TRPCError({ code: "NOT_FOUND", message: "Estimate not found" });
}
return estimate;
}),
@@ -169,14 +171,13 @@ export const estimateRouter = createTRPCRouter({
requirePermission(ctx, PermissionKey.MANAGE_PROJECTS);
if (input.projectId) {
const project = await ctx.db.project.findUnique({
where: { id: input.projectId },
select: { id: true },
});
if (!project) {
throw new TRPCError({ code: "NOT_FOUND", message: "Project not found" });
}
await findUniqueOrThrow(
ctx.db.project.findUnique({
where: { id: input.projectId },
select: { id: true },
}),
"Project",
);
}
const estimate = await createEstimate(
@@ -253,14 +254,13 @@ export const estimateRouter = createTRPCRouter({
requirePermission(ctx, PermissionKey.MANAGE_PROJECTS);
if (input.projectId) {
const project = await ctx.db.project.findUnique({
where: { id: input.projectId },
select: { id: true },
});
if (!project) {
throw new TRPCError({ code: "NOT_FOUND", message: "Project not found" });
}
await findUniqueOrThrow(
ctx.db.project.findUnique({
where: { id: input.projectId },
select: { id: true },
}),
"Project",
);
}
let estimate;
@@ -592,15 +592,14 @@ export const estimateRouter = createTRPCRouter({
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_PROJECTS);
const estimate = await getEstimateById(
ctx.db as unknown as Parameters<typeof getEstimateById>[0],
input.estimateId,
const estimate = await findUniqueOrThrow(
getEstimateById(
ctx.db as unknown as Parameters<typeof getEstimateById>[0],
input.estimateId,
),
"Estimate",
);
if (!estimate) {
throw new TRPCError({ code: "NOT_FOUND", message: "Estimate not found" });
}
const workingVersion = estimate.versions.find(
(v) => v.status === "WORKING",
);
@@ -668,15 +667,14 @@ export const estimateRouter = createTRPCRouter({
getWeeklyPhasing: controllerProcedure
.input(z.object({ estimateId: z.string() }))
.query(async ({ ctx, input }) => {
const estimate = await getEstimateById(
ctx.db as unknown as Parameters<typeof getEstimateById>[0],
input.estimateId,
const estimate = await findUniqueOrThrow(
getEstimateById(
ctx.db as unknown as Parameters<typeof getEstimateById>[0],
input.estimateId,
),
"Estimate",
);
if (!estimate) {
throw new TRPCError({ code: "NOT_FOUND", message: "Estimate not found" });
}
// Get the latest version (first in the sorted array)
const version = estimate.versions[0];
@@ -754,4 +752,89 @@ export const estimateRouter = createTRPCRouter({
chapterAggregation,
};
}),
// ─── Commercial Terms ───────────────────────────────────────────────────
getCommercialTerms: controllerProcedure
.input(z.object({ estimateId: z.string(), versionId: z.string().optional() }))
.query(async ({ ctx, input }) => {
const estimate = await ctx.db.estimate.findUnique({
where: { id: input.estimateId },
include: {
versions: {
...(input.versionId
? { where: { id: input.versionId } }
: { orderBy: { versionNumber: "desc" as const }, take: 1 }),
select: { id: true, commercialTerms: true },
},
},
});
if (!estimate || estimate.versions.length === 0) {
throw new TRPCError({ code: "NOT_FOUND", message: "Estimate version not found" });
}
const version = estimate.versions[0]!;
const raw = version.commercialTerms;
// Parse stored JSON through Zod for type safety, fall back to defaults
const terms = raw
? CommercialTermsSchema.parse(raw)
: CommercialTermsSchema.parse({});
return { versionId: version.id, terms };
}),
updateCommercialTerms: managerProcedure
.input(UpdateCommercialTermsSchema)
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_PROJECTS);
const estimate = await ctx.db.estimate.findUnique({
where: { id: input.estimateId },
include: {
versions: {
...(input.versionId
? { where: { id: input.versionId } }
: { where: { status: "WORKING" }, take: 1 }),
select: { id: true, status: true },
},
},
});
if (!estimate || estimate.versions.length === 0) {
throw new TRPCError({ code: "NOT_FOUND", message: "Estimate version not found" });
}
const version = estimate.versions[0]!;
if (version.status !== "WORKING") {
throw new TRPCError({
code: "PRECONDITION_FAILED",
message: "Commercial terms can only be edited on working versions",
});
}
const validated = CommercialTermsSchema.parse(input.terms);
await ctx.db.estimateVersion.update({
where: { id: version.id },
data: { commercialTerms: validated as unknown as Prisma.InputJsonValue },
});
await ctx.db.auditLog.create({
data: {
entityType: "Estimate",
entityId: estimate.id,
action: "UPDATE",
...(ctx.dbUser?.id ? { userId: ctx.dbUser.id } : {}),
changes: {
field: "commercialTerms",
after: validated,
} as Prisma.InputJsonValue,
},
});
return { versionId: version.id, terms: validated };
}),
});
@@ -10,6 +10,7 @@ import {
} from "@planarchy/shared";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { findUniqueOrThrow } from "../db/helpers.js";
import { createTRPCRouter, controllerProcedure, managerProcedure } from "../trpc.js";
const ruleInclude = {
@@ -51,11 +52,13 @@ export const experienceMultiplierRouter = createTRPCRouter({
getById: controllerProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
const set = await ctx.db.experienceMultiplierSet.findUnique({
where: { id: input.id },
include: ruleInclude,
});
if (!set) throw new TRPCError({ code: "NOT_FOUND", message: "Experience multiplier set not found" });
const set = await findUniqueOrThrow(
ctx.db.experienceMultiplierSet.findUnique({
where: { id: input.id },
include: ruleInclude,
}),
"Experience multiplier set",
);
return set;
}),
@@ -95,8 +98,10 @@ export const experienceMultiplierRouter = createTRPCRouter({
update: managerProcedure
.input(UpdateExperienceMultiplierSetSchema)
.mutation(async ({ ctx, input }) => {
const existing = await ctx.db.experienceMultiplierSet.findUnique({ where: { id: input.id } });
if (!existing) throw new TRPCError({ code: "NOT_FOUND", message: "Experience multiplier set not found" });
await findUniqueOrThrow(
ctx.db.experienceMultiplierSet.findUnique({ where: { id: input.id } }),
"Experience multiplier set",
);
if (input.isDefault) {
await ctx.db.experienceMultiplierSet.updateMany({
@@ -137,8 +142,10 @@ export const experienceMultiplierRouter = createTRPCRouter({
delete: managerProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
const existing = await ctx.db.experienceMultiplierSet.findUnique({ where: { id: input.id } });
if (!existing) throw new TRPCError({ code: "NOT_FOUND", message: "Experience multiplier set not found" });
await findUniqueOrThrow(
ctx.db.experienceMultiplierSet.findUnique({ where: { id: input.id } }),
"Experience multiplier set",
);
await ctx.db.experienceMultiplierSet.delete({ where: { id: input.id } });
return { id: input.id };
}),
@@ -151,25 +158,28 @@ export const experienceMultiplierRouter = createTRPCRouter({
}))
.query(async ({ ctx, input }) => {
const [estimate, multiplierSet] = await Promise.all([
ctx.db.estimate.findUnique({
where: { id: input.estimateId },
include: {
versions: {
orderBy: { versionNumber: "desc" },
take: 1,
include: { demandLines: { orderBy: { createdAt: "asc" } } },
findUniqueOrThrow(
ctx.db.estimate.findUnique({
where: { id: input.estimateId },
include: {
versions: {
orderBy: { versionNumber: "desc" },
take: 1,
include: { demandLines: { orderBy: { createdAt: "asc" } } },
},
},
},
}),
ctx.db.experienceMultiplierSet.findUnique({
where: { id: input.multiplierSetId },
include: ruleInclude,
}),
}),
"Estimate",
),
findUniqueOrThrow(
ctx.db.experienceMultiplierSet.findUnique({
where: { id: input.multiplierSetId },
include: ruleInclude,
}),
"Experience multiplier set",
),
]);
if (!estimate) throw new TRPCError({ code: "NOT_FOUND", message: "Estimate not found" });
if (!multiplierSet) throw new TRPCError({ code: "NOT_FOUND", message: "Experience multiplier set not found" });
const version = estimate.versions[0];
if (!version) throw new TRPCError({ code: "NOT_FOUND", message: "Estimate has no versions" });
@@ -227,29 +237,32 @@ export const experienceMultiplierRouter = createTRPCRouter({
}),
/** Apply multipliers to demand lines on the working version */
apply: managerProcedure
applyRules: managerProcedure
.input(ApplyExperienceMultipliersSchema)
.mutation(async ({ ctx, input }) => {
const [estimate, multiplierSet] = await Promise.all([
ctx.db.estimate.findUnique({
where: { id: input.estimateId },
include: {
versions: {
orderBy: { versionNumber: "desc" },
take: 1,
include: { demandLines: true },
findUniqueOrThrow(
ctx.db.estimate.findUnique({
where: { id: input.estimateId },
include: {
versions: {
orderBy: { versionNumber: "desc" },
take: 1,
include: { demandLines: true },
},
},
},
}),
ctx.db.experienceMultiplierSet.findUnique({
where: { id: input.multiplierSetId },
include: ruleInclude,
}),
}),
"Estimate",
),
findUniqueOrThrow(
ctx.db.experienceMultiplierSet.findUnique({
where: { id: input.multiplierSetId },
include: ruleInclude,
}),
"Experience multiplier set",
),
]);
if (!estimate) throw new TRPCError({ code: "NOT_FOUND", message: "Estimate not found" });
if (!multiplierSet) throw new TRPCError({ code: "NOT_FOUND", message: "Experience multiplier set not found" });
const version = estimate.versions[0];
if (!version) throw new TRPCError({ code: "NOT_FOUND", message: "Estimate has no versions" });
if (version.status !== "WORKING") {
+30 -19
View File
@@ -6,6 +6,7 @@ import {
} from "@planarchy/shared";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { findUniqueOrThrow } from "../db/helpers.js";
import { adminProcedure, createTRPCRouter, protectedProcedure } from "../trpc.js";
export const managementLevelRouter = createTRPCRouter({
@@ -21,14 +22,16 @@ export const managementLevelRouter = createTRPCRouter({
getGroupById: protectedProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
const group = await ctx.db.managementLevelGroup.findUnique({
where: { id: input.id },
include: {
levels: { orderBy: { name: "asc" } },
_count: { select: { resources: true } },
},
});
if (!group) throw new TRPCError({ code: "NOT_FOUND", message: "Management level group not found" });
const group = await findUniqueOrThrow(
ctx.db.managementLevelGroup.findUnique({
where: { id: input.id },
include: {
levels: { orderBy: { name: "asc" } },
_count: { select: { resources: true } },
},
}),
"Management level group",
);
return group;
}),
@@ -52,8 +55,10 @@ export const managementLevelRouter = createTRPCRouter({
updateGroup: adminProcedure
.input(z.object({ id: z.string(), data: UpdateManagementLevelGroupSchema }))
.mutation(async ({ ctx, input }) => {
const existing = await ctx.db.managementLevelGroup.findUnique({ where: { id: input.id } });
if (!existing) throw new TRPCError({ code: "NOT_FOUND", message: "Group not found" });
const existing = await findUniqueOrThrow(
ctx.db.managementLevelGroup.findUnique({ where: { id: input.id } }),
"Group",
);
if (input.data.name && input.data.name !== existing.name) {
const conflict = await ctx.db.managementLevelGroup.findUnique({ where: { name: input.data.name } });
@@ -78,8 +83,10 @@ export const managementLevelRouter = createTRPCRouter({
createLevel: adminProcedure
.input(CreateManagementLevelSchema)
.mutation(async ({ ctx, input }) => {
const group = await ctx.db.managementLevelGroup.findUnique({ where: { id: input.groupId } });
if (!group) throw new TRPCError({ code: "NOT_FOUND", message: "Group not found" });
await findUniqueOrThrow(
ctx.db.managementLevelGroup.findUnique({ where: { id: input.groupId } }),
"Group",
);
const existing = await ctx.db.managementLevel.findUnique({ where: { name: input.name } });
if (existing) {
@@ -94,8 +101,10 @@ export const managementLevelRouter = createTRPCRouter({
updateLevel: adminProcedure
.input(z.object({ id: z.string(), data: UpdateManagementLevelSchema }))
.mutation(async ({ ctx, input }) => {
const existing = await ctx.db.managementLevel.findUnique({ where: { id: input.id } });
if (!existing) throw new TRPCError({ code: "NOT_FOUND", message: "Level not found" });
const existing = await findUniqueOrThrow(
ctx.db.managementLevel.findUnique({ where: { id: input.id } }),
"Level",
);
if (input.data.name && input.data.name !== existing.name) {
const conflict = await ctx.db.managementLevel.findUnique({ where: { name: input.data.name } });
@@ -116,11 +125,13 @@ export const managementLevelRouter = createTRPCRouter({
deleteLevel: adminProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
const level = await ctx.db.managementLevel.findUnique({
where: { id: input.id },
include: { _count: { select: { resources: true } } },
});
if (!level) throw new TRPCError({ code: "NOT_FOUND", message: "Level not found" });
const level = await findUniqueOrThrow(
ctx.db.managementLevel.findUnique({
where: { id: input.id },
include: { _count: { select: { resources: true } } },
}),
"Level",
);
if (level._count.resources > 0) {
throw new TRPCError({
code: "PRECONDITION_FAILED",
+20 -13
View File
@@ -1,6 +1,7 @@
import { CreateOrgUnitSchema, UpdateOrgUnitSchema } from "@planarchy/shared";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { findUniqueOrThrow } from "../db/helpers.js";
import { adminProcedure, createTRPCRouter, protectedProcedure } from "../trpc.js";
import type { OrgUnitTree } from "@planarchy/shared";
@@ -62,15 +63,17 @@ export const orgUnitRouter = createTRPCRouter({
getById: protectedProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
const unit = await ctx.db.orgUnit.findUnique({
where: { id: input.id },
include: {
parent: true,
children: { orderBy: { sortOrder: "asc" } },
_count: { select: { resources: true } },
},
});
if (!unit) throw new TRPCError({ code: "NOT_FOUND", message: "Org unit not found" });
const unit = await findUniqueOrThrow(
ctx.db.orgUnit.findUnique({
where: { id: input.id },
include: {
parent: true,
children: { orderBy: { sortOrder: "asc" } },
_count: { select: { resources: true } },
},
}),
"Org unit",
);
return unit;
}),
@@ -78,8 +81,10 @@ export const orgUnitRouter = createTRPCRouter({
.input(CreateOrgUnitSchema)
.mutation(async ({ ctx, input }) => {
if (input.parentId) {
const parent = await ctx.db.orgUnit.findUnique({ where: { id: input.parentId } });
if (!parent) throw new TRPCError({ code: "NOT_FOUND", message: "Parent org unit not found" });
const parent = await findUniqueOrThrow(
ctx.db.orgUnit.findUnique({ where: { id: input.parentId } }),
"Parent org unit",
);
if (parent.level >= input.level) {
throw new TRPCError({
code: "BAD_REQUEST",
@@ -102,8 +107,10 @@ export const orgUnitRouter = createTRPCRouter({
update: adminProcedure
.input(z.object({ id: z.string(), data: UpdateOrgUnitSchema }))
.mutation(async ({ ctx, input }) => {
const existing = await ctx.db.orgUnit.findUnique({ where: { id: input.id } });
if (!existing) throw new TRPCError({ code: "NOT_FOUND", message: "Org unit not found" });
await findUniqueOrThrow(
ctx.db.orgUnit.findUnique({ where: { id: input.id } }),
"Org unit",
);
return ctx.db.orgUnit.update({
where: { id: input.id },
@@ -44,6 +44,26 @@ export const PROJECT_PLANNING_ASSIGNMENT_INCLUDE = {
roleEntity: PROJECT_PLANNING_ALLOCATION_INCLUDE.roleEntity,
} as const;
/**
* Lighter resource select for timeline rendering (hot path).
* Omits `availability` which is only needed for budget/cost calculations.
*/
const TIMELINE_RESOURCE_SELECT = {
select: {
id: true,
displayName: true,
eid: true,
chapter: true,
lcrCents: true,
},
} as const;
export const TIMELINE_ASSIGNMENT_INCLUDE = {
resource: TIMELINE_RESOURCE_SELECT,
project: PROJECT_PLANNING_ALLOCATION_INCLUDE.project,
roleEntity: PROJECT_PLANNING_ALLOCATION_INCLUDE.roleEntity,
} as const;
type ProjectPlanningReadDbClient = Pick<
PrismaClient,
"demandRequirement" | "assignment"
+40 -44
View File
@@ -5,6 +5,8 @@ import {
import { BlueprintTarget, CreateProjectSchema, FieldType, PermissionKey, ProjectStatus, UpdateProjectSchema } from "@planarchy/shared";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { findUniqueOrThrow } from "../db/helpers.js";
import { paginate, paginateCursor, PaginationInputSchema, CursorInputSchema } from "../db/pagination.js";
import { assertBlueprintDynamicFields } from "./blueprint-validation.js";
import { buildDynamicFieldWhereClauses } from "./custom-field-filters.js";
import { loadProjectPlanningReadModel } from "./project-planning-read-model.js";
@@ -13,13 +15,9 @@ import { controllerProcedure, createTRPCRouter, managerProcedure, protectedProce
export const projectRouter = createTRPCRouter({
list: protectedProcedure
.input(
z.object({
PaginationInputSchema.extend({
status: z.nativeEnum(ProjectStatus).optional(),
search: z.string().optional(),
page: z.number().int().min(1).default(1),
limit: z.number().int().min(1).max(500).default(50),
// Cursor-based pagination (additive — page/limit still supported)
cursor: z.string().optional(),
// Custom field JSONB filters
customFieldFilters: z.array(z.object({
key: z.string(),
@@ -29,7 +27,7 @@ export const projectRouter = createTRPCRouter({
}),
)
.query(async ({ ctx, input }) => {
const { status, search, page, limit, cursor, customFieldFilters } = input;
const { status, search, cursor, customFieldFilters } = input;
const cfConditions = buildDynamicFieldWhereClauses(customFieldFilters).map((dynamicFields) => ({ dynamicFields }));
@@ -47,36 +45,35 @@ export const projectRouter = createTRPCRouter({
...(cfConditions.length > 0 ? { AND: cfConditions } : {}),
};
const skip = cursor ? 0 : (page - 1) * limit;
const whereWithCursor = cursor ? { ...where, id: { gt: cursor } } : where;
const [rawProjects, total] = await Promise.all([
ctx.db.project.findMany({
where: whereWithCursor,
skip,
take: limit + 1,
orderBy: [{ startDate: "asc" }, { id: "asc" }],
}),
ctx.db.project.count({ where }),
]);
const hasMore = rawProjects.length > limit;
const projects = hasMore ? rawProjects.slice(0, limit) : rawProjects;
const nextCursor = hasMore ? projects[projects.length - 1]!.id : null;
const result = await paginate(
({ skip, take }) =>
ctx.db.project.findMany({
where: whereWithCursor,
skip,
take,
orderBy: [{ startDate: "asc" }, { id: "asc" }],
}),
() => ctx.db.project.count({ where }),
input,
);
const { countsByProjectId } = await countPlanningEntries(ctx.db, {
projectIds: projects.map((project) => project.id),
projectIds: result.items.map((project) => project.id),
});
return {
projects: projects.map((project) => ({
projects: result.items.map((project) => ({
...project,
_count: {
allocations: countsByProjectId.get(project.id) ?? 0,
},
})),
total,
page,
limit,
nextCursor,
total: result.total,
page: result.page,
limit: result.limit,
nextCursor: result.nextCursor,
};
}),
@@ -161,10 +158,10 @@ export const projectRouter = createTRPCRouter({
.input(z.object({ id: z.string(), data: UpdateProjectSchema }))
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_PROJECTS);
const existing = await ctx.db.project.findUnique({ where: { id: input.id } });
if (!existing) {
throw new TRPCError({ code: "NOT_FOUND", message: "Project not found" });
}
const existing = await findUniqueOrThrow(
ctx.db.project.findUnique({ where: { id: input.id } }),
"Project",
);
const nextBlueprintId = input.data.blueprintId ?? existing.blueprintId ?? undefined;
const nextDynamicFields = (input.data.dynamicFields ?? existing.dynamicFields ?? {}) as Record<string, unknown>;
@@ -247,15 +244,13 @@ export const projectRouter = createTRPCRouter({
listWithCosts: controllerProcedure
.input(
z.object({
CursorInputSchema.extend({
status: z.nativeEnum(ProjectStatus).optional(),
search: z.string().optional(),
limit: z.number().int().min(1).max(500).default(50),
cursor: z.string().optional(),
}),
)
.query(async ({ ctx, input }) => {
const { status, search, limit, cursor } = input;
const { status, search, cursor } = input;
const where = {
...(status ? { status } : {}),
...(search
@@ -269,16 +264,17 @@ export const projectRouter = createTRPCRouter({
};
const whereWithCursor = cursor ? { ...where, id: { gt: cursor } } : where;
const rawProjects = await ctx.db.project.findMany({
where: whereWithCursor,
take: limit + 1,
orderBy: [{ startDate: "asc" }, { id: "asc" }],
});
const result = await paginateCursor(
({ take }) =>
ctx.db.project.findMany({
where: whereWithCursor,
take,
orderBy: [{ startDate: "asc" }, { id: "asc" }],
}),
input,
);
const hasMore = rawProjects.length > limit;
const projectsRaw = hasMore ? rawProjects.slice(0, limit) : rawProjects;
const nextCursor = hasMore ? projectsRaw[projectsRaw.length - 1]!.id : null;
const projectIds = projectsRaw.map((project) => project.id);
const projectIds = result.items.map((project) => project.id);
const bookings = projectIds.length
? await listAssignmentBookings(ctx.db, {
startDate: new Date("1900-01-01T00:00:00.000Z"),
@@ -288,7 +284,7 @@ export const projectRouter = createTRPCRouter({
: [];
// Compute cost + person days per project
const projects = projectsRaw.map((p) => {
const projects = result.items.map((p) => {
const projectBookings = bookings.filter((booking) => booking.projectId === p.id);
let totalCostCents = 0;
let totalPersonDays = 0;
@@ -311,6 +307,6 @@ export const projectRouter = createTRPCRouter({
};
});
return { projects, nextCursor };
return { projects, nextCursor: result.nextCursor };
}),
});
+35 -21
View File
@@ -7,7 +7,9 @@ import {
} from "@planarchy/shared";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { findUniqueOrThrow } from "../db/helpers.js";
import { createTRPCRouter, controllerProcedure, managerProcedure } from "../trpc.js";
import { ROLE_BRIEF_SELECT } from "../db/selects.js";
const lineSelect = {
id: true,
@@ -22,7 +24,7 @@ const lineSelect = {
billRateCents: true,
machineRateCents: true,
attributes: true,
role: { select: { id: true, name: true, color: true } },
role: { select: ROLE_BRIEF_SELECT },
createdAt: true,
updatedAt: true,
} as const;
@@ -73,17 +75,19 @@ export const rateCardRouter = createTRPCRouter({
getById: controllerProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
const rateCard = await ctx.db.rateCard.findUnique({
where: { id: input.id },
include: {
client: { select: { id: true, name: true, code: true } },
lines: {
select: lineSelect,
orderBy: [{ chapter: "asc" }, { seniority: "asc" }, { createdAt: "asc" }],
const rateCard = await findUniqueOrThrow(
ctx.db.rateCard.findUnique({
where: { id: input.id },
include: {
client: { select: { id: true, name: true, code: true } },
lines: {
select: lineSelect,
orderBy: [{ chapter: "asc" }, { seniority: "asc" }, { createdAt: "asc" }],
},
},
},
});
if (!rateCard) throw new TRPCError({ code: "NOT_FOUND", message: "Rate card not found" });
}),
"Rate card",
);
return rateCard;
}),
@@ -124,8 +128,10 @@ export const rateCardRouter = createTRPCRouter({
update: managerProcedure
.input(z.object({ id: z.string(), data: UpdateRateCardSchema }))
.mutation(async ({ ctx, input }) => {
const existing = await ctx.db.rateCard.findUnique({ where: { id: input.id } });
if (!existing) throw new TRPCError({ code: "NOT_FOUND", message: "Rate card not found" });
await findUniqueOrThrow(
ctx.db.rateCard.findUnique({ where: { id: input.id } }),
"Rate card",
);
return ctx.db.rateCard.update({
where: { id: input.id },
@@ -159,8 +165,10 @@ export const rateCardRouter = createTRPCRouter({
addLine: managerProcedure
.input(z.object({ rateCardId: z.string(), line: CreateRateCardLineSchema }))
.mutation(async ({ ctx, input }) => {
const card = await ctx.db.rateCard.findUnique({ where: { id: input.rateCardId } });
if (!card) throw new TRPCError({ code: "NOT_FOUND", message: "Rate card not found" });
await findUniqueOrThrow(
ctx.db.rateCard.findUnique({ where: { id: input.rateCardId } }),
"Rate card",
);
return ctx.db.rateCardLine.create({
data: {
@@ -183,8 +191,10 @@ export const rateCardRouter = createTRPCRouter({
updateLine: managerProcedure
.input(z.object({ lineId: z.string(), data: UpdateRateCardLineSchema }))
.mutation(async ({ ctx, input }) => {
const existing = await ctx.db.rateCardLine.findUnique({ where: { id: input.lineId } });
if (!existing) throw new TRPCError({ code: "NOT_FOUND", message: "Rate card line not found" });
await findUniqueOrThrow(
ctx.db.rateCardLine.findUnique({ where: { id: input.lineId } }),
"Rate card line",
);
const updateData: Prisma.RateCardLineUpdateInput = {};
if (input.data.roleId !== undefined) updateData.role = input.data.roleId ? { connect: { id: input.data.roleId } } : { disconnect: true };
@@ -208,8 +218,10 @@ export const rateCardRouter = createTRPCRouter({
deleteLine: managerProcedure
.input(z.object({ lineId: z.string() }))
.mutation(async ({ ctx, input }) => {
const existing = await ctx.db.rateCardLine.findUnique({ where: { id: input.lineId } });
if (!existing) throw new TRPCError({ code: "NOT_FOUND", message: "Rate card line not found" });
await findUniqueOrThrow(
ctx.db.rateCardLine.findUnique({ where: { id: input.lineId } }),
"Rate card line",
);
await ctx.db.rateCardLine.delete({ where: { id: input.lineId } });
return { deleted: true };
@@ -223,8 +235,10 @@ export const rateCardRouter = createTRPCRouter({
lines: z.array(CreateRateCardLineSchema),
}))
.mutation(async ({ ctx, input }) => {
const card = await ctx.db.rateCard.findUnique({ where: { id: input.rateCardId } });
if (!card) throw new TRPCError({ code: "NOT_FOUND", message: "Rate card not found" });
await findUniqueOrThrow(
ctx.db.rateCard.findUnique({ where: { id: input.rateCardId } }),
"Rate card",
);
return ctx.db.$transaction(async (tx) => {
await tx.rateCardLine.deleteMany({ where: { rateCardId: input.rateCardId } });
+286 -141
View File
@@ -1,11 +1,22 @@
import { createAiClient, isAiConfigured } from "../ai-client.js";
import { listAssignmentBookings } from "@planarchy/application";
import { BlueprintTarget, CreateResourceSchema, FieldType, PermissionKey, ResourceRoleSchema, SkillEntrySchema, UpdateResourceSchema, VALUE_SCORE_WEIGHTS, inferStateFromPostalCode } from "@planarchy/shared";
import {
isChargeabilityActualBooking,
isChargeabilityRelevantProject,
listAssignmentBookings,
recomputeResourceValueScores,
} from "@planarchy/application";
import { BlueprintTarget, CreateResourceSchema, FieldType, PermissionKey, ResourceRoleSchema, ResourceType, SkillEntrySchema, UpdateResourceSchema, inferStateFromPostalCode } from "@planarchy/shared";
import type { WeekdayAvailability } from "@planarchy/shared";
import { computeValueScore } from "@planarchy/staffing";
import { computeChargeability } from "@planarchy/engine";
import { assertBlueprintDynamicFields } from "./blueprint-validation.js";
import { buildDynamicFieldWhereClauses } from "./custom-field-filters.js";
import {
anonymizeResource,
anonymizeResources,
anonymizeSearchMatches,
getAnonymizationDirectory,
resolveResourceIdsByDisplayedEids,
} from "../lib/anonymization.js";
export const DEFAULT_SUMMARY_PROMPT = `You are writing a short professional profile for an internal resource planning tool.
@@ -18,16 +29,40 @@ Artist profile:
Write a 23 sentence professional bio. Be specific, use skill names. No fluff.`;
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { findUniqueOrThrow } from "../db/helpers.js";
import { adminProcedure, controllerProcedure, createTRPCRouter, managerProcedure, protectedProcedure, requirePermission } from "../trpc.js";
import { ROLE_BRIEF_SELECT } from "../db/selects.js";
function parseResourceCursor(cursor: string | undefined): { displayName: string; id: string } | null {
if (!cursor) return null;
try {
const decoded = JSON.parse(cursor) as { displayName?: string; id?: string };
if (typeof decoded.displayName === "string" && typeof decoded.id === "string") {
return { displayName: decoded.displayName, id: decoded.id };
}
} catch {
return null;
}
return null;
}
export const resourceRouter = createTRPCRouter({
list: protectedProcedure
.input(
z.object({
chapter: z.string().optional(),
chapters: z.array(z.string()).optional(),
isActive: z.boolean().optional().default(true),
search: z.string().optional(),
eids: z.array(z.string()).optional(),
countryIds: z.array(z.string()).optional(),
excludedCountryIds: z.array(z.string()).optional(),
includeWithoutCountry: z.boolean().optional().default(true),
resourceTypes: z.array(z.nativeEnum(ResourceType)).optional(),
excludedResourceTypes: z.array(z.nativeEnum(ResourceType)).optional(),
includeWithoutResourceType: z.boolean().optional().default(true),
rolledOff: z.boolean().optional(),
departed: z.boolean().optional(),
page: z.number().int().min(1).default(1),
limit: z.number().int().min(1).max(500).default(50),
includeRoles: z.boolean().optional().default(false),
@@ -42,31 +77,192 @@ export const resourceRouter = createTRPCRouter({
}),
)
.query(async ({ ctx, input }) => {
const { chapter, isActive, search, eids, page, limit, includeRoles, cursor, customFieldFilters } = input;
const {
chapter,
chapters,
isActive,
search,
eids,
countryIds,
excludedCountryIds,
includeWithoutCountry,
resourceTypes,
excludedResourceTypes,
includeWithoutResourceType,
rolledOff,
departed,
page,
limit,
includeRoles,
cursor,
customFieldFilters,
} = input;
const parsedCursor = parseResourceCursor(cursor);
const cfConditions = buildDynamicFieldWhereClauses(customFieldFilters).map((dynamicFields) => ({ dynamicFields }));
type WhereClause = Record<string, unknown>;
const andClauses: WhereClause[] = [];
const chapterFilters = Array.from(
new Set([
...(chapter ? [chapter] : []),
...(chapters ?? []),
]),
);
const directory = await getAnonymizationDirectory(ctx.db);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const where: any = {
...(eids ? {} : { isActive }),
...(eids ? { eid: { in: eids } } : {}),
...(chapter ? { chapter } : {}),
...(search
? {
OR: [
{ displayName: { contains: search, mode: "insensitive" as const } },
{ eid: { contains: search, mode: "insensitive" as const } },
{ email: { contains: search, mode: "insensitive" as const } },
],
}
: {}),
...(cfConditions.length > 0 ? { AND: cfConditions } : {}),
};
if (!eids) {
andClauses.push({ isActive });
}
if (eids && !directory) {
andClauses.push({ eid: { in: eids } });
}
if (chapterFilters.length === 1) {
andClauses.push({ chapter: chapterFilters[0] });
} else if (chapterFilters.length > 1) {
andClauses.push({ chapter: { in: chapterFilters } });
}
if (search && !directory) {
andClauses.push({
OR: [
{ displayName: { contains: search, mode: "insensitive" as const } },
{ eid: { contains: search, mode: "insensitive" as const } },
{ email: { contains: search, mode: "insensitive" as const } },
],
});
}
if (countryIds && countryIds.length > 0) {
const countryClauses: WhereClause[] = [{ countryId: { in: countryIds } }];
if (includeWithoutCountry) {
countryClauses.push({ countryId: null });
}
andClauses.push(countryClauses.length === 1 ? countryClauses[0]! : { OR: countryClauses });
}
if (excludedCountryIds && excludedCountryIds.length > 0) {
andClauses.push({ NOT: { countryId: { in: excludedCountryIds } } });
}
if (!includeWithoutCountry) {
andClauses.push({ NOT: { countryId: null } });
}
if (resourceTypes && resourceTypes.length > 0) {
const resourceTypeClauses: WhereClause[] = [{ resourceType: { in: resourceTypes } }];
if (includeWithoutResourceType) {
resourceTypeClauses.push({ resourceType: null });
}
andClauses.push(
resourceTypeClauses.length === 1 ? resourceTypeClauses[0]! : { OR: resourceTypeClauses },
);
}
if (excludedResourceTypes && excludedResourceTypes.length > 0) {
andClauses.push({ NOT: { resourceType: { in: excludedResourceTypes } } });
}
if (!includeWithoutResourceType) {
andClauses.push({ NOT: { resourceType: null } });
}
if (rolledOff !== undefined) {
andClauses.push({ rolledOff });
}
if (departed !== undefined) {
andClauses.push({ departed });
}
andClauses.push(...cfConditions);
const where = andClauses.length > 0 ? { AND: andClauses } : {};
if (directory) {
const rawResources = await (includeRoles
? ctx.db.resource.findMany({
where,
include: {
resourceRoles: {
include: { role: { select: ROLE_BRIEF_SELECT } },
},
},
orderBy: [{ displayName: "asc" }, { id: "asc" }],
})
: ctx.db.resource.findMany({
where,
orderBy: [{ displayName: "asc" }, { id: "asc" }],
}));
const directoryResources = rawResources.map((resource) => ({
id: resource.id,
eid: resource.eid,
displayName: resource.displayName,
email: resource.email,
}));
const requestedIds = eids
? resolveResourceIdsByDisplayedEids(directoryResources, directory, eids)
: [];
const requestedIdSet = requestedIds.length > 0 ? new Set(requestedIds) : null;
const filteredResources = rawResources.filter((resource) => {
const alias = directory.byResourceId.get(resource.id);
if (requestedIdSet && !requestedIdSet.has(resource.id)) {
return false;
}
if (eids && eids.length > 0 && requestedIds.length === 0) {
return false;
}
if (search && !anonymizeSearchMatches(
{
id: resource.id,
eid: resource.eid,
displayName: resource.displayName,
email: resource.email,
},
alias,
search,
)) {
return false;
}
return true;
});
const anonymizedResources = anonymizeResources(filteredResources, directory).sort((left, right) => {
const displayNameCompare = left.displayName.localeCompare(right.displayName);
if (displayNameCompare !== 0) {
return displayNameCompare;
}
return left.id.localeCompare(right.id);
});
const total = anonymizedResources.length;
const afterCursor = parsedCursor
? anonymizedResources.filter(
(resource) =>
resource.displayName > parsedCursor.displayName ||
(resource.displayName === parsedCursor.displayName && resource.id > parsedCursor.id),
)
: anonymizedResources;
const skip = cursor ? 0 : (page - 1) * limit;
const paged = afterCursor.slice(skip, skip + limit + 1);
const hasMore = paged.length > limit;
const resources = hasMore ? paged.slice(0, limit) : paged;
const nextCursor = hasMore
? JSON.stringify({
displayName: resources[resources.length - 1]!.displayName,
id: resources[resources.length - 1]!.id,
})
: null;
return { resources, total, page, limit, nextCursor };
}
const skip = cursor ? 0 : (page - 1) * limit;
const orderBy = [{ displayName: "asc" as const }, { id: "asc" as const }];
// Apply cursor filter directly on where to avoid exactOptionalPropertyTypes issues
const whereWithCursor = cursor ? { ...where, id: { gt: cursor } } : where;
const whereWithCursor = parsedCursor
? {
AND: [
...((where as { AND?: WhereClause[] }).AND ?? []),
{
OR: [
{ displayName: { gt: parsedCursor.displayName } },
{ displayName: parsedCursor.displayName, id: { gt: parsedCursor.id } },
],
},
],
}
: where;
const baseQuery = { where: whereWithCursor, skip, take: limit + 1, orderBy };
const [rawResources, total] = await Promise.all([
@@ -75,7 +271,7 @@ export const resourceRouter = createTRPCRouter({
...baseQuery,
include: {
resourceRoles: {
include: { role: { select: { id: true, name: true, color: true } } },
include: { role: { select: ROLE_BRIEF_SELECT } },
},
},
})
@@ -85,7 +281,12 @@ export const resourceRouter = createTRPCRouter({
const hasMore = rawResources.length > limit;
const resources = hasMore ? rawResources.slice(0, limit) : rawResources;
const nextCursor = hasMore ? resources[resources.length - 1]!.id : null;
const nextCursor = hasMore
? JSON.stringify({
displayName: resources[resources.length - 1]!.displayName,
id: resources[resources.length - 1]!.id,
})
: null;
return { resources, total, page, limit, nextCursor };
}),
@@ -93,33 +294,42 @@ export const resourceRouter = createTRPCRouter({
getById: protectedProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
const resource = await ctx.db.resource.findUnique({
where: { id: input.id },
include: {
blueprint: true,
resourceRoles: {
include: { role: { select: { id: true, name: true, color: true } } },
const resource = await findUniqueOrThrow(
ctx.db.resource.findUnique({
where: { id: input.id },
include: {
blueprint: true,
resourceRoles: {
include: { role: { select: ROLE_BRIEF_SELECT } },
},
areaRole: { select: { id: true, name: true } },
},
areaRole: { select: { id: true, name: true } },
user: { select: { email: true } },
},
});
}),
"Resource",
);
if (!resource) {
throw new TRPCError({ code: "NOT_FOUND", message: "Resource not found" });
}
return resource;
const directory = await getAnonymizationDirectory(ctx.db);
return {
...anonymizeResource(resource, directory),
isOwnedByCurrentUser: Boolean(resource.userId && ctx.dbUser?.id && resource.userId === ctx.dbUser.id),
};
}),
getByEid: protectedProcedure
.input(z.object({ eid: z.string() }))
.query(async ({ ctx, input }) => {
const resource = await ctx.db.resource.findUnique({ where: { eid: input.eid } });
const directory = await getAnonymizationDirectory(ctx.db);
let resource = await ctx.db.resource.findUnique({ where: { eid: input.eid } });
if (!resource && directory) {
const resourceId = directory.byAliasEid.get(input.eid.trim().toLowerCase());
if (resourceId) {
resource = await ctx.db.resource.findUnique({ where: { id: resourceId } });
}
}
if (!resource) {
throw new TRPCError({ code: "NOT_FOUND", message: "Resource not found" });
}
return resource;
return anonymizeResource(resource, directory);
}),
create: managerProcedure
@@ -194,7 +404,7 @@ export const resourceRouter = createTRPCRouter({
: undefined,
} as unknown as Parameters<typeof ctx.db.resource.create>[0]["data"],
include: {
resourceRoles: { include: { role: { select: { id: true, name: true, color: true } } } },
resourceRoles: { include: { role: { select: ROLE_BRIEF_SELECT } } },
},
});
@@ -215,10 +425,10 @@ export const resourceRouter = createTRPCRouter({
.input(z.object({ id: z.string(), data: UpdateResourceSchema.extend({ roles: z.array(ResourceRoleSchema).optional() }) }))
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_RESOURCES);
const existing = await ctx.db.resource.findUnique({ where: { id: input.id } });
if (!existing) {
throw new TRPCError({ code: "NOT_FOUND", message: "Resource not found" });
}
const existing = await findUniqueOrThrow(
ctx.db.resource.findUnique({ where: { id: input.id } }),
"Resource",
);
const nextBlueprintId = input.data.blueprintId ?? existing.blueprintId ?? undefined;
const nextDynamicFields = (input.data.dynamicFields ?? existing.dynamicFields ?? {}) as Record<string, unknown>;
@@ -275,7 +485,7 @@ export const resourceRouter = createTRPCRouter({
...(input.data.fte !== undefined ? { fte: input.data.fte } : {}),
} as unknown as Parameters<typeof ctx.db.resource.update>[0]["data"],
include: {
resourceRoles: { include: { role: { select: { id: true, name: true, color: true } } } },
resourceRoles: { include: { role: { select: ROLE_BRIEF_SELECT } } },
},
});
@@ -415,10 +625,10 @@ export const resourceRouter = createTRPCRouter({
)
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_RESOURCES);
const existing = await ctx.db.resource.findUnique({ where: { id: input.resourceId } });
if (!existing) {
throw new TRPCError({ code: "NOT_FOUND", message: "Resource not found" });
}
await findUniqueOrThrow(
ctx.db.resource.findUnique({ where: { id: input.resourceId } }),
"Resource",
);
await ctx.db.resource.update({
where: { id: input.resourceId },
@@ -693,7 +903,8 @@ export const resourceRouter = createTRPCRouter({
.filter((r): r is NonNullable<typeof r> => r !== null)
.sort((a, b) => a.displayName.localeCompare(b.displayName));
return results;
const directory = await getAnonymizationDirectory(ctx.db);
return anonymizeResources(results, directory);
}),
// ─── Self-service ────────────────────────────────────────────────────────────
@@ -706,7 +917,8 @@ export const resourceRouter = createTRPCRouter({
where: { email },
select: { resource: { select: { id: true, displayName: true, eid: true, chapter: true } } },
});
return user?.resource ?? null;
const directory = await getAnonymizationDirectory(ctx.db);
return user?.resource ? anonymizeResource(user.resource, directory) : null;
}),
// ─── Value Score ─────────────────────────────────────────────────────────────
@@ -740,83 +952,12 @@ export const resourceRouter = createTRPCRouter({
take: input.limit,
});
return resources;
const directory = await getAnonymizationDirectory(ctx.db);
return anonymizeResources(resources, directory);
}),
recomputeValueScores: adminProcedure.mutation(async ({ ctx }) => {
const [resources, settings] = await Promise.all([
ctx.db.resource.findMany({
where: { isActive: true },
select: {
id: true,
skills: true,
lcrCents: true,
chargeabilityTarget: true,
},
}),
ctx.db.systemSettings.findUnique({ where: { id: "singleton" } }),
]);
const bookings = await listAssignmentBookings(ctx.db, {
startDate: new Date(Date.now() - 90 * 24 * 60 * 60 * 1000),
endDate: new Date(),
resourceIds: resources.map((resource) => resource.id),
});
const defaultWeights = {
skillDepth: VALUE_SCORE_WEIGHTS.SKILL_DEPTH,
skillBreadth: VALUE_SCORE_WEIGHTS.SKILL_BREADTH,
costEfficiency: VALUE_SCORE_WEIGHTS.COST_EFFICIENCY,
chargeability: VALUE_SCORE_WEIGHTS.CHARGEABILITY,
experience: VALUE_SCORE_WEIGHTS.EXPERIENCE,
};
const weights = (settings?.scoreWeights as unknown as typeof defaultWeights) ?? defaultWeights;
const maxLcrCents = resources.reduce((max, r) => Math.max(max, r.lcrCents), 0);
const now = new Date();
type SkillRow = { skill: string; category?: string; proficiency: number; yearsExperience?: number; isMainSkill?: boolean };
const totalWorkDays = 90 * (5 / 7); // approx working days
const availableHours = totalWorkDays * 8;
const updates = resources.map((resource) => {
const resourceBookings = bookings.filter((booking) => booking.resourceId === resource.id);
const bookedHours = resourceBookings.reduce((sum, booking) => {
const days = Math.max(
0,
(new Date(booking.endDate).getTime() - new Date(booking.startDate).getTime()) /
(1000 * 60 * 60 * 24) +
1,
);
return sum + booking.hoursPerDay * days;
}, 0);
const currentChargeability = availableHours > 0 ? Math.min(100, (bookedHours / availableHours) * 100) : 0;
const skills = (resource.skills as unknown as SkillRow[]) ?? [];
const breakdown = computeValueScore(
{
skills: skills as unknown as import("@planarchy/shared").SkillEntry[],
lcrCents: resource.lcrCents,
chargeabilityTarget: resource.chargeabilityTarget,
currentChargeability,
maxLcrCents,
},
weights,
);
return ctx.db.resource.update({
where: { id: resource.id },
data: {
valueScore: breakdown.total,
valueScoreBreakdown: breakdown as unknown as import("@planarchy/db").Prisma.InputJsonValue,
valueScoreUpdatedAt: now,
},
});
});
await ctx.db.$transaction(updates);
const updated = updates.length;
return { updated };
return recomputeResourceValueScores(ctx.db);
}),
listWithUtilization: controllerProcedure
@@ -825,6 +966,7 @@ export const resourceRouter = createTRPCRouter({
startDate: z.string().datetime().optional(),
endDate: z.string().datetime().optional(),
chapter: z.string().optional(),
includeProposed: z.boolean().default(false),
limit: z.number().int().min(1).max(500).default(100),
}),
)
@@ -872,6 +1014,7 @@ export const resourceRouter = createTRPCRouter({
endDate: end,
resourceIds: resources.map((resource) => resource.id),
});
const directory = await getAnonymizationDirectory(ctx.db);
return resources.map((r) => {
const avail = r.availability as Record<string, number>;
@@ -882,7 +1025,11 @@ export const resourceRouter = createTRPCRouter({
let bookedHours = 0;
let isOverbooked = false;
const resourceBookings = bookings.filter((booking) => booking.resourceId === r.id);
const resourceBookings = bookings.filter(
(booking) =>
booking.resourceId === r.id &&
(input.includeProposed || booking.status !== "PROPOSED"),
);
for (const a of resourceBookings) {
const days =
(new Date(a.endDate).getTime() - new Date(a.startDate).getTime()) /
@@ -895,19 +1042,19 @@ export const resourceRouter = createTRPCRouter({
const utilizationPercent =
availableHours > 0 ? Math.round((bookedHours / availableHours) * 100) : 0;
return {
return anonymizeResource({
...r,
bookingCount: resourceBookings.length,
bookedHours: Math.round(bookedHours),
availableHours: Math.round(availableHours),
utilizationPercent,
isOverbooked,
};
}, directory);
});
}),
getChargeabilityStats: controllerProcedure
.input(z.object({ resourceId: z.string().optional() }))
.input(z.object({ includeProposed: z.boolean().default(false), resourceId: z.string().optional() }))
.query(async ({ ctx, input }) => {
const now = new Date();
const start = new Date(now.getFullYear(), now.getMonth(), 1);
@@ -932,26 +1079,24 @@ export const resourceRouter = createTRPCRouter({
endDate: end,
resourceIds: resources.map((resource) => resource.id),
});
const directory = await getAnonymizationDirectory(ctx.db);
return resources.map((r) => {
const avail = r.availability as unknown as WeekdayAvailability;
const resourceBookings = bookings.filter((booking) => booking.resourceId === r.id);
// Actual: CONFIRMED or ACTIVE allocations on non-DRAFT, non-CANCELLED projects
const actualAllocs = resourceBookings.filter(
(a) =>
(a.status === "CONFIRMED" || a.status === "ACTIVE") &&
a.project.status !== "DRAFT" &&
a.project.status !== "CANCELLED",
const actualAllocs = resourceBookings.filter((booking) =>
isChargeabilityActualBooking(booking, input.includeProposed),
);
// Expected: all non-CANCELLED assignment-like bookings, all project statuses
const expectedAllocs = resourceBookings;
const expectedAllocs = resourceBookings.filter((booking) =>
isChargeabilityRelevantProject(booking.project, true),
);
const actual = computeChargeability(avail, actualAllocs, start, end);
const expected = computeChargeability(avail, expectedAllocs, start, end);
return {
return anonymizeResource({
id: r.id,
eid: r.eid,
displayName: r.displayName,
@@ -960,7 +1105,7 @@ export const resourceRouter = createTRPCRouter({
actualChargeability: actual.chargeability,
expectedChargeability: expected.chargeability,
availableHours: actual.availableHours,
};
}, directory);
});
}),
+24 -23
View File
@@ -2,6 +2,7 @@ import { countPlanningEntries } from "@planarchy/application";
import { CreateRoleSchema, PermissionKey, UpdateRoleSchema } from "@planarchy/shared";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { findUniqueOrThrow } from "../db/helpers.js";
import { emitRoleCreated, emitRoleDeleted, emitRoleUpdated } from "../sse/event-bus.js";
import { createTRPCRouter, managerProcedure, protectedProcedure, requirePermission } from "../trpc.js";
@@ -81,20 +82,20 @@ export const roleRouter = createTRPCRouter({
getById: protectedProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
const role = await ctx.db.role.findUnique({
where: { id: input.id },
include: {
_count: { select: { resourceRoles: true } },
resourceRoles: {
include: {
resource: { select: { id: true, displayName: true, eid: true } },
const role = await findUniqueOrThrow(
ctx.db.role.findUnique({
where: { id: input.id },
include: {
_count: { select: { resourceRoles: true } },
resourceRoles: {
include: {
resource: { select: { id: true, displayName: true, eid: true } },
},
},
},
},
});
if (!role) {
throw new TRPCError({ code: "NOT_FOUND", message: "Role not found" });
}
}),
"Role",
);
return attachSinglePlanningEntryCount(ctx.db, role);
}),
@@ -141,10 +142,10 @@ export const roleRouter = createTRPCRouter({
.input(z.object({ id: z.string(), data: UpdateRoleSchema }))
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_ROLES);
const existing = await ctx.db.role.findUnique({ where: { id: input.id } });
if (!existing) {
throw new TRPCError({ code: "NOT_FOUND", message: "Role not found" });
}
const existing = await findUniqueOrThrow(
ctx.db.role.findUnique({ where: { id: input.id } }),
"Role",
);
if (input.data.name && input.data.name !== existing.name) {
const nameConflict = await ctx.db.role.findUnique({ where: { name: input.data.name } });
@@ -182,13 +183,13 @@ export const roleRouter = createTRPCRouter({
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_ROLES);
const role = await ctx.db.role.findUnique({
where: { id: input.id },
include: { _count: { select: { resourceRoles: true } } },
});
if (!role) {
throw new TRPCError({ code: "NOT_FOUND", message: "Role not found" });
}
const role = await findUniqueOrThrow(
ctx.db.role.findUnique({
where: { id: input.id },
include: { _count: { select: { resourceRoles: true } } },
}),
"Role",
);
const roleWithCounts = await attachSinglePlanningEntryCount(ctx.db, role);
+24 -25
View File
@@ -2,6 +2,7 @@ import { analyzeUtilization, findCapacityWindows, rankResources } from "@planarc
import { listAssignmentBookings } from "@planarchy/application";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { findUniqueOrThrow } from "../db/helpers.js";
import { createTRPCRouter, protectedProcedure } from "../trpc.js";
export const staffingRouter = createTRPCRouter({
@@ -108,19 +109,18 @@ export const staffingRouter = createTRPCRouter({
}),
)
.query(async ({ ctx, input }) => {
const resource = await ctx.db.resource.findUnique({
where: { id: input.resourceId },
select: {
id: true,
displayName: true,
chargeabilityTarget: true,
availability: true,
},
});
if (!resource) {
throw new TRPCError({ code: "NOT_FOUND", message: "Resource not found" });
}
const resource = await findUniqueOrThrow(
ctx.db.resource.findUnique({
where: { id: input.resourceId },
select: {
id: true,
displayName: true,
chargeabilityTarget: true,
availability: true,
},
}),
"Resource",
);
const resourceBookings = await listAssignmentBookings(ctx.db, {
startDate: input.startDate,
@@ -161,18 +161,17 @@ export const staffingRouter = createTRPCRouter({
}),
)
.query(async ({ ctx, input }) => {
const resource = await ctx.db.resource.findUnique({
where: { id: input.resourceId },
select: {
id: true,
displayName: true,
availability: true,
},
});
if (!resource) {
throw new TRPCError({ code: "NOT_FOUND", message: "Resource not found" });
}
const resource = await findUniqueOrThrow(
ctx.db.resource.findUnique({
where: { id: input.resourceId },
select: {
id: true,
displayName: true,
availability: true,
},
}),
"Resource",
);
const resourceBookings = await listAssignmentBookings(ctx.db, {
startDate: input.startDate,
+89 -25
View File
@@ -13,9 +13,10 @@ import { calculateAllocation, computeBudgetStatus, validateShift } from "@planar
import { AllocationStatus, PermissionKey, ShiftProjectSchema, UpdateAllocationHoursSchema } from "@planarchy/shared";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { findUniqueOrThrow } from "../db/helpers.js";
import {
loadProjectPlanningReadModel,
PROJECT_PLANNING_ASSIGNMENT_INCLUDE,
TIMELINE_ASSIGNMENT_INCLUDE,
PROJECT_PLANNING_DEMAND_INCLUDE,
} from "./project-planning-read-model.js";
import {
@@ -25,6 +26,7 @@ import {
} from "../sse/event-bus.js";
import { buildTimelineShiftPlan } from "./timeline-shift-planning.js";
import { createTRPCRouter, managerProcedure, protectedProcedure, requirePermission } from "../trpc.js";
import { anonymizeResource, getAnonymizationDirectory } from "../lib/anonymization.js";
type ShiftDbClient = Pick<
PrismaClient,
@@ -33,7 +35,7 @@ type ShiftDbClient = Pick<
type TimelineEntriesDbClient = Pick<
PrismaClient,
"demandRequirement" | "assignment"
"demandRequirement" | "assignment" | "resource"
>;
type TimelineEntriesFilters = {
@@ -41,6 +43,8 @@ type TimelineEntriesFilters = {
endDate: Date;
resourceIds?: string[] | undefined;
projectIds?: string[] | undefined;
chapters?: string[] | undefined;
eids?: string[] | undefined;
};
function getAssignmentResourceIds(
@@ -59,10 +63,34 @@ async function loadTimelineEntriesReadModel(
db: TimelineEntriesDbClient,
input: TimelineEntriesFilters,
) {
const { startDate, endDate, resourceIds, projectIds } = input;
const { startDate, endDate, resourceIds, projectIds, chapters, eids } = input;
// When resource-level filters are active (resourceIds, chapters, or eids),
// resolve matching resource IDs so we can push the filter to the DB query.
const effectiveResourceIds = await (async () => {
if (resourceIds && resourceIds.length > 0) return resourceIds;
const hasChapters = chapters && chapters.length > 0;
const hasEids = eids && eids.length > 0;
if (!hasChapters && !hasEids) return undefined;
const matching = await db.resource.findMany({
where: {
...(hasChapters && hasEids
? { AND: [{ chapter: { in: chapters } }, { eid: { in: eids } }] }
: hasChapters
? { chapter: { in: chapters } }
: { eid: { in: eids! } }),
},
select: { id: true },
});
return matching.map((r) => r.id);
})();
// When filtering by resource (either explicit resourceIds or derived from chapters),
// demands without a resource are excluded.
const excludeDemands = effectiveResourceIds !== undefined;
const [demandRequirements, assignments] = await Promise.all([
resourceIds && resourceIds.length > 0
excludeDemands
? Promise.resolve([])
: db.demandRequirement.findMany({
where: {
@@ -79,10 +107,10 @@ async function loadTimelineEntriesReadModel(
status: { not: "CANCELLED" },
startDate: { lte: endDate },
endDate: { gte: startDate },
...(resourceIds ? { resourceId: { in: resourceIds } } : {}),
...(effectiveResourceIds ? { resourceId: { in: effectiveResourceIds } } : {}),
...(projectIds ? { projectId: { in: projectIds } } : {}),
},
include: PROJECT_PLANNING_ASSIGNMENT_INCLUDE,
include: TIMELINE_ASSIGNMENT_INCLUDE,
orderBy: [{ startDate: "asc" }, { resourceId: "asc" }],
}),
]);
@@ -144,6 +172,19 @@ async function loadProjectShiftContext(db: ShiftDbClient, projectId: string) {
};
}
function anonymizeResourceOnEntry<T extends { resource?: { id: string } | null }>(
entry: T,
directory: Awaited<ReturnType<typeof getAnonymizationDirectory>>,
): T {
if (!entry.resource) {
return entry;
}
return {
...entry,
resource: anonymizeResource(entry.resource, directory),
};
}
export const timelineRouter = createTRPCRouter({
/**
* Get all timeline entries (projects + allocations) for a date range.
@@ -156,11 +197,14 @@ export const timelineRouter = createTRPCRouter({
endDate: z.coerce.date(),
resourceIds: z.array(z.string()).optional(),
projectIds: z.array(z.string()).optional(),
chapters: z.array(z.string()).optional(),
eids: z.array(z.string()).optional(),
}),
)
.query(async ({ ctx, input }) => {
const readModel = await loadTimelineEntriesReadModel(ctx.db, input);
return readModel.allocations;
const directory = await getAnonymizationDirectory(ctx.db);
return readModel.allocations.map((allocation) => anonymizeResourceOnEntry(allocation, directory));
}),
getEntriesView: protectedProcedure
@@ -170,9 +214,22 @@ export const timelineRouter = createTRPCRouter({
endDate: z.coerce.date(),
resourceIds: z.array(z.string()).optional(),
projectIds: z.array(z.string()).optional(),
chapters: z.array(z.string()).optional(),
eids: z.array(z.string()).optional(),
}),
)
.query(async ({ ctx, input }) => loadTimelineEntriesReadModel(ctx.db, input)),
.query(async ({ ctx, input }) => {
const [readModel, directory] = await Promise.all([
loadTimelineEntriesReadModel(ctx.db, input),
getAnonymizationDirectory(ctx.db),
]);
return {
...readModel,
allocations: readModel.allocations.map((allocation) => anonymizeResourceOnEntry(allocation, directory)),
assignments: readModel.assignments.map((assignment) => anonymizeResourceOnEntry(assignment, directory)),
};
}),
/**
* Get full project context for a project:
@@ -218,12 +275,20 @@ export const timelineRouter = createTRPCRouter({
resourceIds,
});
const directory = await getAnonymizationDirectory(ctx.db);
return {
project,
allocations: planningRead.readModel.allocations,
allocations: planningRead.readModel.allocations.map((allocation) =>
anonymizeResourceOnEntry(allocation, directory),
),
demands: planningRead.readModel.demands,
assignments: planningRead.readModel.assignments,
allResourceAllocations,
assignments: planningRead.readModel.assignments.map((assignment) =>
anonymizeResourceOnEntry(assignment, directory),
),
allResourceAllocations: allResourceAllocations.map((allocation) =>
anonymizeResourceOnEntry(allocation, directory),
),
resourceIds,
};
}),
@@ -572,20 +637,19 @@ export const timelineRouter = createTRPCRouter({
getBudgetStatus: protectedProcedure
.input(z.object({ projectId: z.string() }))
.query(async ({ ctx, input }) => {
const project = await ctx.db.project.findUnique({
where: { id: input.projectId },
select: {
id: true,
budgetCents: true,
winProbability: true,
startDate: true,
endDate: true,
},
});
if (!project) {
throw new TRPCError({ code: "NOT_FOUND", message: "Project not found" });
}
const project = await findUniqueOrThrow(
ctx.db.project.findUnique({
where: { id: input.projectId },
select: {
id: true,
budgetCents: true,
winProbability: true,
startDate: true,
endDate: true,
},
}),
"Project",
);
const bookings = await listAssignmentBookings(ctx.db, {
startDate: project.startDate,
+15 -15
View File
@@ -11,6 +11,7 @@ import {
import { Prisma } from "@planarchy/db";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { findUniqueOrThrow } from "../db/helpers.js";
import { adminProcedure, createTRPCRouter, protectedProcedure } from "../trpc.js";
export const userRouter = createTRPCRouter({
@@ -28,21 +29,20 @@ export const userRouter = createTRPCRouter({
}),
me: protectedProcedure.query(async ({ ctx }) => {
const user = await ctx.db.user.findUnique({
where: { email: ctx.session.user?.email ?? "" },
select: {
id: true,
name: true,
email: true,
systemRole: true,
permissionOverrides: true,
createdAt: true,
},
});
if (!user) {
throw new TRPCError({ code: "NOT_FOUND", message: "User not found" });
}
const user = await findUniqueOrThrow(
ctx.db.user.findUnique({
where: { email: ctx.session.user?.email ?? "" },
select: {
id: true,
name: true,
email: true,
systemRole: true,
permissionOverrides: true,
createdAt: true,
},
}),
"User",
);
return user;
}),
@@ -4,6 +4,7 @@ import {
} from "@planarchy/shared";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { findUniqueOrThrow } from "../db/helpers.js";
import { adminProcedure, createTRPCRouter, protectedProcedure } from "../trpc.js";
export const utilizationCategoryRouter = createTRPCRouter({
@@ -21,11 +22,13 @@ export const utilizationCategoryRouter = createTRPCRouter({
getById: protectedProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
const cat = await ctx.db.utilizationCategory.findUnique({
where: { id: input.id },
include: { _count: { select: { projects: true } } },
});
if (!cat) throw new TRPCError({ code: "NOT_FOUND", message: "Utilization category not found" });
const cat = await findUniqueOrThrow(
ctx.db.utilizationCategory.findUnique({
where: { id: input.id },
include: { _count: { select: { projects: true } } },
}),
"Utilization category",
);
return cat;
}),
@@ -59,8 +62,10 @@ export const utilizationCategoryRouter = createTRPCRouter({
update: adminProcedure
.input(z.object({ id: z.string(), data: UpdateUtilizationCategorySchema }))
.mutation(async ({ ctx, input }) => {
const existing = await ctx.db.utilizationCategory.findUnique({ where: { id: input.id } });
if (!existing) throw new TRPCError({ code: "NOT_FOUND", message: "Utilization category not found" });
const existing = await findUniqueOrThrow(
ctx.db.utilizationCategory.findUnique({ where: { id: input.id } }),
"Utilization category",
);
if (input.data.code && input.data.code !== existing.code) {
const conflict = await ctx.db.utilizationCategory.findUnique({ where: { code: input.data.code } });
+52 -30
View File
@@ -2,13 +2,31 @@ import { UpdateVacationStatusSchema, getPublicHolidays } from "@planarchy/shared
import { VacationStatus, VacationType } from "@planarchy/db";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { findUniqueOrThrow } from "../db/helpers.js";
import { emitVacationCreated, emitVacationUpdated, emitNotificationCreated } from "../sse/event-bus.js";
import { createTRPCRouter, adminProcedure, managerProcedure, protectedProcedure } from "../trpc.js";
import { sendEmail } from "../lib/email.js";
import { anonymizeResource, anonymizeUser, getAnonymizationDirectory } from "../lib/anonymization.js";
/** Types that consume from annual leave balance */
const BALANCE_TYPES = [VacationType.ANNUAL, VacationType.OTHER];
function anonymizeVacationRecord<T extends {
resource?: { id: string } | null;
requestedBy?: { id?: string | null; name?: string | null; email?: string | null } | null;
approvedBy?: { id?: string | null; name?: string | null; email?: string | null } | null;
}>(
vacation: T,
directory: Awaited<ReturnType<typeof getAnonymizationDirectory>>,
): T {
return {
...vacation,
...(vacation.resource ? { resource: anonymizeResource(vacation.resource, directory) } : {}),
...(vacation.requestedBy ? { requestedBy: anonymizeUser(vacation.requestedBy, directory) } : {}),
...(vacation.approvedBy ? { approvedBy: anonymizeUser(vacation.approvedBy, directory) } : {}),
};
}
/** Send in-app notification + optional email when vacation status changes */
async function notifyVacationStatus(
db: Parameters<Parameters<typeof protectedProcedure["query"]>[0]>[0]["ctx"]["db"],
@@ -72,7 +90,7 @@ export const vacationRouter = createTRPCRouter({
}),
)
.query(async ({ ctx, input }) => {
return ctx.db.vacation.findMany({
const vacations = await ctx.db.vacation.findMany({
where: {
...(input.resourceId ? { resourceId: input.resourceId } : {}),
...(input.status ? { status: input.status } : {}),
@@ -88,6 +106,8 @@ export const vacationRouter = createTRPCRouter({
orderBy: { startDate: "asc" },
take: input.limit,
});
const directory = await getAnonymizationDirectory(ctx.db);
return vacations.map((vacation) => anonymizeVacationRecord(vacation, directory));
}),
/**
@@ -96,18 +116,19 @@ export const vacationRouter = createTRPCRouter({
getById: protectedProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
const vacation = await ctx.db.vacation.findUnique({
where: { id: input.id },
include: {
resource: { select: { id: true, displayName: true, eid: true } },
requestedBy: { select: { id: true, name: true, email: true } },
approvedBy: { select: { id: true, name: true, email: true } },
},
});
if (!vacation) {
throw new TRPCError({ code: "NOT_FOUND", message: "Vacation not found" });
}
return vacation;
const vacation = await findUniqueOrThrow(
ctx.db.vacation.findUnique({
where: { id: input.id },
include: {
resource: { select: { id: true, displayName: true, eid: true } },
requestedBy: { select: { id: true, name: true, email: true } },
approvedBy: { select: { id: true, name: true, email: true } },
},
}),
"Vacation",
);
const directory = await getAnonymizationDirectory(ctx.db);
return anonymizeVacationRecord(vacation, directory);
}),
/**
@@ -182,7 +203,8 @@ export const vacationRouter = createTRPCRouter({
emitVacationCreated({ id: vacation.id, resourceId: vacation.resourceId, status: vacation.status });
return vacation;
const directory = await getAnonymizationDirectory(ctx.db);
return anonymizeVacationRecord(vacation, directory);
}),
/**
@@ -191,10 +213,10 @@ export const vacationRouter = createTRPCRouter({
approve: managerProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
const existing = await ctx.db.vacation.findUnique({ where: { id: input.id } });
if (!existing) {
throw new TRPCError({ code: "NOT_FOUND", message: "Vacation not found" });
}
const existing = await findUniqueOrThrow(
ctx.db.vacation.findUnique({ where: { id: input.id } }),
"Vacation",
);
const approvableStatuses: string[] = [VacationStatus.PENDING, VacationStatus.CANCELLED, VacationStatus.REJECTED];
if (!approvableStatuses.includes(existing.status)) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Only PENDING, CANCELLED, or REJECTED vacations can be approved" });
@@ -230,10 +252,10 @@ export const vacationRouter = createTRPCRouter({
reject: managerProcedure
.input(z.object({ id: z.string(), rejectionReason: z.string().max(500).optional() }))
.mutation(async ({ ctx, input }) => {
const existing = await ctx.db.vacation.findUnique({ where: { id: input.id } });
if (!existing) {
throw new TRPCError({ code: "NOT_FOUND", message: "Vacation not found" });
}
const existing = await findUniqueOrThrow(
ctx.db.vacation.findUnique({ where: { id: input.id } }),
"Vacation",
);
if (existing.status !== VacationStatus.PENDING) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Only PENDING vacations can be rejected" });
}
@@ -324,10 +346,10 @@ export const vacationRouter = createTRPCRouter({
cancel: protectedProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
const existing = await ctx.db.vacation.findUnique({ where: { id: input.id } });
if (!existing) {
throw new TRPCError({ code: "NOT_FOUND", message: "Vacation not found" });
}
const existing = await findUniqueOrThrow(
ctx.db.vacation.findUnique({ where: { id: input.id } }),
"Vacation",
);
if (existing.status === VacationStatus.CANCELLED) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Already cancelled" });
}
@@ -511,10 +533,10 @@ export const vacationRouter = createTRPCRouter({
updateStatus: protectedProcedure
.input(UpdateVacationStatusSchema)
.mutation(async ({ ctx, input }) => {
const existing = await ctx.db.vacation.findUnique({ where: { id: input.id } });
if (!existing) {
throw new TRPCError({ code: "NOT_FOUND", message: "Vacation not found" });
}
const existing = await findUniqueOrThrow(
ctx.db.vacation.findUnique({ where: { id: input.id } }),
"Vacation",
);
const userRecord = await ctx.db.user.findUnique({
where: { email: ctx.session.user?.email ?? "" },
+81 -3
View File
@@ -12,6 +12,70 @@ type Subscriber = (event: SseEvent) => void;
// Module-level subscriber registry (shared between EventBus and publishLocal)
const subscribers = new Set<Subscriber>();
// ---------------------------------------------------------------------------
// Debounce buffer: aggregates rapid events of the same type within a 50ms
// window and delivers a single event per type to subscribers.
// ---------------------------------------------------------------------------
const DEBOUNCE_MS = 50;
interface BufferEntry {
payloads: Record<string, unknown>[];
timer: ReturnType<typeof setTimeout>;
firstTimestamp: string;
}
const debounceBuffer = new Map<SseEventType, BufferEntry>();
/** Flush a single event type from the buffer and deliver to subscribers. */
function flushEventType(type: SseEventType): void {
const entry = debounceBuffer.get(type);
if (!entry) return;
debounceBuffer.delete(type);
const event: SseEvent =
entry.payloads.length === 1
? { type, payload: entry.payloads[0]!, timestamp: entry.firstTimestamp }
: {
type,
payload: { _batch: entry.payloads },
timestamp: entry.firstTimestamp,
};
for (const fn of subscribers) {
fn(event);
}
}
/** Flush all pending debounce timers immediately (for cleanup / tests). */
export function flushPendingEvents(): void {
for (const [type, entry] of debounceBuffer) {
clearTimeout(entry.timer);
debounceBuffer.delete(type);
const event: SseEvent =
entry.payloads.length === 1
? { type, payload: entry.payloads[0]!, timestamp: entry.firstTimestamp }
: {
type,
payload: { _batch: entry.payloads },
timestamp: entry.firstTimestamp,
};
for (const fn of subscribers) {
fn(event);
}
}
}
/** Cancel all pending debounce timers without delivering (for shutdown). */
export function cancelPendingEvents(): void {
for (const [, entry] of debounceBuffer) {
clearTimeout(entry.timer);
}
debounceBuffer.clear();
}
// Redis connection — use env var REDIS_URL or fallback to default dev URL
const REDIS_URL = process.env["REDIS_URL"] ?? "redis://localhost:6380";
const CHANNEL = "planarchy:sse";
@@ -81,10 +145,24 @@ class EventBus {
}
}
// Local delivery: deliver to subscribers connected to THIS instance (called from Redis subscriber)
// Local delivery with debounce: buffer events of the same type within a 50ms
// window and then deliver a single (possibly aggregated) event to subscribers.
function publishLocal(event: SseEvent): void {
for (const fn of subscribers) {
fn(event);
const existing = debounceBuffer.get(event.type);
if (existing) {
// Another event of the same type is already buffered — append payload and
// reset the timer so the window starts fresh from the latest arrival.
existing.payloads.push(event.payload);
clearTimeout(existing.timer);
existing.timer = setTimeout(() => flushEventType(event.type), DEBOUNCE_MS);
} else {
// First event of this type — start a new debounce window.
debounceBuffer.set(event.type, {
payloads: [event.payload],
timer: setTimeout(() => flushEventType(event.type), DEBOUNCE_MS),
firstTimestamp: event.timestamp,
});
}
}
@@ -0,0 +1,222 @@
import type { WeekdayAvailability } from "@planarchy/shared";
import {
createWeekdayAvailabilityFromFte,
normalizeDispoRoleToken,
} from "@planarchy/shared";
import type { TxClient, MergedStagedResource } from "./commit-dispo-batch-types.js";
import { deriveRoleTokens } from "./shared.js";
function asNullableString(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value : null;
}
function normalizeFallbackRoleName(value: string): string {
return value.replace(/\s+/g, " ").trim();
}
function isFiniteNumber(value: unknown): value is number {
return typeof value === "number" && Number.isFinite(value);
}
function asObject(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
function mergeScalar<T>(
current: T | null,
incoming: T | null | undefined,
): T | null {
return incoming ?? current;
}
export function inferRoleNameFromResource(resource: MergedStagedResource): string | null {
const explicitRoleName = Array.from(resource.roleTokens)
.map((token) => normalizeDispoRoleToken(token))
.find((roleName): roleName is string => Boolean(roleName));
if (explicitRoleName) {
return explicitRoleName;
}
const derivedRoleName = deriveRoleTokens(
resource.chapter,
asNullableString(resource.rawPayload.department),
asNullableString(resource.rawPayload.mainSkillset),
asNullableString(resource.rawPayload.sapOrgUnitLevelSix),
asNullableString(resource.rawPayload.sapOrgUnitLevelSeven),
asNullableString(resource.rawPayload.sapEmployeeName),
)
.map((token) => normalizeDispoRoleToken(token))
.find((roleName): roleName is string => Boolean(roleName));
if (derivedRoleName) {
return derivedRoleName;
}
if (resource.chapter === "Art Direction") {
return "Art Director";
}
if (resource.chapter === "Project Management") {
return "Project Manager";
}
const fallbackRoleLabel =
asNullableString(resource.rawPayload.department) ??
asNullableString(resource.rawPayload.mainSkillset) ??
asNullableString(resource.chapter) ??
asNullableString(resource.rawPayload.sapOrgUnitLevelSeven) ??
asNullableString(resource.rawPayload.sapOrgUnitLevelSix);
return fallbackRoleLabel ? normalizeFallbackRoleName(fallbackRoleLabel) : null;
}
export function mergeStagedResources(
rows: Awaited<ReturnType<TxClient["stagedResource"]["findMany"]>>,
): Map<string, MergedStagedResource> {
const sourcePriority = new Map([
["CHARGEABILITY", 1],
["ROSTER", 2],
]);
const ordered = [...rows].sort(
(left, right) =>
(sourcePriority.get(left.sourceKind) ?? 0) - (sourcePriority.get(right.sourceKind) ?? 0),
);
const merged = new Map<string, MergedStagedResource>();
for (const row of ordered) {
const existing = merged.get(row.canonicalExternalId);
const rawPayload = asObject(row.rawPayload);
if (!existing) {
merged.set(row.canonicalExternalId, {
availability: row.availability ?? null,
canonicalExternalId: row.canonicalExternalId,
chapter: row.chapter ?? null,
chargeabilityTarget: row.chargeabilityTarget ?? null,
clientUnitName: row.clientUnitName ?? null,
countryCode: row.countryCode ?? null,
displayName: row.displayName ?? null,
email: row.email ?? null,
fte: row.fte ?? null,
lcrCents: row.lcrCents ?? null,
managementLevelGroupName: row.managementLevelGroupName ?? null,
managementLevelName: row.managementLevelName ?? null,
metroCityName: row.metroCityName ?? null,
rawPayload,
resourceType: row.resourceType ?? null,
roleTokens: new Set(row.roleTokens),
sourceKinds: [row.sourceKind],
ucrCents: row.ucrCents ?? null,
vacationDaysPerYear: isFiniteNumber(rawPayload.vacationDaysPerYear)
? rawPayload.vacationDaysPerYear
: null,
warnings: [...row.warnings],
});
continue;
}
existing.availability = mergeScalar(existing.availability, row.availability);
existing.chapter = mergeScalar(existing.chapter, row.chapter);
existing.chargeabilityTarget = mergeScalar(existing.chargeabilityTarget, row.chargeabilityTarget);
existing.clientUnitName = mergeScalar(existing.clientUnitName, row.clientUnitName);
existing.countryCode = mergeScalar(existing.countryCode, row.countryCode);
existing.displayName = mergeScalar(existing.displayName, row.displayName);
existing.email = mergeScalar(existing.email, row.email);
existing.fte = mergeScalar(existing.fte, row.fte);
existing.lcrCents = mergeScalar(existing.lcrCents, row.lcrCents);
existing.managementLevelGroupName = mergeScalar(
existing.managementLevelGroupName,
row.managementLevelGroupName,
);
existing.managementLevelName = mergeScalar(existing.managementLevelName, row.managementLevelName);
existing.metroCityName = mergeScalar(existing.metroCityName, row.metroCityName);
existing.resourceType = mergeScalar(existing.resourceType, row.resourceType);
existing.ucrCents = mergeScalar(existing.ucrCents, row.ucrCents);
if (existing.availability === null && row.availability !== null) {
existing.availability = row.availability;
}
for (const roleToken of row.roleTokens) {
existing.roleTokens.add(roleToken);
}
existing.sourceKinds.push(row.sourceKind);
existing.warnings.push(...row.warnings);
existing.rawPayload = {
...existing.rawPayload,
...rawPayload,
};
if (isFiniteNumber(rawPayload.vacationDaysPerYear)) {
existing.vacationDaysPerYear = rawPayload.vacationDaysPerYear;
}
}
return merged;
}
export function parseWeekdayAvailability(
value: unknown,
fallbackFte: number | null,
): WeekdayAvailability {
const fallback = createWeekdayAvailabilityFromFte(fallbackFte ?? 1);
const source = asObject(value);
return {
monday: isFiniteNumber(source.monday) ? source.monday : fallback.monday,
tuesday: isFiniteNumber(source.tuesday) ? source.tuesday : fallback.tuesday,
wednesday: isFiniteNumber(source.wednesday) ? source.wednesday : fallback.wednesday,
thursday: isFiniteNumber(source.thursday) ? source.thursday : fallback.thursday,
friday: isFiniteNumber(source.friday) ? source.friday : fallback.friday,
};
}
export interface ReferenceDataMaps {
clientIdByCode: Map<string, string>;
clientIdByName: Map<string, string>;
countryIdByCode: Map<string, string>;
managementLevelGroupByName: Map<string, { id: string; name: string; targetPercentage: number }>;
managementLevelIdByName: Map<string, string>;
metroCityIdByName: Map<string, string>;
orgUnitIdByLevelAndName: Map<string, string>;
roleIdByName: Map<string, string>;
utilizationCategoryIdByCode: Map<string, string>;
}
export function buildReferenceDataMaps(data: {
clients: { id: string; code: string | null; name: string }[];
countries: { id: string; code: string }[];
managementLevelGroups: { id: string; name: string; targetPercentage: number }[];
managementLevels: { id: string; name: string }[];
metroCities: { id: string; name: string }[];
orgUnits: { id: string; level: number; name: string }[];
roles: { id: string; name: string }[];
utilizationCategories: { id: string; code: string }[];
}): ReferenceDataMaps {
return {
clientIdByCode: new Map(
data.clients.filter((client) => client.code).map((client) => [client.code!, client.id]),
),
clientIdByName: new Map(
data.clients.map((client) => [client.name.toLowerCase(), client.id]),
),
countryIdByCode: new Map(
data.countries.map((country) => [country.code, country.id]),
),
managementLevelGroupByName: new Map(
data.managementLevelGroups.map((group) => [group.name, group]),
),
managementLevelIdByName: new Map(
data.managementLevels.map((level) => [level.name, level.id]),
),
metroCityIdByName: new Map(
data.metroCities.map((metroCity) => [metroCity.name.toLowerCase(), metroCity.id]),
),
orgUnitIdByLevelAndName: new Map(
data.orgUnits.map((orgUnit) => [`${orgUnit.level}:${orgUnit.name.toLowerCase()}`, orgUnit.id]),
),
roleIdByName: new Map(
data.roles.map((role) => [role.name, role.id]),
),
utilizationCategoryIdByCode: new Map(
data.utilizationCategories.map((category) => [category.code, category.id]),
),
};
}
@@ -0,0 +1,68 @@
import type { Prisma, PrismaClient } from "@planarchy/db";
export type CommitDbClient = Pick<
PrismaClient,
| "$transaction"
| "assignment"
| "client"
| "country"
| "importBatch"
| "managementLevel"
| "managementLevelGroup"
| "metroCity"
| "orgUnit"
| "project"
| "resource"
| "resourceRole"
| "role"
| "stagedAssignment"
| "stagedAvailabilityRule"
| "stagedProject"
| "stagedResource"
| "stagedUnresolvedRecord"
| "stagedVacation"
| "utilizationCategory"
| "user"
| "vacation"
| "vacationEntitlement"
>;
export type TxClient = Parameters<Parameters<CommitDbClient["$transaction"]>[0]>[0];
export interface MergedStagedResource {
availability: Prisma.InputJsonValue | null;
canonicalExternalId: string;
chapter: string | null;
chargeabilityTarget: number | null;
clientUnitName: string | null;
countryCode: string | null;
displayName: string | null;
email: string | null;
fte: number | null;
lcrCents: number | null;
managementLevelGroupName: string | null;
managementLevelName: string | null;
metroCityName: string | null;
rawPayload: Record<string, unknown>;
resourceType: NonNullable<Awaited<ReturnType<TxClient["stagedResource"]["findMany"]>>[number]["resourceType"]> | null;
roleTokens: Set<string>;
sourceKinds: string[];
ucrCents: number | null;
vacationDaysPerYear: number | null;
warnings: string[];
}
export interface AggregatedAssignment {
endDate: Date;
hoursPerDay: number;
percentage: number;
projectId: string;
projectShortCode: string;
resourceId: string;
resourceKey: string;
roleId: string;
roleName: string;
sourceDates: string[];
startDate: Date;
utilizationCategoryCode: string | null;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,197 @@
import type { WeekdayAvailability } from "@planarchy/shared";
import {
DISPO_INTERNAL_PROJECT_BUCKETS,
normalizeDispoRoleToken,
} from "@planarchy/shared";
import type { TxClient, AggregatedAssignment } from "./commit-dispo-batch-types.js";
import { deriveTbdDispoProjectIdentity } from "./tbd-projects.js";
function asObject(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
function normalizeDate(date: Date): Date {
return new Date(`${date.toISOString().slice(0, 10)}T00:00:00.000Z`);
}
function getDateKey(date: Date): string {
return normalizeDate(date).toISOString().slice(0, 10);
}
function addDays(date: Date, days: number): Date {
const next = normalizeDate(date);
next.setUTCDate(next.getUTCDate() + days);
return next;
}
function roundToOneDecimal(value: number): number {
return Math.round(value * 10) / 10;
}
const WEEKDAY_KEYS = ["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"] as const;
const WORKDAY_KEYS = ["monday", "tuesday", "wednesday", "thursday", "friday"] as const;
function resolveInternalProjectShortCode(utilizationCategoryCode: string | null): string | null {
return (
DISPO_INTERNAL_PROJECT_BUCKETS.find(
(bucket) => bucket.utilizationCategoryCode === utilizationCategoryCode,
)?.shortCode ?? null
);
}
export function aggregateAssignments(
rows: Awaited<ReturnType<TxClient["stagedAssignment"]["findMany"]>>,
resourceIdByKey: ReadonlyMap<string, string>,
projectIdByShortCode: ReadonlyMap<string, string>,
roleIdByName: ReadonlyMap<string, string>,
resourceRoleNameByKey: ReadonlyMap<string, string>,
importTbdProjects: boolean,
): AggregatedAssignment[] {
const resolvedRows = rows
.filter((row) => !row.isUnassigned && (!row.isTbd || importTbdProjects))
.map((row) => {
const projectShortCode = row.isInternal
? resolveInternalProjectShortCode(row.utilizationCategoryCode)
: row.isTbd
? deriveTbdDispoProjectIdentity(
String(asObject(row.rawPayload).rawToken ?? ""),
row.utilizationCategoryCode ?? null,
).shortCode
: (row.projectKey ?? null);
const roleName =
row.roleName ??
normalizeDispoRoleToken(row.roleToken) ??
resourceRoleNameByKey.get(row.resourceExternalId) ??
null;
const resourceId = resourceIdByKey.get(row.resourceExternalId);
const projectId = projectShortCode ? projectIdByShortCode.get(projectShortCode) : null;
const roleId = roleName ? roleIdByName.get(roleName) : null;
if (!resourceId) {
throw new Error(`Unable to resolve resource "${row.resourceExternalId}" during assignment commit`);
}
if (!projectShortCode || !projectId) {
throw new Error(
`Unable to resolve project for assignment resource "${row.resourceExternalId}" on ${getDateKey(row.assignmentDate ?? row.startDate ?? new Date())}`,
);
}
if (!roleName || !roleId) {
throw new Error(
`Unable to resolve role for assignment resource "${row.resourceExternalId}" on ${getDateKey(row.assignmentDate ?? row.startDate ?? new Date())}`,
);
}
if (row.assignmentDate === null || row.hoursPerDay === null || row.percentage === null) {
throw new Error(`Assignment row "${row.id}" is missing normalized date or load information`);
}
return {
assignmentDate: normalizeDate(row.assignmentDate),
hoursPerDay: row.hoursPerDay,
percentage: row.percentage,
projectId,
projectShortCode,
resourceId,
resourceKey: row.resourceExternalId,
roleId,
roleName,
utilizationCategoryCode: row.utilizationCategoryCode ?? null,
};
})
.sort((left, right) =>
left.resourceKey.localeCompare(right.resourceKey) ||
left.projectShortCode.localeCompare(right.projectShortCode) ||
left.roleName.localeCompare(right.roleName) ||
left.assignmentDate.getTime() - right.assignmentDate.getTime(),
);
const aggregated: AggregatedAssignment[] = [];
for (const row of resolvedRows) {
const previous = aggregated.at(-1);
const canMerge = previous &&
previous.resourceId === row.resourceId &&
previous.projectId === row.projectId &&
previous.roleId === row.roleId &&
previous.hoursPerDay === row.hoursPerDay &&
previous.percentage === row.percentage &&
previous.endDate.getTime() === addDays(row.assignmentDate, -1).getTime();
if (canMerge) {
previous.endDate = row.assignmentDate;
previous.sourceDates.push(getDateKey(row.assignmentDate));
continue;
}
aggregated.push({
endDate: row.assignmentDate,
hoursPerDay: row.hoursPerDay,
percentage: row.percentage,
projectId: row.projectId,
projectShortCode: row.projectShortCode,
resourceId: row.resourceId,
resourceKey: row.resourceKey,
roleId: row.roleId,
roleName: row.roleName,
sourceDates: [getDateKey(row.assignmentDate)],
startDate: row.assignmentDate,
utilizationCategoryCode: row.utilizationCategoryCode,
});
}
return aggregated;
}
export function deriveOverlayAvailability(
baseAvailability: WeekdayAvailability,
rules: Awaited<ReturnType<TxClient["stagedAvailabilityRule"]["findMany"]>>,
): WeekdayAvailability {
const next = { ...baseAvailability };
const weekdayVotes = new Map<string, Map<number, number>>();
for (const rule of rules) {
const date = rule.effectiveStartDate ?? rule.effectiveEndDate;
if (!date) {
continue;
}
const weekdayIndex = normalizeDate(date).getUTCDay();
if (weekdayIndex === 0 || weekdayIndex === 6) {
continue;
}
const weekdayKey = WEEKDAY_KEYS[weekdayIndex] as (typeof WORKDAY_KEYS)[number] | undefined;
if (!weekdayKey || !WORKDAY_KEYS.includes(weekdayKey)) {
continue;
}
const availableHours = rule.availableHours ?? (
rule.percentage !== null && rule.percentage !== undefined
? roundToOneDecimal((rule.percentage / 100) * 8)
: null
);
if (availableHours === null) {
continue;
}
const hoursMap = weekdayVotes.get(weekdayKey) ?? new Map<number, number>();
hoursMap.set(availableHours, (hoursMap.get(availableHours) ?? 0) + 1);
weekdayVotes.set(weekdayKey, hoursMap);
}
for (const weekdayKey of WORKDAY_KEYS) {
const hoursMap = weekdayVotes.get(weekdayKey);
if (!hoursMap || hoursMap.size === 0) {
continue;
}
const firstEntry = [...hoursMap.entries()].sort(
(left, right) => right[1] - left[1] || left[0] - right[0],
)[0];
if (!firstEntry) {
continue;
}
const [resolvedHours] = firstEntry;
next[weekdayKey] = Math.min(next[weekdayKey], resolvedHours);
}
return next;
}
@@ -0,0 +1,79 @@
import type { CommitDbClient } from "./commit-dispo-batch-types.js";
import { StagedRecordStatus } from "@planarchy/db";
function asObject(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
function isAllowedUnresolvedRecord(
record: Awaited<ReturnType<CommitDbClient["stagedUnresolvedRecord"]["findMany"]>>[number],
): boolean {
const message = record.message.toLowerCase();
const hint = (record.resolutionHint ?? "").toLowerCase();
const rawToken = String(asObject(record.normalizedData).rawToken ?? "").toLowerCase();
return record.recordType === "PROJECT" && (
message.includes("[tbd]") ||
hint.includes("[tbd]") ||
rawToken.includes("[tbd]")
);
}
export interface BatchValidationResult {
batchId: string;
batchSummary: unknown;
blockingUnresolved: number;
skippedTbdUnresolved: number;
}
export async function validateDispoBatch(
db: CommitDbClient,
input: {
allowTbdUnresolved?: boolean;
importBatchId: string;
importTbdProjects?: boolean;
},
): Promise<BatchValidationResult> {
const batch = await db.importBatch.findUnique({
where: { id: input.importBatchId },
select: { id: true, status: true, summary: true },
});
if (!batch) {
throw new Error(`Import batch "${input.importBatchId}" not found`);
}
if (!["STAGED", "REVIEW_READY", "APPROVED"].includes(batch.status)) {
throw new Error(`Import batch "${batch.id}" is not ready to commit from status "${batch.status}"`);
}
const unresolved = await db.stagedUnresolvedRecord.findMany({
where: {
importBatchId: batch.id,
status: StagedRecordStatus.UNRESOLVED,
},
});
const blockingUnresolved = unresolved.filter(
(record) =>
!(
(input.allowTbdUnresolved ?? true) ||
(input.importTbdProjects && isAllowedUnresolvedRecord(record))
) || !isAllowedUnresolvedRecord(record),
);
const skippedTbdUnresolved = unresolved.length - blockingUnresolved.length;
if (blockingUnresolved.length > 0) {
throw new Error(
`Import batch "${batch.id}" still has ${blockingUnresolved.length} blocking unresolved staged record(s)`,
);
}
return {
batchId: batch.id,
batchSummary: batch.summary,
blockingUnresolved: blockingUnresolved.length,
skippedTbdUnresolved,
};
}
+11
View File
@@ -848,6 +848,7 @@ model EstimateVersion {
notes String?
lockedAt DateTime?
projectSnapshot Json @db.JsonB @default("{}")
commercialTerms Json? @db.JsonB
estimate Estimate @relation(fields: [estimateId], references: [id], onDelete: Cascade)
assumptions EstimateAssumption[]
@@ -1170,6 +1171,7 @@ model DemandRequirement {
@@index([projectId])
@@index([startDate, endDate])
@@index([status])
@@index([projectId, status, startDate])
@@map("demand_requirements")
}
@@ -1202,6 +1204,8 @@ model Assignment {
@@index([projectId])
@@index([startDate, endDate])
@@index([status])
@@index([resourceId, status, startDate])
@@index([projectId, startDate, endDate])
@@map("assignments")
}
@@ -1233,6 +1237,7 @@ model Vacation {
@@index([resourceId])
@@index([startDate, endDate])
@@index([status])
@@index([resourceId, status, startDate, endDate])
@@map("vacations")
}
@@ -1297,6 +1302,12 @@ model SystemSettings {
smtpPassword String?
smtpFrom String?
smtpTls Boolean? @default(true)
// Global viewer-side anonymization
anonymizationEnabled Boolean? @default(false)
anonymizationDomain String? @default("superhartmut.de")
anonymizationSeed String?
anonymizationMode String? @default("global")
anonymizationAliases Json? @db.JsonB
// Vacation defaults
vacationDefaultDays Int? @default(28) // default annual entitlement
updatedAt DateTime @updatedAt