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:
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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";
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 } });
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 };
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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
@@ -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 2–3 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);
|
||||
});
|
||||
}),
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 } });
|
||||
|
||||
@@ -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 ?? "" },
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user